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.
Files changed (50) hide show
  1. package/README.md +17 -0
  2. package/dist/cli.d.ts +1 -0
  3. package/dist/cli.js +50 -0
  4. package/dist/index.d.ts +2 -0
  5. package/dist/index.js +3 -0
  6. package/dist/template.d.ts +8 -0
  7. package/dist/template.js +68 -0
  8. package/package.json +28 -0
  9. package/templates/base/.env.example +3 -0
  10. package/templates/base/tsconfig.json +14 -0
  11. package/templates/minimal/routes/web.ts +11 -0
  12. package/templates/minimal/server.ts +10 -0
  13. package/templates/svelte/resources/js/app.ts +12 -0
  14. package/templates/svelte/resources/js/components/DarkModeToggle.svelte +67 -0
  15. package/templates/svelte/resources/js/components/Header.svelte +240 -0
  16. package/templates/svelte/resources/js/components/NaraIcon.svelte +3 -0
  17. package/templates/svelte/resources/js/components/Pagination.svelte +55 -0
  18. package/templates/svelte/resources/js/components/UserModal.svelte +234 -0
  19. package/templates/svelte/resources/js/components/helper.ts +300 -0
  20. package/templates/svelte/resources/js/pages/auth/forgot-password.svelte +97 -0
  21. package/templates/svelte/resources/js/pages/auth/login.svelte +138 -0
  22. package/templates/svelte/resources/js/pages/auth/register.svelte +176 -0
  23. package/templates/svelte/resources/js/pages/auth/reset-password.svelte +106 -0
  24. package/templates/svelte/resources/js/pages/dashboard.svelte +224 -0
  25. package/templates/svelte/resources/js/pages/landing.svelte +446 -0
  26. package/templates/svelte/resources/js/pages/profile.svelte +368 -0
  27. package/templates/svelte/resources/js/pages/users.svelte +260 -0
  28. package/templates/svelte/resources/views/inertia.html +12 -0
  29. package/templates/svelte/routes/web.ts +17 -0
  30. package/templates/svelte/server.ts +12 -0
  31. package/templates/svelte/vite.config.ts +19 -0
  32. package/templates/vue/resources/js/app.ts +14 -0
  33. package/templates/vue/resources/js/components/DarkModeToggle.vue +81 -0
  34. package/templates/vue/resources/js/components/Header.vue +251 -0
  35. package/templates/vue/resources/js/components/NaraIcon.vue +5 -0
  36. package/templates/vue/resources/js/components/Pagination.vue +71 -0
  37. package/templates/vue/resources/js/components/UserModal.vue +276 -0
  38. package/templates/vue/resources/js/components/index.ts +5 -0
  39. package/templates/vue/resources/js/pages/auth/forgot-password.vue +105 -0
  40. package/templates/vue/resources/js/pages/auth/login.vue +142 -0
  41. package/templates/vue/resources/js/pages/auth/register.vue +183 -0
  42. package/templates/vue/resources/js/pages/auth/reset-password.vue +115 -0
  43. package/templates/vue/resources/js/pages/dashboard.vue +233 -0
  44. package/templates/vue/resources/js/pages/landing.vue +358 -0
  45. package/templates/vue/resources/js/pages/profile.vue +370 -0
  46. package/templates/vue/resources/js/pages/users.vue +264 -0
  47. package/templates/vue/resources/views/inertia.html +12 -0
  48. package/templates/vue/routes/web.ts +17 -0
  49. package/templates/vue/server.ts +12 -0
  50. package/templates/vue/vite.config.ts +19 -0
