create-nara 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/README.md +17 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +50 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +3 -0
- package/dist/template.d.ts +8 -0
- package/dist/template.js +68 -0
- package/package.json +28 -0
- package/templates/base/.env.example +3 -0
- package/templates/base/tsconfig.json +14 -0
- package/templates/minimal/routes/web.ts +11 -0
- package/templates/minimal/server.ts +10 -0
- package/templates/svelte/resources/js/app.ts +12 -0
- package/templates/svelte/resources/js/components/DarkModeToggle.svelte +67 -0
- package/templates/svelte/resources/js/components/Header.svelte +240 -0
- package/templates/svelte/resources/js/components/NaraIcon.svelte +3 -0
- package/templates/svelte/resources/js/components/Pagination.svelte +55 -0
- package/templates/svelte/resources/js/components/UserModal.svelte +234 -0
- package/templates/svelte/resources/js/components/helper.ts +300 -0
- package/templates/svelte/resources/js/pages/auth/forgot-password.svelte +97 -0
- package/templates/svelte/resources/js/pages/auth/login.svelte +138 -0
- package/templates/svelte/resources/js/pages/auth/register.svelte +176 -0
- package/templates/svelte/resources/js/pages/auth/reset-password.svelte +106 -0
- package/templates/svelte/resources/js/pages/dashboard.svelte +224 -0
- package/templates/svelte/resources/js/pages/landing.svelte +446 -0
- package/templates/svelte/resources/js/pages/profile.svelte +368 -0
- package/templates/svelte/resources/js/pages/users.svelte +260 -0
- package/templates/svelte/resources/views/inertia.html +12 -0
- package/templates/svelte/routes/web.ts +17 -0
- package/templates/svelte/server.ts +12 -0
- package/templates/svelte/vite.config.ts +19 -0
- package/templates/vue/resources/js/app.ts +14 -0
- package/templates/vue/resources/js/components/DarkModeToggle.vue +81 -0
- package/templates/vue/resources/js/components/Header.vue +251 -0
- package/templates/vue/resources/js/components/NaraIcon.vue +5 -0
- package/templates/vue/resources/js/components/Pagination.vue +71 -0
- package/templates/vue/resources/js/components/UserModal.vue +276 -0
- package/templates/vue/resources/js/components/index.ts +5 -0
- package/templates/vue/resources/js/pages/auth/forgot-password.vue +105 -0
- package/templates/vue/resources/js/pages/auth/login.vue +142 -0
- package/templates/vue/resources/js/pages/auth/register.vue +183 -0
- package/templates/vue/resources/js/pages/auth/reset-password.vue +115 -0
- package/templates/vue/resources/js/pages/dashboard.vue +233 -0
- package/templates/vue/resources/js/pages/landing.vue +358 -0
- package/templates/vue/resources/js/pages/profile.vue +370 -0
- package/templates/vue/resources/js/pages/users.vue +264 -0
- package/templates/vue/resources/views/inertia.html +12 -0
- package/templates/vue/routes/web.ts +17 -0
- package/templates/vue/server.ts +12 -0
- package/templates/vue/vite.config.ts +19 -0
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref } from 'vue';
|
|
3
|
+
import axios from "axios";
|
|
4
|
+
import Header from "../components/Header.vue";
|
|
5
|
+
import { api, Toast } from "../components/helper";
|
|
6
|
+
|
|
7
|
+
interface User {
|
|
8
|
+
id: string;
|
|
9
|
+
name: string;
|
|
10
|
+
email: string;
|
|
11
|
+
phone?: string;
|
|
12
|
+
avatar?: string;
|
|
13
|
+
is_admin: boolean;
|
|
14
|
+
is_verified: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const props = defineProps<{
|
|
18
|
+
user: User;
|
|
19
|
+
}>();
|
|
20
|
+
|
|
21
|
+
const current_password = ref('');
|
|
22
|
+
const new_password = ref('');
|
|
23
|
+
const confirm_password = ref('');
|
|
24
|
+
const isLoading = ref(false);
|
|
25
|
+
const previewUrl = ref(props.user.avatar || null);
|
|
26
|
+
|
|
27
|
+
function handleAvatarChange(event: Event): void {
|
|
28
|
+
const target = event.target as HTMLInputElement;
|
|
29
|
+
const file = target.files?.[0];
|
|
30
|
+
if (file) {
|
|
31
|
+
const formData = new FormData();
|
|
32
|
+
formData.append("file", file);
|
|
33
|
+
isLoading.value = true;
|
|
34
|
+
axios
|
|
35
|
+
.post("/assets/avatar", formData)
|
|
36
|
+
.then((response) => {
|
|
37
|
+
setTimeout(() => {
|
|
38
|
+
isLoading.value = false;
|
|
39
|
+
previewUrl.value = response.data + "?v=" + Date.now();
|
|
40
|
+
}, 500);
|
|
41
|
+
props.user.avatar = response.data + "?v=" + Date.now();
|
|
42
|
+
Toast("Avatar berhasil diupload", "success");
|
|
43
|
+
})
|
|
44
|
+
.catch(() => {
|
|
45
|
+
isLoading.value = false;
|
|
46
|
+
Toast("Gagal mengupload avatar", "error");
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function changeProfile(): Promise<void> {
|
|
52
|
+
isLoading.value = true;
|
|
53
|
+
await api(() => axios.post("/change-profile", props.user));
|
|
54
|
+
isLoading.value = false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function changePassword(): Promise<void> {
|
|
58
|
+
if (new_password.value != confirm_password.value) {
|
|
59
|
+
Toast("Password tidak cocok", "error");
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (!current_password.value || !new_password.value || !confirm_password.value) {
|
|
64
|
+
Toast("Mohon isi semua field", "error");
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
isLoading.value = true;
|
|
69
|
+
const result = await api(() => axios.post("/change-password", {
|
|
70
|
+
current_password: current_password.value,
|
|
71
|
+
new_password: new_password.value,
|
|
72
|
+
}));
|
|
73
|
+
|
|
74
|
+
if (result.success) {
|
|
75
|
+
current_password.value = "";
|
|
76
|
+
new_password.value = "";
|
|
77
|
+
confirm_password.value = "";
|
|
78
|
+
}
|
|
79
|
+
isLoading.value = false;
|
|
80
|
+
}
|
|
81
|
+
</script>
|
|
82
|
+
|
|
83
|
+
<template>
|
|
84
|
+
<Header group="profile" />
|
|
85
|
+
|
|
86
|
+
<div class="min-h-screen bg-surface-light dark:bg-surface-dark text-slate-900 dark:text-slate-100 transition-colors duration-500 overflow-x-hidden selection:bg-primary-400 selection:text-black">
|
|
87
|
+
|
|
88
|
+
<!-- Background Effects -->
|
|
89
|
+
<div class="fixed inset-0 pointer-events-none z-0">
|
|
90
|
+
<div class="absolute top-0 right-0 w-[600px] h-[600px] bg-info-500/5 rounded-full blur-3xl -mr-64 -mt-64"></div>
|
|
91
|
+
<div class="absolute bottom-0 left-0 w-[800px] h-[800px] bg-primary-500/5 rounded-full blur-3xl -ml-96 -mb-96"></div>
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
<section class="relative px-6 sm:px-12 lg:px-24 pt-24 pb-20">
|
|
95
|
+
<div class="max-w-[90rem] mx-auto">
|
|
96
|
+
|
|
97
|
+
<!-- Hero Section -->
|
|
98
|
+
<div class="grid grid-cols-1 lg:grid-cols-12 gap-12 lg:gap-20 mb-20">
|
|
99
|
+
|
|
100
|
+
<!-- Left: Giant Profile Card -->
|
|
101
|
+
<div class="lg:col-span-5">
|
|
102
|
+
<p class="text-xs font-bold uppercase tracking-[0.3em] text-info-600 dark:text-info-400 mb-6">
|
|
103
|
+
Account
|
|
104
|
+
</p>
|
|
105
|
+
<h1 class="text-[8vw] sm:text-[5vw] lg:text-[3.5vw] leading-[0.9] font-bold tracking-tighter mb-8">
|
|
106
|
+
YOUR
|
|
107
|
+
<span class="block text-transparent bg-clip-text bg-gradient-to-r from-info-500 to-primary-400">
|
|
108
|
+
PROFILE
|
|
109
|
+
</span>
|
|
110
|
+
</h1>
|
|
111
|
+
|
|
112
|
+
<!-- Profile Card -->
|
|
113
|
+
<div class="relative">
|
|
114
|
+
<div class="absolute -inset-1 bg-gradient-to-r from-info-500/20 to-primary-500/20 rounded-3xl blur-xl"></div>
|
|
115
|
+
<div class="relative bg-surface-card-light dark:bg-surface-card-dark border border-slate-200 dark:border-white/5 rounded-3xl p-8 overflow-hidden">
|
|
116
|
+
<!-- Decorative -->
|
|
117
|
+
<div class="absolute top-0 right-0 p-32 bg-info-500/5 rounded-full blur-3xl -mr-16 -mt-16"></div>
|
|
118
|
+
|
|
119
|
+
<div class="relative z-10">
|
|
120
|
+
<!-- Avatar Section -->
|
|
121
|
+
<div class="flex items-center gap-6 mb-8">
|
|
122
|
+
<div class="relative group">
|
|
123
|
+
<div class="w-24 h-24 rounded-2xl bg-gradient-to-br from-info-500 to-primary-500 p-0.5">
|
|
124
|
+
<div class="w-full h-full rounded-2xl bg-surface-card-light dark:bg-surface-card-dark overflow-hidden flex items-center justify-center">
|
|
125
|
+
<img
|
|
126
|
+
v-if="previewUrl"
|
|
127
|
+
:src="previewUrl"
|
|
128
|
+
alt="Profile"
|
|
129
|
+
class="w-full h-full object-cover"
|
|
130
|
+
/>
|
|
131
|
+
<span v-else class="text-3xl font-bold text-transparent bg-clip-text bg-gradient-to-br from-info-500 to-primary-500">
|
|
132
|
+
{{ user.name.charAt(0).toUpperCase() }}
|
|
133
|
+
</span>
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
<label
|
|
137
|
+
class="absolute -bottom-2 -right-2 w-8 h-8 bg-slate-900 dark:bg-white text-white dark:text-black rounded-full flex items-center justify-center cursor-pointer hover:scale-110 transition-transform"
|
|
138
|
+
>
|
|
139
|
+
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
140
|
+
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z" stroke-linecap="round" stroke-linejoin="round"/>
|
|
141
|
+
<circle cx="12" cy="13" r="4" stroke-linecap="round" stroke-linejoin="round"/>
|
|
142
|
+
</svg>
|
|
143
|
+
<input
|
|
144
|
+
type="file"
|
|
145
|
+
accept="image/*"
|
|
146
|
+
@change="handleAvatarChange"
|
|
147
|
+
class="hidden"
|
|
148
|
+
/>
|
|
149
|
+
</label>
|
|
150
|
+
</div>
|
|
151
|
+
|
|
152
|
+
<div>
|
|
153
|
+
<h2 class="text-2xl font-bold tracking-tight mb-1">{{ user.name }}</h2>
|
|
154
|
+
<p class="text-sm text-slate-500 dark:text-slate-400">{{ user.email }}</p>
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
|
|
158
|
+
<!-- Status Badges -->
|
|
159
|
+
<div class="flex flex-wrap gap-2 mb-6">
|
|
160
|
+
<span class="px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider rounded-full" :class="user.is_admin
|
|
161
|
+
? 'bg-accent-500/10 text-accent-600 dark:text-accent-400 border border-accent-500/20'
|
|
162
|
+
: 'bg-slate-200 dark:bg-slate-800 text-slate-600 dark:text-slate-400 border border-slate-300 dark:border-slate-700'">
|
|
163
|
+
{{ user.is_admin ? 'Administrator' : 'Standard User' }}
|
|
164
|
+
</span>
|
|
165
|
+
<span class="px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider rounded-full" :class="user.is_verified
|
|
166
|
+
? 'bg-primary-500/10 text-primary-600 dark:text-primary-400 border border-primary-500/20'
|
|
167
|
+
: 'bg-warning-500/10 text-warning-600 dark:text-warning-400 border border-warning-500/20'">
|
|
168
|
+
{{ user.is_verified ? 'Verified' : 'Unverified' }}
|
|
169
|
+
</span>
|
|
170
|
+
</div>
|
|
171
|
+
|
|
172
|
+
<!-- Quick Info -->
|
|
173
|
+
<div class="space-y-3">
|
|
174
|
+
<div class="flex items-center gap-3 text-sm">
|
|
175
|
+
<svg class="w-4 h-4 text-slate-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
176
|
+
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z" stroke-linecap="round" stroke-linejoin="round"/>
|
|
177
|
+
</svg>
|
|
178
|
+
<span class="font-mono text-slate-600 dark:text-slate-300">{{ user.phone || '—' }}</span>
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
</div>
|
|
182
|
+
</div>
|
|
183
|
+
</div>
|
|
184
|
+
|
|
185
|
+
<!-- Security Note -->
|
|
186
|
+
<div class="mt-6 p-4 border border-slate-200 dark:border-white/5 rounded-2xl">
|
|
187
|
+
<div class="flex items-start gap-3">
|
|
188
|
+
<div class="w-8 h-8 rounded-full bg-primary-500/10 flex items-center justify-center flex-shrink-0">
|
|
189
|
+
<svg class="w-4 h-4 text-primary-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
190
|
+
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" stroke-linecap="round" stroke-linejoin="round"/>
|
|
191
|
+
</svg>
|
|
192
|
+
</div>
|
|
193
|
+
<div>
|
|
194
|
+
<p class="text-xs font-bold uppercase tracking-wider text-slate-500 mb-1">Secure Storage</p>
|
|
195
|
+
<p class="text-xs text-slate-500 dark:text-slate-400">Your data is encrypted and stored securely on Nara's backend infrastructure.</p>
|
|
196
|
+
</div>
|
|
197
|
+
</div>
|
|
198
|
+
</div>
|
|
199
|
+
</div>
|
|
200
|
+
|
|
201
|
+
<!-- Right: Forms -->
|
|
202
|
+
<div class="lg:col-span-7 space-y-8">
|
|
203
|
+
|
|
204
|
+
<!-- Personal Info Form -->
|
|
205
|
+
<div class="relative bg-surface-card-light dark:bg-surface-card-dark border border-slate-200 dark:border-white/5 hover:border-info-500/30 rounded-3xl p-8 transition-colors duration-500">
|
|
206
|
+
<div class="flex items-center justify-between mb-8">
|
|
207
|
+
<div>
|
|
208
|
+
<span class="text-[10px] font-bold uppercase tracking-[0.2em] text-info-600 dark:text-info-400">Settings</span>
|
|
209
|
+
<h3 class="text-xl font-bold tracking-tight mt-1">Personal Information</h3>
|
|
210
|
+
</div>
|
|
211
|
+
<div class="w-10 h-10 rounded-full bg-info-500/10 flex items-center justify-center">
|
|
212
|
+
<svg class="w-5 h-5 text-info-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
213
|
+
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
214
|
+
<circle cx="12" cy="7" r="4" stroke-linecap="round" stroke-linejoin="round"/>
|
|
215
|
+
</svg>
|
|
216
|
+
</div>
|
|
217
|
+
</div>
|
|
218
|
+
|
|
219
|
+
<form @submit.prevent="changeProfile" class="space-y-6">
|
|
220
|
+
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
|
221
|
+
<div class="space-y-2">
|
|
222
|
+
<label for="name" class="block text-[10px] font-bold uppercase tracking-[0.15em] text-slate-500">
|
|
223
|
+
Full Name
|
|
224
|
+
</label>
|
|
225
|
+
<input
|
|
226
|
+
v-model="user.name"
|
|
227
|
+
type="text"
|
|
228
|
+
id="name"
|
|
229
|
+
class="w-full px-4 py-3 rounded-xl bg-white dark:bg-black/50 border border-slate-200 dark:border-slate-700 text-sm focus:border-info-500 focus:ring-2 focus:ring-info-500/20 outline-none transition-all"
|
|
230
|
+
placeholder="Your full name"
|
|
231
|
+
/>
|
|
232
|
+
</div>
|
|
233
|
+
|
|
234
|
+
<div class="space-y-2">
|
|
235
|
+
<label for="phone" class="block text-[10px] font-bold uppercase tracking-[0.15em] text-slate-500">
|
|
236
|
+
Phone Number
|
|
237
|
+
</label>
|
|
238
|
+
<input
|
|
239
|
+
v-model="user.phone"
|
|
240
|
+
type="text"
|
|
241
|
+
id="phone"
|
|
242
|
+
class="w-full px-4 py-3 rounded-xl bg-white dark:bg-black/50 border border-slate-200 dark:border-slate-700 text-sm focus:border-info-500 focus:ring-2 focus:ring-info-500/20 outline-none transition-all"
|
|
243
|
+
placeholder="+62 xxx xxxx xxxx"
|
|
244
|
+
/>
|
|
245
|
+
</div>
|
|
246
|
+
</div>
|
|
247
|
+
|
|
248
|
+
<div class="space-y-2">
|
|
249
|
+
<label for="email" class="block text-[10px] font-bold uppercase tracking-[0.15em] text-slate-500">
|
|
250
|
+
Email Address
|
|
251
|
+
</label>
|
|
252
|
+
<input
|
|
253
|
+
v-model="user.email"
|
|
254
|
+
type="email"
|
|
255
|
+
id="email"
|
|
256
|
+
class="w-full px-4 py-3 rounded-xl bg-white dark:bg-black/50 border border-slate-200 dark:border-slate-700 text-sm focus:border-info-500 focus:ring-2 focus:ring-info-500/20 outline-none transition-all"
|
|
257
|
+
placeholder="you@example.com"
|
|
258
|
+
/>
|
|
259
|
+
</div>
|
|
260
|
+
|
|
261
|
+
<div class="flex justify-end pt-4">
|
|
262
|
+
<button
|
|
263
|
+
type="submit"
|
|
264
|
+
:disabled="isLoading"
|
|
265
|
+
class="group relative px-6 py-3 bg-slate-900 dark:bg-white text-white dark:text-black text-xs font-bold uppercase tracking-wider rounded-full overflow-hidden hover:scale-105 transition-transform disabled:opacity-50"
|
|
266
|
+
>
|
|
267
|
+
<span class="relative z-10 flex items-center gap-2">
|
|
268
|
+
<template v-if="isLoading">
|
|
269
|
+
<svg class="animate-spin w-4 h-4" viewBox="0 0 24 24" fill="none">
|
|
270
|
+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
271
|
+
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
272
|
+
</svg>
|
|
273
|
+
Saving...
|
|
274
|
+
</template>
|
|
275
|
+
<template v-else>
|
|
276
|
+
Save Changes
|
|
277
|
+
</template>
|
|
278
|
+
</span>
|
|
279
|
+
<div class="absolute inset-0 bg-info-500 transform translate-y-full group-hover:translate-y-0 transition-transform duration-300"></div>
|
|
280
|
+
</button>
|
|
281
|
+
</div>
|
|
282
|
+
</form>
|
|
283
|
+
</div>
|
|
284
|
+
|
|
285
|
+
<!-- Password Form -->
|
|
286
|
+
<div class="relative bg-surface-card-light dark:bg-surface-card-dark border border-slate-200 dark:border-white/5 hover:border-warning-500/30 rounded-3xl p-8 transition-colors duration-500">
|
|
287
|
+
<div class="flex items-center justify-between mb-8">
|
|
288
|
+
<div>
|
|
289
|
+
<span class="text-[10px] font-bold uppercase tracking-[0.2em] text-warning-600 dark:text-warning-400">Security</span>
|
|
290
|
+
<h3 class="text-xl font-bold tracking-tight mt-1">Change Password</h3>
|
|
291
|
+
</div>
|
|
292
|
+
<div class="w-10 h-10 rounded-full bg-warning-500/10 flex items-center justify-center">
|
|
293
|
+
<svg class="w-5 h-5 text-warning-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
294
|
+
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
295
|
+
<path d="M7 11V7a5 5 0 0 1 10 0v4" stroke-linecap="round" stroke-linejoin="round"/>
|
|
296
|
+
</svg>
|
|
297
|
+
</div>
|
|
298
|
+
</div>
|
|
299
|
+
|
|
300
|
+
<form @submit.prevent="changePassword" class="space-y-6">
|
|
301
|
+
<div class="space-y-2">
|
|
302
|
+
<label for="current_password" class="block text-[10px] font-bold uppercase tracking-[0.15em] text-slate-500">
|
|
303
|
+
Current Password
|
|
304
|
+
</label>
|
|
305
|
+
<input
|
|
306
|
+
v-model="current_password"
|
|
307
|
+
type="password"
|
|
308
|
+
id="current_password"
|
|
309
|
+
class="w-full px-4 py-3 rounded-xl bg-white dark:bg-black/50 border border-slate-200 dark:border-slate-700 text-sm focus:border-warning-500 focus:ring-2 focus:ring-warning-500/20 outline-none transition-all"
|
|
310
|
+
placeholder="••••••••"
|
|
311
|
+
/>
|
|
312
|
+
</div>
|
|
313
|
+
|
|
314
|
+
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
|
315
|
+
<div class="space-y-2">
|
|
316
|
+
<label for="new_password" class="block text-[10px] font-bold uppercase tracking-[0.15em] text-slate-500">
|
|
317
|
+
New Password
|
|
318
|
+
</label>
|
|
319
|
+
<input
|
|
320
|
+
v-model="new_password"
|
|
321
|
+
type="password"
|
|
322
|
+
id="new_password"
|
|
323
|
+
class="w-full px-4 py-3 rounded-xl bg-white dark:bg-black/50 border border-slate-200 dark:border-slate-700 text-sm focus:border-warning-500 focus:ring-2 focus:ring-warning-500/20 outline-none transition-all"
|
|
324
|
+
placeholder="••••••••"
|
|
325
|
+
/>
|
|
326
|
+
</div>
|
|
327
|
+
|
|
328
|
+
<div class="space-y-2">
|
|
329
|
+
<label for="confirm_password" class="block text-[10px] font-bold uppercase tracking-[0.15em] text-slate-500">
|
|
330
|
+
Confirm Password
|
|
331
|
+
</label>
|
|
332
|
+
<input
|
|
333
|
+
v-model="confirm_password"
|
|
334
|
+
type="password"
|
|
335
|
+
id="confirm_password"
|
|
336
|
+
class="w-full px-4 py-3 rounded-xl bg-white dark:bg-black/50 border border-slate-200 dark:border-slate-700 text-sm focus:border-warning-500 focus:ring-2 focus:ring-warning-500/20 outline-none transition-all"
|
|
337
|
+
placeholder="••••••••"
|
|
338
|
+
/>
|
|
339
|
+
</div>
|
|
340
|
+
</div>
|
|
341
|
+
|
|
342
|
+
<div class="flex justify-end pt-4">
|
|
343
|
+
<button
|
|
344
|
+
type="submit"
|
|
345
|
+
:disabled="isLoading"
|
|
346
|
+
class="px-6 py-3 text-xs font-bold uppercase tracking-wider border border-slate-200 dark:border-slate-700 rounded-full hover:border-warning-500 hover:text-warning-500 transition-colors disabled:opacity-50"
|
|
347
|
+
>
|
|
348
|
+
<span v-if="isLoading" class="flex items-center gap-2">
|
|
349
|
+
<svg class="animate-spin w-4 h-4" viewBox="0 0 24 24" fill="none">
|
|
350
|
+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
351
|
+
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
352
|
+
</svg>
|
|
353
|
+
Updating...
|
|
354
|
+
</span>
|
|
355
|
+
<span v-else>
|
|
356
|
+
Update Password
|
|
357
|
+
</span>
|
|
358
|
+
</button>
|
|
359
|
+
</div>
|
|
360
|
+
</form>
|
|
361
|
+
</div>
|
|
362
|
+
|
|
363
|
+
</div>
|
|
364
|
+
</div>
|
|
365
|
+
|
|
366
|
+
</div>
|
|
367
|
+
</section>
|
|
368
|
+
|
|
369
|
+
</div>
|
|
370
|
+
</template>
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, computed } from 'vue';
|
|
3
|
+
import { usePage, router } from '@inertiajs/vue3';
|
|
4
|
+
import Header from '../components/Header.vue';
|
|
5
|
+
import UserModal from '../components/UserModal.vue';
|
|
6
|
+
import Pagination from '../components/Pagination.vue';
|
|
7
|
+
import axios from 'axios';
|
|
8
|
+
import { api, Toast } from '../components/helper';
|
|
9
|
+
import type { User, UserForm, PaginationMeta } from '../types';
|
|
10
|
+
import { createEmptyUserForm, userToForm } from '../types';
|
|
11
|
+
|
|
12
|
+
const props = defineProps<{
|
|
13
|
+
users: User[];
|
|
14
|
+
total: number;
|
|
15
|
+
page: number;
|
|
16
|
+
limit: number;
|
|
17
|
+
totalPages: number;
|
|
18
|
+
hasNext: boolean;
|
|
19
|
+
hasPrev: boolean;
|
|
20
|
+
search: string;
|
|
21
|
+
filter: string;
|
|
22
|
+
}>();
|
|
23
|
+
|
|
24
|
+
const pageData = usePage();
|
|
25
|
+
const currentUser = computed(() => pageData.props.user as User | undefined);
|
|
26
|
+
|
|
27
|
+
const paginationMeta = computed(() => ({
|
|
28
|
+
total: props.total,
|
|
29
|
+
page: props.page,
|
|
30
|
+
limit: props.limit,
|
|
31
|
+
totalPages: props.totalPages,
|
|
32
|
+
hasNext: props.hasNext,
|
|
33
|
+
hasPrev: props.hasPrev
|
|
34
|
+
}) as PaginationMeta);
|
|
35
|
+
|
|
36
|
+
const showUserModal = ref(false);
|
|
37
|
+
const isSubmitting = ref(false);
|
|
38
|
+
const mode = ref<'create' | 'edit'>('create');
|
|
39
|
+
const form = ref<UserForm>(createEmptyUserForm());
|
|
40
|
+
|
|
41
|
+
function openCreateUser(): void {
|
|
42
|
+
mode.value = 'create';
|
|
43
|
+
form.value = createEmptyUserForm();
|
|
44
|
+
showUserModal.value = true;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function openEditUser(userItem: User): void {
|
|
48
|
+
mode.value = 'edit';
|
|
49
|
+
form.value = userToForm(userItem);
|
|
50
|
+
showUserModal.value = true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function closeUserModal(): void {
|
|
54
|
+
showUserModal.value = false;
|
|
55
|
+
form.value = createEmptyUserForm();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function handleSubmit(formData: UserForm): Promise<void> {
|
|
59
|
+
if (!formData.name || !formData.email) {
|
|
60
|
+
Toast('Nama dan email wajib diisi', 'error');
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
isSubmitting.value = true;
|
|
65
|
+
|
|
66
|
+
const payload = {
|
|
67
|
+
name: formData.name,
|
|
68
|
+
email: formData.email,
|
|
69
|
+
phone: formData.phone || null,
|
|
70
|
+
is_admin: formData.is_admin,
|
|
71
|
+
is_verified: formData.is_verified,
|
|
72
|
+
password: formData.password || undefined
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const result = mode.value === 'create'
|
|
76
|
+
? await api(() => axios.post('/users', payload))
|
|
77
|
+
: await api(() => axios.put(`/users/${formData.id}`, payload));
|
|
78
|
+
|
|
79
|
+
if (result.success) {
|
|
80
|
+
closeUserModal();
|
|
81
|
+
router.visit('/users', { preserveScroll: true, preserveState: true });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
isSubmitting.value = false;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function deleteUser(id: string): Promise<void> {
|
|
88
|
+
if (!confirm('Yakin ingin menghapus user ini?')) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
isSubmitting.value = true;
|
|
93
|
+
|
|
94
|
+
const result = await api(() => axios.delete('/users', { data: { ids: [id] } }));
|
|
95
|
+
|
|
96
|
+
if (result.success) {
|
|
97
|
+
router.visit('/users', { preserveScroll: true, preserveState: true });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
isSubmitting.value = false;
|
|
101
|
+
}
|
|
102
|
+
</script>
|
|
103
|
+
|
|
104
|
+
<template>
|
|
105
|
+
<Header group="users" />
|
|
106
|
+
|
|
107
|
+
<div class="min-h-screen bg-surface-light dark:bg-surface-dark text-slate-900 dark:text-slate-100 transition-colors duration-500 overflow-x-hidden selection:bg-primary-400 selection:text-black">
|
|
108
|
+
|
|
109
|
+
<!-- Background Effects -->
|
|
110
|
+
<div class="fixed inset-0 pointer-events-none z-0">
|
|
111
|
+
<div class="absolute top-0 left-1/2 w-[1000px] h-[1000px] bg-accent-500/5 rounded-full blur-3xl -translate-x-1/2 -mt-[500px]"></div>
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
<section class="relative px-6 sm:px-12 lg:px-24 pt-24 pb-20">
|
|
115
|
+
<div class="max-w-[90rem] mx-auto">
|
|
116
|
+
|
|
117
|
+
<!-- Header Section -->
|
|
118
|
+
<div class="flex flex-col lg:flex-row lg:items-end justify-between gap-8 mb-16">
|
|
119
|
+
<div>
|
|
120
|
+
<p class="text-xs font-bold uppercase tracking-[0.3em] text-accent-600 dark:text-accent-400 mb-6">
|
|
121
|
+
Management
|
|
122
|
+
</p>
|
|
123
|
+
<h1 class="text-[8vw] sm:text-[6vw] lg:text-[4vw] leading-[0.9] font-bold tracking-tighter mb-4">
|
|
124
|
+
USERS
|
|
125
|
+
</h1>
|
|
126
|
+
<p class="text-lg text-slate-500 dark:text-slate-400 max-w-xl font-serif italic">
|
|
127
|
+
"Control who enters. Manage who stays."
|
|
128
|
+
</p>
|
|
129
|
+
</div>
|
|
130
|
+
|
|
131
|
+
<div class="flex items-center gap-6">
|
|
132
|
+
<!-- Stats -->
|
|
133
|
+
<div class="text-right">
|
|
134
|
+
<p class="text-[10px] uppercase tracking-[0.2em] text-slate-400 mb-1">Total</p>
|
|
135
|
+
<p class="text-3xl font-bold tracking-tight">{{ total }}</p>
|
|
136
|
+
</div>
|
|
137
|
+
|
|
138
|
+
<div class="h-12 w-px bg-slate-200 dark:bg-slate-700"></div>
|
|
139
|
+
|
|
140
|
+
<button
|
|
141
|
+
v-if="currentUser?.is_admin"
|
|
142
|
+
class="group relative px-6 py-3 bg-slate-900 dark:bg-white text-white dark:text-black text-xs font-bold uppercase tracking-wider rounded-full overflow-hidden hover:scale-105 transition-transform disabled:opacity-50"
|
|
143
|
+
@click="openCreateUser"
|
|
144
|
+
:disabled="isSubmitting"
|
|
145
|
+
>
|
|
146
|
+
<span class="relative z-10 flex items-center gap-2">
|
|
147
|
+
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
148
|
+
<path d="M12 5v14M5 12h14" stroke-linecap="round" stroke-linejoin="round"/>
|
|
149
|
+
</svg>
|
|
150
|
+
Add User
|
|
151
|
+
</span>
|
|
152
|
+
<div class="absolute inset-0 bg-primary-500 transform translate-y-full group-hover:translate-y-0 transition-transform duration-300"></div>
|
|
153
|
+
</button>
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
|
|
157
|
+
<!-- Users Grid - Card Based -->
|
|
158
|
+
<div class="mb-12">
|
|
159
|
+
<div v-if="users && users.length" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
160
|
+
<div
|
|
161
|
+
v-for="(userItem, i) in users"
|
|
162
|
+
:key="userItem.id"
|
|
163
|
+
class="group relative bg-surface-card-light dark:bg-surface-card-dark border border-slate-200 dark:border-white/5 hover:border-accent-500/50 dark:hover:border-accent-500/30 p-6 rounded-2xl transition-all duration-500 overflow-hidden"
|
|
164
|
+
>
|
|
165
|
+
<!-- Hover Glow -->
|
|
166
|
+
<div class="absolute top-0 right-0 p-20 bg-accent-500/5 rounded-full blur-3xl -mr-10 -mt-10 opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
|
|
167
|
+
|
|
168
|
+
<div class="relative z-10">
|
|
169
|
+
<!-- User Header -->
|
|
170
|
+
<div class="flex items-start justify-between mb-4">
|
|
171
|
+
<div class="flex items-center gap-3">
|
|
172
|
+
<!-- Avatar -->
|
|
173
|
+
<div class="w-12 h-12 rounded-full bg-gradient-to-br from-accent-500 to-accent-400 flex items-center justify-center text-white font-bold text-lg">
|
|
174
|
+
{{ userItem.name.charAt(0).toUpperCase() }}
|
|
175
|
+
</div>
|
|
176
|
+
<div>
|
|
177
|
+
<h3 class="font-bold text-lg tracking-tight">{{ userItem.name }}</h3>
|
|
178
|
+
<p class="text-xs text-slate-500 dark:text-slate-400">{{ userItem.email }}</p>
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
|
|
182
|
+
<!-- Status Badge -->
|
|
183
|
+
<span class="px-2.5 py-1 text-[10px] font-bold uppercase tracking-wider rounded-full" :class="userItem.is_verified
|
|
184
|
+
? 'bg-primary-500/10 text-primary-600 dark:text-primary-400 border border-primary-500/20'
|
|
185
|
+
: 'bg-warning-500/10 text-warning-600 dark:text-warning-400 border border-warning-500/20'">
|
|
186
|
+
{{ userItem.is_verified ? 'Verified' : 'Pending' }}
|
|
187
|
+
</span>
|
|
188
|
+
</div>
|
|
189
|
+
|
|
190
|
+
<!-- User Details -->
|
|
191
|
+
<div class="space-y-2 mb-4">
|
|
192
|
+
<div class="flex items-center gap-2 text-xs text-slate-500 dark:text-slate-400">
|
|
193
|
+
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
194
|
+
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z" stroke-linecap="round" stroke-linejoin="round"/>
|
|
195
|
+
</svg>
|
|
196
|
+
<span class="font-mono">{{ userItem.phone || '—' }}</span>
|
|
197
|
+
</div>
|
|
198
|
+
<div class="flex items-center gap-2 text-xs">
|
|
199
|
+
<span class="inline-flex h-1.5 w-1.5 rounded-full" :class="userItem.is_admin ? 'bg-accent-500' : 'bg-slate-400'"></span>
|
|
200
|
+
<span class="text-slate-500 dark:text-slate-400">{{ userItem.is_admin ? 'Administrator' : 'Standard User' }}</span>
|
|
201
|
+
</div>
|
|
202
|
+
</div>
|
|
203
|
+
|
|
204
|
+
<!-- Actions -->
|
|
205
|
+
<div v-if="currentUser?.is_admin" class="flex items-center gap-2 pt-4 border-t border-slate-200 dark:border-white/5">
|
|
206
|
+
<button
|
|
207
|
+
class="flex-1 px-4 py-2 text-xs font-bold uppercase tracking-wider border border-slate-200 dark:border-slate-700 rounded-full hover:border-accent-500/50 hover:text-accent-500 transition-colors disabled:opacity-50"
|
|
208
|
+
@click="openEditUser(userItem)"
|
|
209
|
+
:disabled="isSubmitting"
|
|
210
|
+
>
|
|
211
|
+
Edit
|
|
212
|
+
</button>
|
|
213
|
+
<button
|
|
214
|
+
class="px-4 py-2 text-xs font-bold uppercase tracking-wider border border-danger-500/20 text-danger-500 rounded-full hover:bg-danger-500 hover:text-white transition-colors disabled:opacity-50"
|
|
215
|
+
@click="deleteUser(userItem.id)"
|
|
216
|
+
:disabled="isSubmitting"
|
|
217
|
+
>
|
|
218
|
+
Delete
|
|
219
|
+
</button>
|
|
220
|
+
</div>
|
|
221
|
+
</div>
|
|
222
|
+
</div>
|
|
223
|
+
</div>
|
|
224
|
+
|
|
225
|
+
<!-- Empty State -->
|
|
226
|
+
<div v-else class="text-center py-20 border border-dashed border-slate-200 dark:border-slate-700 rounded-2xl">
|
|
227
|
+
<div class="w-16 h-16 mx-auto mb-6 rounded-full bg-slate-100 dark:bg-slate-800 flex items-center justify-center">
|
|
228
|
+
<svg class="w-8 h-8 text-slate-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
229
|
+
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
230
|
+
<circle cx="9" cy="7" r="4" stroke-linecap="round" stroke-linejoin="round"/>
|
|
231
|
+
<path d="M23 21v-2a4 4 0 0 0-3-3.87" stroke-linecap="round" stroke-linejoin="round"/>
|
|
232
|
+
<path d="M16 3.13a4 4 0 0 1 0 7.75" stroke-linecap="round" stroke-linejoin="round"/>
|
|
233
|
+
</svg>
|
|
234
|
+
</div>
|
|
235
|
+
<h3 class="text-xl font-bold mb-2">No Users Yet</h3>
|
|
236
|
+
<p class="text-sm text-slate-500 dark:text-slate-400 mb-6">Start by adding your first user to the system.</p>
|
|
237
|
+
<button
|
|
238
|
+
v-if="currentUser?.is_admin"
|
|
239
|
+
class="px-6 py-3 bg-slate-900 dark:bg-white text-white dark:text-black text-xs font-bold uppercase tracking-wider rounded-full hover:scale-105 transition-transform"
|
|
240
|
+
@click="openCreateUser"
|
|
241
|
+
>
|
|
242
|
+
Add First User
|
|
243
|
+
</button>
|
|
244
|
+
</div>
|
|
245
|
+
</div>
|
|
246
|
+
|
|
247
|
+
<!-- Pagination -->
|
|
248
|
+
<div>
|
|
249
|
+
<Pagination :meta="paginationMeta" />
|
|
250
|
+
</div>
|
|
251
|
+
|
|
252
|
+
</div>
|
|
253
|
+
</section>
|
|
254
|
+
|
|
255
|
+
<UserModal
|
|
256
|
+
:show="showUserModal"
|
|
257
|
+
:mode="mode"
|
|
258
|
+
:form="form"
|
|
259
|
+
:isSubmitting="isSubmitting"
|
|
260
|
+
@close="closeUserModal"
|
|
261
|
+
@submit="handleSubmit"
|
|
262
|
+
/>
|
|
263
|
+
</div>
|
|
264
|
+
</template>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>NARA App</title>
|
|
7
|
+
@vite
|
|
8
|
+
</head>
|
|
9
|
+
<body class="bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100">
|
|
10
|
+
@inertia
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { NaraApp } from '@nara-web/core';
|
|
2
|
+
|
|
3
|
+
export function registerRoutes(app: NaraApp) {
|
|
4
|
+
app.get('/', (req, res) => {
|
|
5
|
+
return res.inertia('landing', {
|
|
6
|
+
title: 'Welcome to NARA'
|
|
7
|
+
});
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
app.get('/dashboard', (req, res) => {
|
|
11
|
+
return res.inertia('dashboard');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
app.get('/login', (req, res) => {
|
|
15
|
+
return res.inertia('auth/login');
|
|
16
|
+
});
|
|
17
|
+
}
|