@untrustnova/nova-cli 1.0.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 ADDED
@@ -0,0 +1,50 @@
1
+ # Nova CLI
2
+
3
+ CLI resmi untuk membuat dan menjalankan proyek Nova.js.
4
+
5
+ ## Instalasi
6
+
7
+ ```bash
8
+ npm install -g @untrustnova/nova-cli
9
+ ```
10
+
11
+ ## Perintah Utama
12
+
13
+ ```bash
14
+ nova new <name> # Scaffold project baru (fetch template)
15
+ nova dev # Jalankan backend + Vite dev server
16
+ nova build # Build produksi (Vite)
17
+ nova db:init # Drizzle generate
18
+ nova db:push # Drizzle push
19
+ nova create:controller <name> # Controller baru
20
+ nova create:middleware <name> # Middleware baru
21
+ nova create:migration <name> # Migration baru
22
+ ```
23
+
24
+ ## Alur Cepat
25
+
26
+ ```bash
27
+ npm install -g @untrustnova/nova-cli
28
+ nova new my-app
29
+ cd my-app
30
+ npm run dev
31
+ ```
32
+
33
+ ## Struktur Template
34
+
35
+ Template menggunakan:
36
+ - `nova.config.js` sebagai konfigurasi utama (tanpa `vite.config.js`)
37
+ - Routing hybrid (object + file-based)
38
+ - Folder `app/` untuk server-side
39
+ - Folder `web/` untuk frontend React
40
+
41
+ ## Catatan
42
+
43
+ `nova new` akan mencoba fetch template dari repo `nova` dan meng-install dependency
44
+ (`@untrustnova/nova-framework` termasuk). Gunakan `--no-install` jika ingin skip instalasi,
45
+ atau set `NOVA_TEMPLATE_REPO` untuk mengganti repo template.
46
+
47
+ `nova dev` akan menjalankan `server.js` dan Vite dev server secara bersamaan.
48
+
49
+ Perintah `db:*` adalah pembungkus untuk `drizzle-kit`.
50
+ Jika belum terpasang, install: `npm install -D drizzle-kit`.
package/bin/nova.js ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ import { run } from '../src/cli.js';
3
+
4
+ run(process.argv.slice(2));
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "@untrustnova/nova-cli",
3
+ "version": "1.0.0",
4
+ "description": "Nova.js CLI",
5
+ "type": "module",
6
+ "bin": {
7
+ "nova": "./bin/nova.js"
8
+ },
9
+ "engines": {
10
+ "node": ">=18"
11
+ },
12
+ "files": [
13
+ "bin",
14
+ "src",
15
+ "templates"
16
+ ],
17
+ "keywords": [
18
+ "nova",
19
+ "nova-js",
20
+ "cli"
21
+ ],
22
+ "license": "MIT"
23
+ }
package/src/cli.js ADDED
@@ -0,0 +1,348 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
3
+ import { createRequire } from 'node:module';
4
+ import { dirname, join, resolve } from 'node:path';
5
+ import { fileURLToPath, pathToFileURL } from 'node:url';
6
+ import { tmpdir } from 'node:os';
7
+
8
+ const __dirname = dirname(fileURLToPath(import.meta.url));
9
+ const templateRoot = resolve(__dirname, '../templates/base');
10
+
11
+ export async function run(args) {
12
+ const [command, ...rest] = args;
13
+
14
+ if (!command || command === 'help' || command === '--help' || command === '-h') {
15
+ printHelp();
16
+ return;
17
+ }
18
+
19
+ if (command === '--version' || command === '-v') {
20
+ console.log('nova-cli 0.1.0');
21
+ return;
22
+ }
23
+
24
+ try {
25
+ switch (command) {
26
+ case 'new':
27
+ await scaffoldProject(rest);
28
+ break;
29
+ case 'dev':
30
+ await runDev();
31
+ break;
32
+ case 'build':
33
+ await runBuild();
34
+ break;
35
+ case 'db:init':
36
+ await runDrizzle('generate');
37
+ break;
38
+ case 'db:push':
39
+ await runDrizzle('push');
40
+ break;
41
+ case 'create:controller':
42
+ await createController(rest[0]);
43
+ break;
44
+ case 'create:middleware':
45
+ await createMiddleware(rest[0]);
46
+ break;
47
+ case 'create:migration':
48
+ await createMigration(rest[0]);
49
+ break;
50
+ default:
51
+ console.error(`[nova] unknown command "${command}"`);
52
+ printHelp();
53
+ }
54
+ } catch (error) {
55
+ console.error(`[nova] ${error.message}`);
56
+ process.exitCode = 1;
57
+ }
58
+ }
59
+
60
+ function printHelp() {
61
+ console.log(`Nova CLI
62
+
63
+ Usage:
64
+ nova new <name> [--no-install]
65
+ nova dev
66
+ nova build
67
+ nova db:init
68
+ nova db:push
69
+ nova create:controller <name>
70
+ nova create:middleware <name>
71
+ nova create:migration <name>
72
+ `);
73
+ }
74
+
75
+ async function scaffoldProject(args) {
76
+ const [name, ...flags] = args;
77
+ const shouldInstall = !flags.includes('--no-install') && !flags.includes('--skip-install');
78
+
79
+ if (!name) {
80
+ throw new Error('project name is required');
81
+ }
82
+
83
+ const target = resolve(process.cwd(), name);
84
+ if (await pathExists(target)) {
85
+ const entries = await readdir(target);
86
+ if (entries.length > 0) {
87
+ throw new Error(`directory "${name}" is not empty`);
88
+ }
89
+ } else {
90
+ await mkdir(target, { recursive: true });
91
+ }
92
+
93
+ const remoteTemplate = await tryFetchTemplate(
94
+ process.env.NOVA_TEMPLATE_REPO || 'https://github.com/nova-js/nova',
95
+ );
96
+
97
+ const source = remoteTemplate?.path || templateRoot;
98
+ await copyTemplate(source, target, {
99
+ '__APP_NAME__': name,
100
+ });
101
+
102
+ console.log(`[nova] project created at ${target}`);
103
+
104
+ if (remoteTemplate?.cleanup) {
105
+ await remoteTemplate.cleanup();
106
+ }
107
+
108
+ if (shouldInstall) {
109
+ await runCommand('npm', ['install'], { cwd: target });
110
+ console.log('[nova] dependencies installed');
111
+ }
112
+ }
113
+
114
+ async function createController(name) {
115
+ if (!name) {
116
+ throw new Error('controller name is required');
117
+ }
118
+
119
+ const fileName = normalizeSuffix(name, '.controller.js');
120
+ const className = toClassName(fileName.replace('.controller.js', ''));
121
+ const target = resolve(process.cwd(), 'app', 'controllers', fileName);
122
+
123
+ await ensureDir(dirname(target));
124
+ await ensureNotExists(target);
125
+
126
+ const body = `import { Controller } from '@untrustnova/nova-framework/controller';
127
+
128
+ export default class ${className}Controller extends Controller {
129
+ async index({ response }) {
130
+ response.json({ message: '${className} ready' });
131
+ }
132
+ }
133
+ `;
134
+
135
+ await writeFile(target, body, 'utf8');
136
+ console.log(`[nova] controller created: ${relativePath(target)}`);
137
+ }
138
+
139
+ async function createMiddleware(name) {
140
+ if (!name) {
141
+ throw new Error('middleware name is required');
142
+ }
143
+
144
+ const fileName = normalizeSuffix(name, '.middleware.js');
145
+ const className = toClassName(fileName.replace('.middleware.js', ''));
146
+ const target = resolve(process.cwd(), 'app', 'middleware', fileName);
147
+
148
+ await ensureDir(dirname(target));
149
+ await ensureNotExists(target);
150
+
151
+ const body = `export class ${className}Middleware {
152
+ async handle(req, res, next) {
153
+ return next();
154
+ }
155
+ }
156
+ `;
157
+
158
+ await writeFile(target, body, 'utf8');
159
+ console.log(`[nova] middleware created: ${relativePath(target)}`);
160
+ }
161
+
162
+ async function createMigration(name) {
163
+ if (!name) {
164
+ throw new Error('migration name is required');
165
+ }
166
+
167
+ const stamp = timestamp();
168
+ const fileName = `${stamp}_${sanitizeFileName(name)}.js`;
169
+ const target = resolve(process.cwd(), 'app', 'migrations', fileName);
170
+
171
+ await ensureDir(dirname(target));
172
+ await ensureNotExists(target);
173
+
174
+ const body = `export async function up(db) {
175
+ // TODO: add migration
176
+ }
177
+
178
+ export async function down(db) {
179
+ // TODO: rollback migration
180
+ }
181
+ `;
182
+
183
+ await writeFile(target, body, 'utf8');
184
+ console.log(`[nova] migration created: ${relativePath(target)}`);
185
+ }
186
+
187
+ async function runDrizzle(command) {
188
+ const args = ['drizzle-kit', command];
189
+ await runCommand('npx', args);
190
+ }
191
+
192
+ async function runDev() {
193
+ const config = await loadNovaConfig();
194
+ const { runDev: runViteDev } = await loadFrameworkModule('dev');
195
+
196
+ const server = spawn('node', ['server.js'], {
197
+ stdio: 'inherit',
198
+ shell: process.platform === 'win32',
199
+ });
200
+
201
+ process.on('SIGINT', () => server.kill('SIGINT'));
202
+ process.on('SIGTERM', () => server.kill('SIGTERM'));
203
+
204
+ await runViteDev(config);
205
+ }
206
+
207
+ async function runBuild() {
208
+ const config = await loadNovaConfig();
209
+ const { runBuild: runViteBuild } = await loadFrameworkModule('dev');
210
+ await runViteBuild(config);
211
+ }
212
+
213
+ async function runCommand(command, args, options = {}) {
214
+ await new Promise((resolvePromise, reject) => {
215
+ const child = spawn(command, args, {
216
+ stdio: 'inherit',
217
+ shell: process.platform === 'win32',
218
+ cwd: options.cwd || process.cwd(),
219
+ });
220
+
221
+ child.on('exit', (code) => {
222
+ if (code === 0) resolvePromise();
223
+ else reject(new Error(`command failed: ${command} ${args.join(' ')}`));
224
+ });
225
+ });
226
+ }
227
+
228
+ async function tryFetchTemplate(repo) {
229
+ const tempRoot = await mkdtemp(join(tmpdir(), 'nova-template-'));
230
+ const cleanup = async () => {
231
+ await rm(tempRoot, { recursive: true, force: true });
232
+ };
233
+
234
+ try {
235
+ await runCommand('git', ['clone', '--depth', '1', repo, tempRoot]);
236
+ const basePath = join(tempRoot, 'templates', 'base');
237
+ if (await pathExists(basePath)) {
238
+ return { path: basePath, cleanup };
239
+ }
240
+ } catch (error) {
241
+ await cleanup();
242
+ return null;
243
+ }
244
+
245
+ await cleanup();
246
+ return null;
247
+ }
248
+
249
+ async function copyTemplate(src, dest, replacements) {
250
+ const entries = await readdir(src, { withFileTypes: true });
251
+ await ensureDir(dest);
252
+
253
+ for (const entry of entries) {
254
+ const srcPath = join(src, entry.name);
255
+ const destPath = join(dest, entry.name);
256
+
257
+ if (entry.isDirectory()) {
258
+ await copyTemplate(srcPath, destPath, replacements);
259
+ continue;
260
+ }
261
+
262
+ const contents = await readFile(srcPath, 'utf8');
263
+ const replaced = applyReplacements(contents, replacements);
264
+ await writeFile(destPath, replaced, 'utf8');
265
+ }
266
+ }
267
+
268
+ function applyReplacements(contents, replacements) {
269
+ let result = contents;
270
+ for (const [key, value] of Object.entries(replacements)) {
271
+ result = result.split(key).join(value);
272
+ }
273
+ return result;
274
+ }
275
+
276
+ async function ensureDir(path) {
277
+ await mkdir(path, { recursive: true });
278
+ }
279
+
280
+ async function ensureNotExists(path) {
281
+ if (await pathExists(path)) {
282
+ throw new Error(`${relativePath(path)} already exists`);
283
+ }
284
+ }
285
+
286
+ async function pathExists(path) {
287
+ try {
288
+ await stat(path);
289
+ return true;
290
+ } catch (error) {
291
+ if (error.code === 'ENOENT') return false;
292
+ throw error;
293
+ }
294
+ }
295
+
296
+ function normalizeSuffix(name, suffix) {
297
+ if (name.endsWith(suffix)) return name;
298
+ const base = name.replace(/\.[^.]+$/, '');
299
+ if (base.endsWith(suffix.replace('.js', ''))) return `${base}.js`;
300
+ return base + suffix;
301
+ }
302
+
303
+ function toClassName(input) {
304
+ return input
305
+ .split(/[^a-zA-Z0-9]/)
306
+ .filter(Boolean)
307
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
308
+ .join('');
309
+ }
310
+
311
+ function sanitizeFileName(input) {
312
+ return input
313
+ .trim()
314
+ .toLowerCase()
315
+ .replace(/\s+/g, '_')
316
+ .replace(/[^a-z0-9_]+/g, '');
317
+ }
318
+
319
+ function timestamp() {
320
+ const now = new Date();
321
+ const pad = (value) => String(value).padStart(2, '0');
322
+ return [
323
+ now.getFullYear(),
324
+ pad(now.getMonth() + 1),
325
+ pad(now.getDate()),
326
+ pad(now.getHours()),
327
+ pad(now.getMinutes()),
328
+ pad(now.getSeconds()),
329
+ ].join('_');
330
+ }
331
+
332
+ function relativePath(path) {
333
+ return path.replace(process.cwd() + '/', '');
334
+ }
335
+
336
+ async function loadNovaConfig() {
337
+ const configPath = resolve(process.cwd(), 'nova.config.js');
338
+ const module = await import(pathToFileURL(configPath));
339
+ return module.default || module;
340
+ }
341
+
342
+ async function loadFrameworkModule(entry) {
343
+ const require = createRequire(import.meta.url);
344
+ const resolved = require.resolve(`@untrustnova/nova-framework/${entry}`, {
345
+ paths: [process.cwd()],
346
+ });
347
+ return import(pathToFileURL(resolved));
348
+ }
@@ -0,0 +1,30 @@
1
+ NOVA_APP_NAME=Nova App
2
+ NOVA_APP_URL=http://localhost:3000
3
+ NOVA_ENV=development
4
+ NOVA_DEBUG=true
5
+
6
+ NOVA_HOST=0.0.0.0
7
+ NOVA_PORT=3000
8
+ NOVA_KERNEL_ADAPTER=node
9
+
10
+ NOVA_DB_CONNECTION=sqlite
11
+ NOVA_DATABASE_URL=file:./storage/db.sqlite
12
+ # postgresql://user:password@localhost:5432/nova_db
13
+ # mysql://user:password@localhost:3306/nova_db
14
+ # mongodb://user:password@localhost:27017/nova_db
15
+ # supabase://project-ref.supabase.co?key=service-role-key
16
+
17
+ NOVA_STORAGE_DRIVER=local
18
+ # NOVA_AWS_BUCKET=my-bucket
19
+ # NOVA_AWS_REGION=us-east-1
20
+ # NOVA_MINIO_ENDPOINT=minio.example.com
21
+ # NOVA_MINIO_ACCESS_KEY=minioadmin
22
+ # NOVA_MINIO_SECRET_KEY=minioadmin
23
+
24
+ NOVA_CACHE_DRIVER=memory
25
+ # NOVA_REDIS_URL=redis://localhost:6379
26
+
27
+ NOVA_LOG_LEVEL=info
28
+
29
+ VITE_API_URL=http://localhost:3000/api
30
+ VITE_APP_TITLE=Nova.js App
@@ -0,0 +1,19 @@
1
+ NOVA_APP_NAME=Nova
2
+ NOVA_APP_URL=http://localhost:3000
3
+ NOVA_ENV=development
4
+ NOVA_DEBUG=true
5
+
6
+ NOVA_HOST=0.0.0.0
7
+ NOVA_PORT=3000
8
+ NOVA_KERNEL_ADAPTER=node
9
+
10
+ NOVA_DB_CONNECTION=sqlite
11
+ NOVA_DATABASE_URL=file:./storage/db.sqlite
12
+ # postgresql://user:password@localhost:5432/nova_db
13
+ # mysql://user:password@localhost:3306/nova_db
14
+ # mongodb://user:password@localhost:27017/nova_db
15
+ # supabase://project-ref.supabase.co?key=service-role-key
16
+
17
+ NOVA_CACHE_DRIVER=memory
18
+ NOVA_STORAGE_DRIVER=local
19
+ NOVA_LOG_LEVEL=debug
@@ -0,0 +1,21 @@
1
+ module.exports = {
2
+ env: {
3
+ browser: true,
4
+ es2023: true,
5
+ node: true,
6
+ },
7
+ parserOptions: {
8
+ ecmaVersion: 'latest',
9
+ sourceType: 'module',
10
+ },
11
+ plugins: ['react'],
12
+ extends: ['eslint:recommended', 'plugin:react/recommended', 'prettier'],
13
+ settings: {
14
+ react: {
15
+ version: 'detect',
16
+ },
17
+ },
18
+ rules: {
19
+ 'react/react-in-jsx-scope': 'off',
20
+ },
21
+ };
@@ -0,0 +1,6 @@
1
+ {
2
+ "singleQuote": true,
3
+ "semi": true,
4
+ "trailingComma": "all",
5
+ "printWidth": 100
6
+ }
@@ -0,0 +1,7 @@
1
+ import { Controller } from '@untrustnova/nova-framework/controller';
2
+
3
+ export default class HomeController extends Controller {
4
+ async index({ response }) {
5
+ response.json({ message: 'Welcome to Nova.js', status: 'ok' });
6
+ }
7
+ }
@@ -0,0 +1,3 @@
1
+ export default async ({ response }) => {
2
+ response.json({ page: 'home' });
3
+ };
@@ -0,0 +1,12 @@
1
+ import { route } from '@untrustnova/nova-framework/routing';
2
+
3
+ export default () => {
4
+ const routes = route();
5
+
6
+ routes.get('/', 'HomeController@index');
7
+ routes.get('/status', async ({ response }) => {
8
+ response.json({ status: 'ready' });
9
+ });
10
+
11
+ return routes.toArray();
12
+ };
@@ -0,0 +1,17 @@
1
+ {
2
+ "compilerOptions": {
3
+ "baseUrl": ".",
4
+ "paths": {
5
+ "@app/*": ["app/*"],
6
+ "@web/*": ["web/*"],
7
+ "@components/*": ["web/components/*"],
8
+ "@lib/*": ["web/lib/*"]
9
+ }
10
+ },
11
+ "include": [
12
+ "app",
13
+ "web",
14
+ "server.js",
15
+ "nova.config.js"
16
+ ]
17
+ }
@@ -0,0 +1,88 @@
1
+ import { defineConfig, react, tailwindcss } from '@untrustnova/nova-framework/config';
2
+
3
+ export default defineConfig({
4
+ app: {
5
+ name: process.env.NOVA_APP_NAME || '__APP_NAME__',
6
+ url: process.env.NOVA_APP_URL || 'http://localhost:3000',
7
+ env: process.env.NOVA_ENV || 'development',
8
+ debug: process.env.NOVA_DEBUG === 'true',
9
+ },
10
+ server: {
11
+ host: process.env.NOVA_HOST || '0.0.0.0',
12
+ port: Number(process.env.NOVA_PORT || 3000),
13
+ },
14
+ security: {
15
+ bodyLimit: 1024 * 1024,
16
+ },
17
+ kernel: {
18
+ adapter: process.env.NOVA_KERNEL_ADAPTER || 'node',
19
+ },
20
+ database: {
21
+ default: process.env.NOVA_DB_CONNECTION || 'sqlite',
22
+ connections: {
23
+ postgres: {
24
+ driver: 'pg',
25
+ url: process.env.NOVA_DATABASE_URL,
26
+ },
27
+ mysql: {
28
+ driver: 'mysql2',
29
+ url: process.env.NOVA_DATABASE_URL,
30
+ },
31
+ sqlite: {
32
+ driver: 'better-sqlite3',
33
+ url: process.env.NOVA_DATABASE_URL || 'file:./storage/db.sqlite',
34
+ },
35
+ mongodb: {
36
+ driver: 'mongodb',
37
+ url: process.env.NOVA_DATABASE_URL,
38
+ },
39
+ supabase: {
40
+ driver: 'supabase',
41
+ url: process.env.NOVA_DATABASE_URL,
42
+ },
43
+ },
44
+ },
45
+ modules: {
46
+ storage: {
47
+ driver: process.env.NOVA_STORAGE_DRIVER || 'local',
48
+ disks: {
49
+ local: { root: './storage/app' },
50
+ s3: {
51
+ bucket: process.env.NOVA_AWS_BUCKET,
52
+ region: process.env.NOVA_AWS_REGION,
53
+ },
54
+ minio: {
55
+ endPoint: process.env.NOVA_MINIO_ENDPOINT,
56
+ accessKey: process.env.NOVA_MINIO_ACCESS_KEY,
57
+ secretKey: process.env.NOVA_MINIO_SECRET_KEY,
58
+ },
59
+ },
60
+ },
61
+ cache: {
62
+ driver: process.env.NOVA_CACHE_DRIVER || 'memory',
63
+ stores: {
64
+ memory: { max: 500 },
65
+ redis: { url: process.env.NOVA_REDIS_URL },
66
+ },
67
+ },
68
+ logs: {
69
+ driver: 'paperlog',
70
+ level: process.env.NOVA_LOG_LEVEL || 'info',
71
+ },
72
+ },
73
+ frontend: {
74
+ entry: './web/main.jsx',
75
+ globals: './web/styles/globals.css',
76
+ },
77
+ plugins: [
78
+ react(),
79
+ tailwindcss({
80
+ content: ['./web/**/*.{js,jsx}'],
81
+ }),
82
+ ],
83
+ alias: {
84
+ '@': './web',
85
+ '@components': './web/components',
86
+ '@lib': './web/lib',
87
+ },
88
+ });
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "__APP_NAME__",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "nova dev",
8
+ "build": "nova build",
9
+ "start": "node server.js",
10
+ "lint": "eslint ."
11
+ },
12
+ "dependencies": {
13
+ "dotenv": "^16.4.5",
14
+ "drizzle-orm": "^0.40.0",
15
+ "@untrustnova/nova-framework": "^0.1.0",
16
+ "react": "^19.0.0",
17
+ "react-dom": "^19.0.0"
18
+ },
19
+ "devDependencies": {
20
+ "@vitejs/plugin-react": "^4.3.4",
21
+ "eslint": "^9.18.0",
22
+ "eslint-config-prettier": "^9.1.0",
23
+ "eslint-plugin-react": "^7.37.3",
24
+ "vite": "^6.0.0"
25
+ }
26
+ }
File without changes
File without changes
@@ -0,0 +1,12 @@
1
+ import 'dotenv/config';
2
+ import config from './nova.config.js';
3
+ import { NovaKernel } from '@untrustnova/nova-framework/kernel';
4
+ import { storageModule, cacheModule, logsModule } from '@untrustnova/nova-framework/modules';
5
+
6
+ const app = new NovaKernel(config);
7
+
8
+ app.registerModule('storage', storageModule);
9
+ app.registerModule('cache', cacheModule);
10
+ app.registerModule('logs', logsModule);
11
+
12
+ app.start();
File without changes
File without changes
File without changes
@@ -0,0 +1,9 @@
1
+ import Hero from './components/Hero.jsx';
2
+
3
+ export default function App() {
4
+ return (
5
+ <div className="app-shell">
6
+ <Hero />
7
+ </div>
8
+ );
9
+ }
@@ -0,0 +1,18 @@
1
+ export default function Hero() {
2
+ return (
3
+ <main className="hero">
4
+ <div className="badge">Nova</div>
5
+ <h1>DX bersih untuk frontend React & backend modular.</h1>
6
+ <p>
7
+ Mulai dari <code>nova dev</code>, lanjutkan dengan modul Storage, Cache,
8
+ Logs, dan DB yang bisa diganti driver-nya.
9
+ </p>
10
+ <div className="cta">
11
+ <button type="button">Lihat Dokumentasi</button>
12
+ <button type="button" className="ghost">
13
+ Buat Project
14
+ </button>
15
+ </div>
16
+ </main>
17
+ );
18
+ }
@@ -0,0 +1,12 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Nova.js</title>
7
+ </head>
8
+ <body>
9
+ <div id="root"></div>
10
+ <script type="module" src="/main.jsx"></script>
11
+ </body>
12
+ </html>
@@ -0,0 +1,8 @@
1
+ const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:3000/api';
2
+
3
+ export const api = {
4
+ async get(path) {
5
+ const response = await fetch(`${baseUrl}${path}`);
6
+ return response.json();
7
+ },
8
+ };
@@ -0,0 +1,6 @@
1
+ import { createRoot } from 'react-dom/client';
2
+ import App from './App.jsx';
3
+ import './styles/globals.css';
4
+
5
+ const root = createRoot(document.getElementById('root'));
6
+ root.render(<App />);
@@ -0,0 +1,77 @@
1
+ :root {
2
+ font-family: 'Inter', system-ui, sans-serif;
3
+ color: #e6edf3;
4
+ background-color: #0b1220;
5
+ }
6
+
7
+ * {
8
+ box-sizing: border-box;
9
+ margin: 0;
10
+ padding: 0;
11
+ }
12
+
13
+ body {
14
+ min-height: 100vh;
15
+ background: radial-gradient(circle at top, #1c2b4f 0%, #0b1220 60%);
16
+ }
17
+
18
+ .app-shell {
19
+ display: flex;
20
+ min-height: 100vh;
21
+ align-items: center;
22
+ justify-content: center;
23
+ padding: 4rem 1.5rem;
24
+ }
25
+
26
+ .hero {
27
+ max-width: 720px;
28
+ display: grid;
29
+ gap: 1.5rem;
30
+ background: rgba(15, 23, 42, 0.65);
31
+ border: 1px solid rgba(148, 163, 184, 0.2);
32
+ border-radius: 24px;
33
+ padding: 3rem;
34
+ backdrop-filter: blur(20px);
35
+ }
36
+
37
+ .hero h1 {
38
+ font-size: clamp(2rem, 3vw, 2.75rem);
39
+ line-height: 1.2;
40
+ }
41
+
42
+ .hero p {
43
+ font-size: 1.05rem;
44
+ color: #cbd5f5;
45
+ }
46
+
47
+ .badge {
48
+ width: fit-content;
49
+ padding: 0.4rem 0.9rem;
50
+ border-radius: 999px;
51
+ background: rgba(59, 130, 246, 0.2);
52
+ border: 1px solid rgba(59, 130, 246, 0.45);
53
+ color: #93c5fd;
54
+ font-weight: 600;
55
+ }
56
+
57
+ .cta {
58
+ display: flex;
59
+ gap: 1rem;
60
+ flex-wrap: wrap;
61
+ }
62
+
63
+ .cta button {
64
+ border: none;
65
+ border-radius: 12px;
66
+ padding: 0.8rem 1.5rem;
67
+ background: #3b82f6;
68
+ color: white;
69
+ font-weight: 600;
70
+ cursor: pointer;
71
+ }
72
+
73
+ .cta button.ghost {
74
+ background: transparent;
75
+ border: 1px solid rgba(148, 163, 184, 0.5);
76
+ color: #cbd5f5;
77
+ }