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,234 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { fly, fade, scale } from 'svelte/transition';
|
|
3
|
+
import { backOut } from 'svelte/easing';
|
|
4
|
+
import type { UserForm } from '../types';
|
|
5
|
+
import { createEventDispatcher } from 'svelte';
|
|
6
|
+
|
|
7
|
+
export let show: boolean = false;
|
|
8
|
+
export let mode: 'create' | 'edit' = 'create';
|
|
9
|
+
export let form: UserForm;
|
|
10
|
+
export let isSubmitting: boolean = false;
|
|
11
|
+
|
|
12
|
+
const dispatch = createEventDispatcher<{
|
|
13
|
+
close: void;
|
|
14
|
+
submit: UserForm;
|
|
15
|
+
}>();
|
|
16
|
+
|
|
17
|
+
function handleClose(): void {
|
|
18
|
+
dispatch('close');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function handleSubmit(): void {
|
|
22
|
+
dispatch('submit', form);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function handleBackdropClick(e: MouseEvent): void {
|
|
26
|
+
if (e.target === e.currentTarget) {
|
|
27
|
+
handleClose();
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
</script>
|
|
31
|
+
|
|
32
|
+
{#if show}
|
|
33
|
+
<!-- Backdrop -->
|
|
34
|
+
<div
|
|
35
|
+
class="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6"
|
|
36
|
+
on:click={handleBackdropClick}
|
|
37
|
+
on:keydown={(e) => e.key === 'Escape' && handleClose()}
|
|
38
|
+
role="dialog"
|
|
39
|
+
aria-modal="true"
|
|
40
|
+
aria-labelledby="modal-title"
|
|
41
|
+
tabindex="-1"
|
|
42
|
+
transition:fade={{ duration: 300 }}
|
|
43
|
+
>
|
|
44
|
+
<!-- Background Blur & Overlay -->
|
|
45
|
+
<div class="absolute inset-0 bg-black/70 backdrop-blur-md"></div>
|
|
46
|
+
|
|
47
|
+
<!-- Decorative Elements -->
|
|
48
|
+
<div class="absolute top-1/4 left-1/4 w-96 h-96 bg-accent-500/10 rounded-full blur-3xl pointer-events-none"></div>
|
|
49
|
+
<div class="absolute bottom-1/4 right-1/4 w-64 h-64 bg-primary-500/10 rounded-full blur-3xl pointer-events-none"></div>
|
|
50
|
+
|
|
51
|
+
<!-- Modal Container -->
|
|
52
|
+
<div
|
|
53
|
+
class="relative w-full max-w-lg"
|
|
54
|
+
transition:fly={{ y: 50, duration: 500, easing: backOut }}
|
|
55
|
+
>
|
|
56
|
+
<!-- Glowing Border Effect -->
|
|
57
|
+
<div class="absolute -inset-px bg-gradient-to-br from-accent-500/50 via-transparent to-primary-500/50 rounded-3xl blur-sm opacity-60"></div>
|
|
58
|
+
|
|
59
|
+
<!-- Modal Content -->
|
|
60
|
+
<div class="relative bg-white dark:bg-surface-dark rounded-3xl border border-slate-200 dark:border-white/10 overflow-hidden shadow-2xl">
|
|
61
|
+
|
|
62
|
+
<!-- Header -->
|
|
63
|
+
<div class="relative px-8 pt-8 pb-6 border-b border-slate-100 dark:border-white/5">
|
|
64
|
+
<!-- Decorative Line -->
|
|
65
|
+
<div class="absolute top-0 left-8 right-8 h-px bg-gradient-to-r from-transparent via-accent-500/50 to-transparent"></div>
|
|
66
|
+
|
|
67
|
+
<div class="flex items-start justify-between">
|
|
68
|
+
<div>
|
|
69
|
+
<p class="text-[10px] font-bold uppercase tracking-[0.3em] text-accent-600 dark:text-accent-400 mb-2">
|
|
70
|
+
{mode === 'create' ? 'New Entry' : 'Edit Entry'}
|
|
71
|
+
</p>
|
|
72
|
+
<h3 id="modal-title" class="text-2xl font-bold tracking-tight text-slate-900 dark:text-white">
|
|
73
|
+
{mode === 'create' ? 'Create User' : 'Update User'}
|
|
74
|
+
</h3>
|
|
75
|
+
</div>
|
|
76
|
+
|
|
77
|
+
<!-- Close Button -->
|
|
78
|
+
<button
|
|
79
|
+
class="group w-10 h-10 flex items-center justify-center rounded-full border border-slate-200 dark:border-white/10 hover:border-red-500/50 hover:bg-red-500/5 transition-all duration-300"
|
|
80
|
+
on:click={handleClose}
|
|
81
|
+
disabled={isSubmitting}
|
|
82
|
+
aria-label="Close modal"
|
|
83
|
+
>
|
|
84
|
+
<svg class="w-4 h-4 text-slate-400 group-hover:text-red-500 transition-colors" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
85
|
+
<path d="M18 6L6 18M6 6l12 12" stroke-linecap="round" stroke-linejoin="round"/>
|
|
86
|
+
</svg>
|
|
87
|
+
</button>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
<!-- Form -->
|
|
92
|
+
<form class="p-8 space-y-6" on:submit|preventDefault={handleSubmit}>
|
|
93
|
+
|
|
94
|
+
<!-- Name Field -->
|
|
95
|
+
<div class="group">
|
|
96
|
+
<label for="user-name" class="block text-[10px] font-bold uppercase tracking-[0.2em] text-slate-500 dark:text-slate-400 mb-3">
|
|
97
|
+
Full Name
|
|
98
|
+
</label>
|
|
99
|
+
<div class="relative">
|
|
100
|
+
<input
|
|
101
|
+
id="user-name"
|
|
102
|
+
class="w-full px-0 py-3 bg-transparent border-0 border-b-2 border-slate-200 dark:border-white/10 text-lg text-slate-900 dark:text-white placeholder-slate-300 dark:placeholder-slate-600 focus:border-accent-500 dark:focus:border-accent-400 focus:ring-0 outline-none transition-colors"
|
|
103
|
+
type="text"
|
|
104
|
+
bind:value={form.name}
|
|
105
|
+
placeholder="Enter full name"
|
|
106
|
+
/>
|
|
107
|
+
<div class="absolute bottom-0 left-0 w-0 h-0.5 bg-accent-500 group-focus-within:w-full transition-all duration-500"></div>
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
|
|
111
|
+
<!-- Email Field -->
|
|
112
|
+
<div class="group">
|
|
113
|
+
<label for="user-email" class="block text-[10px] font-bold uppercase tracking-[0.2em] text-slate-500 dark:text-slate-400 mb-3">
|
|
114
|
+
Email Address
|
|
115
|
+
</label>
|
|
116
|
+
<div class="relative">
|
|
117
|
+
<input
|
|
118
|
+
id="user-email"
|
|
119
|
+
class="w-full px-0 py-3 bg-transparent border-0 border-b-2 border-slate-200 dark:border-white/10 text-lg text-slate-900 dark:text-white placeholder-slate-300 dark:placeholder-slate-600 focus:border-accent-500 dark:focus:border-accent-400 focus:ring-0 outline-none transition-colors"
|
|
120
|
+
type="email"
|
|
121
|
+
bind:value={form.email}
|
|
122
|
+
placeholder="user@example.com"
|
|
123
|
+
/>
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
|
|
127
|
+
<!-- Phone Field -->
|
|
128
|
+
<div class="group">
|
|
129
|
+
<label for="user-phone" class="block text-[10px] font-bold uppercase tracking-[0.2em] text-slate-500 dark:text-slate-400 mb-3">
|
|
130
|
+
Phone Number
|
|
131
|
+
<span class="text-slate-400 dark:text-slate-600 font-normal normal-case tracking-normal ml-1">(optional)</span>
|
|
132
|
+
</label>
|
|
133
|
+
<div class="relative">
|
|
134
|
+
<input
|
|
135
|
+
id="user-phone"
|
|
136
|
+
class="w-full px-0 py-3 bg-transparent border-0 border-b-2 border-slate-200 dark:border-white/10 text-lg text-slate-900 dark:text-white placeholder-slate-300 dark:placeholder-slate-600 focus:border-purple-500 dark:focus:border-purple-400 focus:ring-0 outline-none transition-colors font-mono"
|
|
137
|
+
type="text"
|
|
138
|
+
bind:value={form.phone}
|
|
139
|
+
placeholder="+62 xxx xxxx xxxx"
|
|
140
|
+
/>
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
|
|
144
|
+
<!-- Role & Status Toggles -->
|
|
145
|
+
<div class="grid grid-cols-2 gap-4 pt-2">
|
|
146
|
+
<!-- Admin Toggle -->
|
|
147
|
+
<label class="group relative flex items-center gap-4 p-4 rounded-2xl border border-slate-200 dark:border-white/10 hover:border-accent-500/30 cursor-pointer transition-all duration-300 {form.is_admin ? 'bg-accent-500/5 border-accent-500/30' : ''}">
|
|
148
|
+
<div class="relative">
|
|
149
|
+
<input
|
|
150
|
+
type="checkbox"
|
|
151
|
+
class="sr-only peer"
|
|
152
|
+
bind:checked={form.is_admin}
|
|
153
|
+
/>
|
|
154
|
+
<div class="w-12 h-7 bg-slate-200 dark:bg-white/10 rounded-full peer-checked:bg-accent-500 transition-colors"></div>
|
|
155
|
+
<div class="absolute top-0.5 left-0.5 w-6 h-6 bg-white rounded-full shadow-md transform peer-checked:translate-x-5 transition-transform"></div>
|
|
156
|
+
</div>
|
|
157
|
+
<div>
|
|
158
|
+
<p class="text-sm font-bold text-slate-900 dark:text-white">Admin</p>
|
|
159
|
+
<p class="text-[10px] text-slate-400 dark:text-slate-500">Full access</p>
|
|
160
|
+
</div>
|
|
161
|
+
</label>
|
|
162
|
+
|
|
163
|
+
<!-- Verified Toggle -->
|
|
164
|
+
<label class="group relative flex items-center gap-4 p-4 rounded-2xl border border-slate-200 dark:border-white/10 hover:border-primary-500/30 cursor-pointer transition-all duration-300 {form.is_verified ? 'bg-primary-500/5 border-primary-500/30' : ''}">
|
|
165
|
+
<div class="relative">
|
|
166
|
+
<input
|
|
167
|
+
type="checkbox"
|
|
168
|
+
class="sr-only peer"
|
|
169
|
+
bind:checked={form.is_verified}
|
|
170
|
+
/>
|
|
171
|
+
<div class="w-12 h-7 bg-slate-200 dark:bg-white/10 rounded-full peer-checked:bg-primary-500 transition-colors"></div>
|
|
172
|
+
<div class="absolute top-0.5 left-0.5 w-6 h-6 bg-white rounded-full shadow-md transform peer-checked:translate-x-5 transition-transform"></div>
|
|
173
|
+
</div>
|
|
174
|
+
<div>
|
|
175
|
+
<p class="text-sm font-bold text-slate-900 dark:text-white">Verified</p>
|
|
176
|
+
<p class="text-[10px] text-slate-400 dark:text-slate-500">Email confirmed</p>
|
|
177
|
+
</div>
|
|
178
|
+
</label>
|
|
179
|
+
</div>
|
|
180
|
+
|
|
181
|
+
<!-- Password Field -->
|
|
182
|
+
<div class="group pt-2">
|
|
183
|
+
<label for="user-password" class="block text-[10px] font-bold uppercase tracking-[0.2em] text-slate-500 dark:text-slate-400 mb-3">
|
|
184
|
+
{mode === 'create' ? 'Password' : 'New Password'}
|
|
185
|
+
<span class="text-slate-400 dark:text-slate-600 font-normal normal-case tracking-normal ml-1">(optional)</span>
|
|
186
|
+
</label>
|
|
187
|
+
<div class="relative">
|
|
188
|
+
<input
|
|
189
|
+
id="user-password"
|
|
190
|
+
class="w-full px-0 py-3 bg-transparent border-0 border-b-2 border-slate-200 dark:border-white/10 text-lg text-slate-900 dark:text-white placeholder-slate-300 dark:placeholder-slate-600 focus:border-accent-500 dark:focus:border-accent-400 focus:ring-0 outline-none transition-colors"
|
|
191
|
+
type="password"
|
|
192
|
+
bind:value={form.password}
|
|
193
|
+
placeholder={mode === 'create' ? 'Leave empty to use email' : 'Leave empty to keep current'}
|
|
194
|
+
/>
|
|
195
|
+
</div>
|
|
196
|
+
</div>
|
|
197
|
+
|
|
198
|
+
<!-- Actions -->
|
|
199
|
+
<div class="flex items-center justify-end gap-4 pt-6 border-t border-slate-100 dark:border-white/5">
|
|
200
|
+
<button
|
|
201
|
+
type="button"
|
|
202
|
+
class="px-6 py-3 text-xs font-bold uppercase tracking-wider text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white transition-colors disabled:opacity-50"
|
|
203
|
+
on:click={handleClose}
|
|
204
|
+
disabled={isSubmitting}
|
|
205
|
+
>
|
|
206
|
+
Cancel
|
|
207
|
+
</button>
|
|
208
|
+
<button
|
|
209
|
+
type="submit"
|
|
210
|
+
class="group relative px-8 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 active:scale-95 transition-transform disabled:opacity-50 disabled:hover:scale-100"
|
|
211
|
+
disabled={isSubmitting}
|
|
212
|
+
>
|
|
213
|
+
<span class="relative z-10 flex items-center gap-2">
|
|
214
|
+
{#if isSubmitting}
|
|
215
|
+
<svg class="w-4 h-4 animate-spin" viewBox="0 0 24 24" fill="none">
|
|
216
|
+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
217
|
+
<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>
|
|
218
|
+
</svg>
|
|
219
|
+
Saving...
|
|
220
|
+
{:else}
|
|
221
|
+
{mode === 'create' ? 'Create User' : 'Save Changes'}
|
|
222
|
+
<svg class="w-4 h-4 transform group-hover:translate-x-1 transition-transform" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
223
|
+
<path d="M5 12h14M12 5l7 7-7 7" stroke-linecap="round" stroke-linejoin="round"/>
|
|
224
|
+
</svg>
|
|
225
|
+
{/if}
|
|
226
|
+
</span>
|
|
227
|
+
<div class="absolute inset-0 bg-gradient-to-r from-accent-500 to-primary-500 transform translate-y-full group-hover:translate-y-0 transition-transform duration-300"></div>
|
|
228
|
+
</button>
|
|
229
|
+
</div>
|
|
230
|
+
</form>
|
|
231
|
+
</div>
|
|
232
|
+
</div>
|
|
233
|
+
</div>
|
|
234
|
+
{/if}
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Svelte action return type
|
|
3
|
+
*/
|
|
4
|
+
interface ActionReturn {
|
|
5
|
+
destroy(): void;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Get CSRF token from cookie
|
|
10
|
+
* Used for protecting state-changing requests (POST, PUT, DELETE)
|
|
11
|
+
*
|
|
12
|
+
* @returns CSRF token string or undefined if not found
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* const token = getCSRFToken();
|
|
16
|
+
* fetch('/api/data', {
|
|
17
|
+
* method: 'POST',
|
|
18
|
+
* headers: {
|
|
19
|
+
* 'X-CSRF-Token': token || '',
|
|
20
|
+
* 'Content-Type': 'application/json',
|
|
21
|
+
* },
|
|
22
|
+
* body: JSON.stringify(data),
|
|
23
|
+
* });
|
|
24
|
+
*/
|
|
25
|
+
export function getCSRFToken(): string | undefined {
|
|
26
|
+
return document.cookie.match(/csrf_token=([^;]+)/)?.[1];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Configure axios instance with CSRF token
|
|
31
|
+
* Call this once at app initialization to automatically include CSRF token in all requests
|
|
32
|
+
*
|
|
33
|
+
* @param axiosInstance - Axios instance to configure
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* import axios from 'axios';
|
|
37
|
+
* import { configureAxiosCSRF } from '$lib/helper';
|
|
38
|
+
*
|
|
39
|
+
* configureAxiosCSRF(axios);
|
|
40
|
+
*/
|
|
41
|
+
export function configureAxiosCSRF(axiosInstance: { interceptors: { request: { use: (fn: (config: any) => any) => void } } }): void {
|
|
42
|
+
axiosInstance.interceptors.request.use((config: any) => {
|
|
43
|
+
const token = getCSRFToken();
|
|
44
|
+
if (token && ['post', 'put', 'patch', 'delete'].includes(config.method?.toLowerCase() || '')) {
|
|
45
|
+
config.headers = config.headers || {};
|
|
46
|
+
config.headers['X-CSRF-Token'] = token;
|
|
47
|
+
}
|
|
48
|
+
return config;
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* API response type
|
|
54
|
+
*/
|
|
55
|
+
export interface ApiResponse<T = unknown> {
|
|
56
|
+
success: boolean;
|
|
57
|
+
message: string;
|
|
58
|
+
data?: T;
|
|
59
|
+
code?: string;
|
|
60
|
+
errors?: Record<string, string[]>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* API options type
|
|
65
|
+
*/
|
|
66
|
+
interface ApiOptions {
|
|
67
|
+
showSuccessToast?: boolean;
|
|
68
|
+
showErrorToast?: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Creates a click outside event listener for a DOM node
|
|
73
|
+
*/
|
|
74
|
+
export function clickOutside(node: HTMLElement): ActionReturn {
|
|
75
|
+
const handleClick = (event: MouseEvent): void => {
|
|
76
|
+
if (node && !node.contains(event.target as Node) && !event.defaultPrevented) {
|
|
77
|
+
node.dispatchEvent(new CustomEvent('click_outside'));
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
document.addEventListener('click', handleClick, true);
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
destroy() {
|
|
85
|
+
document.removeEventListener('click', handleClick, true);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Generates a random password with a mix of letters, numbers and special characters
|
|
92
|
+
*/
|
|
93
|
+
export function password_generator(pLength: number): string {
|
|
94
|
+
const keyListAlpha = "abcdefghijklmnopqrstuvwxyz";
|
|
95
|
+
const keyListInt = "123456789";
|
|
96
|
+
const keyListSpec = "!@#_";
|
|
97
|
+
let password = '';
|
|
98
|
+
let len = Math.ceil(pLength / 2);
|
|
99
|
+
len = len - 1;
|
|
100
|
+
const lenSpec = pLength - 2 * len;
|
|
101
|
+
|
|
102
|
+
for (let i = 0; i < len; i++) {
|
|
103
|
+
password += keyListAlpha.charAt(Math.floor(Math.random() * keyListAlpha.length));
|
|
104
|
+
password += keyListInt.charAt(Math.floor(Math.random() * keyListInt.length));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
for (let i = 0; i < lenSpec; i++) {
|
|
108
|
+
password += keyListSpec.charAt(Math.floor(Math.random() * keyListSpec.length));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
password = password.split('').sort(() => 0.5 - Math.random()).join('');
|
|
112
|
+
|
|
113
|
+
return password;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Creates a debounced version of a function that delays its execution
|
|
118
|
+
*/
|
|
119
|
+
export function debounce<T extends (...args: unknown[]) => unknown>(
|
|
120
|
+
func: T,
|
|
121
|
+
timeout: number = 300
|
|
122
|
+
): (...args: Parameters<T>) => void {
|
|
123
|
+
let timer: ReturnType<typeof setTimeout>;
|
|
124
|
+
return (...args: Parameters<T>): void => {
|
|
125
|
+
clearTimeout(timer);
|
|
126
|
+
timer = setTimeout(() => {
|
|
127
|
+
func(...args);
|
|
128
|
+
}, timeout);
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Format validation errors object into a readable string
|
|
134
|
+
*/
|
|
135
|
+
function formatValidationErrors(errors: Record<string, string[]> | undefined): string {
|
|
136
|
+
if (!errors || typeof errors !== 'object') return '';
|
|
137
|
+
|
|
138
|
+
const messages: string[] = [];
|
|
139
|
+
for (const [field, fieldErrors] of Object.entries(errors)) {
|
|
140
|
+
if (Array.isArray(fieldErrors) && fieldErrors.length > 0) {
|
|
141
|
+
messages.push(`${field}: ${fieldErrors[0]}`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return messages.join('; ');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Standardized API call wrapper that handles JSON responses from backend
|
|
149
|
+
*/
|
|
150
|
+
export async function api<T = unknown>(
|
|
151
|
+
axiosCall: () => Promise<{ data: ApiResponse<T> }>,
|
|
152
|
+
options: ApiOptions = {}
|
|
153
|
+
): Promise<ApiResponse<T>> {
|
|
154
|
+
const { showSuccessToast = true, showErrorToast = true } = options;
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
const response = await axiosCall();
|
|
158
|
+
const result = response.data;
|
|
159
|
+
|
|
160
|
+
if (result.success) {
|
|
161
|
+
if (showSuccessToast && result.message) {
|
|
162
|
+
Toast(result.message, 'success');
|
|
163
|
+
}
|
|
164
|
+
return { success: true, message: result.message, data: result.data };
|
|
165
|
+
} else {
|
|
166
|
+
if (showErrorToast) {
|
|
167
|
+
const errorMsg = result.errors
|
|
168
|
+
? formatValidationErrors(result.errors) || result.message
|
|
169
|
+
: result.message;
|
|
170
|
+
if (errorMsg) Toast(errorMsg, 'error');
|
|
171
|
+
}
|
|
172
|
+
return { success: false, message: result.message, code: result.code, errors: result.errors };
|
|
173
|
+
}
|
|
174
|
+
} catch (error: unknown) {
|
|
175
|
+
const axiosError = error as { response?: { data?: ApiResponse } };
|
|
176
|
+
const message = axiosError?.response?.data?.message || 'Terjadi kesalahan, coba lagi';
|
|
177
|
+
const code = axiosError?.response?.data?.code;
|
|
178
|
+
const errors = axiosError?.response?.data?.errors;
|
|
179
|
+
|
|
180
|
+
if (showErrorToast) {
|
|
181
|
+
const errorMsg = errors
|
|
182
|
+
? formatValidationErrors(errors) || message
|
|
183
|
+
: message;
|
|
184
|
+
Toast(errorMsg, 'error');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return { success: false, message, code, errors };
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
type ToastType = 'success' | 'error' | 'warning' | 'info';
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Displays a toast notification message
|
|
195
|
+
*/
|
|
196
|
+
export function Toast(text: string, type: ToastType = "success", duration: number = 3000): void {
|
|
197
|
+
// Create toast container if it doesn't exist
|
|
198
|
+
let container = document.getElementById('toast-container');
|
|
199
|
+
if (!container) {
|
|
200
|
+
container = document.createElement('div');
|
|
201
|
+
container.id = 'toast-container';
|
|
202
|
+
container.style.cssText = `
|
|
203
|
+
position: fixed;
|
|
204
|
+
bottom: 24px;
|
|
205
|
+
left: 50%;
|
|
206
|
+
transform: translateX(-50%);
|
|
207
|
+
z-index: 9999;
|
|
208
|
+
display: flex;
|
|
209
|
+
flex-direction: column;
|
|
210
|
+
align-items: center;
|
|
211
|
+
gap: 8px;
|
|
212
|
+
`;
|
|
213
|
+
document.body.appendChild(container);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Create toast element
|
|
217
|
+
const toast = document.createElement('div');
|
|
218
|
+
toast.style.cssText = `
|
|
219
|
+
min-width: 300px;
|
|
220
|
+
max-width: 90vw;
|
|
221
|
+
margin: 0;
|
|
222
|
+
padding: 12px 16px;
|
|
223
|
+
border-radius: 8px;
|
|
224
|
+
color: white;
|
|
225
|
+
font-size: 14px;
|
|
226
|
+
font-weight: 500;
|
|
227
|
+
opacity: 0;
|
|
228
|
+
transform: translateY(20px) scale(0.95);
|
|
229
|
+
transition: all 0.2s cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
|
230
|
+
box-shadow: 0 4px 12px rgba(0,0,0,0.15), 0 0 1px rgba(0,0,0,0.1);
|
|
231
|
+
display: flex;
|
|
232
|
+
align-items: center;
|
|
233
|
+
gap: 12px;
|
|
234
|
+
backdrop-filter: blur(8px);
|
|
235
|
+
`;
|
|
236
|
+
|
|
237
|
+
// Define icons for different types with refined styling
|
|
238
|
+
const icons: Record<ToastType, string> = {
|
|
239
|
+
success: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path><polyline points="22 4 12 14.01 9 11.01"></polyline></svg>`,
|
|
240
|
+
error: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="15" y1="9" x2="9" y2="15"></line><line x1="9" y1="9" x2="15" y2="15"></line></svg>`,
|
|
241
|
+
warning: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>`,
|
|
242
|
+
info: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg>`
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
// Set background color based on type with refined colors
|
|
246
|
+
let iconType: ToastType = type;
|
|
247
|
+
switch(type) {
|
|
248
|
+
case 'success':
|
|
249
|
+
toast.style.backgroundColor = 'rgba(34, 197, 94, 0.95)';
|
|
250
|
+
break;
|
|
251
|
+
case 'error':
|
|
252
|
+
toast.style.backgroundColor = 'rgba(239, 68, 68, 0.95)';
|
|
253
|
+
break;
|
|
254
|
+
case 'warning':
|
|
255
|
+
toast.style.backgroundColor = 'rgba(245, 158, 11, 0.95)';
|
|
256
|
+
break;
|
|
257
|
+
default:
|
|
258
|
+
toast.style.backgroundColor = 'rgba(59, 130, 246, 0.95)';
|
|
259
|
+
iconType = 'info';
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Add icon and text with improved styling
|
|
263
|
+
const iconWrapper = document.createElement('div');
|
|
264
|
+
iconWrapper.style.cssText = `
|
|
265
|
+
display: flex;
|
|
266
|
+
align-items: center;
|
|
267
|
+
justify-content: center;
|
|
268
|
+
flex-shrink: 0;
|
|
269
|
+
`;
|
|
270
|
+
iconWrapper.innerHTML = icons[iconType];
|
|
271
|
+
|
|
272
|
+
const textWrapper = document.createElement('div');
|
|
273
|
+
textWrapper.style.cssText = `
|
|
274
|
+
flex-grow: 1;
|
|
275
|
+
line-height: 1.4;
|
|
276
|
+
`;
|
|
277
|
+
textWrapper.textContent = text;
|
|
278
|
+
|
|
279
|
+
toast.appendChild(iconWrapper);
|
|
280
|
+
toast.appendChild(textWrapper);
|
|
281
|
+
container.appendChild(toast);
|
|
282
|
+
|
|
283
|
+
// Enhanced animation
|
|
284
|
+
requestAnimationFrame(() => {
|
|
285
|
+
toast.style.opacity = '1';
|
|
286
|
+
toast.style.transform = 'translateY(0) scale(1)';
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// Remove toast after duration with smooth exit animation
|
|
290
|
+
setTimeout(() => {
|
|
291
|
+
toast.style.opacity = '0';
|
|
292
|
+
toast.style.transform = 'translateY(-20px) scale(0.95)';
|
|
293
|
+
setTimeout(() => {
|
|
294
|
+
container.removeChild(toast);
|
|
295
|
+
if (container.children.length === 0) {
|
|
296
|
+
document.body.removeChild(container);
|
|
297
|
+
}
|
|
298
|
+
}, 200);
|
|
299
|
+
}, duration);
|
|
300
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { inertia, router } from "@inertiajs/svelte";
|
|
3
|
+
import NaraIcon from "../../Components/NaraIcon.svelte";
|
|
4
|
+
import DarkModeToggle from "../../Components/DarkModeToggle.svelte";
|
|
5
|
+
import axios from "axios";
|
|
6
|
+
import { api, Toast } from "../../Components/helper";
|
|
7
|
+
|
|
8
|
+
interface ForgotPasswordForm {
|
|
9
|
+
email: string;
|
|
10
|
+
phone: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
let form: ForgotPasswordForm = {
|
|
14
|
+
email: "",
|
|
15
|
+
phone: "",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
let success: boolean = $state(false);
|
|
19
|
+
let { error }: { error?: string } = $props();
|
|
20
|
+
|
|
21
|
+
$effect(() => {
|
|
22
|
+
if (error) Toast(error, 'error');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
async function submitForm(): Promise<void> {
|
|
26
|
+
const result = await api(() => axios.post("/forgot-password", form));
|
|
27
|
+
|
|
28
|
+
if (result.success) {
|
|
29
|
+
success = true;
|
|
30
|
+
form.email = "";
|
|
31
|
+
form.phone = "";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
</script>
|
|
35
|
+
|
|
36
|
+
<section class="min-h-screen bg-gradient-to-b from-slate-950 via-slate-900 to-slate-950 text-slate-50">
|
|
37
|
+
<!-- Header with dark mode toggle -->
|
|
38
|
+
<header class="fixed inset-x-0 top-0 z-40 border-b border-slate-800/60 bg-slate-950/80 backdrop-blur-xl">
|
|
39
|
+
<div class="max-w-6xl mx-auto flex h-16 items-center justify-between px-4 sm:h-20 sm:px-6 lg:px-8">
|
|
40
|
+
<a href="/" use:inertia class="flex items-center gap-2">
|
|
41
|
+
<img src="/public/nara.png" alt="Nara logo" class="h-7 w-7 rounded-lg object-cover" />
|
|
42
|
+
<div class="flex flex-col leading-tight">
|
|
43
|
+
<span class="text-sm font-semibold tracking-tight text-slate-50">Nara</span>
|
|
44
|
+
<span class="text-[10px] uppercase tracking-[0.22em] text-slate-500">TypeScript framework</span>
|
|
45
|
+
</div>
|
|
46
|
+
</a>
|
|
47
|
+
<div class="flex items-center gap-3">
|
|
48
|
+
<DarkModeToggle />
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
</header>
|
|
52
|
+
|
|
53
|
+
<div class="flex flex-col items-center justify-center px-6 py-8 mx-auto min-h-screen lg:py-0">
|
|
54
|
+
<div class="flex items-center mb-6 text-2xl font-semibold text-slate-50">
|
|
55
|
+
<NaraIcon></NaraIcon>
|
|
56
|
+
</div>
|
|
57
|
+
<div class="w-full max-w-md rounded-3xl border border-slate-800/80 bg-slate-900/70 backdrop-blur-xl shadow-[0_24px_80px_rgba(15,23,42,0.8)] md:mt-0 sm:max-w-md xl:p-0">
|
|
58
|
+
<div class="p-6 space-y-4 md:space-y-6 sm:p-8">
|
|
59
|
+
<h1 class="text-xl font-bold leading-tight tracking-tight text-slate-50 md:text-2xl">
|
|
60
|
+
Reset Password
|
|
61
|
+
</h1>
|
|
62
|
+
|
|
63
|
+
{#if success}
|
|
64
|
+
<div class="p-4 mb-4 text-sm text-green-400 rounded-lg bg-green-900/50" role="alert">
|
|
65
|
+
Link reset password telah dikirim ke email atau nomor telepon Anda.
|
|
66
|
+
</div>
|
|
67
|
+
{/if}
|
|
68
|
+
|
|
69
|
+
<form
|
|
70
|
+
class="space-y-4 md:space-y-6"
|
|
71
|
+
on:submit|preventDefault={submitForm}
|
|
72
|
+
>
|
|
73
|
+
<div>
|
|
74
|
+
<label for="email" class="block mb-2 text-sm font-medium text-slate-200">Email atau Nomor Telepon</label>
|
|
75
|
+
<input
|
|
76
|
+
bind:value={form.email}
|
|
77
|
+
type="text"
|
|
78
|
+
name="email"
|
|
79
|
+
id="email"
|
|
80
|
+
class="bg-slate-900/70 border border-slate-700 text-slate-50 sm:text-sm rounded-lg focus:ring-2 focus:ring-primary-400 focus:border-primary-400 focus:outline-none block w-full py-2.5 px-3 placeholder-slate-500"
|
|
81
|
+
placeholder="email@example.com atau 08xxxxxxxxxx"
|
|
82
|
+
required
|
|
83
|
+
/>
|
|
84
|
+
</div>
|
|
85
|
+
|
|
86
|
+
<button type="submit" class="w-full text-sm font-medium rounded-full px-5 py-2.5 text-slate-950 bg-primary-400 hover:bg-primary-300 focus:ring-4 focus:outline-none focus:ring-primary-300">
|
|
87
|
+
Kirim Link Reset Password
|
|
88
|
+
</button>
|
|
89
|
+
|
|
90
|
+
<p class="text-sm font-light text-slate-400">
|
|
91
|
+
Ingat password Anda? <a href="/login" use:inertia class="font-medium text-primary-400 hover:underline">Login disini</a>
|
|
92
|
+
</p>
|
|
93
|
+
</form>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
</section>
|