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,12 @@
|
|
|
1
|
+
import 'dotenv/config';
|
|
2
|
+
import { createApp } from '@nara-web/core';
|
|
3
|
+
import { svelteAdapter } from '@nara-web/inertia-svelte';
|
|
4
|
+
import { registerRoutes } from './routes/web.js';
|
|
5
|
+
|
|
6
|
+
const app = createApp({
|
|
7
|
+
port: Number(process.env.PORT) || 3000,
|
|
8
|
+
adapter: svelteAdapter()
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
registerRoutes(app);
|
|
12
|
+
app.start();
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { defineConfig } from 'vite';
|
|
2
|
+
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
|
|
5
|
+
export default defineConfig({
|
|
6
|
+
plugins: [svelte()],
|
|
7
|
+
resolve: {
|
|
8
|
+
alias: {
|
|
9
|
+
'@': path.resolve(__dirname, './resources/js')
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
build: {
|
|
13
|
+
manifest: true,
|
|
14
|
+
outDir: 'public/build',
|
|
15
|
+
rollupOptions: {
|
|
16
|
+
input: 'resources/js/app.ts'
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { createApp, h } from 'vue';
|
|
2
|
+
import { createInertiaApp } from '@inertiajs/vue3';
|
|
3
|
+
|
|
4
|
+
createInertiaApp({
|
|
5
|
+
resolve: name => {
|
|
6
|
+
const pages = import.meta.glob('./pages/**/*.vue', { eager: true });
|
|
7
|
+
return pages[`./pages/${name}.vue`];
|
|
8
|
+
},
|
|
9
|
+
setup({ el, App, props, plugin }) {
|
|
10
|
+
createApp({ render: () => h(App, props) })
|
|
11
|
+
.use(plugin)
|
|
12
|
+
.mount(el!);
|
|
13
|
+
}
|
|
14
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, onMounted } from 'vue';
|
|
3
|
+
|
|
4
|
+
const props = defineProps<{
|
|
5
|
+
onchange?: (mode: boolean) => void;
|
|
6
|
+
}>();
|
|
7
|
+
|
|
8
|
+
const darkMode = ref(false);
|
|
9
|
+
const mounted = ref(false);
|
|
10
|
+
|
|
11
|
+
const toggleDarkMode = () => {
|
|
12
|
+
darkMode.value = !darkMode.value;
|
|
13
|
+
|
|
14
|
+
if (darkMode.value) {
|
|
15
|
+
document.documentElement.classList.add('dark');
|
|
16
|
+
} else {
|
|
17
|
+
document.documentElement.classList.remove('dark');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Save preference to localStorage
|
|
21
|
+
localStorage.setItem('darkMode', String(darkMode.value));
|
|
22
|
+
|
|
23
|
+
if (props.onchange) {
|
|
24
|
+
props.onchange(darkMode.value);
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
onMounted(() => {
|
|
29
|
+
// Check system preference
|
|
30
|
+
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
31
|
+
// Check localStorage or fallback to system preference
|
|
32
|
+
const savedMode = localStorage.getItem('darkMode');
|
|
33
|
+
darkMode.value = savedMode === null ? systemPrefersDark : savedMode === 'true';
|
|
34
|
+
|
|
35
|
+
// Apply saved preference
|
|
36
|
+
if (darkMode.value) {
|
|
37
|
+
document.documentElement.classList.add('dark');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Add transition class after initial load to prevent flash
|
|
41
|
+
setTimeout(() => {
|
|
42
|
+
document.documentElement.classList.add('transition-colors');
|
|
43
|
+
mounted.value = true;
|
|
44
|
+
}, 100);
|
|
45
|
+
|
|
46
|
+
// Listen for system preference changes
|
|
47
|
+
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e: MediaQueryListEvent) => {
|
|
48
|
+
if (localStorage.getItem('darkMode') === null) {
|
|
49
|
+
darkMode.value = e.matches;
|
|
50
|
+
// We don't call toggleDarkMode here to avoid double inversion
|
|
51
|
+
if (darkMode.value) {
|
|
52
|
+
document.documentElement.classList.add('dark');
|
|
53
|
+
} else {
|
|
54
|
+
document.documentElement.classList.remove('dark');
|
|
55
|
+
}
|
|
56
|
+
if (props.onchange) props.onchange(darkMode.value);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
</script>
|
|
61
|
+
|
|
62
|
+
<template>
|
|
63
|
+
<button
|
|
64
|
+
@click="toggleDarkMode"
|
|
65
|
+
class="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-slate-800/70 transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-400/50"
|
|
66
|
+
aria-label="Toggle dark mode"
|
|
67
|
+
>
|
|
68
|
+
<template v-if="darkMode">
|
|
69
|
+
<!-- Sun icon for light mode -->
|
|
70
|
+
<svg class="w-5 h-5 text-slate-600 dark:text-slate-300 hover:text-slate-900 dark:hover:text-slate-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
71
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
|
72
|
+
</svg>
|
|
73
|
+
</template>
|
|
74
|
+
<template v-else>
|
|
75
|
+
<!-- Moon icon for dark mode -->
|
|
76
|
+
<svg class="w-5 h-5 text-slate-600 dark:text-slate-300 hover:text-slate-900 dark:hover:text-slate-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
77
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
|
78
|
+
</svg>
|
|
79
|
+
</template>
|
|
80
|
+
</button>
|
|
81
|
+
</template>
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
|
3
|
+
import { usePage, router, Link } from '@inertiajs/vue3';
|
|
4
|
+
import DarkModeToggle from './DarkModeToggle.vue';
|
|
5
|
+
|
|
6
|
+
interface User {
|
|
7
|
+
id: string;
|
|
8
|
+
name: string;
|
|
9
|
+
email: string;
|
|
10
|
+
is_admin: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface MenuLink {
|
|
14
|
+
href: string;
|
|
15
|
+
label: string;
|
|
16
|
+
group: string;
|
|
17
|
+
show: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const props = defineProps<{
|
|
21
|
+
group: string;
|
|
22
|
+
}>();
|
|
23
|
+
|
|
24
|
+
const page = usePage();
|
|
25
|
+
const user = computed(() => page.props.user as User | undefined);
|
|
26
|
+
|
|
27
|
+
const scrollY = ref(0);
|
|
28
|
+
const isMenuOpen = ref(false);
|
|
29
|
+
|
|
30
|
+
const scrolled = computed(() => scrollY.value > 50);
|
|
31
|
+
|
|
32
|
+
const menuLinks = computed(() => [
|
|
33
|
+
{ href: '/dashboard', label: 'Overview', group: 'dashboard', show: true },
|
|
34
|
+
{ href: '/users', label: 'Users', group: 'users', show: !!(user.value?.is_admin) },
|
|
35
|
+
{ href: '/profile', label: 'Profile', group: 'profile', show: !!user.value },
|
|
36
|
+
] as MenuLink[]);
|
|
37
|
+
|
|
38
|
+
const visibleMenuLinks = computed(() => menuLinks.value.filter((item) => item.show));
|
|
39
|
+
|
|
40
|
+
const handleLogout = () => {
|
|
41
|
+
router.post('/logout');
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const updateScroll = () => {
|
|
45
|
+
scrollY.value = window.scrollY;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
onMounted(() => {
|
|
49
|
+
window.addEventListener('scroll', updateScroll);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
onUnmounted(() => {
|
|
53
|
+
window.removeEventListener('scroll', updateScroll);
|
|
54
|
+
});
|
|
55
|
+
</script>
|
|
56
|
+
|
|
57
|
+
<template>
|
|
58
|
+
<header
|
|
59
|
+
class="fixed inset-x-0 top-0 z-50 transition-all duration-500"
|
|
60
|
+
:class="scrolled
|
|
61
|
+
? 'bg-white/90 dark:bg-surface-dark/95 backdrop-blur-xl border-b border-slate-200/50 dark:border-white/5'
|
|
62
|
+
: 'bg-white/90 dark:bg-surface-dark/95 backdrop-blur-xl'"
|
|
63
|
+
>
|
|
64
|
+
<nav class="px-6 sm:px-12 lg:px-24 py-5 flex items-center justify-between">
|
|
65
|
+
|
|
66
|
+
<!-- Left: Brand + Nav -->
|
|
67
|
+
<div class="flex items-center gap-10">
|
|
68
|
+
<!-- Radical Brand -->
|
|
69
|
+
<Link
|
|
70
|
+
href="/"
|
|
71
|
+
class="group flex items-center gap-3"
|
|
72
|
+
>
|
|
73
|
+
<span class="text-xl font-bold tracking-tighter text-slate-900 dark:text-white group-hover:text-primary-500 transition-colors">
|
|
74
|
+
NARA.
|
|
75
|
+
</span>
|
|
76
|
+
<span class="hidden sm:block text-[9px] uppercase tracking-[0.3em] text-slate-400 dark:text-slate-500 font-medium">
|
|
77
|
+
Dashboard
|
|
78
|
+
</span>
|
|
79
|
+
</Link>
|
|
80
|
+
|
|
81
|
+
<!-- Desktop Navigation - Radical Style -->
|
|
82
|
+
<div class="hidden md:flex items-center gap-1">
|
|
83
|
+
<template v-for="(item, i) in visibleMenuLinks" :key="item.href">
|
|
84
|
+
<Link
|
|
85
|
+
:href="item.href"
|
|
86
|
+
class="relative px-4 py-2 text-xs font-medium uppercase tracking-[0.15em] transition-all duration-300"
|
|
87
|
+
:class="item.group === group
|
|
88
|
+
? 'text-primary-500'
|
|
89
|
+
: 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white'"
|
|
90
|
+
>
|
|
91
|
+
{{ item.label }}
|
|
92
|
+
<span v-if="item.group === group" class="absolute bottom-0 left-4 right-4 h-px bg-primary-500"></span>
|
|
93
|
+
</Link>
|
|
94
|
+
<span v-if="i < visibleMenuLinks.length - 1" class="w-px h-3 bg-slate-200 dark:bg-slate-700"></span>
|
|
95
|
+
</template>
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
|
|
99
|
+
<!-- Right: Actions -->
|
|
100
|
+
<div class="flex items-center gap-4">
|
|
101
|
+
<!-- Current Page Indicator (Mobile) -->
|
|
102
|
+
<span class="md:hidden text-[10px] uppercase tracking-[0.2em] text-primary-500 font-medium">
|
|
103
|
+
{{ group }}
|
|
104
|
+
</span>
|
|
105
|
+
|
|
106
|
+
<div class="h-4 w-px bg-slate-200 dark:bg-slate-700 hidden sm:block"></div>
|
|
107
|
+
|
|
108
|
+
<DarkModeToggle />
|
|
109
|
+
|
|
110
|
+
<!-- Auth Actions -->
|
|
111
|
+
<div class="hidden sm:flex items-center gap-3">
|
|
112
|
+
<template v-if="user && user.id">
|
|
113
|
+
<div class="flex items-center gap-3">
|
|
114
|
+
<span class="text-xs text-slate-500 dark:text-slate-400">
|
|
115
|
+
{{ user.name }}
|
|
116
|
+
</span>
|
|
117
|
+
<button
|
|
118
|
+
@click="handleLogout"
|
|
119
|
+
class="group relative px-5 py-2 text-xs font-bold uppercase tracking-wider overflow-hidden rounded-full border border-slate-200 dark:border-slate-700 hover:border-red-500/50 dark:hover:border-red-500/50 transition-colors"
|
|
120
|
+
>
|
|
121
|
+
<span class="relative z-10 text-slate-600 dark:text-slate-300 group-hover:text-red-500 transition-colors">
|
|
122
|
+
Logout
|
|
123
|
+
</span>
|
|
124
|
+
</button>
|
|
125
|
+
</div>
|
|
126
|
+
</template>
|
|
127
|
+
<template v-else>
|
|
128
|
+
<Link
|
|
129
|
+
href="/login"
|
|
130
|
+
class="text-xs font-medium text-slate-600 dark:text-slate-300 hover:text-slate-900 dark:hover:text-white transition-colors"
|
|
131
|
+
>
|
|
132
|
+
Login
|
|
133
|
+
</Link>
|
|
134
|
+
<Link
|
|
135
|
+
href="/register"
|
|
136
|
+
class="group relative px-5 py-2.5 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"
|
|
137
|
+
>
|
|
138
|
+
<span class="relative z-10">Register</span>
|
|
139
|
+
<div class="absolute inset-0 bg-primary-500 transform translate-y-full group-hover:translate-y-0 transition-transform duration-300"></div>
|
|
140
|
+
</Link>
|
|
141
|
+
</template>
|
|
142
|
+
</div>
|
|
143
|
+
|
|
144
|
+
<!-- Mobile Menu Button -->
|
|
145
|
+
<button
|
|
146
|
+
class="md:hidden relative w-10 h-10 flex items-center justify-center rounded-full border border-slate-200 dark:border-slate-700 hover:border-primary-500/50 transition-colors text-slate-900 dark:text-white"
|
|
147
|
+
@click="isMenuOpen = !isMenuOpen"
|
|
148
|
+
aria-label="Menu"
|
|
149
|
+
>
|
|
150
|
+
<div class="flex flex-col gap-1.5 w-4">
|
|
151
|
+
<span class="block h-px bg-slate-900 dark:bg-white transition-transform duration-300" :class="{ 'rotate-45 translate-y-[3.5px]': isMenuOpen }"></span>
|
|
152
|
+
<span class="block h-px bg-slate-900 dark:bg-white transition-opacity duration-300" :class="{ 'opacity-0': isMenuOpen }"></span>
|
|
153
|
+
<span class="block h-px bg-slate-900 dark:bg-white transition-transform duration-300" :class="{ '-rotate-45 -translate-y-[3.5px]': isMenuOpen }"></span>
|
|
154
|
+
</div>
|
|
155
|
+
</button>
|
|
156
|
+
</div>
|
|
157
|
+
</nav>
|
|
158
|
+
</header>
|
|
159
|
+
|
|
160
|
+
<!-- Mobile Menu -->
|
|
161
|
+
<Teleport to="body">
|
|
162
|
+
<Transition
|
|
163
|
+
enter-active-class="transition duration-200 ease-out"
|
|
164
|
+
enter-from-class="opacity-0"
|
|
165
|
+
enter-to-class="opacity-100"
|
|
166
|
+
leave-active-class="transition duration-200 ease-in"
|
|
167
|
+
leave-from-class="opacity-100"
|
|
168
|
+
leave-to-class="opacity-0"
|
|
169
|
+
>
|
|
170
|
+
<div v-if="isMenuOpen" class="fixed inset-0 bg-white dark:bg-surface-dark z-[9999] md:hidden overflow-y-auto">
|
|
171
|
+
<!-- Close Button -->
|
|
172
|
+
<button
|
|
173
|
+
class="absolute top-6 right-6 w-10 h-10 flex items-center justify-center rounded-full border border-slate-200 dark:border-slate-700 text-slate-900 dark:text-white z-10"
|
|
174
|
+
@click="isMenuOpen = false"
|
|
175
|
+
aria-label="Close menu"
|
|
176
|
+
>
|
|
177
|
+
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
178
|
+
<path d="M18 6L6 18M6 6l12 12" stroke-linecap="round" stroke-linejoin="round"/>
|
|
179
|
+
</svg>
|
|
180
|
+
</button>
|
|
181
|
+
|
|
182
|
+
<!-- Brand -->
|
|
183
|
+
<div class="absolute top-6 left-6 z-10">
|
|
184
|
+
<span class="text-xl font-bold tracking-tighter text-slate-900 dark:text-white">NARA.</span>
|
|
185
|
+
</div>
|
|
186
|
+
|
|
187
|
+
<!-- Menu Content -->
|
|
188
|
+
<div class="min-h-screen flex flex-col justify-center px-8 sm:px-12 py-24">
|
|
189
|
+
<!-- Navigation Links -->
|
|
190
|
+
<nav class="space-y-6 mb-12">
|
|
191
|
+
<template v-for="(item, i) in visibleMenuLinks" :key="item.href">
|
|
192
|
+
<Link
|
|
193
|
+
:href="item.href"
|
|
194
|
+
@click="isMenuOpen = false"
|
|
195
|
+
class="block text-4xl sm:text-5xl font-bold tracking-tighter transition-all duration-300"
|
|
196
|
+
:class="item.group === group
|
|
197
|
+
? 'text-primary-500'
|
|
198
|
+
: 'text-slate-900 dark:text-white hover:text-primary-500 dark:hover:text-primary-400 hover:translate-x-2'"
|
|
199
|
+
:style="{ transitionDelay: `${i * 100}ms` }"
|
|
200
|
+
>
|
|
201
|
+
<span class="inline-flex items-center gap-4">
|
|
202
|
+
<span class="text-xs font-mono text-slate-400 dark:text-slate-500">0{{ i + 1 }}</span>
|
|
203
|
+
{{ item.label }}
|
|
204
|
+
</span>
|
|
205
|
+
</Link>
|
|
206
|
+
</template>
|
|
207
|
+
</nav>
|
|
208
|
+
|
|
209
|
+
<!-- Mobile Auth -->
|
|
210
|
+
<div class="pt-8 border-t border-slate-200 dark:border-slate-800">
|
|
211
|
+
<template v-if="user">
|
|
212
|
+
<p class="text-xs uppercase tracking-[0.2em] text-slate-400 dark:text-slate-500 mb-4">Signed in as</p>
|
|
213
|
+
<p class="text-lg font-medium text-slate-900 dark:text-white mb-6">{{ user.name }}</p>
|
|
214
|
+
<button
|
|
215
|
+
@click="handleLogout"
|
|
216
|
+
class="px-6 py-3 text-sm font-bold uppercase tracking-wider border border-slate-200 dark:border-slate-700 text-slate-900 dark:text-white rounded-full hover:border-red-500 hover:text-red-500 transition-colors"
|
|
217
|
+
>
|
|
218
|
+
Logout
|
|
219
|
+
</button>
|
|
220
|
+
</template>
|
|
221
|
+
<template v-else>
|
|
222
|
+
<div class="flex gap-4">
|
|
223
|
+
<Link
|
|
224
|
+
href="/login"
|
|
225
|
+
class="px-6 py-3 text-sm font-bold uppercase tracking-wider border border-slate-200 dark:border-slate-700 text-slate-900 dark:text-white rounded-full hover:border-primary-500 transition-colors"
|
|
226
|
+
>
|
|
227
|
+
Login
|
|
228
|
+
</Link>
|
|
229
|
+
<Link
|
|
230
|
+
href="/register"
|
|
231
|
+
class="px-6 py-3 text-sm font-bold uppercase tracking-wider bg-slate-900 dark:bg-white text-white dark:text-black rounded-full"
|
|
232
|
+
>
|
|
233
|
+
Register
|
|
234
|
+
</Link>
|
|
235
|
+
</div>
|
|
236
|
+
</template>
|
|
237
|
+
</div>
|
|
238
|
+
</div>
|
|
239
|
+
|
|
240
|
+
<!-- Decorative -->
|
|
241
|
+
<div class="absolute bottom-8 left-8 sm:left-12 text-[10px] uppercase tracking-[0.3em] text-slate-300 dark:text-slate-600">
|
|
242
|
+
NARA Framework
|
|
243
|
+
</div>
|
|
244
|
+
|
|
245
|
+
<!-- Background Decoration -->
|
|
246
|
+
<div class="absolute top-0 right-0 w-64 h-64 bg-primary-500/5 rounded-full blur-3xl pointer-events-none"></div>
|
|
247
|
+
<div class="absolute bottom-0 left-0 w-48 h-48 bg-accent-500/5 rounded-full blur-3xl pointer-events-none"></div>
|
|
248
|
+
</div>
|
|
249
|
+
</Transition>
|
|
250
|
+
</Teleport>
|
|
251
|
+
</template>
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue';
|
|
3
|
+
import { router } from '@inertiajs/vue3';
|
|
4
|
+
|
|
5
|
+
interface PaginationMeta {
|
|
6
|
+
page: number;
|
|
7
|
+
total: number;
|
|
8
|
+
totalPages: number;
|
|
9
|
+
hasNext: boolean;
|
|
10
|
+
hasPrev: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const props = withDefaults(defineProps<{
|
|
14
|
+
meta: PaginationMeta;
|
|
15
|
+
preserveState?: boolean;
|
|
16
|
+
}>(), {
|
|
17
|
+
preserveState: true
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const goToPage = (page: number) => {
|
|
21
|
+
const url = new URL(window.location.href);
|
|
22
|
+
url.searchParams.set('page', String(page));
|
|
23
|
+
router.visit(url.pathname + url.search, {
|
|
24
|
+
preserveScroll: true,
|
|
25
|
+
preserveState: props.preserveState
|
|
26
|
+
});
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const pages = computed(() => {
|
|
30
|
+
const length = Math.min(5, props.meta.totalPages);
|
|
31
|
+
const start = Math.max(1, Math.min(props.meta.page - 2, props.meta.totalPages - 4));
|
|
32
|
+
return Array.from({ length }, (_, i) => start + i).filter(p => p <= props.meta.totalPages);
|
|
33
|
+
});
|
|
34
|
+
</script>
|
|
35
|
+
|
|
36
|
+
<template>
|
|
37
|
+
<div v-if="meta.totalPages > 1" class="flex items-center justify-between mt-6 text-xs text-slate-400">
|
|
38
|
+
<div>
|
|
39
|
+
Halaman {{ meta.page }} dari {{ meta.totalPages }} ({{ meta.total }} total)
|
|
40
|
+
</div>
|
|
41
|
+
<div class="flex items-center gap-2">
|
|
42
|
+
<button
|
|
43
|
+
class="px-3 py-1.5 rounded-full bg-slate-800 dark:bg-slate-200 hover:bg-slate-700 dark:hover:bg-slate-300 text-slate-100 dark:text-slate-800 transition disabled:opacity-50 disabled:cursor-not-allowed"
|
|
44
|
+
@click="goToPage(meta.page - 1)"
|
|
45
|
+
:disabled="!meta.hasPrev"
|
|
46
|
+
>
|
|
47
|
+
← Prev
|
|
48
|
+
</button>
|
|
49
|
+
|
|
50
|
+
<button
|
|
51
|
+
v-for="pageNum in pages"
|
|
52
|
+
:key="pageNum"
|
|
53
|
+
class="px-3 py-1.5 rounded-full transition"
|
|
54
|
+
:class="pageNum === meta.page
|
|
55
|
+
? 'bg-primary-500 text-white'
|
|
56
|
+
: 'bg-slate-800 dark:bg-slate-200 hover:bg-slate-700 dark:hover:bg-slate-300 text-slate-100 dark:text-slate-800'"
|
|
57
|
+
@click="goToPage(pageNum)"
|
|
58
|
+
>
|
|
59
|
+
{{ pageNum }}
|
|
60
|
+
</button>
|
|
61
|
+
|
|
62
|
+
<button
|
|
63
|
+
class="px-3 py-1.5 rounded-full bg-slate-800 dark:bg-slate-200 hover:bg-slate-700 dark:hover:bg-slate-300 text-slate-100 dark:text-slate-800 transition disabled:opacity-50 disabled:cursor-not-allowed"
|
|
64
|
+
@click="goToPage(meta.page + 1)"
|
|
65
|
+
:disabled="!meta.hasNext"
|
|
66
|
+
>
|
|
67
|
+
Next →
|
|
68
|
+
</button>
|
|
69
|
+
</div>
|
|
70
|
+
</div>
|
|
71
|
+
</template>
|