package/README.md ADDED
@@ -0,0 +1,17 @@
1
+ # create-nara
2
+
3
+ CLI tool to scaffold new NARA projects.
4
+
5
+ ## Usage
6
+
7
+ ```bash
8
+ npm create nara@latest my-project
9
+ # or
10
+ npx create-nara my-project
11
+ ```
12
+
13
+ ## Modes
14
+
15
+ - **Minimal**: API-only backend without frontend
16
+ - **Svelte**: Full-stack with Svelte 5 + Inertia.js
17
+ - **Vue**: Full-stack with Vue 3 + Inertia.js
package/dist/cli.d.ts ADDED
@@ -0,0 +1 @@
1
+ export declare function main(): Promise<void>;
package/dist/cli.js ADDED
@@ -0,0 +1,50 @@
1
+ import prompts from 'prompts';
2
+ import pc from 'picocolors';
3
+ import path from 'path';
4
+ import { setupProject } from './template.js';
5
+ export async function main() {
6
+ console.log(pc.bold(pc.cyan('\n 🚀 NARA - Create New Project\n')));
7
+ let projectName = process.argv[2];
8
+ if (!projectName) {
9
+ const res = await prompts({
10
+ type: 'text',
11
+ name: 'projectName',
12
+ message: 'Project name:',
13
+ initial: 'my-nara-app'
14
+ });
15
+ projectName = res.projectName;
16
+ if (!projectName)
17
+ process.exit(1);
18
+ }
19
+ const { mode } = await prompts({
20
+ type: 'select',
21
+ name: 'mode',
22
+ message: 'Select project mode:',
23
+ choices: [
24
+ { title: 'Minimal (API only)', value: 'minimal', description: 'Backend API without frontend' },
25
+ { title: 'Fullstack with Svelte 5', value: 'svelte', description: 'Recommended - Full stack with Svelte' },
26
+ { title: 'Fullstack with Vue 3', value: 'vue', description: 'Full stack with Vue' }
27
+ ],
28
+ initial: 1
29
+ });
30
+ if (!mode)
31
+ process.exit(1);
32
+ const { features } = await prompts({
33
+ type: 'multiselect',
34
+ name: 'features',
35
+ message: 'Select features:',
36
+ choices: [
37
+ { title: 'Authentication', value: 'auth', selected: true },
38
+ { title: 'Database (SQLite)', value: 'db', selected: true },
39
+ { title: 'File uploads', value: 'uploads', selected: false }
40
+ ]
41
+ });
42
+ const targetDir = path.resolve(process.cwd(), projectName);
43
+ console.log(pc.dim(`\nCreating project in ${targetDir}...\n`));
44
+ await setupProject({ projectName, targetDir, mode, features: features || [] });
45
+ console.log(pc.green('\n✓ Project created successfully!\n'));
46
+ console.log('Next steps:\n');
47
+ console.log(pc.cyan(` cd ${projectName}`));
48
+ console.log(pc.cyan(' npm install'));
49
+ console.log(pc.cyan(' npm run dev\n'));
50
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ import { main } from './cli.js';
3
+ main().catch(console.error);
@@ -0,0 +1,8 @@
1
+ interface ProjectOptions {
2
+ projectName: string;
3
+ targetDir: string;
4
+ mode: 'minimal' | 'svelte' | 'vue';
5
+ features: string[];
6
+ }
7
+ export declare function setupProject(options: ProjectOptions): Promise<void>;
8
+ export {};
@@ -0,0 +1,68 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { fileURLToPath } from 'url';
4
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
5
+ const templatesDir = path.join(__dirname, '..', 'templates');
6
+ export async function setupProject(options) {
7
+ const { projectName, targetDir, mode, features } = options;
8
+ // Create target directory
9
+ if (!fs.existsSync(targetDir)) {
10
+ fs.mkdirSync(targetDir, { recursive: true });
11
+ }
12
+ // 1. Copy base template (shared files like .gitignore, tsconfig, etc)
13
+ copyDir(path.join(templatesDir, 'base'), targetDir);
14
+ // 2. Copy mode-specific template
15
+ const modeTemplateDir = path.join(templatesDir, mode);
16
+ if (fs.existsSync(modeTemplateDir)) {
17
+ copyDir(modeTemplateDir, targetDir);
18
+ }
19
+ // 3. Ensure required directories exist
20
+ fs.mkdirSync(path.join(targetDir, 'app/controllers'), { recursive: true });
21
+ fs.mkdirSync(path.join(targetDir, 'app/models'), { recursive: true });
22
+ // 4. Generate package.json (dynamic content)
23
+ const pkg = createPackageJson(projectName, mode, features);
24
+ fs.writeFileSync(path.join(targetDir, 'package.json'), JSON.stringify(pkg, null, 2));
25
+ }
26
+ function copyDir(src, dest) {
27
+ fs.cpSync(src, dest, { recursive: true, force: true });
28
+ }
29
+ function createPackageJson(name, mode, features) {
30
+ const pkg = {
31
+ name,
32
+ version: '0.1.0',
33
+ type: 'module',
34
+ scripts: {
35
+ dev: mode === 'minimal' ? 'tsx watch server.ts' : 'concurrently "tsx watch server.ts" "vite"',
36
+ build: 'tsc',
37
+ start: 'node dist/server.js'
38
+ },
39
+ dependencies: {
40
+ '@nara-web/core': '^0.1.0',
41
+ 'dotenv': '^16.4.7'
42
+ },
43
+ devDependencies: {
44
+ 'typescript': '^5.7.0',
45
+ 'tsx': '^4.19.0',
46
+ '@types/node': '^22.0.0'
47
+ }
48
+ };
49
+ if (mode === 'svelte') {
50
+ pkg.dependencies['@nara-web/inertia-svelte'] = '^0.1.0';
51
+ pkg.dependencies['svelte'] = '^5.0.0';
52
+ pkg.devDependencies['vite'] = '^6.0.0';
53
+ pkg.devDependencies['@sveltejs/vite-plugin-svelte'] = '^5.0.0';
54
+ pkg.devDependencies['concurrently'] = '^9.0.0';
55
+ }
56
+ else if (mode === 'vue') {
57
+ pkg.dependencies['@nara-web/inertia-vue'] = '^0.1.0';
58
+ pkg.dependencies['vue'] = '^3.5.0';
59
+ pkg.devDependencies['vite'] = '^6.0.0';
60
+ pkg.devDependencies['@vitejs/plugin-vue'] = '^5.0.0';
61
+ pkg.devDependencies['concurrently'] = '^9.0.0';
62
+ }
63
+ if (features.includes('db')) {
64
+ pkg.dependencies['knex'] = '^3.1.0';
65
+ pkg.dependencies['better-sqlite3'] = '^11.0.0';
66
+ }
67
+ return pkg;
68
+ }
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "create-nara",
3
+ "version": "0.1.0",
4
+ "description": "CLI to scaffold NARA projects",
5
+ "type": "module",
6
+ "bin": {
7
+ "create-nara": "./dist/index.js"
8
+ },
9
+ "main": "./dist/index.js",
10
+ "scripts": {
11
+ "build": "tsc",
12
+ "dev": "tsc --watch"
13
+ },
14
+ "dependencies": {
15
+ "prompts": "^2.4.2",
16
+ "picocolors": "^1.1.1"
17
+ },
18
+ "devDependencies": {
19
+ "typescript": "^5.7.0",
20
+ "@types/node": "^22.0.0",
21
+ "@types/prompts": "^2.4.9"
22
+ },
23
+ "files": [
24
+ "dist",
25
+ "templates"
26
+ ],
27
+ "license": "MIT"
28
+ }
@@ -0,0 +1,3 @@
1
+ PORT=3000
2
+ APP_KEY=change-me-to-random-string
3
+ DB_CONNECTION=sqlite
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "skipLibCheck": true,
9
+ "outDir": "./dist",
10
+ "rootDir": "."
11
+ },
12
+ "include": ["**/*.ts"],
13
+ "exclude": ["node_modules", "dist"]
14
+ }
@@ -0,0 +1,11 @@
1
+ import type { NaraApp } from '@nara-web/core';
2
+
3
+ export function registerRoutes(app: NaraApp) {
4
+ app.get('/', (req, res) => {
5
+ res.json({ message: 'Welcome to NARA API' });
6
+ });
7
+
8
+ app.get('/health', (req, res) => {
9
+ res.json({ status: 'ok' });
10
+ });
11
+ }
@@ -0,0 +1,10 @@
1
+ import 'dotenv/config';
2
+ import { createApp } from '@nara-web/core';
3
+ import { registerRoutes } from './routes/web.js';
4
+
5
+ const app = createApp({
6
+ port: Number(process.env.PORT) || 3000
7
+ });
8
+
9
+ registerRoutes(app);
10
+ app.start();
@@ -0,0 +1,12 @@
1
+ import { createInertiaApp } from '@inertiajs/svelte';
2
+ import { mount } from 'svelte';
3
+
4
+ createInertiaApp({
5
+ resolve: name => {
6
+ const pages = import.meta.glob('./pages/**/*.svelte', { eager: true });
7
+ return pages[`./pages/${name}.svelte`];
8
+ },
9
+ setup({ el, App, props }) {
10
+ mount(App, { target: el!, props });
11
+ }
12
+ });
@@ -0,0 +1,67 @@
1
+ <script lang="ts">
2
+ import { onMount } from 'svelte';
3
+
4
+ let darkMode: boolean = false;
5
+ let mounted: boolean = false;
6
+ export let onchange: (mode: boolean) => void = () => {};
7
+
8
+ onMount(() => {
9
+ // Check system preference
10
+ const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
11
+ // Check localStorage or fallback to system preference
12
+ const savedMode = localStorage.getItem('darkMode');
13
+ darkMode = savedMode === null ? systemPrefersDark : savedMode === 'true';
14
+
15
+ // Apply saved preference
16
+ if (darkMode) {
17
+ document.documentElement.classList.add('dark');
18
+ }
19
+
20
+ // Add transition class after initial load to prevent flash
21
+ setTimeout(() => {
22
+ document.documentElement.classList.add('transition-colors');
23
+ mounted = true;
24
+ }, 100);
25
+
26
+ // Listen for system preference changes
27
+ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e: MediaQueryListEvent) => {
28
+ if (localStorage.getItem('darkMode') === null) {
29
+ darkMode = e.matches;
30
+ toggleDarkMode();
31
+ }
32
+ });
33
+ });
34
+
35
+ function toggleDarkMode(): void {
36
+ darkMode = !darkMode;
37
+
38
+ if (darkMode) {
39
+ document.documentElement.classList.add('dark');
40
+ } else {
41
+ document.documentElement.classList.remove('dark');
42
+ }
43
+
44
+ // Save preference to localStorage
45
+ localStorage.setItem('darkMode', String(darkMode));
46
+
47
+ onchange(darkMode);
48
+ }
49
+ </script>
50
+
51
+ <button
52
+ on:click={toggleDarkMode}
53
+ 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"
54
+ aria-label="Toggle dark mode"
55
+ >
56
+ {#if darkMode}
57
+ <!-- Sun icon for light mode -->
58
+ <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">
59
+ <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" />
60
+ </svg>
61
+ {:else}
62
+ <!-- Moon icon for dark mode -->
63
+ <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">
64
+ <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" />
65
+ </svg>
66
+ {/if}
67
+ </button>
@@ -0,0 +1,240 @@
1
+ <script lang="ts">
2
+ import { fly, fade } from 'svelte/transition';
3
+ import { page, router, inertia } from '@inertiajs/svelte';
4
+ import { clickOutside } from '../Components/helper';
5
+ import DarkModeToggle from '../Components/DarkModeToggle.svelte';
6
+
7
+ interface User {
8
+ id: string;
9
+ name: string;
10
+ email: string;
11
+ is_admin: boolean;
12
+ }
13
+
14
+ interface MenuLink {
15
+ href: string;
16
+ label: string;
17
+ group: string;
18
+ show: boolean;
19
+ }
20
+
21
+ $: user = $page.props.user as User | undefined;
22
+ let scrollY = 0;
23
+ let isMenuOpen: boolean = false;
24
+
25
+ export let group: string;
26
+
27
+ $: scrolled = scrollY > 50;
28
+
29
+ $: menuLinks = [
30
+ { href: '/dashboard', label: 'Overview', group: 'dashboard', show: true },
31
+ { href: '/users', label: 'Users', group: 'users', show: !!(user?.is_admin) },
32
+ { href: '/profile', label: 'Profile', group: 'profile', show: !!user },
33
+ ] as MenuLink[];
34
+
35
+ $: visibleMenuLinks = menuLinks.filter((item) => item.show);
36
+
37
+ function handleLogout(): void {
38
+ router.post('/logout');
39
+ }
40
+ </script>
41
+
42
+ <svelte:window bind:scrollY />
43
+
44
+ <header
45
+ class="fixed inset-x-0 top-0 z-50 transition-all duration-500 {scrolled
46
+ ? 'bg-white/90 dark:bg-surface-dark/95 backdrop-blur-xl border-b border-slate-200/50 dark:border-white/5'
47
+ : 'bg-white/90 dark:bg-surface-dark/95 backdrop-blur-xl'}"
48
+ >
49
+ <nav class="px-6 sm:px-12 lg:px-24 py-5 flex items-center justify-between">
50
+
51
+ <!-- Left: Brand + Nav -->
52
+ <div class="flex items-center gap-10">
53
+ <!-- Radical Brand -->
54
+ <a
55
+ href="/"
56
+ use:inertia
57
+ class="group flex items-center gap-3"
58
+ >
59
+ <span class="text-xl font-bold tracking-tighter text-slate-900 dark:text-white group-hover:text-primary-500 transition-colors">
60
+ NARA.
61
+ </span>
62
+ <span class="hidden sm:block text-[9px] uppercase tracking-[0.3em] text-slate-400 dark:text-slate-500 font-medium">
63
+ Dashboard
64
+ </span>
65
+ </a>
66
+
67
+ <!-- Desktop Navigation - Radical Style -->
68
+ <div class="hidden md:flex items-center gap-1">
69
+ {#each visibleMenuLinks as item, i}
70
+ <a
71
+ use:inertia
72
+ href={item.href}
73
+ class="relative px-4 py-2 text-xs font-medium uppercase tracking-[0.15em] transition-all duration-300
74
+ {item.group === group
75
+ ? 'text-primary-500'
76
+ : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white'}"
77
+ >
78
+ {item.label}
79
+ {#if item.group === group}
80
+ <span class="absolute bottom-0 left-4 right-4 h-px bg-primary-500"></span>
81
+ {/if}
82
+ </a>
83
+ {#if i < visibleMenuLinks.length - 1}
84
+ <span class="w-px h-3 bg-slate-200 dark:bg-slate-700"></span>
85
+ {/if}
86
+ {/each}
87
+ </div>
88
+ </div>
89
+
90
+ <!-- Right: Actions -->
91
+ <div class="flex items-center gap-4">
92
+ <!-- Current Page Indicator (Mobile) -->
93
+ <span class="md:hidden text-[10px] uppercase tracking-[0.2em] text-primary-500 font-medium">
94
+ {group}
95
+ </span>
96
+
97
+ <div class="h-4 w-px bg-slate-200 dark:bg-slate-700 hidden sm:block"></div>
98
+
99
+ <DarkModeToggle />
100
+
101
+ <!-- Auth Actions -->
102
+ <div class="hidden sm:flex items-center gap-3">
103
+ {#if user && user.id}
104
+ <div class="flex items-center gap-3">
105
+ <span class="text-xs text-slate-500 dark:text-slate-400">
106
+ {user.name}
107
+ </span>
108
+ <button
109
+ on:click={handleLogout}
110
+ 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"
111
+ >
112
+ <span class="relative z-10 text-slate-600 dark:text-slate-300 group-hover:text-red-500 transition-colors">
113
+ Logout
114
+ </span>
115
+ </button>
116
+ </div>
117
+ {:else}
118
+ <a
119
+ href="/login"
120
+ use:inertia
121
+ class="text-xs font-medium text-slate-600 dark:text-slate-300 hover:text-slate-900 dark:hover:text-white transition-colors"
122
+ >
123
+ Login
124
+ </a>
125
+ <a
126
+ href="/register"
127
+ use:inertia
128
+ 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"
129
+ >
130
+ <span class="relative z-10">Register</span>
131
+ <div class="absolute inset-0 bg-primary-500 transform translate-y-full group-hover:translate-y-0 transition-transform duration-300"></div>
132
+ </a>
133
+ {/if}
134
+ </div>
135
+
136
+ <!-- Mobile Menu Button -->
137
+ <button
138
+ 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"
139
+ on:click={() => isMenuOpen = !isMenuOpen}
140
+ aria-label="Menu"
141
+ >
142
+ <div class="flex flex-col gap-1.5 w-4">
143
+ <span class="block h-px bg-slate-900 dark:bg-white transition-transform duration-300 {isMenuOpen ? 'rotate-45 translate-y-[3.5px]' : ''}"></span>
144
+ <span class="block h-px bg-slate-900 dark:bg-white transition-opacity duration-300 {isMenuOpen ? 'opacity-0' : ''}"></span>
145
+ <span class="block h-px bg-slate-900 dark:bg-white transition-transform duration-300 {isMenuOpen ? '-rotate-45 -translate-y-[3.5px]' : ''}"></span>
146
+ </div>
147
+ </button>
148
+ </div>
149
+ </nav>
150
+
151
+ </header>
152
+
153
+ <!-- Mobile Menu - Radical Full Screen (Outside header for proper z-index) -->
154
+ {#if isMenuOpen}
155
+ <div
156
+ transition:fade={{ duration: 200 }}
157
+ class="fixed inset-0 bg-white dark:bg-surface-dark z-[9999] md:hidden overflow-y-auto"
158
+ style="top: 0; left: 0; right: 0; bottom: 0;"
159
+ >
160
+ <!-- Close Button -->
161
+ <button
162
+ 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"
163
+ on:click={() => isMenuOpen = false}
164
+ aria-label="Close menu"
165
+ >
166
+ <svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
167
+ <path d="M18 6L6 18M6 6l12 12" stroke-linecap="round" stroke-linejoin="round"/>
168
+ </svg>
169
+ </button>
170
+
171
+ <!-- Brand -->
172
+ <div class="absolute top-6 left-6 z-10">
173
+ <span class="text-xl font-bold tracking-tighter text-slate-900 dark:text-white">NARA.</span>
174
+ </div>
175
+
176
+ <!-- Menu Content -->
177
+ <div class="min-h-screen flex flex-col justify-center px-8 sm:px-12 py-24">
178
+ <!-- Navigation Links -->
179
+ <nav class="space-y-6 mb-12">
180
+ {#each visibleMenuLinks as item, i}
181
+ <a
182
+ href={item.href}
183
+ use:inertia
184
+ on:click={() => isMenuOpen = false}
185
+ class="block text-4xl sm:text-5xl font-bold tracking-tighter transition-all duration-300
186
+ {item.group === group
187
+ ? 'text-primary-500'
188
+ : 'text-slate-900 dark:text-white hover:text-primary-500 dark:hover:text-primary-400 hover:translate-x-2'}"
189
+ in:fly={{ y: 20, duration: 400, delay: i * 100 }}
190
+ >
191
+ <span class="inline-flex items-center gap-4">
192
+ <span class="text-xs font-mono text-slate-400 dark:text-slate-500">0{i + 1}</span>
193
+ {item.label}
194
+ </span>
195
+ </a>
196
+ {/each}
197
+ </nav>
198
+
199
+ <!-- Mobile Auth -->
200
+ <div class="pt-8 border-t border-slate-200 dark:border-slate-800" in:fly={{ y: 20, duration: 400, delay: 300 }}>
201
+ {#if user}
202
+ <p class="text-xs uppercase tracking-[0.2em] text-slate-400 dark:text-slate-500 mb-4">Signed in as</p>
203
+ <p class="text-lg font-medium text-slate-900 dark:text-white mb-6">{user.name}</p>
204
+ <button
205
+ on:click={handleLogout}
206
+ 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"
207
+ >
208
+ Logout
209
+ </button>
210
+ {:else}
211
+ <div class="flex gap-4">
212
+ <a
213
+ href="/login"
214
+ use:inertia
215
+ 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"
216
+ >
217
+ Login
218
+ </a>
219
+ <a
220
+ href="/register"
221
+ use:inertia
222
+ 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"
223
+ >
224
+ Register
225
+ </a>
226
+ </div>
227
+ {/if}
228
+ </div>
229
+ </div>
230
+
231
+ <!-- Decorative -->
232
+ <div class="absolute bottom-8 left-8 sm:left-12 text-[10px] uppercase tracking-[0.3em] text-slate-300 dark:text-slate-600">
233
+ NARA Framework
234
+ </div>
235
+
236
+ <!-- Background Decoration -->
237
+ <div class="absolute top-0 right-0 w-64 h-64 bg-primary-500/5 rounded-full blur-3xl pointer-events-none"></div>
238
+ <div class="absolute bottom-0 left-0 w-48 h-48 bg-accent-500/5 rounded-full blur-3xl pointer-events-none"></div>
239
+ </div>
240
+ {/if}
@@ -0,0 +1,3 @@
1
+ <span class="flex gap-2 items-center">
2
+ <img class="h-12 w-12 rounded-2xl" src="/public/nara.png" alt="Nara logo">
3
+ </span>
@@ -0,0 +1,55 @@
1
+ <script lang="ts">
2
+ import { router } from '@inertiajs/svelte';
3
+ import type { PaginationMeta } from '../types';
4
+
5
+ export let meta: PaginationMeta;
6
+ export let preserveState: boolean = true;
7
+
8
+ function goToPage(page: number): void {
9
+ const url = new URL(window.location.href);
10
+ url.searchParams.set('page', String(page));
11
+ router.visit(url.pathname + url.search, {
12
+ preserveScroll: true,
13
+ preserveState
14
+ });
15
+ }
16
+ </script>
17
+
18
+ {#if meta.totalPages > 1}
19
+ <div class="flex items-center justify-between mt-6 text-xs text-slate-400">
20
+ <div>
21
+ Halaman {meta.page} dari {meta.totalPages} ({meta.total} total)
22
+ </div>
23
+ <div class="flex items-center gap-2">
24
+ <button
25
+ 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"
26
+ on:click={() => goToPage(meta.page - 1)}
27
+ disabled={!meta.hasPrev}
28
+ >
29
+ ← Prev
30
+ </button>
31
+
32
+ {#each Array.from({ length: Math.min(5, meta.totalPages) }, (_, i) => {
33
+ const start = Math.max(1, Math.min(meta.page - 2, meta.totalPages - 4));
34
+ return start + i;
35
+ }).filter(p => p <= meta.totalPages) as pageNum}
36
+ <button
37
+ class="px-3 py-1.5 rounded-full transition {pageNum === meta.page
38
+ ? 'bg-primary-500 text-white'
39
+ : 'bg-slate-800 dark:bg-slate-200 hover:bg-slate-700 dark:hover:bg-slate-300 text-slate-100 dark:text-slate-800'}"
40
+ on:click={() => goToPage(pageNum)}
41
+ >
42
+ {pageNum}
43
+ </button>
44
+ {/each}
45
+
46
+ <button
47
+ 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"
48
+ on:click={() => goToPage(meta.page + 1)}
49
+ disabled={!meta.hasNext}
50
+ >
51
+ Next →
52
+ </button>
53
+ </div>
54
+ </div>
55
+ {/if}