@vincent99/vlib 0.1.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/LICENSE +178 -0
- package/README.md +107 -0
- package/bin/vlib.js +10 -0
- package/dist/AdminForm.vue_vue_type_style_index_0_lang-xCk1ywLq.js +753 -0
- package/dist/auth/middleware.d.ts +18 -0
- package/dist/auth/middleware.d.ts.map +1 -0
- package/dist/auth/middleware.js +44 -0
- package/dist/auth/middleware.js.map +1 -0
- package/dist/auth/password.d.ts +10 -0
- package/dist/auth/password.d.ts.map +1 -0
- package/dist/auth/password.js +44 -0
- package/dist/auth/password.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +104 -0
- package/dist/cli.js.map +1 -0
- package/dist/components/AdminForm.vue.d.ts +7 -0
- package/dist/components/AdminTable.vue.d.ts +5 -0
- package/dist/components/AppLayout.vue.d.ts +36 -0
- package/dist/components/NavSidebar.vue.d.ts +11 -0
- package/dist/components/TableView.vue.d.ts +52 -0
- package/dist/components/index.d.ts +6 -0
- package/dist/components/index.js +8 -0
- package/dist/components/types.d.ts +25 -0
- package/dist/db/index.d.ts +12 -0
- package/dist/db/index.d.ts.map +1 -0
- package/dist/db/index.js +84 -0
- package/dist/db/index.js.map +1 -0
- package/dist/db/migrate.d.ts +2 -0
- package/dist/db/migrate.d.ts.map +1 -0
- package/dist/db/migrate.js +94 -0
- package/dist/db/migrate.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +11 -0
- package/dist/router/index.d.ts +33 -0
- package/dist/router/index.js +62 -0
- package/dist/server/api/admin.d.ts +3 -0
- package/dist/server/api/admin.d.ts.map +1 -0
- package/dist/server/api/admin.js +184 -0
- package/dist/server/api/admin.js.map +1 -0
- package/dist/server/api/auth.d.ts +3 -0
- package/dist/server/api/auth.d.ts.map +1 -0
- package/dist/server/api/auth.js +66 -0
- package/dist/server/api/auth.js.map +1 -0
- package/dist/server/index.d.ts +17 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +47 -0
- package/dist/server/index.js.map +1 -0
- package/dist/types.d.ts +53 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/vlib.css +1 -0
- package/package.json +91 -0
- package/src/components/AdminForm.vue +491 -0
- package/src/components/AdminTable.vue +269 -0
- package/src/components/AppLayout.vue +280 -0
- package/src/components/NavSidebar.vue +176 -0
- package/src/components/TableView.vue +379 -0
- package/src/components/index.ts +13 -0
- package/src/components/types.ts +28 -0
- package/templates/.env.example +4 -0
- package/templates/.prettierignore +3 -0
- package/templates/.prettierrc +6 -0
- package/templates/Dockerfile.ejs +31 -0
- package/templates/docker-compose.prod.yml.ejs +22 -0
- package/templates/docker-compose.yml.ejs +22 -0
- package/templates/eslint.config.mjs +42 -0
- package/templates/index.html.ejs +13 -0
- package/templates/package.json.ejs +44 -0
- package/templates/postcss.config.js.ejs +6 -0
- package/templates/schemas/001-initial.sql +35 -0
- package/templates/scripts/migrate.ts +13 -0
- package/templates/server/index.ts +13 -0
- package/templates/src/App.vue +8 -0
- package/templates/src/main.ts +6 -0
- package/templates/src/router.ts +26 -0
- package/templates/src/routes/_layout.vue +58 -0
- package/templates/src/routes/admin/_layout.vue +8 -0
- package/templates/src/routes/admin/index.vue +88 -0
- package/templates/src/routes/admin/tables/[table]/[id].vue +20 -0
- package/templates/src/routes/admin/tables/[table]/index.vue +10 -0
- package/templates/src/routes/admin/tables/[table]/new.vue +10 -0
- package/templates/src/routes/index.vue +34 -0
- package/templates/src/routes/login.vue +128 -0
- package/templates/src/stores/auth.ts +58 -0
- package/templates/src/styles/main.scss +98 -0
- package/templates/src/styles/variables.scss +7 -0
- package/templates/tailwind.config.js.ejs +27 -0
- package/templates/tsconfig.json.ejs +26 -0
- package/templates/tsconfig.server.json.ejs +17 -0
- package/templates/vite.config.ts.ejs +36 -0
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="vl-table-view">
|
|
3
|
+
<!-- Toolbar -->
|
|
4
|
+
<div class="vl-table-view__toolbar">
|
|
5
|
+
<div class="vl-table-view__toolbar-left">
|
|
6
|
+
<slot name="toolbar-left">
|
|
7
|
+
<span v-if="selectedIds.size > 0" class="vl-table-view__selected-count">
|
|
8
|
+
{{ selectedIds.size }} selected
|
|
9
|
+
</span>
|
|
10
|
+
</slot>
|
|
11
|
+
</div>
|
|
12
|
+
<div class="vl-table-view__toolbar-right">
|
|
13
|
+
<!-- Bulk actions -->
|
|
14
|
+
<template v-if="selectedIds.size > 0 && actions.length > 0">
|
|
15
|
+
<button
|
|
16
|
+
v-for="action in actions"
|
|
17
|
+
:key="action.key"
|
|
18
|
+
class="vl-btn"
|
|
19
|
+
:class="[`vl-btn--${action.variant || 'default'}`]"
|
|
20
|
+
@click="handleAction(action)"
|
|
21
|
+
>
|
|
22
|
+
{{ action.label }}
|
|
23
|
+
</button>
|
|
24
|
+
</template>
|
|
25
|
+
<slot name="toolbar-right" />
|
|
26
|
+
</div>
|
|
27
|
+
</div>
|
|
28
|
+
|
|
29
|
+
<!-- Table -->
|
|
30
|
+
<div class="vl-table-view__scroll">
|
|
31
|
+
<table class="vl-table">
|
|
32
|
+
<thead>
|
|
33
|
+
<tr>
|
|
34
|
+
<!-- Select-all checkbox -->
|
|
35
|
+
<th class="vl-table__th vl-table__th--check">
|
|
36
|
+
<input
|
|
37
|
+
type="checkbox"
|
|
38
|
+
:checked="allSelected"
|
|
39
|
+
:indeterminate="someSelected && !allSelected"
|
|
40
|
+
aria-label="Select all"
|
|
41
|
+
@change="toggleAll"
|
|
42
|
+
/>
|
|
43
|
+
</th>
|
|
44
|
+
<th
|
|
45
|
+
v-for="col in headers"
|
|
46
|
+
:key="col.key"
|
|
47
|
+
class="vl-table__th"
|
|
48
|
+
:class="{ 'vl-table__th--sortable': col.sortable !== false }"
|
|
49
|
+
@click="col.sortable !== false && toggleSort(col.key)"
|
|
50
|
+
>
|
|
51
|
+
{{ col.label }}
|
|
52
|
+
<span v-if="sortKey === col.key" class="vl-table__sort-icon">
|
|
53
|
+
{{ sortDir === 'asc' ? '↑' : '↓' }}
|
|
54
|
+
</span>
|
|
55
|
+
</th>
|
|
56
|
+
<th v-if="$slots['row-actions']" class="vl-table__th vl-table__th--actions">Actions</th>
|
|
57
|
+
</tr>
|
|
58
|
+
</thead>
|
|
59
|
+
<tbody>
|
|
60
|
+
<tr
|
|
61
|
+
v-for="row in rows"
|
|
62
|
+
:key="getRowId(row)"
|
|
63
|
+
class="vl-table__tr"
|
|
64
|
+
:class="{ 'vl-table__tr--selected': selectedIds.has(getRowId(row)) }"
|
|
65
|
+
>
|
|
66
|
+
<td class="vl-table__td vl-table__td--check">
|
|
67
|
+
<input
|
|
68
|
+
type="checkbox"
|
|
69
|
+
:checked="selectedIds.has(getRowId(row))"
|
|
70
|
+
:aria-label="`Select row ${getRowId(row)}`"
|
|
71
|
+
@change="toggleRow(getRowId(row))"
|
|
72
|
+
/>
|
|
73
|
+
</td>
|
|
74
|
+
<td
|
|
75
|
+
v-for="col in headers"
|
|
76
|
+
:key="col.key"
|
|
77
|
+
class="vl-table__td"
|
|
78
|
+
>
|
|
79
|
+
<slot :name="`cell-${col.key}`" :row="row" :value="(row as Record<string, unknown>)[col.key]">
|
|
80
|
+
{{ formatCell((row as Record<string, unknown>)[col.key]) }}
|
|
81
|
+
</slot>
|
|
82
|
+
</td>
|
|
83
|
+
<td v-if="$slots['row-actions']" class="vl-table__td vl-table__td--actions">
|
|
84
|
+
<slot :id="getRowId(row)" name="row-actions" :row="row" />
|
|
85
|
+
</td>
|
|
86
|
+
</tr>
|
|
87
|
+
<tr v-if="rows.length === 0">
|
|
88
|
+
<td :colspan="headers.length + 2" class="vl-table__empty">
|
|
89
|
+
No rows found.
|
|
90
|
+
</td>
|
|
91
|
+
</tr>
|
|
92
|
+
</tbody>
|
|
93
|
+
</table>
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
<!-- Pagination -->
|
|
97
|
+
<div v-if="total > pageSize" class="vl-table-view__pagination">
|
|
98
|
+
<button
|
|
99
|
+
class="vl-btn vl-btn--sm"
|
|
100
|
+
:disabled="page <= 1"
|
|
101
|
+
@click="$emit('update:page', page - 1)"
|
|
102
|
+
>← Prev</button>
|
|
103
|
+
<span class="vl-table-view__pagination-info">
|
|
104
|
+
Page {{ page }} of {{ totalPages }} ({{ total }} total)
|
|
105
|
+
</span>
|
|
106
|
+
<button
|
|
107
|
+
class="vl-btn vl-btn--sm"
|
|
108
|
+
:disabled="page >= totalPages"
|
|
109
|
+
@click="$emit('update:page', page + 1)"
|
|
110
|
+
>Next →</button>
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
</template>
|
|
114
|
+
|
|
115
|
+
<script setup lang="ts">
|
|
116
|
+
import { ref, computed } from 'vue'
|
|
117
|
+
import type { TableHeader, TableAction } from './types.js'
|
|
118
|
+
|
|
119
|
+
export type { TableHeader, TableAction }
|
|
120
|
+
|
|
121
|
+
const props = withDefaults(
|
|
122
|
+
defineProps<{
|
|
123
|
+
rows: Record<string, unknown>[]
|
|
124
|
+
headers: TableHeader[]
|
|
125
|
+
idKey?: string
|
|
126
|
+
total?: number
|
|
127
|
+
page?: number
|
|
128
|
+
pageSize?: number
|
|
129
|
+
actions?: TableAction[]
|
|
130
|
+
}>(),
|
|
131
|
+
{
|
|
132
|
+
idKey: 'id',
|
|
133
|
+
total: 0,
|
|
134
|
+
page: 1,
|
|
135
|
+
pageSize: 50,
|
|
136
|
+
actions: () => [],
|
|
137
|
+
}
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
const emit = defineEmits<{
|
|
141
|
+
'update:page': [page: number]
|
|
142
|
+
'action': [key: string, ids: (string | number)[]]
|
|
143
|
+
'sort': [key: string, dir: 'asc' | 'desc']
|
|
144
|
+
}>()
|
|
145
|
+
|
|
146
|
+
const selectedIds = ref(new Set<string | number>())
|
|
147
|
+
const sortKey = ref<string | null>(null)
|
|
148
|
+
const sortDir = ref<'asc' | 'desc'>('asc')
|
|
149
|
+
|
|
150
|
+
const totalPages = computed(() => Math.ceil((props.total || props.rows.length) / props.pageSize))
|
|
151
|
+
|
|
152
|
+
const allSelected = computed(
|
|
153
|
+
() => props.rows.length > 0 && props.rows.every((r) => selectedIds.value.has(getRowId(r)))
|
|
154
|
+
)
|
|
155
|
+
const someSelected = computed(() => props.rows.some((r) => selectedIds.value.has(getRowId(r))))
|
|
156
|
+
|
|
157
|
+
function getRowId(row: Record<string, unknown>): string | number {
|
|
158
|
+
return row[props.idKey] as string | number
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function toggleAll() {
|
|
162
|
+
if (allSelected.value) {
|
|
163
|
+
props.rows.forEach((r) => selectedIds.value.delete(getRowId(r)))
|
|
164
|
+
} else {
|
|
165
|
+
props.rows.forEach((r) => selectedIds.value.add(getRowId(r)))
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function toggleRow(id: string | number) {
|
|
170
|
+
if (selectedIds.value.has(id)) {selectedIds.value.delete(id)}
|
|
171
|
+
else {selectedIds.value.add(id)}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function handleAction(action: TableAction) {
|
|
175
|
+
emit('action', action.key, Array.from(selectedIds.value))
|
|
176
|
+
selectedIds.value.clear()
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function toggleSort(key: string) {
|
|
180
|
+
if (sortKey.value === key) {
|
|
181
|
+
sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc'
|
|
182
|
+
} else {
|
|
183
|
+
sortKey.value = key
|
|
184
|
+
sortDir.value = 'asc'
|
|
185
|
+
}
|
|
186
|
+
emit('sort', sortKey.value!, sortDir.value)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function formatCell(value: unknown): string {
|
|
190
|
+
if (value === null || value === undefined) {return ''}
|
|
191
|
+
if (typeof value === 'object') {return JSON.stringify(value)}
|
|
192
|
+
const str = String(value)
|
|
193
|
+
return str.length > 120 ? str.slice(0, 120) + '…' : str
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Expose selected IDs for parent use
|
|
197
|
+
defineExpose({ selectedIds })
|
|
198
|
+
</script>
|
|
199
|
+
|
|
200
|
+
<style lang="scss">
|
|
201
|
+
.vl-table-view {
|
|
202
|
+
background: white;
|
|
203
|
+
border-radius: var(--radius-lg);
|
|
204
|
+
box-shadow: var(--shadow);
|
|
205
|
+
overflow: hidden;
|
|
206
|
+
|
|
207
|
+
&__toolbar {
|
|
208
|
+
display: flex;
|
|
209
|
+
align-items: center;
|
|
210
|
+
justify-content: space-between;
|
|
211
|
+
padding: var(--space-3) var(--space-4);
|
|
212
|
+
border-bottom: 1px solid var(--color-border);
|
|
213
|
+
gap: var(--space-3);
|
|
214
|
+
flex-wrap: wrap;
|
|
215
|
+
|
|
216
|
+
&-left,
|
|
217
|
+
&-right {
|
|
218
|
+
display: flex;
|
|
219
|
+
align-items: center;
|
|
220
|
+
gap: var(--space-2);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
&__selected-count {
|
|
225
|
+
font-size: 0.875rem;
|
|
226
|
+
color: var(--color-primary);
|
|
227
|
+
font-weight: 500;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
&__scroll {
|
|
231
|
+
overflow-x: auto;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
&__pagination {
|
|
235
|
+
display: flex;
|
|
236
|
+
align-items: center;
|
|
237
|
+
gap: var(--space-3);
|
|
238
|
+
justify-content: center;
|
|
239
|
+
padding: var(--space-3) var(--space-4);
|
|
240
|
+
border-top: 1px solid var(--color-border);
|
|
241
|
+
font-size: 0.875rem;
|
|
242
|
+
color: var(--color-text-secondary);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
.vl-table {
|
|
247
|
+
width: 100%;
|
|
248
|
+
border-collapse: collapse;
|
|
249
|
+
font-size: 0.875rem;
|
|
250
|
+
|
|
251
|
+
&__th {
|
|
252
|
+
padding: var(--space-2) var(--space-3);
|
|
253
|
+
text-align: left;
|
|
254
|
+
font-weight: 600;
|
|
255
|
+
color: var(--color-text-secondary);
|
|
256
|
+
border-bottom: 2px solid var(--color-border);
|
|
257
|
+
white-space: nowrap;
|
|
258
|
+
background: var(--color-background);
|
|
259
|
+
|
|
260
|
+
&--check,
|
|
261
|
+
&--actions {
|
|
262
|
+
width: 1%;
|
|
263
|
+
white-space: nowrap;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
&--sortable {
|
|
267
|
+
cursor: pointer;
|
|
268
|
+
user-select: none;
|
|
269
|
+
|
|
270
|
+
&:hover {
|
|
271
|
+
color: var(--color-primary);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
&__td {
|
|
277
|
+
padding: var(--space-2) var(--space-3);
|
|
278
|
+
border-bottom: 1px solid var(--color-border);
|
|
279
|
+
color: var(--color-text);
|
|
280
|
+
max-width: 300px;
|
|
281
|
+
overflow: hidden;
|
|
282
|
+
text-overflow: ellipsis;
|
|
283
|
+
white-space: nowrap;
|
|
284
|
+
|
|
285
|
+
&--check,
|
|
286
|
+
&--actions {
|
|
287
|
+
width: 1%;
|
|
288
|
+
white-space: nowrap;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
&--actions {
|
|
292
|
+
display: flex;
|
|
293
|
+
gap: var(--space-1);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
&__tr {
|
|
298
|
+
transition: background 0.1s;
|
|
299
|
+
|
|
300
|
+
&:hover {
|
|
301
|
+
background: var(--color-background);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
&--selected {
|
|
305
|
+
background: color-mix(in srgb, var(--color-primary) 8%, white);
|
|
306
|
+
|
|
307
|
+
&:hover {
|
|
308
|
+
background: color-mix(in srgb, var(--color-primary) 12%, white);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
&__sort-icon {
|
|
314
|
+
margin-left: var(--space-1);
|
|
315
|
+
color: var(--color-primary);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
&__empty {
|
|
319
|
+
padding: var(--space-8) var(--space-4);
|
|
320
|
+
text-align: center;
|
|
321
|
+
color: var(--color-text-secondary);
|
|
322
|
+
font-style: italic;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Button styles (shared)
|
|
327
|
+
.vl-btn {
|
|
328
|
+
display: inline-flex;
|
|
329
|
+
align-items: center;
|
|
330
|
+
gap: var(--space-1);
|
|
331
|
+
padding: var(--space-2) var(--space-3);
|
|
332
|
+
border-radius: var(--radius);
|
|
333
|
+
border: 1px solid var(--color-border);
|
|
334
|
+
background: white;
|
|
335
|
+
color: var(--color-text);
|
|
336
|
+
font-size: 0.875rem;
|
|
337
|
+
cursor: pointer;
|
|
338
|
+
transition: all 0.15s;
|
|
339
|
+
white-space: nowrap;
|
|
340
|
+
|
|
341
|
+
&:hover:not(:disabled) {
|
|
342
|
+
background: var(--color-background);
|
|
343
|
+
border-color: var(--color-primary-light);
|
|
344
|
+
color: var(--color-primary);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
&:disabled {
|
|
348
|
+
opacity: 0.45;
|
|
349
|
+
cursor: not-allowed;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
&--primary {
|
|
353
|
+
background: var(--color-primary);
|
|
354
|
+
border-color: var(--color-primary);
|
|
355
|
+
color: white;
|
|
356
|
+
|
|
357
|
+
&:hover:not(:disabled) {
|
|
358
|
+
background: var(--color-primary-dark);
|
|
359
|
+
border-color: var(--color-primary-dark);
|
|
360
|
+
color: white;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
&--danger {
|
|
365
|
+
color: var(--color-danger);
|
|
366
|
+
border-color: var(--color-danger);
|
|
367
|
+
|
|
368
|
+
&:hover:not(:disabled) {
|
|
369
|
+
background: var(--color-danger);
|
|
370
|
+
color: white;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
&--sm {
|
|
375
|
+
padding: var(--space-1) var(--space-2);
|
|
376
|
+
font-size: 0.8rem;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
</style>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export { default as AppLayout } from './AppLayout.vue';
|
|
2
|
+
export { default as NavSidebar } from './NavSidebar.vue';
|
|
3
|
+
export { default as TableView } from './TableView.vue';
|
|
4
|
+
export { default as AdminTable } from './AdminTable.vue';
|
|
5
|
+
export { default as AdminForm } from './AdminForm.vue';
|
|
6
|
+
|
|
7
|
+
// Type exports from companion .ts (importable without Vue processing)
|
|
8
|
+
export type {
|
|
9
|
+
AppLayoutProps,
|
|
10
|
+
NavItem,
|
|
11
|
+
TableHeader,
|
|
12
|
+
TableAction,
|
|
13
|
+
} from './types.js';
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Component prop interfaces — defined here so they can be imported from both
|
|
2
|
+
// Vue files and TypeScript-only contexts.
|
|
3
|
+
|
|
4
|
+
export interface AppLayoutProps {
|
|
5
|
+
appName?: string;
|
|
6
|
+
user?: { username: string; displayName?: string | null } | null;
|
|
7
|
+
navItems?: NavItem[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface NavItem {
|
|
11
|
+
label: string;
|
|
12
|
+
to?: string;
|
|
13
|
+
icon?: string;
|
|
14
|
+
children?: NavItem[];
|
|
15
|
+
defaultOpen?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface TableHeader {
|
|
19
|
+
key: string;
|
|
20
|
+
label: string;
|
|
21
|
+
sortable?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface TableAction {
|
|
25
|
+
key: string;
|
|
26
|
+
label: string;
|
|
27
|
+
variant?: 'default' | 'danger' | 'primary';
|
|
28
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# ── Build stage ──────────────────────────────────────────────
|
|
2
|
+
FROM node:22-alpine AS builder
|
|
3
|
+
WORKDIR /app
|
|
4
|
+
|
|
5
|
+
# Install dependencies
|
|
6
|
+
COPY package.json yarn.lock* .yarnrc.yml* ./
|
|
7
|
+
RUN yarn install --frozen-lockfile
|
|
8
|
+
|
|
9
|
+
# Copy source and build
|
|
10
|
+
COPY . .
|
|
11
|
+
RUN yarn build
|
|
12
|
+
|
|
13
|
+
# ── Production stage ──────────────────────────────────────────
|
|
14
|
+
FROM node:22-alpine AS production
|
|
15
|
+
WORKDIR /app
|
|
16
|
+
ENV NODE_ENV=production
|
|
17
|
+
|
|
18
|
+
# Only install production deps
|
|
19
|
+
COPY package.json yarn.lock* .yarnrc.yml* ./
|
|
20
|
+
RUN yarn install --frozen-lockfile --production
|
|
21
|
+
|
|
22
|
+
# Copy compiled server and built frontend
|
|
23
|
+
COPY --from=builder /app/dist ./dist
|
|
24
|
+
COPY --from=builder /app/schemas ./schemas
|
|
25
|
+
|
|
26
|
+
# Data directory for SQLite
|
|
27
|
+
VOLUME ["/data"]
|
|
28
|
+
|
|
29
|
+
EXPOSE 3000
|
|
30
|
+
|
|
31
|
+
CMD ["sh", "-c", "node dist/scripts/migrate.js && node dist/server/index.js"]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
name: <%= name.toLowerCase().replace(/\s+/g, '-') %>-prod
|
|
2
|
+
|
|
3
|
+
services:
|
|
4
|
+
app:
|
|
5
|
+
build:
|
|
6
|
+
context: .
|
|
7
|
+
dockerfile: Dockerfile
|
|
8
|
+
ports:
|
|
9
|
+
- "3000:3000"
|
|
10
|
+
volumes:
|
|
11
|
+
- ./data:/data
|
|
12
|
+
environment:
|
|
13
|
+
- NODE_ENV=production
|
|
14
|
+
- DB_PATH=/data/app.db
|
|
15
|
+
- PORT=3000
|
|
16
|
+
restart: unless-stopped
|
|
17
|
+
healthcheck:
|
|
18
|
+
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/auth/me"]
|
|
19
|
+
interval: 30s
|
|
20
|
+
timeout: 10s
|
|
21
|
+
retries: 3
|
|
22
|
+
start_period: 10s
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
name: <%= name.toLowerCase().replace(/\s+/g, '-') %>
|
|
2
|
+
|
|
3
|
+
services:
|
|
4
|
+
app:
|
|
5
|
+
image: node:22-alpine
|
|
6
|
+
working_dir: /app
|
|
7
|
+
volumes:
|
|
8
|
+
- .:/app
|
|
9
|
+
- app_node_modules:/app/node_modules
|
|
10
|
+
- ./data:/data
|
|
11
|
+
ports:
|
|
12
|
+
- "5173:5173"
|
|
13
|
+
- "3001:3001"
|
|
14
|
+
environment:
|
|
15
|
+
- NODE_ENV=development
|
|
16
|
+
- DB_PATH=/data/app.db
|
|
17
|
+
- PORT=3001
|
|
18
|
+
command: sh -c "yarn install && yarn migrate && yarn dev"
|
|
19
|
+
restart: unless-stopped
|
|
20
|
+
|
|
21
|
+
volumes:
|
|
22
|
+
app_node_modules:
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import eslint from '@eslint/js';
|
|
4
|
+
import { defineConfig } from 'eslint/config';
|
|
5
|
+
import tseslint from 'typescript-eslint';
|
|
6
|
+
import pluginVue from 'eslint-plugin-vue';
|
|
7
|
+
import vueTs from '@vue/eslint-config-typescript';
|
|
8
|
+
|
|
9
|
+
export default defineConfig(
|
|
10
|
+
{ ignores: ['dist/', 'node_modules/'] },
|
|
11
|
+
eslint.configs.recommended,
|
|
12
|
+
tseslint.configs.recommended,
|
|
13
|
+
pluginVue.configs['flat/recommended'],
|
|
14
|
+
vueTs(),
|
|
15
|
+
{
|
|
16
|
+
rules: {
|
|
17
|
+
curly: ['error', 'all'],
|
|
18
|
+
'vue/multi-word-component-names': 'off',
|
|
19
|
+
'vue/valid-template-root': 'off',
|
|
20
|
+
'@typescript-eslint/no-explicit-any': 'off',
|
|
21
|
+
// Defer HTML formatting to prettier
|
|
22
|
+
'vue/max-attributes-per-line': 'off',
|
|
23
|
+
'vue/singleline-html-element-content-newline': 'off',
|
|
24
|
+
'vue/multiline-html-element-content-newline': 'off',
|
|
25
|
+
'vue/html-self-closing': 'off',
|
|
26
|
+
'vue/html-indent': 'off',
|
|
27
|
+
'vue/html-closing-bracket-newline': 'off',
|
|
28
|
+
'@typescript-eslint/no-unused-vars': [
|
|
29
|
+
'error',
|
|
30
|
+
{
|
|
31
|
+
args: 'all',
|
|
32
|
+
argsIgnorePattern: '^_',
|
|
33
|
+
caughtErrors: 'all',
|
|
34
|
+
caughtErrorsIgnorePattern: '^_',
|
|
35
|
+
destructuredArrayIgnorePattern: '^_',
|
|
36
|
+
varsIgnorePattern: '^_',
|
|
37
|
+
ignoreRestSiblings: true,
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
);
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
|
+
<title><%= name %></title>
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div id="app"></div>
|
|
11
|
+
<script type="module" src="/src/main.ts"></script>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "<%= name.toLowerCase().replace(/\s+/g, '-') %>",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "concurrently -n vite,server -c cyan,green \"vite\" \"tsx watch server/index.ts\"",
|
|
8
|
+
"dev:vite": "vite",
|
|
9
|
+
"dev:server": "tsx watch server/index.ts",
|
|
10
|
+
"build": "vue-tsc && vite build && tsc -p tsconfig.server.json",
|
|
11
|
+
"start": "node dist/server/index.js",
|
|
12
|
+
"migrate": "tsx scripts/migrate.ts",
|
|
13
|
+
"lint": "eslint .",
|
|
14
|
+
"lint:fix": "eslint --fix .",
|
|
15
|
+
"format": "prettier --write .",
|
|
16
|
+
"format:check": "prettier --check .",
|
|
17
|
+
"docker:dev": "docker compose up",
|
|
18
|
+
"docker:prod": "docker compose -f docker-compose.prod.yml up --build"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@vincent99/vlib": "^0.1.0",
|
|
22
|
+
"vue": "^3.5.13",
|
|
23
|
+
"vue-router": "^4.5.0"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@eslint/js": "^9.18.0",
|
|
27
|
+
"@types/node": "^22.10.0",
|
|
28
|
+
"@vitejs/plugin-vue": "^5.2.1",
|
|
29
|
+
"@vue/eslint-config-typescript": "^14.2.0",
|
|
30
|
+
"autoprefixer": "^10.4.20",
|
|
31
|
+
"concurrently": "^9.1.2",
|
|
32
|
+
"eslint": "^9.18.0",
|
|
33
|
+
"eslint-plugin-vue": "^9.32.0",
|
|
34
|
+
"postcss": "^8.5.1",
|
|
35
|
+
"prettier": "^3.4.2",
|
|
36
|
+
"sass": "^1.83.4",
|
|
37
|
+
"tailwindcss": "^3.4.17",
|
|
38
|
+
"tsx": "^4.19.2",
|
|
39
|
+
"typescript": "^5.7.2",
|
|
40
|
+
"typescript-eslint": "^8.20.0",
|
|
41
|
+
"vite": "^6.0.7",
|
|
42
|
+
"vue-tsc": "^2.2.0"
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
-- Schema version 1: initial tables
|
|
2
|
+
-- Applied by the migration runner. DO NOT edit this file after first application.
|
|
3
|
+
-- To make changes, create 002-<description>.sql
|
|
4
|
+
|
|
5
|
+
-- Key-value settings store
|
|
6
|
+
CREATE TABLE IF NOT EXISTS settings (
|
|
7
|
+
key TEXT PRIMARY KEY NOT NULL,
|
|
8
|
+
value TEXT NOT NULL DEFAULT 'null' -- stored as JSON
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
-- schema_version is managed by the migration runner; initialize at 0 here,
|
|
12
|
+
-- the runner will update it to 1 after this file is applied.
|
|
13
|
+
INSERT OR IGNORE INTO settings (key, value) VALUES ('schema_version', '0');
|
|
14
|
+
|
|
15
|
+
-- Users
|
|
16
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
17
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
18
|
+
username TEXT UNIQUE NOT NULL,
|
|
19
|
+
password TEXT NOT NULL, -- pbkdf2:<iters>:<salt>:<hash>
|
|
20
|
+
displayName TEXT,
|
|
21
|
+
preferences TEXT NOT NULL DEFAULT '{}' -- JSON object
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
-- Sessions
|
|
25
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
26
|
+
id TEXT PRIMARY KEY NOT NULL, -- UUID v4
|
|
27
|
+
userId INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
28
|
+
created TEXT NOT NULL, -- ISO-8601
|
|
29
|
+
expires TEXT NOT NULL, -- ISO-8601
|
|
30
|
+
userAgent TEXT NOT NULL DEFAULT '',
|
|
31
|
+
ip TEXT NOT NULL DEFAULT ''
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_userId ON sessions(userId);
|
|
35
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires);
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import { fileURLToPath } from 'url';
|
|
3
|
+
import { runMigrations } from '@vincent99/vlib/server';
|
|
4
|
+
|
|
5
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
|
|
7
|
+
const dbPath = process.env.DB_PATH ?? path.resolve(__dirname, '../data/app.db');
|
|
8
|
+
const schemasDir = path.resolve(__dirname, '../schemas');
|
|
9
|
+
|
|
10
|
+
runMigrations(dbPath, schemasDir).catch((err) => {
|
|
11
|
+
console.error('Migration failed:', err);
|
|
12
|
+
process.exit(1);
|
|
13
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import { fileURLToPath } from 'url';
|
|
3
|
+
import { startServer } from '@vincent99/vlib/server';
|
|
4
|
+
|
|
5
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
const isProd = process.env.NODE_ENV === 'production';
|
|
7
|
+
|
|
8
|
+
startServer({
|
|
9
|
+
dbPath: process.env.DB_PATH ?? path.resolve(__dirname, '../data/app.db'),
|
|
10
|
+
schemasDir: path.resolve(__dirname, '../schemas'),
|
|
11
|
+
staticDir: isProd ? path.resolve(__dirname, '../dist/public') : undefined,
|
|
12
|
+
port: process.env.PORT ? parseInt(process.env.PORT, 10) : undefined,
|
|
13
|
+
});
|