@bobbykim/manguito-cms-admin 0.1.0 → 0.2.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.md +21 -0
- package/index.html +13 -0
- package/package.json +19 -7
- package/public/favicon.ico +0 -0
- package/src/App.vue +147 -0
- package/src/adapters/admin.ts +13 -0
- package/src/components/.gitkeep +0 -0
- package/src/components/fields/BooleanToggle.vue +63 -0
- package/src/components/fields/DatePicker.vue +61 -0
- package/src/components/fields/EnumSelect.vue +69 -0
- package/src/components/fields/MediaUpload.vue +445 -0
- package/src/components/fields/NumberInput.vue +66 -0
- package/src/components/fields/ParagraphEmbed.vue +149 -0
- package/src/components/fields/ReferenceSelect.vue +275 -0
- package/src/components/fields/RichTextEditor.vue +293 -0
- package/src/components/fields/TextInput.vue +93 -0
- package/src/components/fields/__tests__/BooleanToggle.test.ts +37 -0
- package/src/components/fields/__tests__/DatePicker.test.ts +37 -0
- package/src/components/fields/__tests__/EnumSelect.test.ts +53 -0
- package/src/components/fields/__tests__/NumberInput.test.ts +64 -0
- package/src/components/fields/__tests__/ReferenceSelect.test.ts +196 -0
- package/src/components/fields/__tests__/RichTextEditor.test.ts +81 -0
- package/src/components/fields/__tests__/TextInput.test.ts +72 -0
- package/src/components/layout/AppShell.vue +33 -0
- package/src/components/layout/Footer.vue +11 -0
- package/src/components/layout/Sidebar.vue +254 -0
- package/src/components/layout/Topbar.vue +46 -0
- package/src/components/shared/ConfirmDialog.vue +38 -0
- package/src/components/shared/MediaSelectModal.vue +273 -0
- package/src/components/shared/Pagination.vue +93 -0
- package/src/components/shared/StatusBadge.vue +14 -0
- package/src/components/shared/ToastContainer.vue +44 -0
- package/src/composables/.gitkeep +0 -0
- package/src/composables/__tests__/useApiClient.test.ts +90 -0
- package/src/composables/__tests__/useFormValidation.test.ts +83 -0
- package/src/composables/__tests__/usePermission.test.ts +116 -0
- package/src/composables/useApiClient.ts +68 -0
- package/src/composables/useDirtyState.ts +28 -0
- package/src/composables/useFormValidation.ts +107 -0
- package/src/composables/useNotification.ts +26 -0
- package/src/composables/usePermission.ts +20 -0
- package/src/composables/useTabIndicator.ts +25 -0
- package/src/env.d.ts +2 -0
- package/src/main.ts +21 -0
- package/src/router/index.ts +174 -0
- package/src/stores/auth.ts +82 -0
- package/src/stores/content.ts +40 -0
- package/src/stores/media.ts +31 -0
- package/src/stores/schema.ts +130 -0
- package/src/stores/taxonomy.ts +40 -0
- package/src/stores/ui.ts +94 -0
- package/src/stores/users.ts +44 -0
- package/src/style.css +46 -0
- package/src/test-utils/server.ts +54 -0
- package/src/types/index.ts +0 -0
- package/src/views/.gitkeep +0 -0
- package/src/views/ChangePasswordView.vue +134 -0
- package/src/views/LoginView.vue +187 -0
- package/src/views/RolesView.vue +84 -0
- package/src/views/content/ContentFormView.vue +548 -0
- package/src/views/content/ContentListView.vue +236 -0
- package/src/views/content/ContentSettingsView.vue +122 -0
- package/src/views/media/MediaDetailView.vue +222 -0
- package/src/views/media/MediaLibraryView.vue +492 -0
- package/src/views/taxonomy/TaxonomyFormView.vue +281 -0
- package/src/views/taxonomy/TaxonomyListView.vue +198 -0
- package/src/views/users/UserFormView.vue +307 -0
- package/src/views/users/UserListView.vue +127 -0
- package/src/vite-env.d.ts +1 -0
- package/vite.config.ts +17 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, computed, onMounted } from 'vue'
|
|
3
|
+
import { useRoute, useRouter } from 'vue-router'
|
|
4
|
+
import { useApiClient } from '../../composables/useApiClient'
|
|
5
|
+
import { usePermission } from '../../composables/usePermission'
|
|
6
|
+
import { useNotification } from '../../composables/useNotification'
|
|
7
|
+
import { useAuthStore } from '../../stores/auth'
|
|
8
|
+
import { useUsersStore } from '../../stores/users'
|
|
9
|
+
import type { UserResponse } from '../../stores/users'
|
|
10
|
+
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
|
|
11
|
+
|
|
12
|
+
const route = useRoute()
|
|
13
|
+
const router = useRouter()
|
|
14
|
+
const api = useApiClient()
|
|
15
|
+
const { can, rolesBelow } = usePermission()
|
|
16
|
+
const notify = useNotification()
|
|
17
|
+
const authStore = useAuthStore()
|
|
18
|
+
const usersStore = useUsersStore()
|
|
19
|
+
|
|
20
|
+
// ── Mode ──────────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
const routeId = computed(() => route.params.id as string | undefined)
|
|
23
|
+
const isEdit = computed(() => !!routeId.value)
|
|
24
|
+
const isOwnProfile = computed(() => authStore.id === routeId.value)
|
|
25
|
+
|
|
26
|
+
// ── Form state ────────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
const email = ref('')
|
|
29
|
+
const selectedRole = ref('')
|
|
30
|
+
const loading = ref(false)
|
|
31
|
+
const saving = ref(false)
|
|
32
|
+
const formError = ref('')
|
|
33
|
+
|
|
34
|
+
// Shown once after successful user creation.
|
|
35
|
+
const temporaryPassword = ref<string | null>(null)
|
|
36
|
+
const copied = ref(false)
|
|
37
|
+
|
|
38
|
+
// Password reset result.
|
|
39
|
+
const resetPassword = ref<string | null>(null)
|
|
40
|
+
const resetting = ref(false)
|
|
41
|
+
|
|
42
|
+
const showDeleteConfirm = ref(false)
|
|
43
|
+
|
|
44
|
+
// ── Load user (edit mode) ─────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
onMounted(async () => {
|
|
47
|
+
if (!isEdit.value || !routeId.value) {
|
|
48
|
+
// Create mode: default role to first available below current user.
|
|
49
|
+
selectedRole.value = rolesBelow()[0]?.name ?? ''
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
loading.value = true
|
|
54
|
+
const cached = usersStore.users.find(u => u.id === routeId.value)
|
|
55
|
+
if (cached) {
|
|
56
|
+
email.value = cached.email
|
|
57
|
+
selectedRole.value = cached.role
|
|
58
|
+
loading.value = false
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const res = await api.get<UserResponse>(`/users/${routeId.value}`)
|
|
63
|
+
loading.value = false
|
|
64
|
+
if (res.ok) {
|
|
65
|
+
email.value = res.data.email
|
|
66
|
+
selectedRole.value = res.data.role
|
|
67
|
+
usersStore.setUser(res.data)
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
// ── Submit ────────────────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
async function onSubmit() {
|
|
74
|
+
formError.value = ''
|
|
75
|
+
saving.value = true
|
|
76
|
+
|
|
77
|
+
if (isEdit.value) {
|
|
78
|
+
// Edit: patch email and role (role hidden for own profile so selectedRole stays unchanged).
|
|
79
|
+
const res = await api.patch<UserResponse>(`/users/${routeId.value!}`, {
|
|
80
|
+
email: email.value.trim(),
|
|
81
|
+
role: selectedRole.value,
|
|
82
|
+
})
|
|
83
|
+
saving.value = false
|
|
84
|
+
if (!res.ok) {
|
|
85
|
+
formError.value = res.error.message
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
usersStore.setUser(res.data)
|
|
89
|
+
notify.success('User updated.')
|
|
90
|
+
} else {
|
|
91
|
+
// Create: POST and show temp password.
|
|
92
|
+
const res = await api.post<UserResponse & { temporary_password?: string }>('/users', {
|
|
93
|
+
email: email.value.trim(),
|
|
94
|
+
role: selectedRole.value,
|
|
95
|
+
})
|
|
96
|
+
saving.value = false
|
|
97
|
+
if (!res.ok) {
|
|
98
|
+
formError.value = res.error.message
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
usersStore.setUser(res.data)
|
|
102
|
+
temporaryPassword.value = res.data.temporary_password ?? null
|
|
103
|
+
notify.success('User created.')
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── Copy to clipboard ─────────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
async function copyPassword(text: string) {
|
|
110
|
+
try {
|
|
111
|
+
await navigator.clipboard.writeText(text)
|
|
112
|
+
copied.value = true
|
|
113
|
+
setTimeout(() => { copied.value = false }, 2000)
|
|
114
|
+
} catch {
|
|
115
|
+
// Fallback: select the input text.
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── Reset password ────────────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
async function doResetPassword() {
|
|
122
|
+
if (!routeId.value) return
|
|
123
|
+
resetting.value = true
|
|
124
|
+
const res = await api.post<{ temporary_password: string }>(`/users/${routeId.value}/reset-password`, {})
|
|
125
|
+
resetting.value = false
|
|
126
|
+
if (res.ok) {
|
|
127
|
+
resetPassword.value = res.data.temporary_password
|
|
128
|
+
copied.value = false
|
|
129
|
+
} else {
|
|
130
|
+
notify.error(res.error.message)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── Delete ────────────────────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
async function doDelete() {
|
|
137
|
+
showDeleteConfirm.value = false
|
|
138
|
+
if (!routeId.value) return
|
|
139
|
+
saving.value = true
|
|
140
|
+
const res = await api.del(`/users/${routeId.value}`)
|
|
141
|
+
saving.value = false
|
|
142
|
+
if (!res.ok) {
|
|
143
|
+
formError.value = (res as { ok: false; error: { message: string } }).error.message
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
usersStore.removeUser(routeId.value)
|
|
147
|
+
void router.push({ name: 'user-list' })
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── Page title ────────────────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
const pageTitle = computed(() => isEdit.value ? (email.value || 'Edit user') : 'New user')
|
|
153
|
+
</script>
|
|
154
|
+
|
|
155
|
+
<template>
|
|
156
|
+
<!-- Loading -->
|
|
157
|
+
<div v-if="loading" class="flex items-center justify-center py-20">
|
|
158
|
+
<span
|
|
159
|
+
class="inline-block h-6 w-6 animate-spin rounded-full border-2 border-indigo-600 border-t-transparent"
|
|
160
|
+
aria-hidden="true"
|
|
161
|
+
/>
|
|
162
|
+
</div>
|
|
163
|
+
|
|
164
|
+
<div v-else class="mx-auto max-w-lg">
|
|
165
|
+
<!-- Header -->
|
|
166
|
+
<div class="mb-6 flex items-start justify-between gap-4">
|
|
167
|
+
<h1 class="text-[26px] font-bold tracking-tight text-ink">{{ pageTitle }}</h1>
|
|
168
|
+
|
|
169
|
+
<div class="flex shrink-0 items-center gap-2">
|
|
170
|
+
<!-- Reset password -->
|
|
171
|
+
<button
|
|
172
|
+
v-if="can('users:edit') && isEdit && !isOwnProfile"
|
|
173
|
+
type="button"
|
|
174
|
+
:disabled="resetting"
|
|
175
|
+
class="rounded-md px-3 py-2 text-[13px] font-semibold text-[#3D3D52] ring-1 ring-inset ring-gray-300 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
|
|
176
|
+
@click="doResetPassword"
|
|
177
|
+
>
|
|
178
|
+
{{ resetting ? 'Resetting…' : 'Reset password' }}
|
|
179
|
+
</button>
|
|
180
|
+
|
|
181
|
+
<!-- Delete -->
|
|
182
|
+
<button
|
|
183
|
+
v-if="can('users:delete') && isEdit && !isOwnProfile"
|
|
184
|
+
type="button"
|
|
185
|
+
:disabled="saving"
|
|
186
|
+
class="rounded-md px-3 py-2 text-sm font-medium text-red-600 ring-1 ring-inset ring-red-300 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-50"
|
|
187
|
+
@click="showDeleteConfirm = true"
|
|
188
|
+
>
|
|
189
|
+
Delete
|
|
190
|
+
</button>
|
|
191
|
+
</div>
|
|
192
|
+
</div>
|
|
193
|
+
|
|
194
|
+
<!-- Temporary password box (shown once after creation) -->
|
|
195
|
+
<div
|
|
196
|
+
v-if="temporaryPassword"
|
|
197
|
+
class="mb-5 rounded-md border border-amber-200 bg-amber-50 p-4"
|
|
198
|
+
role="alert"
|
|
199
|
+
>
|
|
200
|
+
<p class="mb-2 text-sm font-medium text-amber-800">
|
|
201
|
+
User created. Share this temporary password — it will not be shown again.
|
|
202
|
+
</p>
|
|
203
|
+
<div class="flex items-center gap-2">
|
|
204
|
+
<input
|
|
205
|
+
:value="temporaryPassword"
|
|
206
|
+
type="text"
|
|
207
|
+
readonly
|
|
208
|
+
class="flex-1 rounded-md border border-amber-200 bg-white px-3 py-1.5 font-mono text-sm"
|
|
209
|
+
/>
|
|
210
|
+
<button
|
|
211
|
+
type="button"
|
|
212
|
+
class="shrink-0 rounded-md bg-amber-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-amber-700"
|
|
213
|
+
@click="copyPassword(temporaryPassword)"
|
|
214
|
+
>
|
|
215
|
+
{{ copied ? 'Copied!' : 'Copy' }}
|
|
216
|
+
</button>
|
|
217
|
+
</div>
|
|
218
|
+
</div>
|
|
219
|
+
|
|
220
|
+
<!-- Reset password box (shown after password reset) -->
|
|
221
|
+
<div
|
|
222
|
+
v-if="resetPassword"
|
|
223
|
+
class="mb-5 rounded-md border border-amber-200 bg-amber-50 p-4"
|
|
224
|
+
role="alert"
|
|
225
|
+
>
|
|
226
|
+
<p class="mb-2 text-sm font-medium text-amber-800">
|
|
227
|
+
Password reset. Share this temporary password — it will not be shown again.
|
|
228
|
+
</p>
|
|
229
|
+
<div class="flex items-center gap-2">
|
|
230
|
+
<input
|
|
231
|
+
:value="resetPassword"
|
|
232
|
+
type="text"
|
|
233
|
+
readonly
|
|
234
|
+
class="flex-1 rounded-md border border-amber-200 bg-white px-3 py-1.5 font-mono text-sm"
|
|
235
|
+
/>
|
|
236
|
+
<button
|
|
237
|
+
type="button"
|
|
238
|
+
class="shrink-0 rounded-md bg-amber-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-amber-700"
|
|
239
|
+
@click="copyPassword(resetPassword)"
|
|
240
|
+
>
|
|
241
|
+
{{ copied ? 'Copied!' : 'Copy' }}
|
|
242
|
+
</button>
|
|
243
|
+
</div>
|
|
244
|
+
</div>
|
|
245
|
+
|
|
246
|
+
<!-- Form -->
|
|
247
|
+
<form class="space-y-5" novalidate @submit.prevent="onSubmit">
|
|
248
|
+
<!-- Email -->
|
|
249
|
+
<div>
|
|
250
|
+
<label for="user-email" class="block text-[13px] font-semibold text-[#3D3D52]">Email</label>
|
|
251
|
+
<input
|
|
252
|
+
id="user-email"
|
|
253
|
+
v-model="email"
|
|
254
|
+
type="email"
|
|
255
|
+
required
|
|
256
|
+
autocomplete="email"
|
|
257
|
+
:disabled="saving"
|
|
258
|
+
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-400 focus:outline-none focus:ring-2 focus:ring-indigo-200 disabled:cursor-not-allowed disabled:opacity-60"
|
|
259
|
+
/>
|
|
260
|
+
</div>
|
|
261
|
+
|
|
262
|
+
<!-- Role picker — hidden for own profile to prevent self-demotion -->
|
|
263
|
+
<div v-if="!isOwnProfile">
|
|
264
|
+
<label for="user-role" class="block text-[13px] font-semibold text-[#3D3D52]">Role</label>
|
|
265
|
+
<select
|
|
266
|
+
id="user-role"
|
|
267
|
+
v-model="selectedRole"
|
|
268
|
+
:disabled="saving || rolesBelow().length === 0"
|
|
269
|
+
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-400 focus:outline-none focus:ring-2 focus:ring-indigo-200 disabled:cursor-not-allowed disabled:opacity-60"
|
|
270
|
+
>
|
|
271
|
+
<option value="" disabled>Select a role</option>
|
|
272
|
+
<option
|
|
273
|
+
v-for="r in rolesBelow()"
|
|
274
|
+
:key="r.name"
|
|
275
|
+
:value="r.name"
|
|
276
|
+
>
|
|
277
|
+
{{ r.label }}
|
|
278
|
+
</option>
|
|
279
|
+
</select>
|
|
280
|
+
<p v-if="rolesBelow().length === 0" class="mt-1 text-xs text-gray-400">
|
|
281
|
+
No assignable roles available.
|
|
282
|
+
</p>
|
|
283
|
+
</div>
|
|
284
|
+
|
|
285
|
+
<!-- Form error -->
|
|
286
|
+
<p v-if="formError" class="text-sm text-red-600" role="alert">{{ formError }}</p>
|
|
287
|
+
|
|
288
|
+
<!-- Submit -->
|
|
289
|
+
<button
|
|
290
|
+
v-if="isEdit ? can('users:edit') : can('users:create')"
|
|
291
|
+
type="submit"
|
|
292
|
+
:disabled="saving || !email.trim() || (!isOwnProfile && !selectedRole)"
|
|
293
|
+
class="w-full rounded-md bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-50"
|
|
294
|
+
>
|
|
295
|
+
{{ saving ? 'Saving…' : isEdit ? 'Save changes' : 'Create user' }}
|
|
296
|
+
</button>
|
|
297
|
+
</form>
|
|
298
|
+
|
|
299
|
+
<!-- Delete confirmation -->
|
|
300
|
+
<ConfirmDialog
|
|
301
|
+
v-if="showDeleteConfirm"
|
|
302
|
+
message="Are you sure you want to delete this user? This action cannot be undone."
|
|
303
|
+
:on-confirm="doDelete"
|
|
304
|
+
:on-cancel="() => (showDeleteConfirm = false)"
|
|
305
|
+
/>
|
|
306
|
+
</div>
|
|
307
|
+
</template>
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { onMounted, ref, computed } from 'vue'
|
|
3
|
+
import { useRouter } from 'vue-router'
|
|
4
|
+
import { useApiClient } from '../../composables/useApiClient'
|
|
5
|
+
import { usePermission } from '../../composables/usePermission'
|
|
6
|
+
import { useUsersStore } from '../../stores/users'
|
|
7
|
+
import type { UserResponse } from '../../stores/users'
|
|
8
|
+
|
|
9
|
+
const router = useRouter()
|
|
10
|
+
const api = useApiClient()
|
|
11
|
+
const { can } = usePermission()
|
|
12
|
+
const usersStore = useUsersStore()
|
|
13
|
+
|
|
14
|
+
const search = ref('')
|
|
15
|
+
|
|
16
|
+
// Full list is already loaded eagerly (no pagination on this endpoint) — filter client-side.
|
|
17
|
+
const filteredUsers = computed(() => {
|
|
18
|
+
const term = search.value.trim().toLowerCase()
|
|
19
|
+
if (term === '') return usersStore.users
|
|
20
|
+
return usersStore.users.filter(u => u.email.toLowerCase().includes(term))
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
function formatDate(val: string | unknown): string {
|
|
24
|
+
if (typeof val !== 'string' || !val) return '—'
|
|
25
|
+
return new Date(val).toLocaleDateString(undefined, {
|
|
26
|
+
year: 'numeric', month: 'short', day: 'numeric',
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
onMounted(async () => {
|
|
31
|
+
if (usersStore.users.length > 0) return // already loaded
|
|
32
|
+
usersStore.loading = true
|
|
33
|
+
const res = await api.get<UserResponse[]>('/users')
|
|
34
|
+
usersStore.loading = false
|
|
35
|
+
if (res.ok) usersStore.setUsers(res.data)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
function goToEdit(user: UserResponse) {
|
|
39
|
+
void router.push({ name: 'user-edit', params: { id: user.id } })
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function goToCreate() {
|
|
43
|
+
void router.push({ name: 'user-new' })
|
|
44
|
+
}
|
|
45
|
+
</script>
|
|
46
|
+
|
|
47
|
+
<template>
|
|
48
|
+
<div>
|
|
49
|
+
<!-- Header -->
|
|
50
|
+
<div class="mb-5.5 flex flex-wrap items-center justify-between gap-4">
|
|
51
|
+
<h1 class="text-[26px] font-bold tracking-tight text-ink">Users</h1>
|
|
52
|
+
<button
|
|
53
|
+
v-if="can('users:create')"
|
|
54
|
+
type="button"
|
|
55
|
+
class="inline-flex items-center gap-1.75 rounded-[11px] bg-indigo-600 px-4 py-2.5 text-[13.5px] font-semibold text-white shadow-[0_3px_10px_rgba(91,87,232,0.3)] transition-all hover:bg-indigo-700 hover:shadow-[0_6px_18px_rgba(91,87,232,0.4)]"
|
|
56
|
+
@click="goToCreate"
|
|
57
|
+
>
|
|
58
|
+
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14" /><path d="M5 12h14" /></svg>
|
|
59
|
+
New user
|
|
60
|
+
</button>
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
<!-- Loading -->
|
|
64
|
+
<div v-if="usersStore.loading" class="space-y-2">
|
|
65
|
+
<div v-for="n in 5" :key="n" class="h-12 animate-pulse rounded-[10px] bg-gray-100" />
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
<!-- Search -->
|
|
69
|
+
<div
|
|
70
|
+
v-else-if="usersStore.users.length > 0"
|
|
71
|
+
class="mb-4 flex h-9.5 items-center gap-2 rounded-[10px] border border-card-border bg-[#FBFBFD] px-2.75 text-[13px] transition-colors focus-within:border-indigo-400"
|
|
72
|
+
>
|
|
73
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" class="shrink-0 text-faint">
|
|
74
|
+
<path d="M21 21l-4.3-4.3" /><path d="M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16z" />
|
|
75
|
+
</svg>
|
|
76
|
+
<input
|
|
77
|
+
v-model="search"
|
|
78
|
+
type="text"
|
|
79
|
+
placeholder="Search users…"
|
|
80
|
+
class="w-full bg-transparent text-ink outline-none placeholder:text-faint"
|
|
81
|
+
/>
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
<!-- Empty -->
|
|
85
|
+
<div
|
|
86
|
+
v-if="!usersStore.loading && usersStore.users.length === 0"
|
|
87
|
+
class="rounded-2xl border border-dashed border-gray-300 p-12 text-center text-sm text-muted"
|
|
88
|
+
>
|
|
89
|
+
No users found.
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
<!-- No search results -->
|
|
93
|
+
<div
|
|
94
|
+
v-else-if="!usersStore.loading && filteredUsers.length === 0"
|
|
95
|
+
class="rounded-2xl border border-dashed border-gray-300 p-12 text-center text-sm text-muted"
|
|
96
|
+
>
|
|
97
|
+
No results for “{{ search.trim() }}”.
|
|
98
|
+
</div>
|
|
99
|
+
|
|
100
|
+
<!-- Table -->
|
|
101
|
+
<div v-else-if="!usersStore.loading" class="overflow-hidden rounded-2xl border border-card-border bg-white shadow-[0_1px_2px_rgba(24,24,48,0.04),0_10px_28px_rgba(24,24,48,0.04)]">
|
|
102
|
+
<table class="w-full text-sm">
|
|
103
|
+
<thead class="text-[11.5px] font-bold uppercase tracking-[.06em] text-faint">
|
|
104
|
+
<tr class="border-b border-divider">
|
|
105
|
+
<th class="px-5.5 py-3.5 text-left">Email</th>
|
|
106
|
+
<th class="px-5.5 py-3.5 text-left">Role</th>
|
|
107
|
+
<th class="px-5.5 py-3.5 text-left">Created</th>
|
|
108
|
+
</tr>
|
|
109
|
+
</thead>
|
|
110
|
+
<tbody class="divide-y divide-[#F4F3F9]">
|
|
111
|
+
<tr
|
|
112
|
+
v-for="user in filteredUsers"
|
|
113
|
+
:key="user.id"
|
|
114
|
+
class="cursor-pointer transition-colors hover:bg-[#FAFAFE]"
|
|
115
|
+
@click="goToEdit(user)"
|
|
116
|
+
>
|
|
117
|
+
<td class="px-5.5 py-4 text-[14.5px] font-semibold text-ink">{{ user.email }}</td>
|
|
118
|
+
<td class="px-5.5 py-4">
|
|
119
|
+
<span class="rounded-[7px] bg-indigo-50 px-2.5 py-1 font-mono text-xs font-medium text-indigo-600">{{ user.role }}</span>
|
|
120
|
+
</td>
|
|
121
|
+
<td class="px-5.5 py-4 text-[13px] text-faint">{{ formatDate(user.created_at) }}</td>
|
|
122
|
+
</tr>
|
|
123
|
+
</tbody>
|
|
124
|
+
</table>
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
</template>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
package/vite.config.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { defineConfig } from 'vite'
|
|
2
|
+
import vue from '@vitejs/plugin-vue'
|
|
3
|
+
import tailwindcss from '@tailwindcss/vite'
|
|
4
|
+
|
|
5
|
+
export default defineConfig({
|
|
6
|
+
plugins: [vue(), tailwindcss()],
|
|
7
|
+
define: {
|
|
8
|
+
__ADMIN_PREFIX__: JSON.stringify('/admin'),
|
|
9
|
+
__API_PREFIX__: JSON.stringify('/api'),
|
|
10
|
+
},
|
|
11
|
+
build: {
|
|
12
|
+
// No inline module-preload polyfill — keeps script-src 'self' working
|
|
13
|
+
// without an inline-script exception. Modern browsers preload modules
|
|
14
|
+
// natively via <link rel="modulepreload">.
|
|
15
|
+
modulePreload: { polyfill: false },
|
|
16
|
+
},
|
|
17
|
+
})
|