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
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
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
package/dist/template.js
ADDED
|
@@ -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,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,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,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}
|