@illuma-ai/code-sandbox 1.4.0 → 1.5.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/LICENSE +18 -12
- package/README.md +10 -11
- package/dist/index.cjs +91 -96
- package/dist/index.js +11895 -17954
- package/dist/styles.css +1 -1
- package/package.json +9 -12
- package/dist/index.cjs.map +0 -1
- package/dist/index.js.map +0 -1
- package/src/components/BootOverlay.tsx +0 -145
- package/src/components/CodeEditor.tsx +0 -298
- package/src/components/FileTree.tsx +0 -678
- package/src/components/Preview.tsx +0 -262
- package/src/components/Terminal.tsx +0 -111
- package/src/components/ViewSlider.tsx +0 -87
- package/src/components/Workbench.tsx +0 -382
- package/src/hooks/useRuntime.ts +0 -637
- package/src/index.ts +0 -51
- package/src/services/runtime.ts +0 -775
- package/src/styles.css +0 -178
- package/src/templates/fullstack-starter.ts +0 -3507
- package/src/templates/index.ts +0 -607
- package/src/types.ts +0 -375
|
@@ -1,3507 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* fullstack-starter — Production-equivalent full-stack starter template.
|
|
3
|
-
*
|
|
4
|
-
* A properly structured Express + React application with:
|
|
5
|
-
* - sql.js (SQLite WASM) as a true relational DB — same schemas work on Postgres
|
|
6
|
-
* - React Router v6 for client-side routing
|
|
7
|
-
* - React Context for auth + app state management
|
|
8
|
-
* - JWT authentication with role-based access
|
|
9
|
-
* - .ranger manifest for AI agent context
|
|
10
|
-
*
|
|
11
|
-
* Architecture:
|
|
12
|
-
* ┌─────────────────────────────────────────────────────┐
|
|
13
|
-
* │ server.js — Entry, middleware chain │
|
|
14
|
-
* │ config.js — Environment / .env config │
|
|
15
|
-
* │ db/database.js — sql.js init + migrations │
|
|
16
|
-
* │ db/migrations/001.js — Schema definitions (typed) │
|
|
17
|
-
* │ db/repositories/ — CRUD repos over SQL │
|
|
18
|
-
* │ middleware/ — Auth, errors, static │
|
|
19
|
-
* │ routes/ — Express route definitions │
|
|
20
|
-
* │ public/ — React SPA (CDN-loaded) │
|
|
21
|
-
* │ public/store/ — React Context providers │
|
|
22
|
-
* │ public/pages/ — Route-level components │
|
|
23
|
-
* │ public/components/ — Reusable UI components │
|
|
24
|
-
* │ .ranger — AI agent instruction file │
|
|
25
|
-
* │ .env.example — Env var documentation │
|
|
26
|
-
* └─────────────────────────────────────────────────────┘
|
|
27
|
-
*
|
|
28
|
-
* DATABASE:
|
|
29
|
-
* Uses sql.js (SQLite compiled to WASM) — runs in the browser.
|
|
30
|
-
* Schemas use standard SQL types (INTEGER, TEXT, REAL, BOOLEAN, TIMESTAMP).
|
|
31
|
-
* To migrate to Postgres: change db/database.js to use 'pg', keep same schemas.
|
|
32
|
-
* The repository layer abstracts all SQL — swap the query runner, not the repos.
|
|
33
|
-
*/
|
|
34
|
-
|
|
35
|
-
import type { FileMap } from "../types";
|
|
36
|
-
|
|
37
|
-
// ---------------------------------------------------------------------------
|
|
38
|
-
// .ranger — AI Agent Instruction File
|
|
39
|
-
// ---------------------------------------------------------------------------
|
|
40
|
-
|
|
41
|
-
const RANGER_MANIFEST = `# .ranger — Project Manifest
|
|
42
|
-
# This file provides context to the Ranger AI agent about project structure,
|
|
43
|
-
# conventions, and schemas. Update this file when you add new files, routes,
|
|
44
|
-
# or database tables.
|
|
45
|
-
|
|
46
|
-
## Project Type
|
|
47
|
-
type: fullstack-express-react
|
|
48
|
-
runtime: nodepod (browser-based Node.js)
|
|
49
|
-
database: sql.js (SQLite WASM — Postgres-compatible schemas)
|
|
50
|
-
frontend: React 18 (CDN) + React Router v6 + React Context
|
|
51
|
-
styling: Tailwind CSS (CDN)
|
|
52
|
-
|
|
53
|
-
## File Structure
|
|
54
|
-
\`\`\`
|
|
55
|
-
├── server.js # Express entry point — middleware chain, server start
|
|
56
|
-
├── config.js # Environment config — reads process.env with defaults
|
|
57
|
-
├── .env.example # Environment variable documentation
|
|
58
|
-
├── package.json # Dependencies (express, sql.js)
|
|
59
|
-
├── Dockerfile # Multi-stage production Docker build
|
|
60
|
-
├── docker-compose.yml # Local dev + optional Postgres profile
|
|
61
|
-
├── .dockerignore # Docker build exclusions
|
|
62
|
-
├── README.md # Project docs, API reference, deploy guide
|
|
63
|
-
├── CONTRIBUTING.md # Contribution guidelines, adding resources
|
|
64
|
-
│
|
|
65
|
-
├── db/
|
|
66
|
-
│ ├── database.js # sql.js initialization, migration runner, query helpers
|
|
67
|
-
│ └── migrations/
|
|
68
|
-
│ └── 001_initial.js # Initial schema — users, items tables
|
|
69
|
-
│
|
|
70
|
-
├── db/repositories/
|
|
71
|
-
│ ├── userRepository.js # User CRUD — findById, findByEmail, create, update
|
|
72
|
-
│ └── itemRepository.js # Item CRUD — findAll, findById, create, update, delete, stats
|
|
73
|
-
│
|
|
74
|
-
├── middleware/
|
|
75
|
-
│ ├── auth.js # JWT auth — generateToken, authenticate, requireRole
|
|
76
|
-
│ ├── errorHandler.js # Global error handler + 404 with SPA fallback
|
|
77
|
-
│ ├── staticFiles.js # Static file serving (fs.readFileSync for Nodepod)
|
|
78
|
-
│ └── validate.js # Request body validation helpers
|
|
79
|
-
│
|
|
80
|
-
├── routes/
|
|
81
|
-
│ ├── auth.js # POST /api/auth/register, POST /api/auth/login, GET /api/auth/me
|
|
82
|
-
│ └── items.js # GET/POST /api/items, GET/PUT/DELETE /api/items/:id, GET /api/items/stats
|
|
83
|
-
│
|
|
84
|
-
├── public/
|
|
85
|
-
│ ├── index.html # HTML shell — loads React, Router, Tailwind from CDN
|
|
86
|
-
│ ├── styles.css # Custom CSS animations
|
|
87
|
-
│ │
|
|
88
|
-
│ ├── lib/
|
|
89
|
-
│ │ └── api.js # API client — fetch wrapper with auth token handling
|
|
90
|
-
│ │
|
|
91
|
-
│ ├── store/
|
|
92
|
-
│ │ ├── AuthContext.js # Auth state — user, token, login/logout/register actions
|
|
93
|
-
│ │ └── AppContext.js # App state — items, loading, filters, CRUD actions
|
|
94
|
-
│ │
|
|
95
|
-
│ ├── pages/
|
|
96
|
-
│ │ ├── WelcomePage.js # Landing page — hero, features, architecture overview
|
|
97
|
-
│ │ ├── LoginPage.js # Login form with demo credentials
|
|
98
|
-
│ │ ├── RegisterPage.js # Registration form
|
|
99
|
-
│ │ └── DashboardPage.js # Main app — stats, items list, create/edit/delete
|
|
100
|
-
│ │
|
|
101
|
-
│ ├── components/
|
|
102
|
-
│ │ ├── Navbar.js # Top nav — logo, nav links, auth status
|
|
103
|
-
│ │ ├── Toast.js # Notification toasts
|
|
104
|
-
│ │ ├── ItemCard.js # Item display with status/priority badges
|
|
105
|
-
│ │ └── ItemForm.js # Create/edit item form with validation
|
|
106
|
-
│ │
|
|
107
|
-
│ └── App.js # Root — providers, router, route definitions
|
|
108
|
-
\`\`\`
|
|
109
|
-
|
|
110
|
-
## Database Schema
|
|
111
|
-
|
|
112
|
-
### users
|
|
113
|
-
| Column | Type | Constraints | Notes |
|
|
114
|
-
|------------|-----------|-------------------------------|--------------------------|
|
|
115
|
-
| id | INTEGER | PRIMARY KEY AUTOINCREMENT | Maps to SERIAL in Postgres |
|
|
116
|
-
| email | TEXT | UNIQUE NOT NULL | |
|
|
117
|
-
| password | TEXT | NOT NULL | Hashed (base64 in sandbox) |
|
|
118
|
-
| name | TEXT | NOT NULL | |
|
|
119
|
-
| role | TEXT | NOT NULL DEFAULT 'user' | 'admin' or 'user' |
|
|
120
|
-
| created_at | TEXT | DEFAULT CURRENT_TIMESTAMP | ISO 8601 string |
|
|
121
|
-
|
|
122
|
-
### items
|
|
123
|
-
| Column | Type | Constraints | Notes |
|
|
124
|
-
|-------------|-----------|-------------------------------|--------------------------|
|
|
125
|
-
| id | INTEGER | PRIMARY KEY AUTOINCREMENT | Maps to SERIAL in Postgres |
|
|
126
|
-
| title | TEXT | NOT NULL | |
|
|
127
|
-
| description | TEXT | | |
|
|
128
|
-
| status | TEXT | NOT NULL DEFAULT 'todo' | 'todo', 'in_progress', 'done' |
|
|
129
|
-
| priority | TEXT | NOT NULL DEFAULT 'medium' | 'low', 'medium', 'high' |
|
|
130
|
-
| user_id | INTEGER | REFERENCES users(id) | Foreign key |
|
|
131
|
-
| created_at | TEXT | DEFAULT CURRENT_TIMESTAMP | |
|
|
132
|
-
| updated_at | TEXT | DEFAULT CURRENT_TIMESTAMP | |
|
|
133
|
-
|
|
134
|
-
## API Routes
|
|
135
|
-
|
|
136
|
-
### Health
|
|
137
|
-
| Method | Path | Auth | Body | Response |
|
|
138
|
-
|--------|---------------------|----------|-----------------------------------|-----------------------|
|
|
139
|
-
| GET | /api/health | None | — | { status, uptime } |
|
|
140
|
-
|
|
141
|
-
### Authentication
|
|
142
|
-
| Method | Path | Auth | Body | Response |
|
|
143
|
-
|--------|---------------------|----------|-----------------------------------|-----------------------|
|
|
144
|
-
| POST | /api/auth/register | None | { email, password, name } | { user, token } |
|
|
145
|
-
| POST | /api/auth/login | None | { email, password } | { user, token } |
|
|
146
|
-
| GET | /api/auth/me | Required | — | { user } |
|
|
147
|
-
|
|
148
|
-
### Items
|
|
149
|
-
| Method | Path | Auth | Body | Response |
|
|
150
|
-
|--------|---------------------|----------|-----------------------------------|-----------------------|
|
|
151
|
-
| GET | /api/items | Required | — | { items: [...] } |
|
|
152
|
-
| POST | /api/items | Required | { title, description?, status?, priority? } | { item } |
|
|
153
|
-
| GET | /api/items/stats | Required | — | { total, byStatus, byPriority } |
|
|
154
|
-
| GET | /api/items/:id | Required | — | { item } |
|
|
155
|
-
| PUT | /api/items/:id | Required | { title?, description?, status?, priority? } | { item } |
|
|
156
|
-
| DELETE | /api/items/:id | Required | — | { message } |
|
|
157
|
-
|
|
158
|
-
## Coding Conventions
|
|
159
|
-
- Backend: CommonJS (require/module.exports), ES5-compatible for Nodepod
|
|
160
|
-
- Frontend: React.createElement (aliased as 'h'), no JSX — loaded from CDN
|
|
161
|
-
- React Router: ReactRouterDOM global, v6 API (Routes, Route, Link, Outlet, useParams, useNavigate)
|
|
162
|
-
- State: React Context with useReducer for complex state, useState for simple
|
|
163
|
-
- API calls: Use /public/lib/api.js client — handles auth headers, JSON parsing, errors
|
|
164
|
-
- SQL: Use parameterized queries (?) — never string interpolation
|
|
165
|
-
- Error handling: All async route handlers wrapped in try/catch, errors forwarded to errorHandler
|
|
166
|
-
- Validation: Use middleware/validate.js for request body validation
|
|
167
|
-
|
|
168
|
-
## Postgres Migration Guide
|
|
169
|
-
To deploy to production with Postgres:
|
|
170
|
-
1. Replace db/database.js: use 'pg' Pool instead of sql.js
|
|
171
|
-
2. Update query syntax: ? params → $1, $2 (Postgres style)
|
|
172
|
-
3. Replace INTEGER PRIMARY KEY AUTOINCREMENT → SERIAL PRIMARY KEY
|
|
173
|
-
4. Replace TEXT for timestamps → TIMESTAMP WITH TIME ZONE
|
|
174
|
-
5. Add connection pooling config to config.js
|
|
175
|
-
6. Everything else (repos, routes, middleware) stays the same
|
|
176
|
-
`;
|
|
177
|
-
|
|
178
|
-
// ---------------------------------------------------------------------------
|
|
179
|
-
// .env.example
|
|
180
|
-
// ---------------------------------------------------------------------------
|
|
181
|
-
|
|
182
|
-
const ENV_EXAMPLE = `# =============================================================================
|
|
183
|
-
# Environment Configuration
|
|
184
|
-
# Copy to .env and fill in values for your deployment
|
|
185
|
-
# =============================================================================
|
|
186
|
-
|
|
187
|
-
# Server
|
|
188
|
-
PORT=3000
|
|
189
|
-
NODE_ENV=development
|
|
190
|
-
|
|
191
|
-
# Database
|
|
192
|
-
# In sandbox: uses sql.js (SQLite WASM, in-memory)
|
|
193
|
-
# In production: DATABASE_URL=postgresql://user:pass@host:5432/dbname
|
|
194
|
-
DATABASE_URL=sqlite::memory:
|
|
195
|
-
|
|
196
|
-
# Authentication
|
|
197
|
-
JWT_SECRET=change-me-in-production
|
|
198
|
-
JWT_EXPIRES_IN=24h
|
|
199
|
-
|
|
200
|
-
# CORS
|
|
201
|
-
CORS_ORIGIN=*
|
|
202
|
-
`;
|
|
203
|
-
|
|
204
|
-
// ---------------------------------------------------------------------------
|
|
205
|
-
// config.js
|
|
206
|
-
// ---------------------------------------------------------------------------
|
|
207
|
-
|
|
208
|
-
const CONFIG_JS = `/**
|
|
209
|
-
* config.js — Centralized configuration.
|
|
210
|
-
*
|
|
211
|
-
* Reads from process.env with sensible defaults for the sandbox.
|
|
212
|
-
* In production, set real values via .env file or environment variables.
|
|
213
|
-
*/
|
|
214
|
-
var config = {
|
|
215
|
-
port: parseInt(process.env.PORT || '3000', 10),
|
|
216
|
-
nodeEnv: process.env.NODE_ENV || 'development',
|
|
217
|
-
jwtSecret: process.env.JWT_SECRET || 'sandbox-secret-key',
|
|
218
|
-
jwtExpiresIn: process.env.JWT_EXPIRES_IN || '24h',
|
|
219
|
-
corsOrigin: process.env.CORS_ORIGIN || '*',
|
|
220
|
-
databaseUrl: process.env.DATABASE_URL || 'sqlite::memory:',
|
|
221
|
-
};
|
|
222
|
-
|
|
223
|
-
module.exports = config;
|
|
224
|
-
`;
|
|
225
|
-
|
|
226
|
-
// ---------------------------------------------------------------------------
|
|
227
|
-
// db/database.js — sql.js initialization + migration runner
|
|
228
|
-
// ---------------------------------------------------------------------------
|
|
229
|
-
|
|
230
|
-
const DATABASE_JS = `/**
|
|
231
|
-
* db/database.js — SQLite database via sql.js (WASM).
|
|
232
|
-
*
|
|
233
|
-
* Provides a Postgres-compatible interface:
|
|
234
|
-
* - db.run(sql, params) — Execute INSERT/UPDATE/DELETE, returns { changes }
|
|
235
|
-
* - db.get(sql, params) — SELECT single row, returns object or undefined
|
|
236
|
-
* - db.all(sql, params) — SELECT multiple rows, returns array
|
|
237
|
-
* - db.exec(sql) — Execute raw SQL (DDL, multi-statement)
|
|
238
|
-
*
|
|
239
|
-
* Migration runner applies numbered migration files in order.
|
|
240
|
-
* Tracks applied migrations in a _migrations table.
|
|
241
|
-
*
|
|
242
|
-
* POSTGRES MIGRATION:
|
|
243
|
-
* Replace this file with a 'pg' Pool wrapper exposing the same interface.
|
|
244
|
-
* Change ? params to $1, $2 style. Everything else stays the same.
|
|
245
|
-
*/
|
|
246
|
-
|
|
247
|
-
var initSqlJs;
|
|
248
|
-
try {
|
|
249
|
-
initSqlJs = require('sql.js');
|
|
250
|
-
} catch (e) {
|
|
251
|
-
// Fallback: sql.js not installed yet, will be loaded later
|
|
252
|
-
initSqlJs = null;
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
var _db = null;
|
|
256
|
-
var _ready = null;
|
|
257
|
-
|
|
258
|
-
var WASM_CDN_URL = 'https://cdn.jsdelivr.net/npm/sql.js@1.11.0/dist/sql-wasm.wasm';
|
|
259
|
-
|
|
260
|
-
/**
|
|
261
|
-
* Initialize the database. Called once at server startup.
|
|
262
|
-
* Returns a promise that resolves when the DB is ready.
|
|
263
|
-
*/
|
|
264
|
-
function initialize() {
|
|
265
|
-
if (_ready) return _ready;
|
|
266
|
-
|
|
267
|
-
_ready = _initializeInternal();
|
|
268
|
-
return _ready;
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
async function _initializeInternal() {
|
|
272
|
-
if (!initSqlJs) {
|
|
273
|
-
initSqlJs = require('sql.js');
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
// PERF: Pre-fetch the WASM binary ourselves using browser fetch().
|
|
277
|
-
// sql.js detects Nodepod as Node.js (because process.versions.node exists)
|
|
278
|
-
// and tries to use fs.readFileSync() to load the WASM binary — which fails.
|
|
279
|
-
// By fetching the binary ourselves and passing it as wasmBinary, we bypass
|
|
280
|
-
// sql.js's environment detection entirely.
|
|
281
|
-
console.log('[DB] Fetching sql.js WASM binary from CDN...');
|
|
282
|
-
var wasmResponse = await fetch(WASM_CDN_URL);
|
|
283
|
-
if (!wasmResponse.ok) {
|
|
284
|
-
throw new Error('[DB] Failed to fetch WASM binary: HTTP ' + wasmResponse.status);
|
|
285
|
-
}
|
|
286
|
-
var wasmBinary = new Uint8Array(await wasmResponse.arrayBuffer());
|
|
287
|
-
console.log('[DB] WASM binary loaded (' + wasmBinary.length + ' bytes)');
|
|
288
|
-
|
|
289
|
-
// Initialize sql.js with the pre-fetched WASM binary
|
|
290
|
-
var SQL = await initSqlJs({ wasmBinary: wasmBinary });
|
|
291
|
-
|
|
292
|
-
_db = new SQL.Database();
|
|
293
|
-
|
|
294
|
-
// Enable WAL mode equivalent (not applicable in-memory, but documents intent)
|
|
295
|
-
_db.run('PRAGMA journal_mode=MEMORY');
|
|
296
|
-
_db.run('PRAGMA foreign_keys=ON');
|
|
297
|
-
|
|
298
|
-
// Create migrations tracking table
|
|
299
|
-
_db.run(\`
|
|
300
|
-
CREATE TABLE IF NOT EXISTS _migrations (
|
|
301
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
302
|
-
name TEXT NOT NULL UNIQUE,
|
|
303
|
-
applied_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
304
|
-
)
|
|
305
|
-
\`);
|
|
306
|
-
|
|
307
|
-
// Run migrations
|
|
308
|
-
await _runMigrations();
|
|
309
|
-
|
|
310
|
-
console.log('[DB] SQLite database initialized (sql.js WASM)');
|
|
311
|
-
return _db;
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
/**
|
|
315
|
-
* Run all pending migrations in order.
|
|
316
|
-
*/
|
|
317
|
-
async function _runMigrations() {
|
|
318
|
-
var migrations = require('./migrations/001_initial');
|
|
319
|
-
|
|
320
|
-
var applied = {};
|
|
321
|
-
var rows = _db.exec('SELECT name FROM _migrations');
|
|
322
|
-
if (rows.length > 0) {
|
|
323
|
-
rows[0].values.forEach(function(row) {
|
|
324
|
-
applied[row[0]] = true;
|
|
325
|
-
});
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
migrations.forEach(function(migration) {
|
|
329
|
-
if (!applied[migration.name]) {
|
|
330
|
-
console.log('[DB] Running migration: ' + migration.name);
|
|
331
|
-
// Use exec() instead of run() — migrations may contain multiple statements
|
|
332
|
-
_db.exec(migration.up);
|
|
333
|
-
_db.run('INSERT INTO _migrations (name) VALUES (?)', [migration.name]);
|
|
334
|
-
}
|
|
335
|
-
});
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
/**
|
|
339
|
-
* Execute a query that modifies data (INSERT, UPDATE, DELETE).
|
|
340
|
-
* @param {string} sql - SQL with ? placeholders
|
|
341
|
-
* @param {Array} params - Parameter values
|
|
342
|
-
* @returns {{ changes: number }} Number of affected rows
|
|
343
|
-
*/
|
|
344
|
-
function run(sql, params) {
|
|
345
|
-
_db.run(sql, params || []);
|
|
346
|
-
var result = _db.exec('SELECT changes() as changes');
|
|
347
|
-
var changes = result.length > 0 ? result[0].values[0][0] : 0;
|
|
348
|
-
return { changes: changes };
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
/**
|
|
352
|
-
* Execute a SELECT query returning a single row.
|
|
353
|
-
* @param {string} sql - SQL with ? placeholders
|
|
354
|
-
* @param {Array} params - Parameter values
|
|
355
|
-
* @returns {Object|undefined} Row as { column: value } or undefined
|
|
356
|
-
*/
|
|
357
|
-
function get(sql, params) {
|
|
358
|
-
var stmt = _db.prepare(sql);
|
|
359
|
-
stmt.bind(params || []);
|
|
360
|
-
var row = undefined;
|
|
361
|
-
if (stmt.step()) {
|
|
362
|
-
row = stmt.getAsObject();
|
|
363
|
-
}
|
|
364
|
-
stmt.free();
|
|
365
|
-
return row;
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
/**
|
|
369
|
-
* Execute a SELECT query returning all matching rows.
|
|
370
|
-
* @param {string} sql - SQL with ? placeholders
|
|
371
|
-
* @param {Array} params - Parameter values
|
|
372
|
-
* @returns {Array<Object>} Array of { column: value } objects
|
|
373
|
-
*/
|
|
374
|
-
function all(sql, params) {
|
|
375
|
-
var stmt = _db.prepare(sql);
|
|
376
|
-
stmt.bind(params || []);
|
|
377
|
-
var rows = [];
|
|
378
|
-
while (stmt.step()) {
|
|
379
|
-
rows.push(stmt.getAsObject());
|
|
380
|
-
}
|
|
381
|
-
stmt.free();
|
|
382
|
-
return rows;
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
/**
|
|
386
|
-
* Execute raw SQL (for DDL or multi-statement queries).
|
|
387
|
-
* @param {string} sql - Raw SQL
|
|
388
|
-
*/
|
|
389
|
-
function exec(sql) {
|
|
390
|
-
_db.exec(sql);
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
/**
|
|
394
|
-
* Get the last inserted row ID.
|
|
395
|
-
* @returns {number}
|
|
396
|
-
*/
|
|
397
|
-
function lastInsertRowId() {
|
|
398
|
-
var result = _db.exec('SELECT last_insert_rowid() as id');
|
|
399
|
-
return result.length > 0 ? result[0].values[0][0] : 0;
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
module.exports = {
|
|
403
|
-
initialize: initialize,
|
|
404
|
-
run: run,
|
|
405
|
-
get: get,
|
|
406
|
-
all: all,
|
|
407
|
-
exec: exec,
|
|
408
|
-
lastInsertRowId: lastInsertRowId,
|
|
409
|
-
};
|
|
410
|
-
`;
|
|
411
|
-
|
|
412
|
-
// ---------------------------------------------------------------------------
|
|
413
|
-
// db/migrations/001_initial.js
|
|
414
|
-
// ---------------------------------------------------------------------------
|
|
415
|
-
|
|
416
|
-
const MIGRATION_001 = `/**
|
|
417
|
-
* Migration 001 — Initial schema.
|
|
418
|
-
*
|
|
419
|
-
* Creates the core tables with proper types, constraints, and indexes.
|
|
420
|
-
* These schemas are designed to map directly to Postgres:
|
|
421
|
-
*
|
|
422
|
-
* SQLite → Postgres equivalents:
|
|
423
|
-
* INTEGER PRIMARY KEY AUTOINCREMENT → SERIAL PRIMARY KEY
|
|
424
|
-
* TEXT → VARCHAR or TEXT
|
|
425
|
-
* TEXT (timestamps) → TIMESTAMP WITH TIME ZONE
|
|
426
|
-
* REAL → DOUBLE PRECISION
|
|
427
|
-
* INTEGER (boolean) → BOOLEAN
|
|
428
|
-
*/
|
|
429
|
-
|
|
430
|
-
module.exports = [
|
|
431
|
-
{
|
|
432
|
-
name: '001_create_users',
|
|
433
|
-
up: \`
|
|
434
|
-
CREATE TABLE IF NOT EXISTS users (
|
|
435
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
436
|
-
email TEXT NOT NULL UNIQUE,
|
|
437
|
-
password TEXT NOT NULL,
|
|
438
|
-
name TEXT NOT NULL,
|
|
439
|
-
role TEXT NOT NULL DEFAULT 'user',
|
|
440
|
-
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
441
|
-
)
|
|
442
|
-
\`,
|
|
443
|
-
},
|
|
444
|
-
{
|
|
445
|
-
name: '002_create_items',
|
|
446
|
-
up: \`
|
|
447
|
-
CREATE TABLE IF NOT EXISTS items (
|
|
448
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
449
|
-
title TEXT NOT NULL,
|
|
450
|
-
description TEXT,
|
|
451
|
-
status TEXT NOT NULL DEFAULT 'todo',
|
|
452
|
-
priority TEXT NOT NULL DEFAULT 'medium',
|
|
453
|
-
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
454
|
-
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
455
|
-
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
456
|
-
)
|
|
457
|
-
\`,
|
|
458
|
-
},
|
|
459
|
-
{
|
|
460
|
-
name: '003_create_items_indexes',
|
|
461
|
-
up: \`
|
|
462
|
-
CREATE INDEX IF NOT EXISTS idx_items_user_id ON items(user_id);
|
|
463
|
-
CREATE INDEX IF NOT EXISTS idx_items_status ON items(status);
|
|
464
|
-
CREATE INDEX IF NOT EXISTS idx_items_priority ON items(priority);
|
|
465
|
-
\`,
|
|
466
|
-
},
|
|
467
|
-
{
|
|
468
|
-
name: '004_seed_data',
|
|
469
|
-
up: \`
|
|
470
|
-
INSERT INTO users (email, password, name, role) VALUES
|
|
471
|
-
('admin@example.com', 'YWRtaW4xMjM=', 'Admin User', 'admin'),
|
|
472
|
-
('user@example.com', 'dXNlcjEyMw==', 'Demo User', 'user');
|
|
473
|
-
|
|
474
|
-
INSERT INTO items (title, description, status, priority, user_id) VALUES
|
|
475
|
-
('Set up project structure', 'Initialize the repository with proper folder structure and configuration files', 'done', 'high', 1),
|
|
476
|
-
('Design database schema', 'Create the initial database tables with proper types, constraints, and indexes', 'in_progress', 'high', 1),
|
|
477
|
-
('Build authentication flow', 'Implement JWT-based login and registration with role-based access control', 'todo', 'medium', 1),
|
|
478
|
-
('Create API endpoints', 'Build RESTful CRUD endpoints for all resources', 'todo', 'medium', 2),
|
|
479
|
-
('Write frontend components', 'Build React components for dashboard, forms, and navigation', 'todo', 'low', 2);
|
|
480
|
-
\`,
|
|
481
|
-
},
|
|
482
|
-
];
|
|
483
|
-
`;
|
|
484
|
-
|
|
485
|
-
// ---------------------------------------------------------------------------
|
|
486
|
-
// db/repositories/userRepository.js
|
|
487
|
-
// ---------------------------------------------------------------------------
|
|
488
|
-
|
|
489
|
-
const USER_REPOSITORY_JS = `/**
|
|
490
|
-
* db/repositories/userRepository.js — User data access layer.
|
|
491
|
-
*
|
|
492
|
-
* All database queries for the users table. Uses parameterized SQL.
|
|
493
|
-
* This file works identically with Postgres — just change ? to $1, $2.
|
|
494
|
-
*/
|
|
495
|
-
|
|
496
|
-
var db = require('../database');
|
|
497
|
-
|
|
498
|
-
/**
|
|
499
|
-
* Find a user by ID.
|
|
500
|
-
* @param {number} id
|
|
501
|
-
* @returns {Object|undefined} User object (without password)
|
|
502
|
-
*/
|
|
503
|
-
function findById(id) {
|
|
504
|
-
var user = db.get(
|
|
505
|
-
'SELECT id, email, name, role, created_at FROM users WHERE id = ?',
|
|
506
|
-
[id]
|
|
507
|
-
);
|
|
508
|
-
return user || undefined;
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
/**
|
|
512
|
-
* Find a user by email (includes password for auth).
|
|
513
|
-
* @param {string} email
|
|
514
|
-
* @returns {Object|undefined} User object with password
|
|
515
|
-
*/
|
|
516
|
-
function findByEmail(email) {
|
|
517
|
-
return db.get('SELECT * FROM users WHERE email = ?', [email]) || undefined;
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
/**
|
|
521
|
-
* Create a new user.
|
|
522
|
-
* @param {{ email: string, password: string, name: string, role?: string }} data
|
|
523
|
-
* @returns {Object} Created user (without password)
|
|
524
|
-
*/
|
|
525
|
-
function create(data) {
|
|
526
|
-
var role = data.role || 'user';
|
|
527
|
-
db.run(
|
|
528
|
-
'INSERT INTO users (email, password, name, role) VALUES (?, ?, ?, ?)',
|
|
529
|
-
[data.email, data.password, data.name, role]
|
|
530
|
-
);
|
|
531
|
-
var id = db.lastInsertRowId();
|
|
532
|
-
return findById(id);
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
/**
|
|
536
|
-
* Update a user.
|
|
537
|
-
* @param {number} id
|
|
538
|
-
* @param {{ email?: string, name?: string, role?: string }} data
|
|
539
|
-
* @returns {Object|undefined} Updated user
|
|
540
|
-
*/
|
|
541
|
-
function update(id, data) {
|
|
542
|
-
var fields = [];
|
|
543
|
-
var values = [];
|
|
544
|
-
|
|
545
|
-
if (data.email !== undefined) { fields.push('email = ?'); values.push(data.email); }
|
|
546
|
-
if (data.name !== undefined) { fields.push('name = ?'); values.push(data.name); }
|
|
547
|
-
if (data.role !== undefined) { fields.push('role = ?'); values.push(data.role); }
|
|
548
|
-
|
|
549
|
-
if (fields.length === 0) return findById(id);
|
|
550
|
-
|
|
551
|
-
values.push(id);
|
|
552
|
-
db.run('UPDATE users SET ' + fields.join(', ') + ' WHERE id = ?', values);
|
|
553
|
-
return findById(id);
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
/**
|
|
557
|
-
* Get all users (without passwords).
|
|
558
|
-
* @returns {Array<Object>}
|
|
559
|
-
*/
|
|
560
|
-
function findAll() {
|
|
561
|
-
return db.all('SELECT id, email, name, role, created_at FROM users ORDER BY created_at DESC');
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
/**
|
|
565
|
-
* Count total users.
|
|
566
|
-
* @returns {number}
|
|
567
|
-
*/
|
|
568
|
-
function count() {
|
|
569
|
-
var row = db.get('SELECT COUNT(*) as count FROM users');
|
|
570
|
-
return row ? row.count : 0;
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
module.exports = {
|
|
574
|
-
findById: findById,
|
|
575
|
-
findByEmail: findByEmail,
|
|
576
|
-
create: create,
|
|
577
|
-
update: update,
|
|
578
|
-
findAll: findAll,
|
|
579
|
-
count: count,
|
|
580
|
-
};
|
|
581
|
-
`;
|
|
582
|
-
|
|
583
|
-
// ---------------------------------------------------------------------------
|
|
584
|
-
// db/repositories/itemRepository.js
|
|
585
|
-
// ---------------------------------------------------------------------------
|
|
586
|
-
|
|
587
|
-
const ITEM_REPOSITORY_JS = `/**
|
|
588
|
-
* db/repositories/itemRepository.js — Item data access layer.
|
|
589
|
-
*
|
|
590
|
-
* All database queries for the items table. Uses parameterized SQL.
|
|
591
|
-
* This file works identically with Postgres — just change ? to $1, $2.
|
|
592
|
-
*/
|
|
593
|
-
|
|
594
|
-
var db = require('../database');
|
|
595
|
-
|
|
596
|
-
/**
|
|
597
|
-
* Find all items, optionally filtered by user_id, status, or priority.
|
|
598
|
-
* @param {{ userId?: number, status?: string, priority?: string }} filters
|
|
599
|
-
* @returns {Array<Object>}
|
|
600
|
-
*/
|
|
601
|
-
function findAll(filters) {
|
|
602
|
-
var where = [];
|
|
603
|
-
var params = [];
|
|
604
|
-
filters = filters || {};
|
|
605
|
-
|
|
606
|
-
if (filters.userId) {
|
|
607
|
-
where.push('i.user_id = ?');
|
|
608
|
-
params.push(filters.userId);
|
|
609
|
-
}
|
|
610
|
-
if (filters.status) {
|
|
611
|
-
where.push('i.status = ?');
|
|
612
|
-
params.push(filters.status);
|
|
613
|
-
}
|
|
614
|
-
if (filters.priority) {
|
|
615
|
-
where.push('i.priority = ?');
|
|
616
|
-
params.push(filters.priority);
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
var sql = \`
|
|
620
|
-
SELECT i.*, u.name as user_name, u.email as user_email
|
|
621
|
-
FROM items i
|
|
622
|
-
LEFT JOIN users u ON i.user_id = u.id
|
|
623
|
-
\`;
|
|
624
|
-
if (where.length > 0) {
|
|
625
|
-
sql += ' WHERE ' + where.join(' AND ');
|
|
626
|
-
}
|
|
627
|
-
sql += ' ORDER BY i.created_at DESC';
|
|
628
|
-
|
|
629
|
-
return db.all(sql, params);
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
/**
|
|
633
|
-
* Find a single item by ID.
|
|
634
|
-
* @param {number} id
|
|
635
|
-
* @returns {Object|undefined}
|
|
636
|
-
*/
|
|
637
|
-
function findById(id) {
|
|
638
|
-
return db.get(
|
|
639
|
-
\`SELECT i.*, u.name as user_name, u.email as user_email
|
|
640
|
-
FROM items i
|
|
641
|
-
LEFT JOIN users u ON i.user_id = u.id
|
|
642
|
-
WHERE i.id = ?\`,
|
|
643
|
-
[id]
|
|
644
|
-
) || undefined;
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
/**
|
|
648
|
-
* Create a new item.
|
|
649
|
-
* @param {{ title: string, description?: string, status?: string, priority?: string, user_id: number }} data
|
|
650
|
-
* @returns {Object} Created item
|
|
651
|
-
*/
|
|
652
|
-
function create(data) {
|
|
653
|
-
var status = data.status || 'todo';
|
|
654
|
-
var priority = data.priority || 'medium';
|
|
655
|
-
var description = data.description || '';
|
|
656
|
-
|
|
657
|
-
db.run(
|
|
658
|
-
'INSERT INTO items (title, description, status, priority, user_id) VALUES (?, ?, ?, ?, ?)',
|
|
659
|
-
[data.title, description, status, priority, data.user_id]
|
|
660
|
-
);
|
|
661
|
-
var id = db.lastInsertRowId();
|
|
662
|
-
return findById(id);
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
/**
|
|
666
|
-
* Update an existing item.
|
|
667
|
-
* @param {number} id
|
|
668
|
-
* @param {{ title?: string, description?: string, status?: string, priority?: string }} data
|
|
669
|
-
* @returns {Object|undefined} Updated item
|
|
670
|
-
*/
|
|
671
|
-
function update(id, data) {
|
|
672
|
-
var fields = [];
|
|
673
|
-
var values = [];
|
|
674
|
-
|
|
675
|
-
if (data.title !== undefined) { fields.push('title = ?'); values.push(data.title); }
|
|
676
|
-
if (data.description !== undefined) { fields.push('description = ?'); values.push(data.description); }
|
|
677
|
-
if (data.status !== undefined) { fields.push('status = ?'); values.push(data.status); }
|
|
678
|
-
if (data.priority !== undefined) { fields.push('priority = ?'); values.push(data.priority); }
|
|
679
|
-
|
|
680
|
-
if (fields.length === 0) return findById(id);
|
|
681
|
-
|
|
682
|
-
fields.push("updated_at = datetime('now')");
|
|
683
|
-
values.push(id);
|
|
684
|
-
db.run('UPDATE items SET ' + fields.join(', ') + ' WHERE id = ?', values);
|
|
685
|
-
return findById(id);
|
|
686
|
-
}
|
|
687
|
-
|
|
688
|
-
/**
|
|
689
|
-
* Delete an item by ID.
|
|
690
|
-
* @param {number} id
|
|
691
|
-
* @returns {boolean} True if deleted
|
|
692
|
-
*/
|
|
693
|
-
function remove(id) {
|
|
694
|
-
var result = db.run('DELETE FROM items WHERE id = ?', [id]);
|
|
695
|
-
return result.changes > 0;
|
|
696
|
-
}
|
|
697
|
-
|
|
698
|
-
/**
|
|
699
|
-
* Get aggregate statistics.
|
|
700
|
-
* @returns {{ total: number, byStatus: Object, byPriority: Object }}
|
|
701
|
-
*/
|
|
702
|
-
function getStats() {
|
|
703
|
-
var total = db.get('SELECT COUNT(*) as count FROM items');
|
|
704
|
-
var byStatus = db.all('SELECT status, COUNT(*) as count FROM items GROUP BY status');
|
|
705
|
-
var byPriority = db.all('SELECT priority, COUNT(*) as count FROM items GROUP BY priority');
|
|
706
|
-
|
|
707
|
-
var statusMap = {};
|
|
708
|
-
byStatus.forEach(function(row) { statusMap[row.status] = row.count; });
|
|
709
|
-
|
|
710
|
-
var priorityMap = {};
|
|
711
|
-
byPriority.forEach(function(row) { priorityMap[row.priority] = row.count; });
|
|
712
|
-
|
|
713
|
-
return {
|
|
714
|
-
total: total ? total.count : 0,
|
|
715
|
-
byStatus: statusMap,
|
|
716
|
-
byPriority: priorityMap,
|
|
717
|
-
};
|
|
718
|
-
}
|
|
719
|
-
|
|
720
|
-
module.exports = {
|
|
721
|
-
findAll: findAll,
|
|
722
|
-
findById: findById,
|
|
723
|
-
create: create,
|
|
724
|
-
update: update,
|
|
725
|
-
remove: remove,
|
|
726
|
-
getStats: getStats,
|
|
727
|
-
};
|
|
728
|
-
`;
|
|
729
|
-
|
|
730
|
-
// ---------------------------------------------------------------------------
|
|
731
|
-
// middleware/auth.js
|
|
732
|
-
// ---------------------------------------------------------------------------
|
|
733
|
-
|
|
734
|
-
const AUTH_JS = `/**
|
|
735
|
-
* middleware/auth.js — JWT authentication.
|
|
736
|
-
*
|
|
737
|
-
* In the sandbox, JWT is a base64-encoded JSON payload (no crypto).
|
|
738
|
-
* In production, swap for the 'jsonwebtoken' package with real signing.
|
|
739
|
-
*
|
|
740
|
-
* Provides:
|
|
741
|
-
* - generateToken(payload) — Create a JWT
|
|
742
|
-
* - authenticate — Middleware to verify JWT and attach req.user
|
|
743
|
-
* - requireRole(role) — Middleware to enforce role-based access
|
|
744
|
-
*/
|
|
745
|
-
|
|
746
|
-
var config = require('../config');
|
|
747
|
-
|
|
748
|
-
/**
|
|
749
|
-
* Generate a JWT token.
|
|
750
|
-
* Sandbox: base64-encoded JSON. Production: use jsonwebtoken.sign().
|
|
751
|
-
*
|
|
752
|
-
* @param {{ id: number, email: string, role: string }} payload
|
|
753
|
-
* @returns {string} Token
|
|
754
|
-
*/
|
|
755
|
-
function generateToken(payload) {
|
|
756
|
-
var data = {
|
|
757
|
-
sub: payload.id,
|
|
758
|
-
email: payload.email,
|
|
759
|
-
role: payload.role,
|
|
760
|
-
iat: Date.now(),
|
|
761
|
-
exp: Date.now() + 24 * 60 * 60 * 1000, // 24h
|
|
762
|
-
};
|
|
763
|
-
// Sandbox: base64 encode (NOT secure — for demo only)
|
|
764
|
-
// Production: return jwt.sign(data, config.jwtSecret, { expiresIn: config.jwtExpiresIn });
|
|
765
|
-
return btoa(JSON.stringify(data));
|
|
766
|
-
}
|
|
767
|
-
|
|
768
|
-
/**
|
|
769
|
-
* Verify and decode a JWT token.
|
|
770
|
-
* @param {string} token
|
|
771
|
-
* @returns {Object|null} Decoded payload or null
|
|
772
|
-
*/
|
|
773
|
-
function verifyToken(token) {
|
|
774
|
-
try {
|
|
775
|
-
// Sandbox: base64 decode
|
|
776
|
-
// Production: return jwt.verify(token, config.jwtSecret);
|
|
777
|
-
var data = JSON.parse(atob(token));
|
|
778
|
-
if (data.exp && data.exp < Date.now()) return null;
|
|
779
|
-
return data;
|
|
780
|
-
} catch (e) {
|
|
781
|
-
return null;
|
|
782
|
-
}
|
|
783
|
-
}
|
|
784
|
-
|
|
785
|
-
/**
|
|
786
|
-
* Hash a password.
|
|
787
|
-
* Sandbox: base64 encode. Production: use bcrypt.hash().
|
|
788
|
-
*/
|
|
789
|
-
function hashPassword(password) {
|
|
790
|
-
return btoa(password);
|
|
791
|
-
}
|
|
792
|
-
|
|
793
|
-
/**
|
|
794
|
-
* Compare a password with its hash.
|
|
795
|
-
* Sandbox: base64 compare. Production: use bcrypt.compare().
|
|
796
|
-
*/
|
|
797
|
-
function comparePassword(password, hash) {
|
|
798
|
-
return btoa(password) === hash;
|
|
799
|
-
}
|
|
800
|
-
|
|
801
|
-
/**
|
|
802
|
-
* Authentication middleware — verifies JWT from Authorization header.
|
|
803
|
-
* Attaches decoded user info to req.user.
|
|
804
|
-
*/
|
|
805
|
-
function authenticate(req, res, next) {
|
|
806
|
-
var authHeader = req.headers.authorization;
|
|
807
|
-
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
808
|
-
return res.status(401).json({ error: 'Authentication required' });
|
|
809
|
-
}
|
|
810
|
-
|
|
811
|
-
var token = authHeader.split(' ')[1];
|
|
812
|
-
var decoded = verifyToken(token);
|
|
813
|
-
if (!decoded) {
|
|
814
|
-
return res.status(401).json({ error: 'Invalid or expired token' });
|
|
815
|
-
}
|
|
816
|
-
|
|
817
|
-
req.user = { id: decoded.sub, email: decoded.email, role: decoded.role };
|
|
818
|
-
next();
|
|
819
|
-
}
|
|
820
|
-
|
|
821
|
-
/**
|
|
822
|
-
* Role-based access middleware.
|
|
823
|
-
* @param {...string} roles — Allowed roles
|
|
824
|
-
* @returns {Function} Express middleware
|
|
825
|
-
*/
|
|
826
|
-
function requireRole() {
|
|
827
|
-
var roles = Array.prototype.slice.call(arguments);
|
|
828
|
-
return function(req, res, next) {
|
|
829
|
-
if (!req.user) {
|
|
830
|
-
return res.status(401).json({ error: 'Authentication required' });
|
|
831
|
-
}
|
|
832
|
-
if (roles.indexOf(req.user.role) === -1) {
|
|
833
|
-
return res.status(403).json({ error: 'Insufficient permissions' });
|
|
834
|
-
}
|
|
835
|
-
next();
|
|
836
|
-
};
|
|
837
|
-
}
|
|
838
|
-
|
|
839
|
-
module.exports = {
|
|
840
|
-
generateToken: generateToken,
|
|
841
|
-
verifyToken: verifyToken,
|
|
842
|
-
hashPassword: hashPassword,
|
|
843
|
-
comparePassword: comparePassword,
|
|
844
|
-
authenticate: authenticate,
|
|
845
|
-
requireRole: requireRole,
|
|
846
|
-
};
|
|
847
|
-
`;
|
|
848
|
-
|
|
849
|
-
// ---------------------------------------------------------------------------
|
|
850
|
-
// middleware/errorHandler.js
|
|
851
|
-
// ---------------------------------------------------------------------------
|
|
852
|
-
|
|
853
|
-
const ERROR_HANDLER_JS = `/**
|
|
854
|
-
* middleware/errorHandler.js — Global error handling.
|
|
855
|
-
*
|
|
856
|
-
* - notFound: 404 handler that serves index.html for SPA routes
|
|
857
|
-
* - errorHandler: Catches all errors, returns structured JSON
|
|
858
|
-
*/
|
|
859
|
-
|
|
860
|
-
var fs = require('fs');
|
|
861
|
-
var path = require('path');
|
|
862
|
-
|
|
863
|
-
/**
|
|
864
|
-
* 404 handler — serves index.html for SPA routes, 404 JSON for API routes.
|
|
865
|
-
*/
|
|
866
|
-
function notFound(req, res, next) {
|
|
867
|
-
// API routes get a JSON 404
|
|
868
|
-
if (req.path.startsWith('/api/')) {
|
|
869
|
-
return res.status(404).json({ error: 'Endpoint not found: ' + req.method + ' ' + req.path });
|
|
870
|
-
}
|
|
871
|
-
|
|
872
|
-
// SPA fallback — serve index.html for all non-API routes
|
|
873
|
-
try {
|
|
874
|
-
var html = fs.readFileSync(path.join(__dirname, '..', 'public', 'index.html'), 'utf-8');
|
|
875
|
-
res.setHeader('Content-Type', 'text/html');
|
|
876
|
-
res.send(html);
|
|
877
|
-
} catch (e) {
|
|
878
|
-
res.status(404).json({ error: 'Not found' });
|
|
879
|
-
}
|
|
880
|
-
}
|
|
881
|
-
|
|
882
|
-
/**
|
|
883
|
-
* Global error handler — catches thrown errors and returns structured JSON.
|
|
884
|
-
*/
|
|
885
|
-
function errorHandler(err, req, res, next) {
|
|
886
|
-
var status = err.status || err.statusCode || 500;
|
|
887
|
-
var message = err.message || 'Internal server error';
|
|
888
|
-
|
|
889
|
-
console.error('[Error] ' + req.method + ' ' + req.path + ' — ' + status + ': ' + message);
|
|
890
|
-
|
|
891
|
-
res.status(status).json({
|
|
892
|
-
error: message,
|
|
893
|
-
...(process.env.NODE_ENV === 'development' ? { stack: err.stack } : {}),
|
|
894
|
-
});
|
|
895
|
-
}
|
|
896
|
-
|
|
897
|
-
module.exports = {
|
|
898
|
-
notFound: notFound,
|
|
899
|
-
errorHandler: errorHandler,
|
|
900
|
-
};
|
|
901
|
-
`;
|
|
902
|
-
|
|
903
|
-
// ---------------------------------------------------------------------------
|
|
904
|
-
// middleware/staticFiles.js
|
|
905
|
-
// ---------------------------------------------------------------------------
|
|
906
|
-
|
|
907
|
-
const STATIC_FILES_JS = `/**
|
|
908
|
-
* middleware/staticFiles.js — Static file serving for Nodepod.
|
|
909
|
-
*
|
|
910
|
-
* Nodepod doesn't support express.static() or res.sendFile().
|
|
911
|
-
* This middleware reads files via fs.readFileSync and sends them
|
|
912
|
-
* with proper Content-Type headers.
|
|
913
|
-
*
|
|
914
|
-
* PRODUCTION: Replace this entire middleware with express.static('public').
|
|
915
|
-
*/
|
|
916
|
-
|
|
917
|
-
var fs = require('fs');
|
|
918
|
-
var path = require('path');
|
|
919
|
-
|
|
920
|
-
var MIME_TYPES = {
|
|
921
|
-
'.html': 'text/html; charset=utf-8',
|
|
922
|
-
'.js': 'application/javascript; charset=utf-8',
|
|
923
|
-
'.css': 'text/css; charset=utf-8',
|
|
924
|
-
'.json': 'application/json; charset=utf-8',
|
|
925
|
-
'.svg': 'image/svg+xml',
|
|
926
|
-
'.png': 'image/png',
|
|
927
|
-
'.jpg': 'image/jpeg',
|
|
928
|
-
'.ico': 'image/x-icon',
|
|
929
|
-
'.woff': 'font/woff',
|
|
930
|
-
'.woff2': 'font/woff2',
|
|
931
|
-
'.ttf': 'font/ttf',
|
|
932
|
-
};
|
|
933
|
-
|
|
934
|
-
/**
|
|
935
|
-
* Static file serving middleware.
|
|
936
|
-
* Serves files from the /public directory.
|
|
937
|
-
*/
|
|
938
|
-
function serveStatic(req, res, next) {
|
|
939
|
-
if (req.method !== 'GET' && req.method !== 'HEAD') return next();
|
|
940
|
-
|
|
941
|
-
// Skip API routes
|
|
942
|
-
if (req.path.startsWith('/api/')) return next();
|
|
943
|
-
|
|
944
|
-
var reqPath = req.path === '/' ? '/index.html' : req.path;
|
|
945
|
-
var filePath = path.join(__dirname, '..', 'public', reqPath);
|
|
946
|
-
var ext = path.extname(filePath);
|
|
947
|
-
|
|
948
|
-
// If no extension and not an API route, try serving index.html (SPA fallback)
|
|
949
|
-
if (!ext) {
|
|
950
|
-
filePath = path.join(__dirname, '..', 'public', 'index.html');
|
|
951
|
-
ext = '.html';
|
|
952
|
-
}
|
|
953
|
-
|
|
954
|
-
try {
|
|
955
|
-
var isBinary = ext === '.png' || ext === '.jpg' || ext === '.ico' || ext === '.woff' || ext === '.woff2' || ext === '.ttf';
|
|
956
|
-
var data = fs.readFileSync(filePath, isBinary ? undefined : 'utf-8');
|
|
957
|
-
res.setHeader('Content-Type', MIME_TYPES[ext] || 'application/octet-stream');
|
|
958
|
-
res.send(data);
|
|
959
|
-
} catch (e) {
|
|
960
|
-
next();
|
|
961
|
-
}
|
|
962
|
-
}
|
|
963
|
-
|
|
964
|
-
module.exports = serveStatic;
|
|
965
|
-
`;
|
|
966
|
-
|
|
967
|
-
// ---------------------------------------------------------------------------
|
|
968
|
-
// middleware/validate.js
|
|
969
|
-
// ---------------------------------------------------------------------------
|
|
970
|
-
|
|
971
|
-
const VALIDATE_JS = `/**
|
|
972
|
-
* middleware/validate.js — Request validation helpers.
|
|
973
|
-
*
|
|
974
|
-
* Simple validation middleware factory. For production, consider
|
|
975
|
-
* using Joi, Zod, or express-validator.
|
|
976
|
-
*/
|
|
977
|
-
|
|
978
|
-
/**
|
|
979
|
-
* Validate that required fields exist in req.body.
|
|
980
|
-
* @param {Array<string>} fields — Required field names
|
|
981
|
-
* @returns {Function} Express middleware
|
|
982
|
-
*/
|
|
983
|
-
function requireFields(fields) {
|
|
984
|
-
return function(req, res, next) {
|
|
985
|
-
var missing = [];
|
|
986
|
-
fields.forEach(function(field) {
|
|
987
|
-
if (req.body[field] === undefined || req.body[field] === null || req.body[field] === '') {
|
|
988
|
-
missing.push(field);
|
|
989
|
-
}
|
|
990
|
-
});
|
|
991
|
-
|
|
992
|
-
if (missing.length > 0) {
|
|
993
|
-
return res.status(400).json({
|
|
994
|
-
error: 'Missing required fields: ' + missing.join(', '),
|
|
995
|
-
});
|
|
996
|
-
}
|
|
997
|
-
next();
|
|
998
|
-
};
|
|
999
|
-
}
|
|
1000
|
-
|
|
1001
|
-
/**
|
|
1002
|
-
* Validate that a field value is one of the allowed values.
|
|
1003
|
-
* @param {string} field — Field name in req.body
|
|
1004
|
-
* @param {Array<string>} allowed — Allowed values
|
|
1005
|
-
* @returns {Function} Express middleware
|
|
1006
|
-
*/
|
|
1007
|
-
function allowedValues(field, allowed) {
|
|
1008
|
-
return function(req, res, next) {
|
|
1009
|
-
if (req.body[field] !== undefined && allowed.indexOf(req.body[field]) === -1) {
|
|
1010
|
-
return res.status(400).json({
|
|
1011
|
-
error: field + ' must be one of: ' + allowed.join(', '),
|
|
1012
|
-
});
|
|
1013
|
-
}
|
|
1014
|
-
next();
|
|
1015
|
-
};
|
|
1016
|
-
}
|
|
1017
|
-
|
|
1018
|
-
module.exports = {
|
|
1019
|
-
requireFields: requireFields,
|
|
1020
|
-
allowedValues: allowedValues,
|
|
1021
|
-
};
|
|
1022
|
-
`;
|
|
1023
|
-
|
|
1024
|
-
// ---------------------------------------------------------------------------
|
|
1025
|
-
// routes/auth.js
|
|
1026
|
-
// ---------------------------------------------------------------------------
|
|
1027
|
-
|
|
1028
|
-
const ROUTES_AUTH_JS = `/**
|
|
1029
|
-
* routes/auth.js — Authentication endpoints.
|
|
1030
|
-
*
|
|
1031
|
-
* POST /api/auth/register — Create new account
|
|
1032
|
-
* POST /api/auth/login — Sign in, receive JWT
|
|
1033
|
-
* GET /api/auth/me — Get current user profile
|
|
1034
|
-
*/
|
|
1035
|
-
|
|
1036
|
-
var express = require('express');
|
|
1037
|
-
var router = express.Router();
|
|
1038
|
-
var auth = require('../middleware/auth');
|
|
1039
|
-
var validate = require('../middleware/validate');
|
|
1040
|
-
var userRepo = require('../db/repositories/userRepository');
|
|
1041
|
-
|
|
1042
|
-
/**
|
|
1043
|
-
* POST /api/auth/register
|
|
1044
|
-
* Body: { email, password, name }
|
|
1045
|
-
* Returns: { user, token }
|
|
1046
|
-
*/
|
|
1047
|
-
router.post('/register',
|
|
1048
|
-
validate.requireFields(['email', 'password', 'name']),
|
|
1049
|
-
function(req, res) {
|
|
1050
|
-
try {
|
|
1051
|
-
// Check if email already exists
|
|
1052
|
-
var existing = userRepo.findByEmail(req.body.email);
|
|
1053
|
-
if (existing) {
|
|
1054
|
-
return res.status(409).json({ error: 'Email already registered' });
|
|
1055
|
-
}
|
|
1056
|
-
|
|
1057
|
-
// Create user with hashed password
|
|
1058
|
-
var user = userRepo.create({
|
|
1059
|
-
email: req.body.email,
|
|
1060
|
-
password: auth.hashPassword(req.body.password),
|
|
1061
|
-
name: req.body.name,
|
|
1062
|
-
role: 'user',
|
|
1063
|
-
});
|
|
1064
|
-
|
|
1065
|
-
var token = auth.generateToken(user);
|
|
1066
|
-
res.status(201).json({ user: user, token: token });
|
|
1067
|
-
} catch (err) {
|
|
1068
|
-
console.error('[Auth] Register error:', err.message);
|
|
1069
|
-
res.status(500).json({ error: 'Registration failed' });
|
|
1070
|
-
}
|
|
1071
|
-
}
|
|
1072
|
-
);
|
|
1073
|
-
|
|
1074
|
-
/**
|
|
1075
|
-
* POST /api/auth/login
|
|
1076
|
-
* Body: { email, password }
|
|
1077
|
-
* Returns: { user, token }
|
|
1078
|
-
*/
|
|
1079
|
-
router.post('/login',
|
|
1080
|
-
validate.requireFields(['email', 'password']),
|
|
1081
|
-
function(req, res) {
|
|
1082
|
-
try {
|
|
1083
|
-
var user = userRepo.findByEmail(req.body.email);
|
|
1084
|
-
if (!user) {
|
|
1085
|
-
return res.status(401).json({ error: 'Invalid email or password' });
|
|
1086
|
-
}
|
|
1087
|
-
|
|
1088
|
-
if (!auth.comparePassword(req.body.password, user.password)) {
|
|
1089
|
-
return res.status(401).json({ error: 'Invalid email or password' });
|
|
1090
|
-
}
|
|
1091
|
-
|
|
1092
|
-
// Return user without password
|
|
1093
|
-
var safeUser = {
|
|
1094
|
-
id: user.id,
|
|
1095
|
-
email: user.email,
|
|
1096
|
-
name: user.name,
|
|
1097
|
-
role: user.role,
|
|
1098
|
-
created_at: user.created_at,
|
|
1099
|
-
};
|
|
1100
|
-
|
|
1101
|
-
var token = auth.generateToken(safeUser);
|
|
1102
|
-
res.json({ user: safeUser, token: token });
|
|
1103
|
-
} catch (err) {
|
|
1104
|
-
console.error('[Auth] Login error:', err.message);
|
|
1105
|
-
res.status(500).json({ error: 'Login failed' });
|
|
1106
|
-
}
|
|
1107
|
-
}
|
|
1108
|
-
);
|
|
1109
|
-
|
|
1110
|
-
/**
|
|
1111
|
-
* GET /api/auth/me
|
|
1112
|
-
* Headers: Authorization: Bearer <token>
|
|
1113
|
-
* Returns: { user }
|
|
1114
|
-
*/
|
|
1115
|
-
router.get('/me', auth.authenticate, function(req, res) {
|
|
1116
|
-
try {
|
|
1117
|
-
var user = userRepo.findById(req.user.id);
|
|
1118
|
-
if (!user) {
|
|
1119
|
-
return res.status(404).json({ error: 'User not found' });
|
|
1120
|
-
}
|
|
1121
|
-
res.json({ user: user });
|
|
1122
|
-
} catch (err) {
|
|
1123
|
-
console.error('[Auth] Me error:', err.message);
|
|
1124
|
-
res.status(500).json({ error: 'Failed to fetch profile' });
|
|
1125
|
-
}
|
|
1126
|
-
});
|
|
1127
|
-
|
|
1128
|
-
module.exports = router;
|
|
1129
|
-
`;
|
|
1130
|
-
|
|
1131
|
-
// ---------------------------------------------------------------------------
|
|
1132
|
-
// routes/items.js
|
|
1133
|
-
// ---------------------------------------------------------------------------
|
|
1134
|
-
|
|
1135
|
-
const ROUTES_ITEMS_JS = `/**
|
|
1136
|
-
* routes/items.js — Item CRUD endpoints.
|
|
1137
|
-
*
|
|
1138
|
-
* All routes require authentication (JWT).
|
|
1139
|
-
*
|
|
1140
|
-
* GET /api/items — List items (with optional filters)
|
|
1141
|
-
* POST /api/items — Create item
|
|
1142
|
-
* GET /api/items/stats — Get aggregate statistics
|
|
1143
|
-
* GET /api/items/:id — Get single item
|
|
1144
|
-
* PUT /api/items/:id — Update item
|
|
1145
|
-
* DELETE /api/items/:id — Delete item
|
|
1146
|
-
*/
|
|
1147
|
-
|
|
1148
|
-
var express = require('express');
|
|
1149
|
-
var router = express.Router();
|
|
1150
|
-
var auth = require('../middleware/auth');
|
|
1151
|
-
var validate = require('../middleware/validate');
|
|
1152
|
-
var itemRepo = require('../db/repositories/itemRepository');
|
|
1153
|
-
|
|
1154
|
-
// All item routes require authentication
|
|
1155
|
-
router.use(auth.authenticate);
|
|
1156
|
-
|
|
1157
|
-
/**
|
|
1158
|
-
* GET /api/items
|
|
1159
|
-
* Query: ?status=todo&priority=high
|
|
1160
|
-
* Returns: { items: [...] }
|
|
1161
|
-
*/
|
|
1162
|
-
router.get('/', function(req, res) {
|
|
1163
|
-
try {
|
|
1164
|
-
var filters = {
|
|
1165
|
-
status: req.query.status || undefined,
|
|
1166
|
-
priority: req.query.priority || undefined,
|
|
1167
|
-
};
|
|
1168
|
-
var items = itemRepo.findAll(filters);
|
|
1169
|
-
res.json({ items: items });
|
|
1170
|
-
} catch (err) {
|
|
1171
|
-
console.error('[Items] List error:', err.message);
|
|
1172
|
-
res.status(500).json({ error: 'Failed to fetch items' });
|
|
1173
|
-
}
|
|
1174
|
-
});
|
|
1175
|
-
|
|
1176
|
-
/**
|
|
1177
|
-
* GET /api/items/stats
|
|
1178
|
-
* Returns: { total, byStatus, byPriority }
|
|
1179
|
-
*/
|
|
1180
|
-
router.get('/stats', function(req, res) {
|
|
1181
|
-
try {
|
|
1182
|
-
var stats = itemRepo.getStats();
|
|
1183
|
-
res.json(stats);
|
|
1184
|
-
} catch (err) {
|
|
1185
|
-
console.error('[Items] Stats error:', err.message);
|
|
1186
|
-
res.status(500).json({ error: 'Failed to fetch stats' });
|
|
1187
|
-
}
|
|
1188
|
-
});
|
|
1189
|
-
|
|
1190
|
-
/**
|
|
1191
|
-
* GET /api/items/:id
|
|
1192
|
-
* Returns: { item }
|
|
1193
|
-
*/
|
|
1194
|
-
router.get('/:id', function(req, res) {
|
|
1195
|
-
try {
|
|
1196
|
-
var item = itemRepo.findById(parseInt(req.params.id, 10));
|
|
1197
|
-
if (!item) {
|
|
1198
|
-
return res.status(404).json({ error: 'Item not found' });
|
|
1199
|
-
}
|
|
1200
|
-
res.json({ item: item });
|
|
1201
|
-
} catch (err) {
|
|
1202
|
-
console.error('[Items] Get error:', err.message);
|
|
1203
|
-
res.status(500).json({ error: 'Failed to fetch item' });
|
|
1204
|
-
}
|
|
1205
|
-
});
|
|
1206
|
-
|
|
1207
|
-
/**
|
|
1208
|
-
* POST /api/items
|
|
1209
|
-
* Body: { title, description?, status?, priority? }
|
|
1210
|
-
* Returns: { item }
|
|
1211
|
-
*/
|
|
1212
|
-
router.post('/',
|
|
1213
|
-
validate.requireFields(['title']),
|
|
1214
|
-
validate.allowedValues('status', ['todo', 'in_progress', 'done']),
|
|
1215
|
-
validate.allowedValues('priority', ['low', 'medium', 'high']),
|
|
1216
|
-
function(req, res) {
|
|
1217
|
-
try {
|
|
1218
|
-
var item = itemRepo.create({
|
|
1219
|
-
title: req.body.title,
|
|
1220
|
-
description: req.body.description || '',
|
|
1221
|
-
status: req.body.status || 'todo',
|
|
1222
|
-
priority: req.body.priority || 'medium',
|
|
1223
|
-
user_id: req.user.id,
|
|
1224
|
-
});
|
|
1225
|
-
res.status(201).json({ item: item });
|
|
1226
|
-
} catch (err) {
|
|
1227
|
-
console.error('[Items] Create error:', err.message);
|
|
1228
|
-
res.status(500).json({ error: 'Failed to create item' });
|
|
1229
|
-
}
|
|
1230
|
-
}
|
|
1231
|
-
);
|
|
1232
|
-
|
|
1233
|
-
/**
|
|
1234
|
-
* PUT /api/items/:id
|
|
1235
|
-
* Body: { title?, description?, status?, priority? }
|
|
1236
|
-
* Returns: { item }
|
|
1237
|
-
*/
|
|
1238
|
-
router.put('/:id',
|
|
1239
|
-
validate.allowedValues('status', ['todo', 'in_progress', 'done']),
|
|
1240
|
-
validate.allowedValues('priority', ['low', 'medium', 'high']),
|
|
1241
|
-
function(req, res) {
|
|
1242
|
-
try {
|
|
1243
|
-
var existing = itemRepo.findById(parseInt(req.params.id, 10));
|
|
1244
|
-
if (!existing) {
|
|
1245
|
-
return res.status(404).json({ error: 'Item not found' });
|
|
1246
|
-
}
|
|
1247
|
-
|
|
1248
|
-
var item = itemRepo.update(parseInt(req.params.id, 10), {
|
|
1249
|
-
title: req.body.title,
|
|
1250
|
-
description: req.body.description,
|
|
1251
|
-
status: req.body.status,
|
|
1252
|
-
priority: req.body.priority,
|
|
1253
|
-
});
|
|
1254
|
-
res.json({ item: item });
|
|
1255
|
-
} catch (err) {
|
|
1256
|
-
console.error('[Items] Update error:', err.message);
|
|
1257
|
-
res.status(500).json({ error: 'Failed to update item' });
|
|
1258
|
-
}
|
|
1259
|
-
}
|
|
1260
|
-
);
|
|
1261
|
-
|
|
1262
|
-
/**
|
|
1263
|
-
* DELETE /api/items/:id
|
|
1264
|
-
* Returns: { message }
|
|
1265
|
-
*/
|
|
1266
|
-
router.delete('/:id', function(req, res) {
|
|
1267
|
-
try {
|
|
1268
|
-
var deleted = itemRepo.remove(parseInt(req.params.id, 10));
|
|
1269
|
-
if (!deleted) {
|
|
1270
|
-
return res.status(404).json({ error: 'Item not found' });
|
|
1271
|
-
}
|
|
1272
|
-
res.json({ message: 'Item deleted' });
|
|
1273
|
-
} catch (err) {
|
|
1274
|
-
console.error('[Items] Delete error:', err.message);
|
|
1275
|
-
res.status(500).json({ error: 'Failed to delete item' });
|
|
1276
|
-
}
|
|
1277
|
-
});
|
|
1278
|
-
|
|
1279
|
-
module.exports = router;
|
|
1280
|
-
`;
|
|
1281
|
-
|
|
1282
|
-
// ---------------------------------------------------------------------------
|
|
1283
|
-
// server.js
|
|
1284
|
-
// ---------------------------------------------------------------------------
|
|
1285
|
-
|
|
1286
|
-
const SERVER_JS = `/**
|
|
1287
|
-
* server.js — Express application entry point.
|
|
1288
|
-
*
|
|
1289
|
-
* Middleware chain:
|
|
1290
|
-
* 1. Body parsing (JSON)
|
|
1291
|
-
* 2. Request logging
|
|
1292
|
-
* 3. Static file serving (Nodepod-compatible)
|
|
1293
|
-
* 4. API routes (auth, items)
|
|
1294
|
-
* 5. SPA fallback (404 → index.html)
|
|
1295
|
-
* 6. Error handler
|
|
1296
|
-
*/
|
|
1297
|
-
|
|
1298
|
-
var express = require('express');
|
|
1299
|
-
var config = require('./config');
|
|
1300
|
-
var db = require('./db/database');
|
|
1301
|
-
var serveStatic = require('./middleware/staticFiles');
|
|
1302
|
-
var errorHandler = require('./middleware/errorHandler');
|
|
1303
|
-
var authRoutes = require('./routes/auth');
|
|
1304
|
-
var itemRoutes = require('./routes/items');
|
|
1305
|
-
|
|
1306
|
-
var app = express();
|
|
1307
|
-
|
|
1308
|
-
// ---------------------------------------------------------------------------
|
|
1309
|
-
// Middleware
|
|
1310
|
-
// ---------------------------------------------------------------------------
|
|
1311
|
-
|
|
1312
|
-
// Parse JSON bodies
|
|
1313
|
-
app.use(express.json());
|
|
1314
|
-
|
|
1315
|
-
// Request logging
|
|
1316
|
-
app.use(function(req, res, next) {
|
|
1317
|
-
var start = Date.now();
|
|
1318
|
-
res.on('finish', function() {
|
|
1319
|
-
var duration = Date.now() - start;
|
|
1320
|
-
console.log(
|
|
1321
|
-
'[' + req.method + '] ' + req.path + ' → ' + res.statusCode + ' (' + duration + 'ms)'
|
|
1322
|
-
);
|
|
1323
|
-
});
|
|
1324
|
-
next();
|
|
1325
|
-
});
|
|
1326
|
-
|
|
1327
|
-
// Static files (must be before API routes for / to serve index.html)
|
|
1328
|
-
app.use(serveStatic);
|
|
1329
|
-
|
|
1330
|
-
// ---------------------------------------------------------------------------
|
|
1331
|
-
// API Routes
|
|
1332
|
-
// ---------------------------------------------------------------------------
|
|
1333
|
-
|
|
1334
|
-
// Health check — used by Docker HEALTHCHECK, load balancers, and uptime monitors
|
|
1335
|
-
app.get('/api/health', function(req, res) {
|
|
1336
|
-
res.json({ status: 'ok', uptime: process.uptime() });
|
|
1337
|
-
});
|
|
1338
|
-
|
|
1339
|
-
app.use('/api/auth', authRoutes);
|
|
1340
|
-
app.use('/api/items', itemRoutes);
|
|
1341
|
-
|
|
1342
|
-
// ---------------------------------------------------------------------------
|
|
1343
|
-
// Error handling
|
|
1344
|
-
// ---------------------------------------------------------------------------
|
|
1345
|
-
|
|
1346
|
-
app.use(errorHandler.notFound);
|
|
1347
|
-
app.use(errorHandler.errorHandler);
|
|
1348
|
-
|
|
1349
|
-
// ---------------------------------------------------------------------------
|
|
1350
|
-
// Start server after DB initialization
|
|
1351
|
-
// ---------------------------------------------------------------------------
|
|
1352
|
-
|
|
1353
|
-
console.log('[Server] Initializing database...');
|
|
1354
|
-
|
|
1355
|
-
db.initialize().then(function() {
|
|
1356
|
-
console.log('[Server] Database ready, starting HTTP server...');
|
|
1357
|
-
app.listen(config.port, function() {
|
|
1358
|
-
console.log('');
|
|
1359
|
-
console.log('===========================================');
|
|
1360
|
-
console.log(' Server running on port ' + config.port);
|
|
1361
|
-
console.log(' Database: SQLite (sql.js WASM)');
|
|
1362
|
-
console.log(' Environment: ' + config.nodeEnv);
|
|
1363
|
-
console.log('===========================================');
|
|
1364
|
-
console.log('');
|
|
1365
|
-
console.log('Demo accounts:');
|
|
1366
|
-
console.log(' admin@example.com / admin123');
|
|
1367
|
-
console.log(' user@example.com / user123');
|
|
1368
|
-
console.log('');
|
|
1369
|
-
});
|
|
1370
|
-
}).catch(function(err) {
|
|
1371
|
-
console.error('[FATAL] Failed to initialize database:', err && err.message ? err.message : err);
|
|
1372
|
-
console.error('[FATAL] Stack:', err && err.stack ? err.stack : 'no stack');
|
|
1373
|
-
});
|
|
1374
|
-
`;
|
|
1375
|
-
|
|
1376
|
-
// ---------------------------------------------------------------------------
|
|
1377
|
-
// public/index.html
|
|
1378
|
-
// ---------------------------------------------------------------------------
|
|
1379
|
-
|
|
1380
|
-
const INDEX_HTML = `<!DOCTYPE html>
|
|
1381
|
-
<html lang="en">
|
|
1382
|
-
<head>
|
|
1383
|
-
<meta charset="UTF-8">
|
|
1384
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1385
|
-
<title>Starter App</title>
|
|
1386
|
-
<script src="https://cdn.tailwindcss.com"><\/script>
|
|
1387
|
-
<script>
|
|
1388
|
-
tailwind.config = {
|
|
1389
|
-
theme: {
|
|
1390
|
-
extend: {
|
|
1391
|
-
colors: {
|
|
1392
|
-
primary: { 50: '#eff6ff', 100: '#dbeafe', 200: '#bfdbfe', 300: '#93c5fd', 400: '#60a5fa', 500: '#3b82f6', 600: '#2563eb', 700: '#1d4ed8', 800: '#1e40af', 900: '#1e3a8a' },
|
|
1393
|
-
}
|
|
1394
|
-
}
|
|
1395
|
-
}
|
|
1396
|
-
}
|
|
1397
|
-
<\/script>
|
|
1398
|
-
<script src="https://unpkg.com/react@18.2.0/umd/react.production.min.js"><\/script>
|
|
1399
|
-
<script src="https://unpkg.com/react-dom@18.2.0/umd/react-dom.production.min.js"><\/script>
|
|
1400
|
-
<script src="https://unpkg.com/@remix-run/router@1.21.0/dist/router.umd.min.js"><\/script>
|
|
1401
|
-
<script src="https://unpkg.com/react-router@6.28.0/dist/umd/react-router.production.min.js"><\/script>
|
|
1402
|
-
<script src="https://unpkg.com/react-router-dom@6.28.0/dist/umd/react-router-dom.production.min.js"><\/script>
|
|
1403
|
-
<script src="https://unpkg.com/chart.js@4.4.0/dist/chart.umd.js"><\/script>
|
|
1404
|
-
<link rel="stylesheet" href="/styles.css">
|
|
1405
|
-
</head>
|
|
1406
|
-
<body class="bg-gray-50 min-h-screen antialiased">
|
|
1407
|
-
<div id="root"></div>
|
|
1408
|
-
|
|
1409
|
-
<!-- Lib -->
|
|
1410
|
-
<script src="/lib/api.js"><\/script>
|
|
1411
|
-
|
|
1412
|
-
<!-- Store -->
|
|
1413
|
-
<script src="/store/AuthContext.js"><\/script>
|
|
1414
|
-
<script src="/store/AppContext.js"><\/script>
|
|
1415
|
-
|
|
1416
|
-
<!-- Components -->
|
|
1417
|
-
<script src="/components/Toast.js"><\/script>
|
|
1418
|
-
<script src="/components/Navbar.js"><\/script>
|
|
1419
|
-
<script src="/components/ItemCard.js"><\/script>
|
|
1420
|
-
<script src="/components/ItemForm.js"><\/script>
|
|
1421
|
-
<script src="/components/StatsChart.js"><\/script>
|
|
1422
|
-
|
|
1423
|
-
<!-- Pages -->
|
|
1424
|
-
<script src="/pages/WelcomePage.js"><\/script>
|
|
1425
|
-
<script src="/pages/LoginPage.js"><\/script>
|
|
1426
|
-
<script src="/pages/RegisterPage.js"><\/script>
|
|
1427
|
-
<script src="/pages/DashboardPage.js"><\/script>
|
|
1428
|
-
|
|
1429
|
-
<!-- App (must be last) -->
|
|
1430
|
-
<script src="/App.js"><\/script>
|
|
1431
|
-
</body>
|
|
1432
|
-
</html>`;
|
|
1433
|
-
|
|
1434
|
-
// ---------------------------------------------------------------------------
|
|
1435
|
-
// public/styles.css
|
|
1436
|
-
// ---------------------------------------------------------------------------
|
|
1437
|
-
|
|
1438
|
-
const STYLES_CSS = `/*
|
|
1439
|
-
* Ranger Theme Variables — chart colors and design tokens.
|
|
1440
|
-
* HSL values stored WITHOUT hsl() wrapper; consume as: color: hsl(var(--chart-1));
|
|
1441
|
-
*/
|
|
1442
|
-
|
|
1443
|
-
/* --- Light mode (default) ----------------------------------------------- */
|
|
1444
|
-
:root {
|
|
1445
|
-
--text-primary: #1a1a1a;
|
|
1446
|
-
--text-secondary: #565869;
|
|
1447
|
-
--text-tertiary: #8e8ea0;
|
|
1448
|
-
--surface-primary: #ffffff;
|
|
1449
|
-
--surface-secondary: #f9fafb;
|
|
1450
|
-
--surface-tertiary: #f3f4f6;
|
|
1451
|
-
--border-light: #e5e7eb;
|
|
1452
|
-
--border-medium: #d1d5db;
|
|
1453
|
-
--border-heavy: #9ca3af;
|
|
1454
|
-
--background: 0 0% 100%;
|
|
1455
|
-
--foreground: 0 0% 3.9%;
|
|
1456
|
-
--card: 0 0% 100%;
|
|
1457
|
-
--card-foreground: 0 0% 3.9%;
|
|
1458
|
-
--primary: 0 0% 9%;
|
|
1459
|
-
--primary-foreground: 0 0% 98%;
|
|
1460
|
-
--secondary: 0 0% 96.1%;
|
|
1461
|
-
--secondary-foreground: 0 0% 9%;
|
|
1462
|
-
--muted: 0 0% 96.1%;
|
|
1463
|
-
--muted-foreground: 0 0% 45.1%;
|
|
1464
|
-
--accent: 0 0% 96.1%;
|
|
1465
|
-
--accent-foreground: 0 0% 9%;
|
|
1466
|
-
--destructive: 0 84.2% 60.2%;
|
|
1467
|
-
--destructive-foreground: 0 0% 98%;
|
|
1468
|
-
--border: 0 0% 89.8%;
|
|
1469
|
-
--input: 0 0% 89.8%;
|
|
1470
|
-
--ring: 0 0% 3.9%;
|
|
1471
|
-
--radius: 0.5rem;
|
|
1472
|
-
--chart-1: 12 76% 61%;
|
|
1473
|
-
--chart-2: 173 58% 39%;
|
|
1474
|
-
--chart-3: 197 37% 24%;
|
|
1475
|
-
--chart-4: 43 74% 66%;
|
|
1476
|
-
--chart-5: 27 87% 67%;
|
|
1477
|
-
}
|
|
1478
|
-
|
|
1479
|
-
/* --- Dark mode ---------------------------------------------------------- */
|
|
1480
|
-
.dark {
|
|
1481
|
-
--text-primary: #f3f4f6;
|
|
1482
|
-
--text-secondary: #d1d5db;
|
|
1483
|
-
--text-tertiary: #6b7280;
|
|
1484
|
-
--surface-primary: #111827;
|
|
1485
|
-
--surface-secondary: #1f2937;
|
|
1486
|
-
--surface-tertiary: #374151;
|
|
1487
|
-
--border-light: #3a3a3b;
|
|
1488
|
-
--border-medium: #4b5563;
|
|
1489
|
-
--border-heavy: #6b7280;
|
|
1490
|
-
--background: 0 0% 7%;
|
|
1491
|
-
--foreground: 0 0% 98%;
|
|
1492
|
-
--card: 0 0% 3.9%;
|
|
1493
|
-
--card-foreground: 0 0% 98%;
|
|
1494
|
-
--primary: 0 0% 98%;
|
|
1495
|
-
--primary-foreground: 0 0% 9%;
|
|
1496
|
-
--secondary: 0 0% 14.9%;
|
|
1497
|
-
--secondary-foreground: 0 0% 98%;
|
|
1498
|
-
--muted: 0 0% 14.9%;
|
|
1499
|
-
--muted-foreground: 0 0% 63.9%;
|
|
1500
|
-
--accent: 0 0% 14.9%;
|
|
1501
|
-
--accent-foreground: 0 0% 98%;
|
|
1502
|
-
--destructive: 0 62.8% 40.6%;
|
|
1503
|
-
--destructive-foreground: 0 0% 98%;
|
|
1504
|
-
--border: 0 0% 14.9%;
|
|
1505
|
-
--input: 0 0% 14.9%;
|
|
1506
|
-
--ring: 0 0% 83.1%;
|
|
1507
|
-
--chart-1: 220 70% 50%;
|
|
1508
|
-
--chart-2: 160 60% 45%;
|
|
1509
|
-
--chart-3: 30 80% 55%;
|
|
1510
|
-
--chart-4: 280 65% 60%;
|
|
1511
|
-
--chart-5: 340 75% 55%;
|
|
1512
|
-
}
|
|
1513
|
-
|
|
1514
|
-
/* --- Animations --------------------------------------------------------- */
|
|
1515
|
-
.fade-in {
|
|
1516
|
-
animation: fadeIn 0.3s ease-out;
|
|
1517
|
-
}
|
|
1518
|
-
@keyframes fadeIn {
|
|
1519
|
-
from { opacity: 0; transform: translateY(8px); }
|
|
1520
|
-
to { opacity: 1; transform: translateY(0); }
|
|
1521
|
-
}
|
|
1522
|
-
.slide-in {
|
|
1523
|
-
animation: slideIn 0.3s ease-out;
|
|
1524
|
-
}
|
|
1525
|
-
@keyframes slideIn {
|
|
1526
|
-
from { opacity: 0; transform: translateX(-12px); }
|
|
1527
|
-
to { opacity: 1; transform: translateX(0); }
|
|
1528
|
-
}
|
|
1529
|
-
.card-hover {
|
|
1530
|
-
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
|
1531
|
-
}
|
|
1532
|
-
.card-hover:hover {
|
|
1533
|
-
transform: translateY(-2px);
|
|
1534
|
-
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
|
|
1535
|
-
}
|
|
1536
|
-
.toast-enter {
|
|
1537
|
-
animation: toastIn 0.3s ease-out;
|
|
1538
|
-
}
|
|
1539
|
-
@keyframes toastIn {
|
|
1540
|
-
from { opacity: 0; transform: translateY(-16px) scale(0.95); }
|
|
1541
|
-
to { opacity: 1; transform: translateY(0) scale(1); }
|
|
1542
|
-
}
|
|
1543
|
-
`;
|
|
1544
|
-
|
|
1545
|
-
// ---------------------------------------------------------------------------
|
|
1546
|
-
// public/lib/api.js
|
|
1547
|
-
// ---------------------------------------------------------------------------
|
|
1548
|
-
|
|
1549
|
-
const API_JS = `/**
|
|
1550
|
-
* lib/api.js — API client.
|
|
1551
|
-
*
|
|
1552
|
-
* Wraps fetch() with:
|
|
1553
|
-
* - Automatic Authorization header from stored token
|
|
1554
|
-
* - JSON parsing
|
|
1555
|
-
* - Error handling with structured error messages
|
|
1556
|
-
*/
|
|
1557
|
-
|
|
1558
|
-
window.ApiClient = (function() {
|
|
1559
|
-
var TOKEN_KEY = 'auth_token';
|
|
1560
|
-
|
|
1561
|
-
function getToken() {
|
|
1562
|
-
try { return localStorage.getItem(TOKEN_KEY); } catch(e) { return null; }
|
|
1563
|
-
}
|
|
1564
|
-
|
|
1565
|
-
function setToken(token) {
|
|
1566
|
-
try { if (token) localStorage.setItem(TOKEN_KEY, token); else localStorage.removeItem(TOKEN_KEY); } catch(e) {}
|
|
1567
|
-
}
|
|
1568
|
-
|
|
1569
|
-
function clearToken() {
|
|
1570
|
-
try { localStorage.removeItem(TOKEN_KEY); } catch(e) {}
|
|
1571
|
-
}
|
|
1572
|
-
|
|
1573
|
-
/**
|
|
1574
|
-
* Make an API request.
|
|
1575
|
-
* @param {string} path — API path (e.g., '/api/items')
|
|
1576
|
-
* @param {Object} options — fetch options
|
|
1577
|
-
* @returns {Promise<Object>} Parsed JSON response
|
|
1578
|
-
*/
|
|
1579
|
-
async function request(path, options) {
|
|
1580
|
-
options = options || {};
|
|
1581
|
-
var headers = { 'Content-Type': 'application/json' };
|
|
1582
|
-
var token = getToken();
|
|
1583
|
-
if (token) {
|
|
1584
|
-
headers['Authorization'] = 'Bearer ' + token;
|
|
1585
|
-
}
|
|
1586
|
-
options.headers = Object.assign(headers, options.headers || {});
|
|
1587
|
-
|
|
1588
|
-
var response = await fetch(path, options);
|
|
1589
|
-
var data;
|
|
1590
|
-
try {
|
|
1591
|
-
data = await response.json();
|
|
1592
|
-
} catch (e) {
|
|
1593
|
-
data = { error: 'Invalid response from server' };
|
|
1594
|
-
}
|
|
1595
|
-
|
|
1596
|
-
if (!response.ok) {
|
|
1597
|
-
var err = new Error(data.error || 'Request failed');
|
|
1598
|
-
err.status = response.status;
|
|
1599
|
-
err.data = data;
|
|
1600
|
-
throw err;
|
|
1601
|
-
}
|
|
1602
|
-
return data;
|
|
1603
|
-
}
|
|
1604
|
-
|
|
1605
|
-
return {
|
|
1606
|
-
getToken: getToken,
|
|
1607
|
-
setToken: setToken,
|
|
1608
|
-
clearToken: clearToken,
|
|
1609
|
-
get: function(path) { return request(path, { method: 'GET' }); },
|
|
1610
|
-
post: function(path, body) { return request(path, { method: 'POST', body: JSON.stringify(body) }); },
|
|
1611
|
-
put: function(path, body) { return request(path, { method: 'PUT', body: JSON.stringify(body) }); },
|
|
1612
|
-
del: function(path) { return request(path, { method: 'DELETE' }); },
|
|
1613
|
-
};
|
|
1614
|
-
})();
|
|
1615
|
-
`;
|
|
1616
|
-
|
|
1617
|
-
// ---------------------------------------------------------------------------
|
|
1618
|
-
// public/store/AuthContext.js
|
|
1619
|
-
// ---------------------------------------------------------------------------
|
|
1620
|
-
|
|
1621
|
-
const AUTH_CONTEXT_JS = `/**
|
|
1622
|
-
* store/AuthContext.js — Authentication state management.
|
|
1623
|
-
*
|
|
1624
|
-
* Provides:
|
|
1625
|
-
* - AuthProvider: Wraps app with auth state
|
|
1626
|
-
* - useAuth(): Hook to access { user, token, loading, login, register, logout }
|
|
1627
|
-
*
|
|
1628
|
-
* Uses React Context + useReducer for predictable state updates.
|
|
1629
|
-
*/
|
|
1630
|
-
|
|
1631
|
-
var h = React.createElement;
|
|
1632
|
-
|
|
1633
|
-
// Action types
|
|
1634
|
-
var AUTH_ACTIONS = {
|
|
1635
|
-
SET_LOADING: 'SET_LOADING',
|
|
1636
|
-
LOGIN_SUCCESS: 'LOGIN_SUCCESS',
|
|
1637
|
-
LOGOUT: 'LOGOUT',
|
|
1638
|
-
SET_USER: 'SET_USER',
|
|
1639
|
-
};
|
|
1640
|
-
|
|
1641
|
-
// Reducer
|
|
1642
|
-
function authReducer(state, action) {
|
|
1643
|
-
switch (action.type) {
|
|
1644
|
-
case AUTH_ACTIONS.SET_LOADING:
|
|
1645
|
-
return Object.assign({}, state, { loading: action.payload });
|
|
1646
|
-
case AUTH_ACTIONS.LOGIN_SUCCESS:
|
|
1647
|
-
return Object.assign({}, state, {
|
|
1648
|
-
user: action.payload.user,
|
|
1649
|
-
token: action.payload.token,
|
|
1650
|
-
loading: false,
|
|
1651
|
-
});
|
|
1652
|
-
case AUTH_ACTIONS.SET_USER:
|
|
1653
|
-
return Object.assign({}, state, { user: action.payload, loading: false });
|
|
1654
|
-
case AUTH_ACTIONS.LOGOUT:
|
|
1655
|
-
return { user: null, token: null, loading: false };
|
|
1656
|
-
default:
|
|
1657
|
-
return state;
|
|
1658
|
-
}
|
|
1659
|
-
}
|
|
1660
|
-
|
|
1661
|
-
// Context
|
|
1662
|
-
var AuthContext = React.createContext(null);
|
|
1663
|
-
|
|
1664
|
-
/**
|
|
1665
|
-
* AuthProvider — Wraps children with auth state.
|
|
1666
|
-
* On mount, checks for stored token and fetches user profile.
|
|
1667
|
-
*/
|
|
1668
|
-
window.AuthProvider = function AuthProvider(props) {
|
|
1669
|
-
var initialState = { user: null, token: null, loading: true };
|
|
1670
|
-
var stateAndDispatch = React.useReducer(authReducer, initialState);
|
|
1671
|
-
var state = stateAndDispatch[0];
|
|
1672
|
-
var dispatch = stateAndDispatch[1];
|
|
1673
|
-
|
|
1674
|
-
// Check stored token on mount
|
|
1675
|
-
React.useEffect(function() {
|
|
1676
|
-
var token = ApiClient.getToken();
|
|
1677
|
-
if (!token) {
|
|
1678
|
-
dispatch({ type: AUTH_ACTIONS.SET_LOADING, payload: false });
|
|
1679
|
-
return;
|
|
1680
|
-
}
|
|
1681
|
-
|
|
1682
|
-
ApiClient.get('/api/auth/me')
|
|
1683
|
-
.then(function(data) {
|
|
1684
|
-
dispatch({ type: AUTH_ACTIONS.LOGIN_SUCCESS, payload: { user: data.user, token: token } });
|
|
1685
|
-
})
|
|
1686
|
-
.catch(function() {
|
|
1687
|
-
ApiClient.clearToken();
|
|
1688
|
-
dispatch({ type: AUTH_ACTIONS.LOGOUT });
|
|
1689
|
-
});
|
|
1690
|
-
}, []);
|
|
1691
|
-
|
|
1692
|
-
/**
|
|
1693
|
-
* Login with email and password.
|
|
1694
|
-
* @returns {Promise<Object>} User data
|
|
1695
|
-
*/
|
|
1696
|
-
var login = React.useCallback(function(email, password) {
|
|
1697
|
-
dispatch({ type: AUTH_ACTIONS.SET_LOADING, payload: true });
|
|
1698
|
-
return ApiClient.post('/api/auth/login', { email: email, password: password })
|
|
1699
|
-
.then(function(data) {
|
|
1700
|
-
ApiClient.setToken(data.token);
|
|
1701
|
-
dispatch({ type: AUTH_ACTIONS.LOGIN_SUCCESS, payload: data });
|
|
1702
|
-
return data;
|
|
1703
|
-
})
|
|
1704
|
-
.catch(function(err) {
|
|
1705
|
-
dispatch({ type: AUTH_ACTIONS.SET_LOADING, payload: false });
|
|
1706
|
-
throw err;
|
|
1707
|
-
});
|
|
1708
|
-
}, []);
|
|
1709
|
-
|
|
1710
|
-
/**
|
|
1711
|
-
* Register a new account.
|
|
1712
|
-
* @returns {Promise<Object>} User data
|
|
1713
|
-
*/
|
|
1714
|
-
var register = React.useCallback(function(email, password, name) {
|
|
1715
|
-
dispatch({ type: AUTH_ACTIONS.SET_LOADING, payload: true });
|
|
1716
|
-
return ApiClient.post('/api/auth/register', { email: email, password: password, name: name })
|
|
1717
|
-
.then(function(data) {
|
|
1718
|
-
ApiClient.setToken(data.token);
|
|
1719
|
-
dispatch({ type: AUTH_ACTIONS.LOGIN_SUCCESS, payload: data });
|
|
1720
|
-
return data;
|
|
1721
|
-
})
|
|
1722
|
-
.catch(function(err) {
|
|
1723
|
-
dispatch({ type: AUTH_ACTIONS.SET_LOADING, payload: false });
|
|
1724
|
-
throw err;
|
|
1725
|
-
});
|
|
1726
|
-
}, []);
|
|
1727
|
-
|
|
1728
|
-
/**
|
|
1729
|
-
* Logout — clear token and state.
|
|
1730
|
-
*/
|
|
1731
|
-
var logout = React.useCallback(function() {
|
|
1732
|
-
ApiClient.clearToken();
|
|
1733
|
-
dispatch({ type: AUTH_ACTIONS.LOGOUT });
|
|
1734
|
-
}, []);
|
|
1735
|
-
|
|
1736
|
-
var value = {
|
|
1737
|
-
user: state.user,
|
|
1738
|
-
token: state.token,
|
|
1739
|
-
loading: state.loading,
|
|
1740
|
-
login: login,
|
|
1741
|
-
register: register,
|
|
1742
|
-
logout: logout,
|
|
1743
|
-
isAuthenticated: !!state.user,
|
|
1744
|
-
};
|
|
1745
|
-
|
|
1746
|
-
return h(AuthContext.Provider, { value: value }, props.children);
|
|
1747
|
-
};
|
|
1748
|
-
|
|
1749
|
-
/**
|
|
1750
|
-
* useAuth — Hook to access auth state and actions.
|
|
1751
|
-
* @returns {{ user, token, loading, login, register, logout, isAuthenticated }}
|
|
1752
|
-
*/
|
|
1753
|
-
window.useAuth = function useAuth() {
|
|
1754
|
-
var context = React.useContext(AuthContext);
|
|
1755
|
-
if (!context) throw new Error('useAuth must be used within AuthProvider');
|
|
1756
|
-
return context;
|
|
1757
|
-
};
|
|
1758
|
-
`;
|
|
1759
|
-
|
|
1760
|
-
// ---------------------------------------------------------------------------
|
|
1761
|
-
// public/store/AppContext.js
|
|
1762
|
-
// ---------------------------------------------------------------------------
|
|
1763
|
-
|
|
1764
|
-
const APP_CONTEXT_JS = `/**
|
|
1765
|
-
* store/AppContext.js — Application state management.
|
|
1766
|
-
*
|
|
1767
|
-
* Manages items, loading states, filters, and toasts.
|
|
1768
|
-
* Uses React Context + useReducer.
|
|
1769
|
-
*
|
|
1770
|
-
* Provides:
|
|
1771
|
-
* - AppProvider: Wraps app with state
|
|
1772
|
-
* - useApp(): Hook to access state and CRUD actions
|
|
1773
|
-
*/
|
|
1774
|
-
|
|
1775
|
-
var h = React.createElement;
|
|
1776
|
-
|
|
1777
|
-
var APP_ACTIONS = {
|
|
1778
|
-
SET_LOADING: 'SET_LOADING',
|
|
1779
|
-
SET_ITEMS: 'SET_ITEMS',
|
|
1780
|
-
ADD_ITEM: 'ADD_ITEM',
|
|
1781
|
-
UPDATE_ITEM: 'UPDATE_ITEM',
|
|
1782
|
-
REMOVE_ITEM: 'REMOVE_ITEM',
|
|
1783
|
-
SET_STATS: 'SET_STATS',
|
|
1784
|
-
SET_FILTER: 'SET_FILTER',
|
|
1785
|
-
ADD_TOAST: 'ADD_TOAST',
|
|
1786
|
-
REMOVE_TOAST: 'REMOVE_TOAST',
|
|
1787
|
-
};
|
|
1788
|
-
|
|
1789
|
-
function appReducer(state, action) {
|
|
1790
|
-
switch (action.type) {
|
|
1791
|
-
case APP_ACTIONS.SET_LOADING:
|
|
1792
|
-
return Object.assign({}, state, { loading: action.payload });
|
|
1793
|
-
case APP_ACTIONS.SET_ITEMS:
|
|
1794
|
-
return Object.assign({}, state, { items: action.payload, loading: false });
|
|
1795
|
-
case APP_ACTIONS.ADD_ITEM:
|
|
1796
|
-
return Object.assign({}, state, { items: [action.payload].concat(state.items) });
|
|
1797
|
-
case APP_ACTIONS.UPDATE_ITEM:
|
|
1798
|
-
return Object.assign({}, state, {
|
|
1799
|
-
items: state.items.map(function(i) { return i.id === action.payload.id ? action.payload : i; }),
|
|
1800
|
-
});
|
|
1801
|
-
case APP_ACTIONS.REMOVE_ITEM:
|
|
1802
|
-
return Object.assign({}, state, {
|
|
1803
|
-
items: state.items.filter(function(i) { return i.id !== action.payload; }),
|
|
1804
|
-
});
|
|
1805
|
-
case APP_ACTIONS.SET_STATS:
|
|
1806
|
-
return Object.assign({}, state, { stats: action.payload });
|
|
1807
|
-
case APP_ACTIONS.SET_FILTER:
|
|
1808
|
-
return Object.assign({}, state, {
|
|
1809
|
-
filters: Object.assign({}, state.filters, action.payload),
|
|
1810
|
-
});
|
|
1811
|
-
case APP_ACTIONS.ADD_TOAST:
|
|
1812
|
-
return Object.assign({}, state, {
|
|
1813
|
-
toasts: state.toasts.concat([action.payload]),
|
|
1814
|
-
});
|
|
1815
|
-
case APP_ACTIONS.REMOVE_TOAST:
|
|
1816
|
-
return Object.assign({}, state, {
|
|
1817
|
-
toasts: state.toasts.filter(function(t) { return t.id !== action.payload; }),
|
|
1818
|
-
});
|
|
1819
|
-
default:
|
|
1820
|
-
return state;
|
|
1821
|
-
}
|
|
1822
|
-
}
|
|
1823
|
-
|
|
1824
|
-
var AppContext = React.createContext(null);
|
|
1825
|
-
|
|
1826
|
-
window.AppProvider = function AppProvider(props) {
|
|
1827
|
-
var initialState = {
|
|
1828
|
-
items: [],
|
|
1829
|
-
stats: null,
|
|
1830
|
-
loading: false,
|
|
1831
|
-
filters: { status: '', priority: '' },
|
|
1832
|
-
toasts: [],
|
|
1833
|
-
};
|
|
1834
|
-
var stateAndDispatch = React.useReducer(appReducer, initialState);
|
|
1835
|
-
var state = stateAndDispatch[0];
|
|
1836
|
-
var dispatch = stateAndDispatch[1];
|
|
1837
|
-
|
|
1838
|
-
var toastId = React.useRef(0);
|
|
1839
|
-
|
|
1840
|
-
// Toast helper
|
|
1841
|
-
function addToast(message, type) {
|
|
1842
|
-
var id = ++toastId.current;
|
|
1843
|
-
dispatch({ type: APP_ACTIONS.ADD_TOAST, payload: { id: id, message: message, type: type || 'info' } });
|
|
1844
|
-
setTimeout(function() {
|
|
1845
|
-
dispatch({ type: APP_ACTIONS.REMOVE_TOAST, payload: id });
|
|
1846
|
-
}, 3000);
|
|
1847
|
-
}
|
|
1848
|
-
|
|
1849
|
-
// CRUD actions
|
|
1850
|
-
function fetchItems(filters) {
|
|
1851
|
-
dispatch({ type: APP_ACTIONS.SET_LOADING, payload: true });
|
|
1852
|
-
var query = '';
|
|
1853
|
-
if (filters) {
|
|
1854
|
-
var parts = [];
|
|
1855
|
-
if (filters.status) parts.push('status=' + filters.status);
|
|
1856
|
-
if (filters.priority) parts.push('priority=' + filters.priority);
|
|
1857
|
-
if (parts.length > 0) query = '?' + parts.join('&');
|
|
1858
|
-
}
|
|
1859
|
-
return ApiClient.get('/api/items' + query)
|
|
1860
|
-
.then(function(data) {
|
|
1861
|
-
dispatch({ type: APP_ACTIONS.SET_ITEMS, payload: data.items });
|
|
1862
|
-
return data.items;
|
|
1863
|
-
})
|
|
1864
|
-
.catch(function(err) {
|
|
1865
|
-
dispatch({ type: APP_ACTIONS.SET_LOADING, payload: false });
|
|
1866
|
-
addToast(err.message, 'error');
|
|
1867
|
-
});
|
|
1868
|
-
}
|
|
1869
|
-
|
|
1870
|
-
function fetchStats() {
|
|
1871
|
-
return ApiClient.get('/api/items/stats')
|
|
1872
|
-
.then(function(data) {
|
|
1873
|
-
dispatch({ type: APP_ACTIONS.SET_STATS, payload: data });
|
|
1874
|
-
return data;
|
|
1875
|
-
})
|
|
1876
|
-
.catch(function(err) {
|
|
1877
|
-
addToast(err.message, 'error');
|
|
1878
|
-
});
|
|
1879
|
-
}
|
|
1880
|
-
|
|
1881
|
-
function createItem(data) {
|
|
1882
|
-
return ApiClient.post('/api/items', data)
|
|
1883
|
-
.then(function(result) {
|
|
1884
|
-
dispatch({ type: APP_ACTIONS.ADD_ITEM, payload: result.item });
|
|
1885
|
-
addToast('Item created', 'success');
|
|
1886
|
-
fetchStats();
|
|
1887
|
-
return result.item;
|
|
1888
|
-
})
|
|
1889
|
-
.catch(function(err) {
|
|
1890
|
-
addToast(err.message, 'error');
|
|
1891
|
-
throw err;
|
|
1892
|
-
});
|
|
1893
|
-
}
|
|
1894
|
-
|
|
1895
|
-
function updateItem(id, data) {
|
|
1896
|
-
return ApiClient.put('/api/items/' + id, data)
|
|
1897
|
-
.then(function(result) {
|
|
1898
|
-
dispatch({ type: APP_ACTIONS.UPDATE_ITEM, payload: result.item });
|
|
1899
|
-
addToast('Item updated', 'success');
|
|
1900
|
-
fetchStats();
|
|
1901
|
-
return result.item;
|
|
1902
|
-
})
|
|
1903
|
-
.catch(function(err) {
|
|
1904
|
-
addToast(err.message, 'error');
|
|
1905
|
-
throw err;
|
|
1906
|
-
});
|
|
1907
|
-
}
|
|
1908
|
-
|
|
1909
|
-
function deleteItem(id) {
|
|
1910
|
-
return ApiClient.del('/api/items/' + id)
|
|
1911
|
-
.then(function() {
|
|
1912
|
-
dispatch({ type: APP_ACTIONS.REMOVE_ITEM, payload: id });
|
|
1913
|
-
addToast('Item deleted', 'success');
|
|
1914
|
-
fetchStats();
|
|
1915
|
-
})
|
|
1916
|
-
.catch(function(err) {
|
|
1917
|
-
addToast(err.message, 'error');
|
|
1918
|
-
throw err;
|
|
1919
|
-
});
|
|
1920
|
-
}
|
|
1921
|
-
|
|
1922
|
-
function setFilter(filter) {
|
|
1923
|
-
dispatch({ type: APP_ACTIONS.SET_FILTER, payload: filter });
|
|
1924
|
-
}
|
|
1925
|
-
|
|
1926
|
-
var value = {
|
|
1927
|
-
items: state.items,
|
|
1928
|
-
stats: state.stats,
|
|
1929
|
-
loading: state.loading,
|
|
1930
|
-
filters: state.filters,
|
|
1931
|
-
toasts: state.toasts,
|
|
1932
|
-
fetchItems: fetchItems,
|
|
1933
|
-
fetchStats: fetchStats,
|
|
1934
|
-
createItem: createItem,
|
|
1935
|
-
updateItem: updateItem,
|
|
1936
|
-
deleteItem: deleteItem,
|
|
1937
|
-
setFilter: setFilter,
|
|
1938
|
-
addToast: addToast,
|
|
1939
|
-
};
|
|
1940
|
-
|
|
1941
|
-
return h(AppContext.Provider, { value: value }, props.children);
|
|
1942
|
-
};
|
|
1943
|
-
|
|
1944
|
-
window.useApp = function useApp() {
|
|
1945
|
-
var context = React.useContext(AppContext);
|
|
1946
|
-
if (!context) throw new Error('useApp must be used within AppProvider');
|
|
1947
|
-
return context;
|
|
1948
|
-
};
|
|
1949
|
-
`;
|
|
1950
|
-
|
|
1951
|
-
// ---------------------------------------------------------------------------
|
|
1952
|
-
// public/components/Toast.js
|
|
1953
|
-
// ---------------------------------------------------------------------------
|
|
1954
|
-
|
|
1955
|
-
const TOAST_JS = `var h = React.createElement;
|
|
1956
|
-
|
|
1957
|
-
/**
|
|
1958
|
-
* Toast — Notification toast displayed at top-right.
|
|
1959
|
-
*/
|
|
1960
|
-
window.Toast = function Toast(props) {
|
|
1961
|
-
var toasts = props.toasts || [];
|
|
1962
|
-
if (toasts.length === 0) return null;
|
|
1963
|
-
|
|
1964
|
-
var colors = {
|
|
1965
|
-
success: 'bg-emerald-500',
|
|
1966
|
-
error: 'bg-red-500',
|
|
1967
|
-
info: 'bg-blue-500',
|
|
1968
|
-
warning: 'bg-amber-500',
|
|
1969
|
-
};
|
|
1970
|
-
|
|
1971
|
-
return h('div', { className: 'fixed top-4 right-4 z-50 space-y-2' },
|
|
1972
|
-
toasts.map(function(toast) {
|
|
1973
|
-
return h('div', {
|
|
1974
|
-
key: toast.id,
|
|
1975
|
-
className: 'toast-enter px-4 py-3 rounded-lg text-white text-sm font-medium shadow-lg ' + (colors[toast.type] || colors.info),
|
|
1976
|
-
}, toast.message);
|
|
1977
|
-
})
|
|
1978
|
-
);
|
|
1979
|
-
};
|
|
1980
|
-
`;
|
|
1981
|
-
|
|
1982
|
-
// ---------------------------------------------------------------------------
|
|
1983
|
-
// public/components/Navbar.js
|
|
1984
|
-
// ---------------------------------------------------------------------------
|
|
1985
|
-
|
|
1986
|
-
const NAVBAR_JS = `var h = React.createElement;
|
|
1987
|
-
var RR = ReactRouterDOM;
|
|
1988
|
-
|
|
1989
|
-
/**
|
|
1990
|
-
* Navbar — Top navigation bar with logo, links, and auth status.
|
|
1991
|
-
*/
|
|
1992
|
-
window.Navbar = function Navbar() {
|
|
1993
|
-
var auth = window.useAuth();
|
|
1994
|
-
|
|
1995
|
-
return h('header', { className: 'bg-white border-b border-gray-200 sticky top-0 z-40' },
|
|
1996
|
-
h('div', { className: 'max-w-6xl mx-auto px-4 sm:px-6' },
|
|
1997
|
-
h('div', { className: 'flex items-center justify-between h-14' },
|
|
1998
|
-
// Logo + nav
|
|
1999
|
-
h('div', { className: 'flex items-center gap-6' },
|
|
2000
|
-
h(RR.Link, { to: '/', className: 'flex items-center gap-2' },
|
|
2001
|
-
h('div', { className: 'w-7 h-7 bg-primary-600 rounded-lg flex items-center justify-center' },
|
|
2002
|
-
h('span', { className: 'text-white font-bold text-sm' }, 'S')
|
|
2003
|
-
),
|
|
2004
|
-
h('span', { className: 'font-semibold text-gray-800' }, 'Starter')
|
|
2005
|
-
),
|
|
2006
|
-
auth.isAuthenticated ? h('nav', { className: 'hidden sm:flex items-center gap-4' },
|
|
2007
|
-
h(RR.NavLink, {
|
|
2008
|
-
to: '/dashboard',
|
|
2009
|
-
className: function(a) { return 'text-sm font-medium ' + (a.isActive ? 'text-primary-600' : 'text-gray-500 hover:text-gray-700'); },
|
|
2010
|
-
}, 'Dashboard')
|
|
2011
|
-
) : null
|
|
2012
|
-
),
|
|
2013
|
-
|
|
2014
|
-
// Auth section
|
|
2015
|
-
h('div', { className: 'flex items-center gap-3' },
|
|
2016
|
-
auth.isAuthenticated
|
|
2017
|
-
? h('div', { className: 'flex items-center gap-3' },
|
|
2018
|
-
h('span', { className: 'text-sm text-gray-500 hidden sm:block' }, auth.user.name),
|
|
2019
|
-
h('span', { className: 'text-xs px-2 py-0.5 rounded-full font-medium ' +
|
|
2020
|
-
(auth.user.role === 'admin' ? 'bg-purple-100 text-purple-700' : 'bg-gray-100 text-gray-600')
|
|
2021
|
-
}, auth.user.role),
|
|
2022
|
-
h('button', {
|
|
2023
|
-
onClick: function() { auth.logout(); },
|
|
2024
|
-
className: 'text-sm text-gray-500 hover:text-gray-700',
|
|
2025
|
-
}, 'Sign out')
|
|
2026
|
-
)
|
|
2027
|
-
: h('div', { className: 'flex items-center gap-2' },
|
|
2028
|
-
h(RR.Link, {
|
|
2029
|
-
to: '/login',
|
|
2030
|
-
className: 'text-sm text-gray-600 hover:text-gray-800 px-3 py-1.5',
|
|
2031
|
-
}, 'Sign in'),
|
|
2032
|
-
h(RR.Link, {
|
|
2033
|
-
to: '/register',
|
|
2034
|
-
className: 'text-sm bg-primary-600 text-white px-3 py-1.5 rounded-lg hover:bg-primary-700',
|
|
2035
|
-
}, 'Get started')
|
|
2036
|
-
)
|
|
2037
|
-
)
|
|
2038
|
-
)
|
|
2039
|
-
)
|
|
2040
|
-
);
|
|
2041
|
-
};
|
|
2042
|
-
`;
|
|
2043
|
-
|
|
2044
|
-
// ---------------------------------------------------------------------------
|
|
2045
|
-
// public/components/ItemCard.js
|
|
2046
|
-
// ---------------------------------------------------------------------------
|
|
2047
|
-
|
|
2048
|
-
const ITEM_CARD_JS = `var h = React.createElement;
|
|
2049
|
-
|
|
2050
|
-
var STATUS_STYLES = {
|
|
2051
|
-
todo: { bg: 'bg-gray-100', text: 'text-gray-600', label: 'To Do' },
|
|
2052
|
-
in_progress: { bg: 'bg-blue-100', text: 'text-blue-700', label: 'In Progress' },
|
|
2053
|
-
done: { bg: 'bg-emerald-100', text: 'text-emerald-700', label: 'Done' },
|
|
2054
|
-
};
|
|
2055
|
-
|
|
2056
|
-
var PRIORITY_STYLES = {
|
|
2057
|
-
low: { bg: 'bg-gray-100', text: 'text-gray-500', label: 'Low' },
|
|
2058
|
-
medium: { bg: 'bg-amber-100', text: 'text-amber-700', label: 'Medium' },
|
|
2059
|
-
high: { bg: 'bg-red-100', text: 'text-red-700', label: 'High' },
|
|
2060
|
-
};
|
|
2061
|
-
|
|
2062
|
-
/**
|
|
2063
|
-
* ItemCard — Displays a single item with status/priority badges and actions.
|
|
2064
|
-
*/
|
|
2065
|
-
window.ItemCard = function ItemCard(props) {
|
|
2066
|
-
var item = props.item;
|
|
2067
|
-
var onEdit = props.onEdit;
|
|
2068
|
-
var onDelete = props.onDelete;
|
|
2069
|
-
|
|
2070
|
-
var s = STATUS_STYLES[item.status] || STATUS_STYLES.todo;
|
|
2071
|
-
var p = PRIORITY_STYLES[item.priority] || PRIORITY_STYLES.medium;
|
|
2072
|
-
|
|
2073
|
-
return h('div', { className: 'bg-white rounded-lg p-4 border border-gray-200 card-hover fade-in' },
|
|
2074
|
-
// Header row
|
|
2075
|
-
h('div', { className: 'flex items-start justify-between mb-2' },
|
|
2076
|
-
h('h3', { className: 'font-medium text-gray-800 flex-1 mr-2' }, item.title),
|
|
2077
|
-
h('div', { className: 'flex gap-1.5 flex-shrink-0' },
|
|
2078
|
-
h('span', { className: 'text-xs px-2 py-0.5 rounded-full font-medium ' + s.bg + ' ' + s.text }, s.label),
|
|
2079
|
-
h('span', { className: 'text-xs px-2 py-0.5 rounded-full font-medium ' + p.bg + ' ' + p.text }, p.label)
|
|
2080
|
-
)
|
|
2081
|
-
),
|
|
2082
|
-
|
|
2083
|
-
// Description
|
|
2084
|
-
item.description
|
|
2085
|
-
? h('p', { className: 'text-sm text-gray-500 mb-3 line-clamp-2' }, item.description)
|
|
2086
|
-
: null,
|
|
2087
|
-
|
|
2088
|
-
// Footer
|
|
2089
|
-
h('div', { className: 'flex items-center justify-between' },
|
|
2090
|
-
h('span', { className: 'text-xs text-gray-400' },
|
|
2091
|
-
(item.user_name || 'Unknown') + ' · ' + (item.created_at || '').split('T')[0]
|
|
2092
|
-
),
|
|
2093
|
-
h('div', { className: 'flex gap-2' },
|
|
2094
|
-
h('button', {
|
|
2095
|
-
onClick: function() { onEdit(item); },
|
|
2096
|
-
className: 'text-xs text-primary-600 hover:text-primary-700 font-medium',
|
|
2097
|
-
}, 'Edit'),
|
|
2098
|
-
h('button', {
|
|
2099
|
-
onClick: function() { onDelete(item.id); },
|
|
2100
|
-
className: 'text-xs text-red-500 hover:text-red-600 font-medium',
|
|
2101
|
-
}, 'Delete')
|
|
2102
|
-
)
|
|
2103
|
-
)
|
|
2104
|
-
);
|
|
2105
|
-
};
|
|
2106
|
-
`;
|
|
2107
|
-
|
|
2108
|
-
// ---------------------------------------------------------------------------
|
|
2109
|
-
// public/components/ItemForm.js
|
|
2110
|
-
// ---------------------------------------------------------------------------
|
|
2111
|
-
|
|
2112
|
-
const ITEM_FORM_JS = `var h = React.createElement;
|
|
2113
|
-
|
|
2114
|
-
/**
|
|
2115
|
-
* ItemForm — Create or edit an item.
|
|
2116
|
-
* @param {{ item?: Object, onSave: Function, onCancel: Function }} props
|
|
2117
|
-
*/
|
|
2118
|
-
window.ItemForm = function ItemForm(props) {
|
|
2119
|
-
var item = props.item;
|
|
2120
|
-
var onSave = props.onSave;
|
|
2121
|
-
var onCancel = props.onCancel;
|
|
2122
|
-
|
|
2123
|
-
var titleState = React.useState(item ? item.title : '');
|
|
2124
|
-
var title = titleState[0]; var setTitle = titleState[1];
|
|
2125
|
-
|
|
2126
|
-
var descState = React.useState(item ? (item.description || '') : '');
|
|
2127
|
-
var desc = descState[0]; var setDesc = descState[1];
|
|
2128
|
-
|
|
2129
|
-
var statusState = React.useState(item ? item.status : 'todo');
|
|
2130
|
-
var status = statusState[0]; var setStatus = statusState[1];
|
|
2131
|
-
|
|
2132
|
-
var priorityState = React.useState(item ? item.priority : 'medium');
|
|
2133
|
-
var priority = priorityState[0]; var setPriority = priorityState[1];
|
|
2134
|
-
|
|
2135
|
-
var savingState = React.useState(false);
|
|
2136
|
-
var saving = savingState[0]; var setSaving = savingState[1];
|
|
2137
|
-
|
|
2138
|
-
function handleSubmit(e) {
|
|
2139
|
-
e.preventDefault();
|
|
2140
|
-
if (!title.trim()) return;
|
|
2141
|
-
setSaving(true);
|
|
2142
|
-
onSave({
|
|
2143
|
-
title: title.trim(),
|
|
2144
|
-
description: desc.trim(),
|
|
2145
|
-
status: status,
|
|
2146
|
-
priority: priority,
|
|
2147
|
-
}).then(function() {
|
|
2148
|
-
setSaving(false);
|
|
2149
|
-
}).catch(function() {
|
|
2150
|
-
setSaving(false);
|
|
2151
|
-
});
|
|
2152
|
-
}
|
|
2153
|
-
|
|
2154
|
-
return h('div', { className: 'bg-white rounded-lg border border-gray-200 p-5 fade-in' },
|
|
2155
|
-
h('h3', { className: 'text-lg font-semibold text-gray-800 mb-4' },
|
|
2156
|
-
item ? 'Edit Item' : 'New Item'
|
|
2157
|
-
),
|
|
2158
|
-
h('form', { onSubmit: handleSubmit, className: 'space-y-4' },
|
|
2159
|
-
// Title
|
|
2160
|
-
h('div', null,
|
|
2161
|
-
h('label', { className: 'block text-sm font-medium text-gray-700 mb-1' }, 'Title'),
|
|
2162
|
-
h('input', {
|
|
2163
|
-
type: 'text',
|
|
2164
|
-
value: title,
|
|
2165
|
-
onChange: function(e) { setTitle(e.target.value); },
|
|
2166
|
-
className: 'w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent',
|
|
2167
|
-
placeholder: 'Enter item title...',
|
|
2168
|
-
required: true,
|
|
2169
|
-
})
|
|
2170
|
-
),
|
|
2171
|
-
// Description
|
|
2172
|
-
h('div', null,
|
|
2173
|
-
h('label', { className: 'block text-sm font-medium text-gray-700 mb-1' }, 'Description'),
|
|
2174
|
-
h('textarea', {
|
|
2175
|
-
value: desc,
|
|
2176
|
-
onChange: function(e) { setDesc(e.target.value); },
|
|
2177
|
-
className: 'w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent resize-none',
|
|
2178
|
-
rows: 3,
|
|
2179
|
-
placeholder: 'Optional description...',
|
|
2180
|
-
})
|
|
2181
|
-
),
|
|
2182
|
-
// Status + Priority row
|
|
2183
|
-
h('div', { className: 'grid grid-cols-2 gap-4' },
|
|
2184
|
-
h('div', null,
|
|
2185
|
-
h('label', { className: 'block text-sm font-medium text-gray-700 mb-1' }, 'Status'),
|
|
2186
|
-
h('select', {
|
|
2187
|
-
value: status,
|
|
2188
|
-
onChange: function(e) { setStatus(e.target.value); },
|
|
2189
|
-
className: 'w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500',
|
|
2190
|
-
},
|
|
2191
|
-
h('option', { value: 'todo' }, 'To Do'),
|
|
2192
|
-
h('option', { value: 'in_progress' }, 'In Progress'),
|
|
2193
|
-
h('option', { value: 'done' }, 'Done')
|
|
2194
|
-
)
|
|
2195
|
-
),
|
|
2196
|
-
h('div', null,
|
|
2197
|
-
h('label', { className: 'block text-sm font-medium text-gray-700 mb-1' }, 'Priority'),
|
|
2198
|
-
h('select', {
|
|
2199
|
-
value: priority,
|
|
2200
|
-
onChange: function(e) { setPriority(e.target.value); },
|
|
2201
|
-
className: 'w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500',
|
|
2202
|
-
},
|
|
2203
|
-
h('option', { value: 'low' }, 'Low'),
|
|
2204
|
-
h('option', { value: 'medium' }, 'Medium'),
|
|
2205
|
-
h('option', { value: 'high' }, 'High')
|
|
2206
|
-
)
|
|
2207
|
-
)
|
|
2208
|
-
),
|
|
2209
|
-
// Buttons
|
|
2210
|
-
h('div', { className: 'flex justify-end gap-2 pt-2' },
|
|
2211
|
-
h('button', {
|
|
2212
|
-
type: 'button',
|
|
2213
|
-
onClick: onCancel,
|
|
2214
|
-
className: 'px-4 py-2 text-sm text-gray-600 hover:text-gray-800 border border-gray-300 rounded-lg',
|
|
2215
|
-
}, 'Cancel'),
|
|
2216
|
-
h('button', {
|
|
2217
|
-
type: 'submit',
|
|
2218
|
-
disabled: saving || !title.trim(),
|
|
2219
|
-
className: 'px-4 py-2 text-sm text-white bg-primary-600 hover:bg-primary-700 rounded-lg disabled:opacity-50',
|
|
2220
|
-
}, saving ? 'Saving...' : (item ? 'Update' : 'Create'))
|
|
2221
|
-
)
|
|
2222
|
-
)
|
|
2223
|
-
);
|
|
2224
|
-
};
|
|
2225
|
-
`;
|
|
2226
|
-
|
|
2227
|
-
// ---------------------------------------------------------------------------
|
|
2228
|
-
// public/pages/WelcomePage.js
|
|
2229
|
-
// ---------------------------------------------------------------------------
|
|
2230
|
-
|
|
2231
|
-
const WELCOME_PAGE_JS = `var h = React.createElement;
|
|
2232
|
-
var RR = ReactRouterDOM;
|
|
2233
|
-
|
|
2234
|
-
/**
|
|
2235
|
-
* WelcomePage — Landing page with hero, features, and architecture overview.
|
|
2236
|
-
*/
|
|
2237
|
-
window.WelcomePage = function WelcomePage() {
|
|
2238
|
-
var auth = window.useAuth();
|
|
2239
|
-
|
|
2240
|
-
return h('div', { className: 'fade-in' },
|
|
2241
|
-
// Hero
|
|
2242
|
-
h('section', { className: 'py-16 px-4' },
|
|
2243
|
-
h('div', { className: 'max-w-3xl mx-auto text-center' },
|
|
2244
|
-
h('div', { className: 'inline-flex items-center gap-2 px-3 py-1 bg-primary-50 text-primary-700 rounded-full text-sm font-medium mb-6' },
|
|
2245
|
-
h('span', { className: 'w-2 h-2 bg-emerald-400 rounded-full' }),
|
|
2246
|
-
'Full-Stack Application'
|
|
2247
|
-
),
|
|
2248
|
-
h('h1', { className: 'text-4xl sm:text-5xl font-bold text-gray-900 mb-4 tracking-tight' },
|
|
2249
|
-
'Production-Ready', h('br'),
|
|
2250
|
-
h('span', { className: 'text-primary-600' }, 'Starter Template')
|
|
2251
|
-
),
|
|
2252
|
-
h('p', { className: 'text-lg text-gray-500 mb-8 max-w-xl mx-auto' },
|
|
2253
|
-
'Express + React + SQLite with proper separation of concerns. ' +
|
|
2254
|
-
'Same code structure deploys to production with Postgres.'
|
|
2255
|
-
),
|
|
2256
|
-
h('div', { className: 'flex flex-col sm:flex-row gap-3 justify-center' },
|
|
2257
|
-
auth.isAuthenticated
|
|
2258
|
-
? h(RR.Link, {
|
|
2259
|
-
to: '/dashboard',
|
|
2260
|
-
className: 'px-6 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 font-medium',
|
|
2261
|
-
}, 'Go to Dashboard')
|
|
2262
|
-
: h(React.Fragment, null,
|
|
2263
|
-
h(RR.Link, {
|
|
2264
|
-
to: '/register',
|
|
2265
|
-
className: 'px-6 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 font-medium',
|
|
2266
|
-
}, 'Get Started'),
|
|
2267
|
-
h(RR.Link, {
|
|
2268
|
-
to: '/login',
|
|
2269
|
-
className: 'px-6 py-3 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 font-medium',
|
|
2270
|
-
}, 'Sign In')
|
|
2271
|
-
)
|
|
2272
|
-
)
|
|
2273
|
-
)
|
|
2274
|
-
),
|
|
2275
|
-
|
|
2276
|
-
// Features
|
|
2277
|
-
h('section', { className: 'py-12 px-4 bg-white border-t border-gray-100' },
|
|
2278
|
-
h('div', { className: 'max-w-4xl mx-auto' },
|
|
2279
|
-
h('h2', { className: 'text-2xl font-bold text-gray-800 text-center mb-8' }, 'Architecture'),
|
|
2280
|
-
h('div', { className: 'grid sm:grid-cols-2 lg:grid-cols-3 gap-4' },
|
|
2281
|
-
_featureCard('SQLite (sql.js)', 'True relational database with typed schemas, migrations, and indexes. Same SQL works on Postgres.', 'DB'),
|
|
2282
|
-
_featureCard('React Router v6', 'Client-side routing with nested routes, URL params, navigation guards, and SPA fallback.', 'RT'),
|
|
2283
|
-
_featureCard('React Context', 'Predictable state management with useReducer. Auth context + app state context.', 'ST'),
|
|
2284
|
-
_featureCard('JWT Auth', 'Token-based authentication with role-based access control. Login, register, protected routes.', 'AU'),
|
|
2285
|
-
_featureCard('REST API', 'Express routes with validation, error handling, and structured JSON responses.', 'AP'),
|
|
2286
|
-
_featureCard('Repository Pattern', 'Data access layer abstracts SQL queries. Swap database without changing routes.', 'RP')
|
|
2287
|
-
)
|
|
2288
|
-
)
|
|
2289
|
-
),
|
|
2290
|
-
|
|
2291
|
-
// Demo credentials
|
|
2292
|
-
h('section', { className: 'py-12 px-4' },
|
|
2293
|
-
h('div', { className: 'max-w-md mx-auto text-center' },
|
|
2294
|
-
h('h3', { className: 'text-lg font-semibold text-gray-700 mb-4' }, 'Demo Accounts'),
|
|
2295
|
-
h('div', { className: 'bg-white rounded-lg border border-gray-200 divide-y' },
|
|
2296
|
-
h('div', { className: 'p-4 flex items-center justify-between' },
|
|
2297
|
-
h('div', { className: 'text-left' },
|
|
2298
|
-
h('p', { className: 'text-sm font-medium text-gray-800' }, 'admin@example.com'),
|
|
2299
|
-
h('p', { className: 'text-xs text-gray-400' }, 'Password: admin123')
|
|
2300
|
-
),
|
|
2301
|
-
h('span', { className: 'text-xs bg-purple-100 text-purple-700 px-2 py-0.5 rounded-full font-medium' }, 'admin')
|
|
2302
|
-
),
|
|
2303
|
-
h('div', { className: 'p-4 flex items-center justify-between' },
|
|
2304
|
-
h('div', { className: 'text-left' },
|
|
2305
|
-
h('p', { className: 'text-sm font-medium text-gray-800' }, 'user@example.com'),
|
|
2306
|
-
h('p', { className: 'text-xs text-gray-400' }, 'Password: user123')
|
|
2307
|
-
),
|
|
2308
|
-
h('span', { className: 'text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full font-medium' }, 'user')
|
|
2309
|
-
)
|
|
2310
|
-
)
|
|
2311
|
-
)
|
|
2312
|
-
)
|
|
2313
|
-
);
|
|
2314
|
-
};
|
|
2315
|
-
|
|
2316
|
-
function _featureCard(title, desc, icon) {
|
|
2317
|
-
return h('div', { className: 'p-4 rounded-lg border border-gray-100 hover:border-primary-200 transition-colors' },
|
|
2318
|
-
h('div', { className: 'w-8 h-8 bg-primary-50 rounded-lg flex items-center justify-center text-primary-600 font-bold text-xs mb-3' }, icon),
|
|
2319
|
-
h('h3', { className: 'font-medium text-gray-800 mb-1' }, title),
|
|
2320
|
-
h('p', { className: 'text-sm text-gray-500' }, desc)
|
|
2321
|
-
);
|
|
2322
|
-
}
|
|
2323
|
-
`;
|
|
2324
|
-
|
|
2325
|
-
// ---------------------------------------------------------------------------
|
|
2326
|
-
// public/pages/LoginPage.js
|
|
2327
|
-
// ---------------------------------------------------------------------------
|
|
2328
|
-
|
|
2329
|
-
const LOGIN_PAGE_JS = `var h = React.createElement;
|
|
2330
|
-
var RR = ReactRouterDOM;
|
|
2331
|
-
|
|
2332
|
-
/**
|
|
2333
|
-
* LoginPage — Login form with email and password.
|
|
2334
|
-
*/
|
|
2335
|
-
window.LoginPage = function LoginPage() {
|
|
2336
|
-
var auth = window.useAuth();
|
|
2337
|
-
var navigate = RR.useNavigate();
|
|
2338
|
-
|
|
2339
|
-
var emailState = React.useState('');
|
|
2340
|
-
var email = emailState[0]; var setEmail = emailState[1];
|
|
2341
|
-
|
|
2342
|
-
var passState = React.useState('');
|
|
2343
|
-
var pass = passState[0]; var setPass = passState[1];
|
|
2344
|
-
|
|
2345
|
-
var errorState = React.useState('');
|
|
2346
|
-
var error = errorState[0]; var setError = errorState[1];
|
|
2347
|
-
|
|
2348
|
-
var loadingState = React.useState(false);
|
|
2349
|
-
var loading = loadingState[0]; var setLoading = loadingState[1];
|
|
2350
|
-
|
|
2351
|
-
// Redirect if already logged in
|
|
2352
|
-
React.useEffect(function() {
|
|
2353
|
-
if (auth.isAuthenticated) navigate('/dashboard');
|
|
2354
|
-
}, [auth.isAuthenticated]);
|
|
2355
|
-
|
|
2356
|
-
function handleSubmit(e) {
|
|
2357
|
-
e.preventDefault();
|
|
2358
|
-
setError('');
|
|
2359
|
-
setLoading(true);
|
|
2360
|
-
auth.login(email, pass)
|
|
2361
|
-
.then(function() {
|
|
2362
|
-
navigate('/dashboard');
|
|
2363
|
-
})
|
|
2364
|
-
.catch(function(err) {
|
|
2365
|
-
setError(err.message || 'Login failed');
|
|
2366
|
-
setLoading(false);
|
|
2367
|
-
});
|
|
2368
|
-
}
|
|
2369
|
-
|
|
2370
|
-
return h('div', { className: 'min-h-[calc(100vh-56px)] flex items-center justify-center px-4 fade-in' },
|
|
2371
|
-
h('div', { className: 'w-full max-w-sm' },
|
|
2372
|
-
h('div', { className: 'text-center mb-6' },
|
|
2373
|
-
h('h1', { className: 'text-2xl font-bold text-gray-800' }, 'Welcome back'),
|
|
2374
|
-
h('p', { className: 'text-sm text-gray-500 mt-1' }, 'Sign in to your account')
|
|
2375
|
-
),
|
|
2376
|
-
|
|
2377
|
-
h('form', { onSubmit: handleSubmit, className: 'bg-white rounded-lg border border-gray-200 p-6 space-y-4' },
|
|
2378
|
-
error ? h('div', { className: 'text-sm text-red-600 bg-red-50 px-3 py-2 rounded-lg' }, error) : null,
|
|
2379
|
-
|
|
2380
|
-
h('div', null,
|
|
2381
|
-
h('label', { className: 'block text-sm font-medium text-gray-700 mb-1' }, 'Email'),
|
|
2382
|
-
h('input', {
|
|
2383
|
-
type: 'email', value: email, onChange: function(e) { setEmail(e.target.value); },
|
|
2384
|
-
className: 'w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent',
|
|
2385
|
-
placeholder: 'admin@example.com', required: true,
|
|
2386
|
-
})
|
|
2387
|
-
),
|
|
2388
|
-
|
|
2389
|
-
h('div', null,
|
|
2390
|
-
h('label', { className: 'block text-sm font-medium text-gray-700 mb-1' }, 'Password'),
|
|
2391
|
-
h('input', {
|
|
2392
|
-
type: 'password', value: pass, onChange: function(e) { setPass(e.target.value); },
|
|
2393
|
-
className: 'w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent',
|
|
2394
|
-
placeholder: 'admin123', required: true,
|
|
2395
|
-
})
|
|
2396
|
-
),
|
|
2397
|
-
|
|
2398
|
-
h('button', {
|
|
2399
|
-
type: 'submit', disabled: loading,
|
|
2400
|
-
className: 'w-full py-2.5 bg-primary-600 text-white rounded-lg text-sm font-medium hover:bg-primary-700 disabled:opacity-50',
|
|
2401
|
-
}, loading ? 'Signing in...' : 'Sign in')
|
|
2402
|
-
),
|
|
2403
|
-
|
|
2404
|
-
h('p', { className: 'text-center text-sm text-gray-500 mt-4' },
|
|
2405
|
-
"Don't have an account? ",
|
|
2406
|
-
h(RR.Link, { to: '/register', className: 'text-primary-600 hover:text-primary-700 font-medium' }, 'Register')
|
|
2407
|
-
)
|
|
2408
|
-
)
|
|
2409
|
-
);
|
|
2410
|
-
};
|
|
2411
|
-
`;
|
|
2412
|
-
|
|
2413
|
-
// ---------------------------------------------------------------------------
|
|
2414
|
-
// public/pages/RegisterPage.js
|
|
2415
|
-
// ---------------------------------------------------------------------------
|
|
2416
|
-
|
|
2417
|
-
const REGISTER_PAGE_JS = `var h = React.createElement;
|
|
2418
|
-
var RR = ReactRouterDOM;
|
|
2419
|
-
|
|
2420
|
-
/**
|
|
2421
|
-
* RegisterPage — Registration form.
|
|
2422
|
-
*/
|
|
2423
|
-
window.RegisterPage = function RegisterPage() {
|
|
2424
|
-
var auth = window.useAuth();
|
|
2425
|
-
var navigate = RR.useNavigate();
|
|
2426
|
-
|
|
2427
|
-
var nameState = React.useState('');
|
|
2428
|
-
var name = nameState[0]; var setName = nameState[1];
|
|
2429
|
-
|
|
2430
|
-
var emailState = React.useState('');
|
|
2431
|
-
var email = emailState[0]; var setEmail = emailState[1];
|
|
2432
|
-
|
|
2433
|
-
var passState = React.useState('');
|
|
2434
|
-
var pass = passState[0]; var setPass = passState[1];
|
|
2435
|
-
|
|
2436
|
-
var errorState = React.useState('');
|
|
2437
|
-
var error = errorState[0]; var setError = errorState[1];
|
|
2438
|
-
|
|
2439
|
-
var loadingState = React.useState(false);
|
|
2440
|
-
var loading = loadingState[0]; var setLoading = loadingState[1];
|
|
2441
|
-
|
|
2442
|
-
React.useEffect(function() {
|
|
2443
|
-
if (auth.isAuthenticated) navigate('/dashboard');
|
|
2444
|
-
}, [auth.isAuthenticated]);
|
|
2445
|
-
|
|
2446
|
-
function handleSubmit(e) {
|
|
2447
|
-
e.preventDefault();
|
|
2448
|
-
if (pass.length < 6) { setError('Password must be at least 6 characters'); return; }
|
|
2449
|
-
setError('');
|
|
2450
|
-
setLoading(true);
|
|
2451
|
-
auth.register(email, pass, name)
|
|
2452
|
-
.then(function() {
|
|
2453
|
-
navigate('/dashboard');
|
|
2454
|
-
})
|
|
2455
|
-
.catch(function(err) {
|
|
2456
|
-
setError(err.message || 'Registration failed');
|
|
2457
|
-
setLoading(false);
|
|
2458
|
-
});
|
|
2459
|
-
}
|
|
2460
|
-
|
|
2461
|
-
return h('div', { className: 'min-h-[calc(100vh-56px)] flex items-center justify-center px-4 fade-in' },
|
|
2462
|
-
h('div', { className: 'w-full max-w-sm' },
|
|
2463
|
-
h('div', { className: 'text-center mb-6' },
|
|
2464
|
-
h('h1', { className: 'text-2xl font-bold text-gray-800' }, 'Create account'),
|
|
2465
|
-
h('p', { className: 'text-sm text-gray-500 mt-1' }, 'Start building with the starter template')
|
|
2466
|
-
),
|
|
2467
|
-
|
|
2468
|
-
h('form', { onSubmit: handleSubmit, className: 'bg-white rounded-lg border border-gray-200 p-6 space-y-4' },
|
|
2469
|
-
error ? h('div', { className: 'text-sm text-red-600 bg-red-50 px-3 py-2 rounded-lg' }, error) : null,
|
|
2470
|
-
|
|
2471
|
-
h('div', null,
|
|
2472
|
-
h('label', { className: 'block text-sm font-medium text-gray-700 mb-1' }, 'Name'),
|
|
2473
|
-
h('input', {
|
|
2474
|
-
type: 'text', value: name, onChange: function(e) { setName(e.target.value); },
|
|
2475
|
-
className: 'w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent',
|
|
2476
|
-
placeholder: 'Your name', required: true,
|
|
2477
|
-
})
|
|
2478
|
-
),
|
|
2479
|
-
|
|
2480
|
-
h('div', null,
|
|
2481
|
-
h('label', { className: 'block text-sm font-medium text-gray-700 mb-1' }, 'Email'),
|
|
2482
|
-
h('input', {
|
|
2483
|
-
type: 'email', value: email, onChange: function(e) { setEmail(e.target.value); },
|
|
2484
|
-
className: 'w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent',
|
|
2485
|
-
placeholder: 'you@example.com', required: true,
|
|
2486
|
-
})
|
|
2487
|
-
),
|
|
2488
|
-
|
|
2489
|
-
h('div', null,
|
|
2490
|
-
h('label', { className: 'block text-sm font-medium text-gray-700 mb-1' }, 'Password'),
|
|
2491
|
-
h('input', {
|
|
2492
|
-
type: 'password', value: pass, onChange: function(e) { setPass(e.target.value); },
|
|
2493
|
-
className: 'w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent',
|
|
2494
|
-
placeholder: 'Min 6 characters', required: true,
|
|
2495
|
-
})
|
|
2496
|
-
),
|
|
2497
|
-
|
|
2498
|
-
h('button', {
|
|
2499
|
-
type: 'submit', disabled: loading,
|
|
2500
|
-
className: 'w-full py-2.5 bg-primary-600 text-white rounded-lg text-sm font-medium hover:bg-primary-700 disabled:opacity-50',
|
|
2501
|
-
}, loading ? 'Creating account...' : 'Create account')
|
|
2502
|
-
),
|
|
2503
|
-
|
|
2504
|
-
h('p', { className: 'text-center text-sm text-gray-500 mt-4' },
|
|
2505
|
-
'Already have an account? ',
|
|
2506
|
-
h(RR.Link, { to: '/login', className: 'text-primary-600 hover:text-primary-700 font-medium' }, 'Sign in')
|
|
2507
|
-
)
|
|
2508
|
-
)
|
|
2509
|
-
);
|
|
2510
|
-
};
|
|
2511
|
-
`;
|
|
2512
|
-
|
|
2513
|
-
// ---------------------------------------------------------------------------
|
|
2514
|
-
// public/components/StatsChart.js
|
|
2515
|
-
// ---------------------------------------------------------------------------
|
|
2516
|
-
|
|
2517
|
-
const STATS_CHART_JS = `var h = React.createElement;
|
|
2518
|
-
|
|
2519
|
-
/**
|
|
2520
|
-
* StatsChart — Renders a doughnut chart of item status distribution
|
|
2521
|
-
* using Chart.js with Ranger theme CSS variables (--chart-1 through --chart-5).
|
|
2522
|
-
*
|
|
2523
|
-
* Props:
|
|
2524
|
-
* stats — { total, byStatus: { todo, in_progress, done }, byPriority: { low, medium, high } }
|
|
2525
|
-
*
|
|
2526
|
-
* Uses the global Chart class from chart.js UMD CDN.
|
|
2527
|
-
*/
|
|
2528
|
-
window.StatsChart = function StatsChart(props) {
|
|
2529
|
-
var stats = props.stats || { total: 0, byStatus: {}, byPriority: {} };
|
|
2530
|
-
var canvasRef = React.useRef(null);
|
|
2531
|
-
var chartRef = React.useRef(null);
|
|
2532
|
-
|
|
2533
|
-
/**
|
|
2534
|
-
* Read a CSS custom property value from :root and convert to usable color.
|
|
2535
|
-
* Ranger stores HSL values without the hsl() wrapper (e.g. "12 76% 61%"),
|
|
2536
|
-
* so we wrap them: hsl(12 76% 61%).
|
|
2537
|
-
*/
|
|
2538
|
-
function getChartColor(varName, fallback) {
|
|
2539
|
-
var raw = getComputedStyle(document.documentElement)
|
|
2540
|
-
.getPropertyValue(varName)
|
|
2541
|
-
.trim();
|
|
2542
|
-
if (!raw) return fallback;
|
|
2543
|
-
if (raw.startsWith('#') || raw.startsWith('rgb') || raw.startsWith('hsl(')) {
|
|
2544
|
-
return raw;
|
|
2545
|
-
}
|
|
2546
|
-
return 'hsl(' + raw + ')';
|
|
2547
|
-
}
|
|
2548
|
-
|
|
2549
|
-
React.useEffect(function () {
|
|
2550
|
-
if (!canvasRef.current) return;
|
|
2551
|
-
|
|
2552
|
-
if (chartRef.current) {
|
|
2553
|
-
chartRef.current.destroy();
|
|
2554
|
-
chartRef.current = null;
|
|
2555
|
-
}
|
|
2556
|
-
|
|
2557
|
-
var todo = (stats.byStatus && stats.byStatus.todo) || 0;
|
|
2558
|
-
var inProgress = (stats.byStatus && stats.byStatus.in_progress) || 0;
|
|
2559
|
-
var done = (stats.byStatus && stats.byStatus.done) || 0;
|
|
2560
|
-
|
|
2561
|
-
if (todo + inProgress + done === 0) return;
|
|
2562
|
-
|
|
2563
|
-
var colors = [
|
|
2564
|
-
getChartColor('--chart-1', 'hsl(12 76% 61%)'),
|
|
2565
|
-
getChartColor('--chart-2', 'hsl(173 58% 39%)'),
|
|
2566
|
-
getChartColor('--chart-3', 'hsl(197 37% 24%)'),
|
|
2567
|
-
];
|
|
2568
|
-
|
|
2569
|
-
chartRef.current = new Chart(canvasRef.current, {
|
|
2570
|
-
type: 'doughnut',
|
|
2571
|
-
data: {
|
|
2572
|
-
labels: ['To Do', 'In Progress', 'Done'],
|
|
2573
|
-
datasets: [
|
|
2574
|
-
{
|
|
2575
|
-
data: [todo, inProgress, done],
|
|
2576
|
-
backgroundColor: colors,
|
|
2577
|
-
borderWidth: 0,
|
|
2578
|
-
hoverOffset: 6,
|
|
2579
|
-
},
|
|
2580
|
-
],
|
|
2581
|
-
},
|
|
2582
|
-
options: {
|
|
2583
|
-
responsive: true,
|
|
2584
|
-
maintainAspectRatio: false,
|
|
2585
|
-
cutout: '65%',
|
|
2586
|
-
plugins: {
|
|
2587
|
-
legend: {
|
|
2588
|
-
position: 'bottom',
|
|
2589
|
-
labels: {
|
|
2590
|
-
padding: 16,
|
|
2591
|
-
usePointStyle: true,
|
|
2592
|
-
pointStyleWidth: 8,
|
|
2593
|
-
font: { size: 12 },
|
|
2594
|
-
color: getChartColor('--text-secondary', '#565869'),
|
|
2595
|
-
},
|
|
2596
|
-
},
|
|
2597
|
-
tooltip: {
|
|
2598
|
-
backgroundColor: 'rgba(0,0,0,0.8)',
|
|
2599
|
-
titleFont: { size: 13 },
|
|
2600
|
-
bodyFont: { size: 12 },
|
|
2601
|
-
padding: 10,
|
|
2602
|
-
cornerRadius: 8,
|
|
2603
|
-
},
|
|
2604
|
-
},
|
|
2605
|
-
},
|
|
2606
|
-
});
|
|
2607
|
-
|
|
2608
|
-
return function () {
|
|
2609
|
-
if (chartRef.current) {
|
|
2610
|
-
chartRef.current.destroy();
|
|
2611
|
-
chartRef.current = null;
|
|
2612
|
-
}
|
|
2613
|
-
};
|
|
2614
|
-
}, [stats.byStatus && stats.byStatus.todo,
|
|
2615
|
-
stats.byStatus && stats.byStatus.in_progress,
|
|
2616
|
-
stats.byStatus && stats.byStatus.done]);
|
|
2617
|
-
|
|
2618
|
-
var hasData =
|
|
2619
|
-
((stats.byStatus && stats.byStatus.todo) || 0) +
|
|
2620
|
-
((stats.byStatus && stats.byStatus.in_progress) || 0) +
|
|
2621
|
-
((stats.byStatus && stats.byStatus.done) || 0) > 0;
|
|
2622
|
-
|
|
2623
|
-
return h(
|
|
2624
|
-
'div',
|
|
2625
|
-
{ className: 'bg-white rounded-lg border border-gray-200 p-4 card-hover' },
|
|
2626
|
-
h('h3', { className: 'text-sm font-semibold text-gray-700 mb-3' }, 'Status Distribution'),
|
|
2627
|
-
hasData
|
|
2628
|
-
? h('div', { style: { height: '200px', position: 'relative' } },
|
|
2629
|
-
h('canvas', { ref: canvasRef })
|
|
2630
|
-
)
|
|
2631
|
-
: h('div', { className: 'flex items-center justify-center h-48 text-gray-400 text-sm' },
|
|
2632
|
-
'No data to display'
|
|
2633
|
-
)
|
|
2634
|
-
);
|
|
2635
|
-
};
|
|
2636
|
-
`;
|
|
2637
|
-
|
|
2638
|
-
// ---------------------------------------------------------------------------
|
|
2639
|
-
// public/pages/DashboardPage.js
|
|
2640
|
-
// ---------------------------------------------------------------------------
|
|
2641
|
-
|
|
2642
|
-
const DASHBOARD_PAGE_JS = `var h = React.createElement;
|
|
2643
|
-
var RR = ReactRouterDOM;
|
|
2644
|
-
|
|
2645
|
-
/**
|
|
2646
|
-
* DashboardPage — Main application view with stats, filters, and item CRUD.
|
|
2647
|
-
*/
|
|
2648
|
-
window.DashboardPage = function DashboardPage() {
|
|
2649
|
-
var auth = window.useAuth();
|
|
2650
|
-
var app = window.useApp();
|
|
2651
|
-
var navigate = RR.useNavigate();
|
|
2652
|
-
|
|
2653
|
-
var showFormState = React.useState(false);
|
|
2654
|
-
var showForm = showFormState[0]; var setShowForm = showFormState[1];
|
|
2655
|
-
|
|
2656
|
-
var editingState = React.useState(null);
|
|
2657
|
-
var editing = editingState[0]; var setEditing = editingState[1];
|
|
2658
|
-
|
|
2659
|
-
// Redirect if not authenticated
|
|
2660
|
-
React.useEffect(function() {
|
|
2661
|
-
if (!auth.loading && !auth.isAuthenticated) navigate('/login');
|
|
2662
|
-
}, [auth.loading, auth.isAuthenticated]);
|
|
2663
|
-
|
|
2664
|
-
// Fetch data on mount
|
|
2665
|
-
React.useEffect(function() {
|
|
2666
|
-
if (auth.isAuthenticated) {
|
|
2667
|
-
app.fetchItems();
|
|
2668
|
-
app.fetchStats();
|
|
2669
|
-
}
|
|
2670
|
-
}, [auth.isAuthenticated]);
|
|
2671
|
-
|
|
2672
|
-
// Re-fetch when filters change
|
|
2673
|
-
React.useEffect(function() {
|
|
2674
|
-
if (auth.isAuthenticated) {
|
|
2675
|
-
app.fetchItems(app.filters);
|
|
2676
|
-
}
|
|
2677
|
-
}, [app.filters.status, app.filters.priority]);
|
|
2678
|
-
|
|
2679
|
-
function handleSave(data) {
|
|
2680
|
-
if (editing) {
|
|
2681
|
-
return app.updateItem(editing.id, data).then(function() {
|
|
2682
|
-
setEditing(null);
|
|
2683
|
-
setShowForm(false);
|
|
2684
|
-
});
|
|
2685
|
-
}
|
|
2686
|
-
return app.createItem(data).then(function() {
|
|
2687
|
-
setShowForm(false);
|
|
2688
|
-
});
|
|
2689
|
-
}
|
|
2690
|
-
|
|
2691
|
-
function handleEdit(item) {
|
|
2692
|
-
setEditing(item);
|
|
2693
|
-
setShowForm(true);
|
|
2694
|
-
}
|
|
2695
|
-
|
|
2696
|
-
function handleDelete(id) {
|
|
2697
|
-
app.deleteItem(id);
|
|
2698
|
-
}
|
|
2699
|
-
|
|
2700
|
-
function handleCancel() {
|
|
2701
|
-
setEditing(null);
|
|
2702
|
-
setShowForm(false);
|
|
2703
|
-
}
|
|
2704
|
-
|
|
2705
|
-
if (auth.loading) {
|
|
2706
|
-
return h('div', { className: 'flex items-center justify-center py-20' },
|
|
2707
|
-
h('div', { className: 'w-6 h-6 border-2 border-primary-500 border-t-transparent rounded-full animate-spin' })
|
|
2708
|
-
);
|
|
2709
|
-
}
|
|
2710
|
-
|
|
2711
|
-
var stats = app.stats || { total: 0, byStatus: {}, byPriority: {} };
|
|
2712
|
-
|
|
2713
|
-
return h('div', { className: 'max-w-5xl mx-auto px-4 sm:px-6 py-6 fade-in' },
|
|
2714
|
-
// Header
|
|
2715
|
-
h('div', { className: 'flex items-center justify-between mb-6' },
|
|
2716
|
-
h('div', null,
|
|
2717
|
-
h('h1', { className: 'text-2xl font-bold text-gray-800' }, 'Dashboard'),
|
|
2718
|
-
h('p', { className: 'text-sm text-gray-500' }, 'Manage your items')
|
|
2719
|
-
),
|
|
2720
|
-
h('button', {
|
|
2721
|
-
onClick: function() { setEditing(null); setShowForm(true); },
|
|
2722
|
-
className: 'px-4 py-2 bg-primary-600 text-white text-sm rounded-lg hover:bg-primary-700 font-medium',
|
|
2723
|
-
}, '+ New Item')
|
|
2724
|
-
),
|
|
2725
|
-
|
|
2726
|
-
// Stats cards
|
|
2727
|
-
h('div', { className: 'grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6' },
|
|
2728
|
-
_statCard('Total', stats.total, 'bg-gray-50'),
|
|
2729
|
-
_statCard('To Do', stats.byStatus.todo || 0, 'bg-gray-50'),
|
|
2730
|
-
_statCard('In Progress', stats.byStatus.in_progress || 0, 'bg-blue-50'),
|
|
2731
|
-
_statCard('Done', stats.byStatus.done || 0, 'bg-emerald-50')
|
|
2732
|
-
),
|
|
2733
|
-
|
|
2734
|
-
// Chart — doughnut showing status distribution
|
|
2735
|
-
h('div', { className: 'mb-6' },
|
|
2736
|
-
h(window.StatsChart, { stats: stats })
|
|
2737
|
-
),
|
|
2738
|
-
|
|
2739
|
-
// Filters
|
|
2740
|
-
h('div', { className: 'flex gap-2 mb-4' },
|
|
2741
|
-
_filterSelect('Status', app.filters.status, ['todo', 'in_progress', 'done'],
|
|
2742
|
-
function(v) { app.setFilter({ status: v }); }),
|
|
2743
|
-
_filterSelect('Priority', app.filters.priority, ['low', 'medium', 'high'],
|
|
2744
|
-
function(v) { app.setFilter({ priority: v }); })
|
|
2745
|
-
),
|
|
2746
|
-
|
|
2747
|
-
// Form (show/hide)
|
|
2748
|
-
showForm
|
|
2749
|
-
? h(window.ItemForm, { item: editing, onSave: handleSave, onCancel: handleCancel })
|
|
2750
|
-
: null,
|
|
2751
|
-
|
|
2752
|
-
// Items list
|
|
2753
|
-
app.loading
|
|
2754
|
-
? h('div', { className: 'text-center py-12' },
|
|
2755
|
-
h('div', { className: 'w-6 h-6 border-2 border-primary-500 border-t-transparent rounded-full animate-spin mx-auto mb-2' }),
|
|
2756
|
-
h('p', { className: 'text-sm text-gray-400' }, 'Loading...')
|
|
2757
|
-
)
|
|
2758
|
-
: app.items.length === 0
|
|
2759
|
-
? h('div', { className: 'text-center py-12 bg-white rounded-lg border border-gray-200' },
|
|
2760
|
-
h('p', { className: 'text-gray-400 mb-2' }, 'No items yet'),
|
|
2761
|
-
h('button', {
|
|
2762
|
-
onClick: function() { setShowForm(true); },
|
|
2763
|
-
className: 'text-sm text-primary-600 hover:text-primary-700 font-medium',
|
|
2764
|
-
}, 'Create your first item')
|
|
2765
|
-
)
|
|
2766
|
-
: h('div', { className: 'grid gap-3 ' + (showForm ? 'mt-4' : '') },
|
|
2767
|
-
app.items.map(function(item) {
|
|
2768
|
-
return h(window.ItemCard, {
|
|
2769
|
-
key: item.id,
|
|
2770
|
-
item: item,
|
|
2771
|
-
onEdit: handleEdit,
|
|
2772
|
-
onDelete: handleDelete,
|
|
2773
|
-
});
|
|
2774
|
-
})
|
|
2775
|
-
)
|
|
2776
|
-
);
|
|
2777
|
-
};
|
|
2778
|
-
|
|
2779
|
-
function _statCard(label, value, bg) {
|
|
2780
|
-
return h('div', { className: 'rounded-lg p-3 ' + bg },
|
|
2781
|
-
h('p', { className: 'text-xs text-gray-500 font-medium' }, label),
|
|
2782
|
-
h('p', { className: 'text-xl font-bold text-gray-800' }, value)
|
|
2783
|
-
);
|
|
2784
|
-
}
|
|
2785
|
-
|
|
2786
|
-
function _filterSelect(label, value, options, onChange) {
|
|
2787
|
-
return h('select', {
|
|
2788
|
-
value: value,
|
|
2789
|
-
onChange: function(e) { onChange(e.target.value); },
|
|
2790
|
-
className: 'px-3 py-1.5 border border-gray-300 rounded-lg text-sm text-gray-600 focus:outline-none focus:ring-2 focus:ring-primary-500',
|
|
2791
|
-
},
|
|
2792
|
-
h('option', { value: '' }, 'All ' + label),
|
|
2793
|
-
options.map(function(opt) {
|
|
2794
|
-
var label = opt.replace('_', ' ');
|
|
2795
|
-
label = label.charAt(0).toUpperCase() + label.slice(1);
|
|
2796
|
-
return h('option', { key: opt, value: opt }, label);
|
|
2797
|
-
})
|
|
2798
|
-
);
|
|
2799
|
-
}
|
|
2800
|
-
`;
|
|
2801
|
-
|
|
2802
|
-
// ---------------------------------------------------------------------------
|
|
2803
|
-
// public/App.js — Root component with providers and router
|
|
2804
|
-
// ---------------------------------------------------------------------------
|
|
2805
|
-
|
|
2806
|
-
const APP_JS = `var h = React.createElement;
|
|
2807
|
-
var RR = ReactRouterDOM;
|
|
2808
|
-
|
|
2809
|
-
/**
|
|
2810
|
-
* Detect the basename for React Router.
|
|
2811
|
-
*
|
|
2812
|
-
* When running inside a Nodepod preview iframe, the URL path is
|
|
2813
|
-
* something like "/__preview__/3000/" — React Router needs to
|
|
2814
|
-
* know this prefix so it can match routes correctly. In production
|
|
2815
|
-
* (real server), the pathname is just "/" and basename is empty.
|
|
2816
|
-
*/
|
|
2817
|
-
var BASENAME = (function() {
|
|
2818
|
-
var m = window.location.pathname.match(/^(\\/__.+?__\\/\\d+)/);
|
|
2819
|
-
return m ? m[1] : '';
|
|
2820
|
-
})();
|
|
2821
|
-
|
|
2822
|
-
/**
|
|
2823
|
-
* ProtectedRoute — Redirects to /login if not authenticated.
|
|
2824
|
-
*/
|
|
2825
|
-
function ProtectedRoute(props) {
|
|
2826
|
-
var auth = window.useAuth();
|
|
2827
|
-
|
|
2828
|
-
if (auth.loading) {
|
|
2829
|
-
return h('div', { className: 'flex items-center justify-center min-h-[calc(100vh-56px)]' },
|
|
2830
|
-
h('div', { className: 'w-6 h-6 border-2 border-primary-500 border-t-transparent rounded-full animate-spin' })
|
|
2831
|
-
);
|
|
2832
|
-
}
|
|
2833
|
-
|
|
2834
|
-
if (!auth.isAuthenticated) {
|
|
2835
|
-
return h(RR.Navigate, { to: '/login', replace: true });
|
|
2836
|
-
}
|
|
2837
|
-
|
|
2838
|
-
return props.children || h(RR.Outlet);
|
|
2839
|
-
}
|
|
2840
|
-
|
|
2841
|
-
/**
|
|
2842
|
-
* Layout — App shell with navbar.
|
|
2843
|
-
*/
|
|
2844
|
-
function Layout() {
|
|
2845
|
-
var app = window.useApp();
|
|
2846
|
-
|
|
2847
|
-
return h(React.Fragment, null,
|
|
2848
|
-
h(window.Navbar),
|
|
2849
|
-
h(window.Toast, { toasts: app.toasts }),
|
|
2850
|
-
h('main', null, h(RR.Outlet))
|
|
2851
|
-
);
|
|
2852
|
-
}
|
|
2853
|
-
|
|
2854
|
-
/**
|
|
2855
|
-
* App — Root component. Sets up providers and router.
|
|
2856
|
-
*/
|
|
2857
|
-
function App() {
|
|
2858
|
-
return h(window.AuthProvider, null,
|
|
2859
|
-
h(window.AppProvider, null,
|
|
2860
|
-
h(RR.BrowserRouter, { basename: BASENAME },
|
|
2861
|
-
h(RR.Routes, null,
|
|
2862
|
-
h(RR.Route, { element: h(Layout) },
|
|
2863
|
-
// Public routes
|
|
2864
|
-
h(RR.Route, { index: true, element: h(window.WelcomePage) }),
|
|
2865
|
-
h(RR.Route, { path: 'login', element: h(window.LoginPage) }),
|
|
2866
|
-
h(RR.Route, { path: 'register', element: h(window.RegisterPage) }),
|
|
2867
|
-
// Protected routes
|
|
2868
|
-
h(RR.Route, { element: h(ProtectedRoute) },
|
|
2869
|
-
h(RR.Route, { path: 'dashboard', element: h(window.DashboardPage) })
|
|
2870
|
-
),
|
|
2871
|
-
// 404
|
|
2872
|
-
h(RR.Route, { path: '*', element: h('div', { className: 'text-center py-20' },
|
|
2873
|
-
h('h1', { className: 'text-4xl font-bold text-gray-300 mb-2' }, '404'),
|
|
2874
|
-
h('p', { className: 'text-gray-400 mb-4' }, 'Page not found'),
|
|
2875
|
-
h(RR.Link, { to: '/', className: 'text-primary-600 hover:text-primary-700 text-sm font-medium' }, 'Go home')
|
|
2876
|
-
)})
|
|
2877
|
-
)
|
|
2878
|
-
)
|
|
2879
|
-
)
|
|
2880
|
-
)
|
|
2881
|
-
);
|
|
2882
|
-
}
|
|
2883
|
-
|
|
2884
|
-
// Mount
|
|
2885
|
-
ReactDOM.createRoot(document.getElementById('root')).render(h(App));
|
|
2886
|
-
`;
|
|
2887
|
-
|
|
2888
|
-
// ---------------------------------------------------------------------------
|
|
2889
|
-
// Dockerfile — Multi-stage production build
|
|
2890
|
-
// ---------------------------------------------------------------------------
|
|
2891
|
-
|
|
2892
|
-
const DOCKERFILE = `# ===========================================================================
|
|
2893
|
-
# Dockerfile — Production-ready multi-stage build
|
|
2894
|
-
#
|
|
2895
|
-
# Supports two modes:
|
|
2896
|
-
# 1. SQLite (default) — zero external dependencies, great for POC/MVP
|
|
2897
|
-
# 2. Postgres — set DATABASE_URL to switch (no code changes needed)
|
|
2898
|
-
#
|
|
2899
|
-
# Build:
|
|
2900
|
-
# docker build -t starter-app .
|
|
2901
|
-
#
|
|
2902
|
-
# Run (SQLite — data persists via mounted volume):
|
|
2903
|
-
# docker run -p 3000:3000 \\
|
|
2904
|
-
# -v app-data:/app/data \\
|
|
2905
|
-
# -e JWT_SECRET=your-secret-here \\
|
|
2906
|
-
# starter-app
|
|
2907
|
-
#
|
|
2908
|
-
# Run (Postgres):
|
|
2909
|
-
# docker run -p 3000:3000 \\
|
|
2910
|
-
# -e DATABASE_URL=postgresql://user:pass@host:5432/dbname \\
|
|
2911
|
-
# -e JWT_SECRET=your-secret-here \\
|
|
2912
|
-
# starter-app
|
|
2913
|
-
# ===========================================================================
|
|
2914
|
-
|
|
2915
|
-
# --- Stage 1: Dependencies ---------------------------------------------------
|
|
2916
|
-
FROM node:20-alpine AS deps
|
|
2917
|
-
|
|
2918
|
-
WORKDIR /app
|
|
2919
|
-
|
|
2920
|
-
COPY package.json package-lock.json* ./
|
|
2921
|
-
RUN npm ci --omit=dev 2>/dev/null || npm install --omit=dev
|
|
2922
|
-
|
|
2923
|
-
# --- Stage 2: Production image -----------------------------------------------
|
|
2924
|
-
FROM node:20-alpine AS runtime
|
|
2925
|
-
|
|
2926
|
-
# Security: run as non-root
|
|
2927
|
-
RUN addgroup -S app && adduser -S app -G app
|
|
2928
|
-
|
|
2929
|
-
WORKDIR /app
|
|
2930
|
-
|
|
2931
|
-
# Copy dependencies from builder stage
|
|
2932
|
-
COPY --from=deps /app/node_modules ./node_modules
|
|
2933
|
-
|
|
2934
|
-
# Copy application source
|
|
2935
|
-
COPY . .
|
|
2936
|
-
|
|
2937
|
-
# Remove files not needed in production
|
|
2938
|
-
RUN rm -f Dockerfile .dockerignore docker-compose.yml CONTRIBUTING.md
|
|
2939
|
-
|
|
2940
|
-
# Create data directory for SQLite persistence (mounted as volume)
|
|
2941
|
-
RUN mkdir -p /app/data && chown -R app:app /app/data
|
|
2942
|
-
|
|
2943
|
-
# Switch to non-root user
|
|
2944
|
-
USER app
|
|
2945
|
-
|
|
2946
|
-
# Environment defaults — override at runtime via -e or .env
|
|
2947
|
-
ENV NODE_ENV=production \\
|
|
2948
|
-
PORT=3000 \\
|
|
2949
|
-
DATABASE_URL=sqlite:/app/data/app.db \\
|
|
2950
|
-
JWT_SECRET=change-me-in-production
|
|
2951
|
-
|
|
2952
|
-
EXPOSE 3000
|
|
2953
|
-
|
|
2954
|
-
# Health check — ensures container is marked healthy in orchestrators
|
|
2955
|
-
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \\
|
|
2956
|
-
CMD wget -qO- http://localhost:3000/api/health || exit 1
|
|
2957
|
-
|
|
2958
|
-
CMD ["node", "server.js"]
|
|
2959
|
-
`;
|
|
2960
|
-
|
|
2961
|
-
// ---------------------------------------------------------------------------
|
|
2962
|
-
// .dockerignore
|
|
2963
|
-
// ---------------------------------------------------------------------------
|
|
2964
|
-
|
|
2965
|
-
const DOCKERIGNORE = `node_modules
|
|
2966
|
-
npm-debug.log*
|
|
2967
|
-
.env
|
|
2968
|
-
.env.local
|
|
2969
|
-
.env.*.local
|
|
2970
|
-
.git
|
|
2971
|
-
.gitignore
|
|
2972
|
-
.DS_Store
|
|
2973
|
-
*.md
|
|
2974
|
-
!README.md
|
|
2975
|
-
`;
|
|
2976
|
-
|
|
2977
|
-
// ---------------------------------------------------------------------------
|
|
2978
|
-
// docker-compose.yml — Local dev with optional Postgres
|
|
2979
|
-
// ---------------------------------------------------------------------------
|
|
2980
|
-
|
|
2981
|
-
const DOCKER_COMPOSE = `# docker-compose.yml — Local development and production deployment
|
|
2982
|
-
#
|
|
2983
|
-
# Usage:
|
|
2984
|
-
# SQLite (default): docker compose up
|
|
2985
|
-
# With Postgres: docker compose --profile postgres up
|
|
2986
|
-
#
|
|
2987
|
-
# The app works identically with both — swap DATABASE_URL and restart.
|
|
2988
|
-
|
|
2989
|
-
services:
|
|
2990
|
-
app:
|
|
2991
|
-
build: .
|
|
2992
|
-
ports:
|
|
2993
|
-
- "\${PORT:-3000}:3000"
|
|
2994
|
-
environment:
|
|
2995
|
-
- NODE_ENV=\${NODE_ENV:-production}
|
|
2996
|
-
- PORT=3000
|
|
2997
|
-
- JWT_SECRET=\${JWT_SECRET:-change-me-in-production}
|
|
2998
|
-
- JWT_EXPIRES_IN=\${JWT_EXPIRES_IN:-24h}
|
|
2999
|
-
- CORS_ORIGIN=\${CORS_ORIGIN:-*}
|
|
3000
|
-
- DATABASE_URL=\${DATABASE_URL:-sqlite:/app/data/app.db}
|
|
3001
|
-
volumes:
|
|
3002
|
-
- app-data:/app/data
|
|
3003
|
-
restart: unless-stopped
|
|
3004
|
-
healthcheck:
|
|
3005
|
-
test: wget -qO- http://localhost:3000/api/health || exit 1
|
|
3006
|
-
interval: 30s
|
|
3007
|
-
timeout: 5s
|
|
3008
|
-
start_period: 10s
|
|
3009
|
-
retries: 3
|
|
3010
|
-
|
|
3011
|
-
# --- Optional: Postgres (activate with --profile postgres) ----------------
|
|
3012
|
-
postgres:
|
|
3013
|
-
image: postgres:16-alpine
|
|
3014
|
-
profiles: [postgres]
|
|
3015
|
-
environment:
|
|
3016
|
-
POSTGRES_DB: starter_app
|
|
3017
|
-
POSTGRES_USER: app
|
|
3018
|
-
POSTGRES_PASSWORD: \${POSTGRES_PASSWORD:-postgres}
|
|
3019
|
-
ports:
|
|
3020
|
-
- "5432:5432"
|
|
3021
|
-
volumes:
|
|
3022
|
-
- pg-data:/var/lib/postgresql/data
|
|
3023
|
-
healthcheck:
|
|
3024
|
-
test: pg_isready -U app -d starter_app
|
|
3025
|
-
interval: 10s
|
|
3026
|
-
timeout: 5s
|
|
3027
|
-
retries: 5
|
|
3028
|
-
|
|
3029
|
-
volumes:
|
|
3030
|
-
app-data:
|
|
3031
|
-
pg-data:
|
|
3032
|
-
`;
|
|
3033
|
-
|
|
3034
|
-
// ---------------------------------------------------------------------------
|
|
3035
|
-
// README.md
|
|
3036
|
-
// ---------------------------------------------------------------------------
|
|
3037
|
-
|
|
3038
|
-
const README_MD = `# Starter App
|
|
3039
|
-
|
|
3040
|
-
A production-ready full-stack application built with **Express.js** and **React**.
|
|
3041
|
-
|
|
3042
|
-
Designed as a proper starting point — not a toy demo. The architecture, patterns,
|
|
3043
|
-
and code quality are what you'd ship to production. Start building features
|
|
3044
|
-
immediately, deploy when ready.
|
|
3045
|
-
|
|
3046
|
-
## Stack
|
|
3047
|
-
|
|
3048
|
-
| Layer | Technology | Notes |
|
|
3049
|
-
|--------------|-----------------------------------------|------------------------------------|
|
|
3050
|
-
| **Server** | Express.js 4 | Middleware chain, route modules |
|
|
3051
|
-
| **Database** | SQLite (sql.js) / Postgres | Swap via env var, zero code changes |
|
|
3052
|
-
| **Auth** | JWT + role-based access control | Register, login, protected routes |
|
|
3053
|
-
| **Frontend** | React 18 + React Router v6 | SPA with client-side routing |
|
|
3054
|
-
| **State** | React Context + useReducer | AuthContext + AppContext |
|
|
3055
|
-
| **Styling** | Tailwind CSS (CDN) | Utility-first, responsive |
|
|
3056
|
-
|
|
3057
|
-
## Quick Start
|
|
3058
|
-
|
|
3059
|
-
\`\`\`bash
|
|
3060
|
-
# Install dependencies
|
|
3061
|
-
npm install
|
|
3062
|
-
|
|
3063
|
-
# Start development server
|
|
3064
|
-
node server.js
|
|
3065
|
-
|
|
3066
|
-
# Open http://localhost:3000
|
|
3067
|
-
\`\`\`
|
|
3068
|
-
|
|
3069
|
-
**Demo accounts** (seeded automatically):
|
|
3070
|
-
- \`admin@example.com\` / \`admin123\` (admin role)
|
|
3071
|
-
- \`user@example.com\` / \`user123\` (user role)
|
|
3072
|
-
|
|
3073
|
-
## Project Structure
|
|
3074
|
-
|
|
3075
|
-
\`\`\`
|
|
3076
|
-
├── server.js # Entry point — middleware chain + startup
|
|
3077
|
-
├── config.js # Centralized env var config
|
|
3078
|
-
├── package.json # Dependencies
|
|
3079
|
-
├── .env.example # Environment variable documentation
|
|
3080
|
-
├── .ranger # AI agent context (project manifest)
|
|
3081
|
-
│
|
|
3082
|
-
├── db/
|
|
3083
|
-
│ ├── database.js # Database init, migrations, query helpers
|
|
3084
|
-
│ ├── migrations/
|
|
3085
|
-
│ │ └── 001_initial.js # Schema: users, items tables + seed data
|
|
3086
|
-
│ └── repositories/
|
|
3087
|
-
│ ├── userRepository.js # User CRUD (parameterized SQL)
|
|
3088
|
-
│ └── itemRepository.js # Item CRUD with JOINs, filters, stats
|
|
3089
|
-
│
|
|
3090
|
-
├── middleware/
|
|
3091
|
-
│ ├── auth.js # JWT generation, verification, role checks
|
|
3092
|
-
│ ├── errorHandler.js # Global error handler + 404 / SPA fallback
|
|
3093
|
-
│ ├── staticFiles.js # Static file serving
|
|
3094
|
-
│ └── validate.js # Request body validation helpers
|
|
3095
|
-
│
|
|
3096
|
-
├── routes/
|
|
3097
|
-
│ ├── auth.js # POST /api/auth/register, login, GET /me
|
|
3098
|
-
│ └── items.js # CRUD /api/items + GET /api/items/stats
|
|
3099
|
-
│
|
|
3100
|
-
├── public/ # React SPA (served as static files)
|
|
3101
|
-
│ ├── index.html # HTML shell (loads React + Router from CDN)
|
|
3102
|
-
│ ├── styles.css # Custom animations
|
|
3103
|
-
│ ├── lib/api.js # API client with auth token handling
|
|
3104
|
-
│ ├── store/
|
|
3105
|
-
│ │ ├── AuthContext.js # Auth state (login/register/logout)
|
|
3106
|
-
│ │ └── AppContext.js # App state (items CRUD, stats, filters)
|
|
3107
|
-
│ ├── components/
|
|
3108
|
-
│ │ ├── Navbar.js # Top nav with auth status
|
|
3109
|
-
│ │ ├── Toast.js # Notification toasts
|
|
3110
|
-
│ │ ├── ItemCard.js # Item display card
|
|
3111
|
-
│ │ └── ItemForm.js # Item create/edit form
|
|
3112
|
-
│ ├── pages/
|
|
3113
|
-
│ │ ├── WelcomePage.js # Landing page
|
|
3114
|
-
│ │ ├── LoginPage.js # Login form
|
|
3115
|
-
│ │ ├── RegisterPage.js # Registration form
|
|
3116
|
-
│ │ └── DashboardPage.js # Dashboard with stats + item list
|
|
3117
|
-
│ └── App.js # Root — providers, router, routes
|
|
3118
|
-
│
|
|
3119
|
-
├── Dockerfile # Multi-stage production build
|
|
3120
|
-
├── docker-compose.yml # Local dev + optional Postgres
|
|
3121
|
-
├── .dockerignore # Docker build exclusions
|
|
3122
|
-
├── README.md # This file
|
|
3123
|
-
└── CONTRIBUTING.md # Contribution guidelines
|
|
3124
|
-
\`\`\`
|
|
3125
|
-
|
|
3126
|
-
## API Reference
|
|
3127
|
-
|
|
3128
|
-
### Authentication
|
|
3129
|
-
|
|
3130
|
-
| Method | Endpoint | Auth | Description |
|
|
3131
|
-
|--------|----------------------|----------|------------------------|
|
|
3132
|
-
| POST | /api/auth/register | Public | Create account |
|
|
3133
|
-
| POST | /api/auth/login | Public | Login, returns JWT |
|
|
3134
|
-
| GET | /api/auth/me | Required | Get current user |
|
|
3135
|
-
|
|
3136
|
-
### Items
|
|
3137
|
-
|
|
3138
|
-
| Method | Endpoint | Auth | Description |
|
|
3139
|
-
|--------|----------------------|----------|------------------------|
|
|
3140
|
-
| GET | /api/items | Required | List items (filterable)|
|
|
3141
|
-
| POST | /api/items | Required | Create item |
|
|
3142
|
-
| GET | /api/items/stats | Required | Dashboard statistics |
|
|
3143
|
-
| GET | /api/items/:id | Required | Get single item |
|
|
3144
|
-
| PUT | /api/items/:id | Required | Update item |
|
|
3145
|
-
| DELETE | /api/items/:id | Required | Delete item (owner/admin) |
|
|
3146
|
-
|
|
3147
|
-
**Query parameters** for GET /api/items:
|
|
3148
|
-
- \`status\` — Filter by status (todo, in_progress, done)
|
|
3149
|
-
- \`priority\` — Filter by priority (low, medium, high)
|
|
3150
|
-
|
|
3151
|
-
## Deployment
|
|
3152
|
-
|
|
3153
|
-
### Docker (Recommended)
|
|
3154
|
-
|
|
3155
|
-
\`\`\`bash
|
|
3156
|
-
# Build the image
|
|
3157
|
-
docker build -t starter-app .
|
|
3158
|
-
|
|
3159
|
-
# Run with SQLite (data persists in a Docker volume)
|
|
3160
|
-
docker run -d -p 3000:3000 \\
|
|
3161
|
-
-v app-data:/app/data \\
|
|
3162
|
-
-e JWT_SECRET=your-secret-here \\
|
|
3163
|
-
starter-app
|
|
3164
|
-
|
|
3165
|
-
# Run with Postgres
|
|
3166
|
-
docker run -d -p 3000:3000 \\
|
|
3167
|
-
-e DATABASE_URL=postgresql://user:pass@host:5432/dbname \\
|
|
3168
|
-
-e JWT_SECRET=your-secret-here \\
|
|
3169
|
-
starter-app
|
|
3170
|
-
\`\`\`
|
|
3171
|
-
|
|
3172
|
-
### Docker Compose
|
|
3173
|
-
|
|
3174
|
-
\`\`\`bash
|
|
3175
|
-
# SQLite mode (default)
|
|
3176
|
-
docker compose up -d
|
|
3177
|
-
|
|
3178
|
-
# With Postgres
|
|
3179
|
-
docker compose --profile postgres up -d
|
|
3180
|
-
\`\`\`
|
|
3181
|
-
|
|
3182
|
-
### Cloud Platforms
|
|
3183
|
-
|
|
3184
|
-
Works out of the box on any platform that supports Docker or Node.js:
|
|
3185
|
-
|
|
3186
|
-
| Platform | Deploy Command / Notes |
|
|
3187
|
-
|----------------------|-----------------------------------------------------------|
|
|
3188
|
-
| **Railway** | Connect repo, auto-detects Dockerfile |
|
|
3189
|
-
| **Render** | New Web Service, Docker, set env vars |
|
|
3190
|
-
| **Fly.io** | \`fly launch\`, creates fly.toml automatically |
|
|
3191
|
-
| **AWS ECS** | Push image to ECR, create task definition |
|
|
3192
|
-
| **Google Cloud Run** | \`gcloud run deploy --source .\` |
|
|
3193
|
-
| **DigitalOcean** | App Platform, auto-detect Dockerfile |
|
|
3194
|
-
| **Heroku** | \`heroku container:push web\` then \`heroku container:release web\` |
|
|
3195
|
-
|
|
3196
|
-
### Manual Deployment (No Docker)
|
|
3197
|
-
|
|
3198
|
-
\`\`\`bash
|
|
3199
|
-
npm install --omit=dev
|
|
3200
|
-
NODE_ENV=production JWT_SECRET=your-secret node server.js
|
|
3201
|
-
\`\`\`
|
|
3202
|
-
|
|
3203
|
-
## Database: SQLite vs Postgres
|
|
3204
|
-
|
|
3205
|
-
This app runs on **SQLite by default** — zero setup, great for POC/MVP/demos.
|
|
3206
|
-
When you need multi-server or production scale, switch to Postgres with **zero
|
|
3207
|
-
code changes** in your route/repository layer.
|
|
3208
|
-
|
|
3209
|
-
### When to Use SQLite (Default)
|
|
3210
|
-
|
|
3211
|
-
- POC, MVP, demos, hackathons
|
|
3212
|
-
- Single-server deployments (< 1000 concurrent users)
|
|
3213
|
-
- Embedded applications, edge deployments
|
|
3214
|
-
- You want zero infrastructure dependencies
|
|
3215
|
-
|
|
3216
|
-
### When to Switch to Postgres
|
|
3217
|
-
|
|
3218
|
-
- Multiple application servers (horizontal scaling)
|
|
3219
|
-
- Need concurrent writes from many users
|
|
3220
|
-
- Want managed backups, point-in-time recovery
|
|
3221
|
-
- Compliance requirements (SOC2, HIPAA)
|
|
3222
|
-
|
|
3223
|
-
### How to Migrate
|
|
3224
|
-
|
|
3225
|
-
1. **Install the Postgres driver:**
|
|
3226
|
-
\`\`\`bash
|
|
3227
|
-
npm install pg
|
|
3228
|
-
\`\`\`
|
|
3229
|
-
|
|
3230
|
-
2. **Replace \`db/database.js\`** with a Postgres version:
|
|
3231
|
-
\`\`\`javascript
|
|
3232
|
-
const { Pool } = require('pg');
|
|
3233
|
-
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
|
3234
|
-
|
|
3235
|
-
module.exports = {
|
|
3236
|
-
initialize: async () => { await runMigrations(); },
|
|
3237
|
-
run: (sql, params) => pool.query(sql, params),
|
|
3238
|
-
get: async (sql, params) => { const r = await pool.query(sql, params); return r.rows[0]; },
|
|
3239
|
-
all: async (sql, params) => { const r = await pool.query(sql, params); return r.rows; },
|
|
3240
|
-
exec: (sql) => pool.query(sql),
|
|
3241
|
-
};
|
|
3242
|
-
\`\`\`
|
|
3243
|
-
|
|
3244
|
-
3. **Set the environment variable:**
|
|
3245
|
-
\`\`\`bash
|
|
3246
|
-
DATABASE_URL=postgresql://user:pass@host:5432/dbname
|
|
3247
|
-
\`\`\`
|
|
3248
|
-
|
|
3249
|
-
4. **Run migrations** — they execute automatically on startup. The SQL is
|
|
3250
|
-
standard and works on both SQLite and Postgres.
|
|
3251
|
-
|
|
3252
|
-
> The repository layer (\`db/repositories/*.js\`) uses parameterized SQL.
|
|
3253
|
-
> For Postgres, change \`?\` placeholders to \`$1, $2\` style in the adapter.
|
|
3254
|
-
|
|
3255
|
-
## Environment Variables
|
|
3256
|
-
|
|
3257
|
-
| Variable | Default | Description |
|
|
3258
|
-
|----------------|----------------------------|------------------------------------|
|
|
3259
|
-
| PORT | 3000 | Server listen port |
|
|
3260
|
-
| NODE_ENV | development | Environment (development/production)|
|
|
3261
|
-
| DATABASE_URL | sqlite::memory: | Database connection string |
|
|
3262
|
-
| JWT_SECRET | sandbox-secret-key | JWT signing secret (**change this**) |
|
|
3263
|
-
| JWT_EXPIRES_IN | 24h | Token expiry duration |
|
|
3264
|
-
| CORS_ORIGIN | * | Allowed CORS origins |
|
|
3265
|
-
|
|
3266
|
-
## License
|
|
3267
|
-
|
|
3268
|
-
MIT
|
|
3269
|
-
`;
|
|
3270
|
-
|
|
3271
|
-
// ---------------------------------------------------------------------------
|
|
3272
|
-
// CONTRIBUTING.md
|
|
3273
|
-
// ---------------------------------------------------------------------------
|
|
3274
|
-
|
|
3275
|
-
const CONTRIBUTING_MD = `# Contributing
|
|
3276
|
-
|
|
3277
|
-
Thank you for considering contributing to this project.
|
|
3278
|
-
|
|
3279
|
-
## Development Setup
|
|
3280
|
-
|
|
3281
|
-
\`\`\`bash
|
|
3282
|
-
# Clone the repository
|
|
3283
|
-
git clone <repo-url>
|
|
3284
|
-
cd starter-app
|
|
3285
|
-
|
|
3286
|
-
# Install dependencies
|
|
3287
|
-
npm install
|
|
3288
|
-
|
|
3289
|
-
# Start the development server
|
|
3290
|
-
node server.js
|
|
3291
|
-
\`\`\`
|
|
3292
|
-
|
|
3293
|
-
The server starts on http://localhost:3000 with hot-reloadable frontend files.
|
|
3294
|
-
|
|
3295
|
-
## Project Architecture
|
|
3296
|
-
|
|
3297
|
-
This is a **monolith with clean separation of concerns**:
|
|
3298
|
-
|
|
3299
|
-
\`\`\`
|
|
3300
|
-
Request -> Express middleware chain -> Route handler -> Repository -> Database
|
|
3301
|
-
|
|
|
3302
|
-
Response <- JSON / HTML <- Route handler <- Repository result
|
|
3303
|
-
\`\`\`
|
|
3304
|
-
|
|
3305
|
-
### Key Conventions
|
|
3306
|
-
|
|
3307
|
-
1. **Database access goes through repositories** — never write SQL directly in
|
|
3308
|
-
routes. Add methods to \`db/repositories/*.js\`.
|
|
3309
|
-
|
|
3310
|
-
2. **Validation happens in middleware** — use \`middleware/validate.js\` helpers
|
|
3311
|
-
or add new ones. Routes assume valid data.
|
|
3312
|
-
|
|
3313
|
-
3. **Auth is JWT-based** — \`middleware/auth.js\` handles token generation,
|
|
3314
|
-
verification, and role checks. All \`/api/*\` routes (except auth) require
|
|
3315
|
-
a valid token.
|
|
3316
|
-
|
|
3317
|
-
4. **Frontend is a static SPA** — React components live in \`public/\`. State
|
|
3318
|
-
is managed via React Context providers in \`public/store/\`.
|
|
3319
|
-
|
|
3320
|
-
5. **Migrations are numbered** — add new files as \`db/migrations/002_*.js\`,
|
|
3321
|
-
\`003_*.js\`, etc. They run automatically on startup.
|
|
3322
|
-
|
|
3323
|
-
## Adding a New Resource
|
|
3324
|
-
|
|
3325
|
-
Example: adding a "comments" feature.
|
|
3326
|
-
|
|
3327
|
-
### 1. Database Migration
|
|
3328
|
-
|
|
3329
|
-
Create \`db/migrations/002_comments.js\`:
|
|
3330
|
-
\`\`\`javascript
|
|
3331
|
-
module.exports = [
|
|
3332
|
-
{
|
|
3333
|
-
name: '005_create_comments',
|
|
3334
|
-
up: \`
|
|
3335
|
-
CREATE TABLE IF NOT EXISTS comments (
|
|
3336
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
3337
|
-
body TEXT NOT NULL,
|
|
3338
|
-
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
|
3339
|
-
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
3340
|
-
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
3341
|
-
)
|
|
3342
|
-
\`,
|
|
3343
|
-
},
|
|
3344
|
-
];
|
|
3345
|
-
\`\`\`
|
|
3346
|
-
|
|
3347
|
-
### 2. Repository
|
|
3348
|
-
|
|
3349
|
-
Create \`db/repositories/commentRepository.js\`:
|
|
3350
|
-
\`\`\`javascript
|
|
3351
|
-
var db = require('../database');
|
|
3352
|
-
|
|
3353
|
-
function findByItemId(itemId) {
|
|
3354
|
-
return db.all(
|
|
3355
|
-
'SELECT c.*, u.name as author FROM comments c JOIN users u ON c.user_id = u.id WHERE c.item_id = ? ORDER BY c.created_at DESC',
|
|
3356
|
-
[itemId]
|
|
3357
|
-
);
|
|
3358
|
-
}
|
|
3359
|
-
|
|
3360
|
-
function create(body, itemId, userId) {
|
|
3361
|
-
return db.run(
|
|
3362
|
-
'INSERT INTO comments (body, item_id, user_id) VALUES (?, ?, ?)',
|
|
3363
|
-
[body, itemId, userId]
|
|
3364
|
-
);
|
|
3365
|
-
}
|
|
3366
|
-
|
|
3367
|
-
module.exports = { findByItemId, create };
|
|
3368
|
-
\`\`\`
|
|
3369
|
-
|
|
3370
|
-
### 3. Route
|
|
3371
|
-
|
|
3372
|
-
Create \`routes/comments.js\`:
|
|
3373
|
-
\`\`\`javascript
|
|
3374
|
-
var express = require('express');
|
|
3375
|
-
var router = express.Router();
|
|
3376
|
-
var auth = require('../middleware/auth');
|
|
3377
|
-
var comments = require('../db/repositories/commentRepository');
|
|
3378
|
-
|
|
3379
|
-
router.get('/items/:itemId/comments', auth.authenticate, function(req, res) {
|
|
3380
|
-
var rows = comments.findByItemId(req.params.itemId);
|
|
3381
|
-
res.json(rows);
|
|
3382
|
-
});
|
|
3383
|
-
|
|
3384
|
-
router.post('/items/:itemId/comments', auth.authenticate, function(req, res) {
|
|
3385
|
-
var result = comments.create(req.body.body, req.params.itemId, req.user.id);
|
|
3386
|
-
res.status(201).json({ id: result.lastInsertRowId });
|
|
3387
|
-
});
|
|
3388
|
-
|
|
3389
|
-
module.exports = router;
|
|
3390
|
-
\`\`\`
|
|
3391
|
-
|
|
3392
|
-
### 4. Mount the route in \`server.js\`:
|
|
3393
|
-
\`\`\`javascript
|
|
3394
|
-
var commentRoutes = require('./routes/comments');
|
|
3395
|
-
app.use('/api', commentRoutes);
|
|
3396
|
-
\`\`\`
|
|
3397
|
-
|
|
3398
|
-
### 5. Update \`.ranger\`
|
|
3399
|
-
|
|
3400
|
-
Add the new table schema, routes, and files to the \`.ranger\` manifest so the
|
|
3401
|
-
AI agent knows about your changes.
|
|
3402
|
-
|
|
3403
|
-
## Code Style
|
|
3404
|
-
|
|
3405
|
-
- **JavaScript** (CommonJS) — no build step required
|
|
3406
|
-
- **Semicolons** — yes
|
|
3407
|
-
- **Quotes** — single quotes for strings
|
|
3408
|
-
- **Indentation** — 2 spaces
|
|
3409
|
-
- **Comments** — JSDoc on exported functions, inline comments for non-obvious logic
|
|
3410
|
-
- **Error handling** — always pass errors to \`next()\` in Express middleware
|
|
3411
|
-
|
|
3412
|
-
## Testing
|
|
3413
|
-
|
|
3414
|
-
\`\`\`bash
|
|
3415
|
-
# Run all tests
|
|
3416
|
-
npm test
|
|
3417
|
-
|
|
3418
|
-
# Run with coverage
|
|
3419
|
-
npm run test:coverage
|
|
3420
|
-
\`\`\`
|
|
3421
|
-
|
|
3422
|
-
When adding new features:
|
|
3423
|
-
1. Add unit tests for repository methods
|
|
3424
|
-
2. Add integration tests for API endpoints
|
|
3425
|
-
3. Test both SQLite and Postgres if applicable
|
|
3426
|
-
|
|
3427
|
-
## Pull Request Checklist
|
|
3428
|
-
|
|
3429
|
-
- [ ] Code follows the project conventions above
|
|
3430
|
-
- [ ] New database tables have a migration file
|
|
3431
|
-
- [ ] New routes are documented in README.md API Reference
|
|
3432
|
-
- [ ] New files are listed in \`.ranger\` manifest
|
|
3433
|
-
- [ ] Tests pass locally
|
|
3434
|
-
- [ ] No hardcoded secrets or credentials
|
|
3435
|
-
`;
|
|
3436
|
-
|
|
3437
|
-
// ---------------------------------------------------------------------------
|
|
3438
|
-
// package.json
|
|
3439
|
-
// ---------------------------------------------------------------------------
|
|
3440
|
-
|
|
3441
|
-
const PACKAGE_JSON = JSON.stringify(
|
|
3442
|
-
{
|
|
3443
|
-
name: "starter-app",
|
|
3444
|
-
version: "1.0.0",
|
|
3445
|
-
description: "Full-stack Express + React starter with SQLite (sql.js)",
|
|
3446
|
-
dependencies: {
|
|
3447
|
-
express: "^4.18.0",
|
|
3448
|
-
"sql.js": "^1.11.0",
|
|
3449
|
-
},
|
|
3450
|
-
// PRODUCTION: Add these dependencies:
|
|
3451
|
-
// "pg": "^8.11.0" or "mongoose": "^7.0.0",
|
|
3452
|
-
// "jsonwebtoken": "^9.0.0",
|
|
3453
|
-
// "bcryptjs": "^2.4.3",
|
|
3454
|
-
// "dotenv": "^16.0.0",
|
|
3455
|
-
// "cors": "^2.8.5",
|
|
3456
|
-
// "helmet": "^7.0.0",
|
|
3457
|
-
// "express-rate-limit": "^7.0.0"
|
|
3458
|
-
},
|
|
3459
|
-
null,
|
|
3460
|
-
2,
|
|
3461
|
-
);
|
|
3462
|
-
|
|
3463
|
-
// ---------------------------------------------------------------------------
|
|
3464
|
-
// Export
|
|
3465
|
-
// ---------------------------------------------------------------------------
|
|
3466
|
-
|
|
3467
|
-
export const fullstackStarterTemplate = {
|
|
3468
|
-
entryCommand: "node server.js",
|
|
3469
|
-
port: 3000,
|
|
3470
|
-
files: {
|
|
3471
|
-
".ranger": RANGER_MANIFEST,
|
|
3472
|
-
"package.json": PACKAGE_JSON,
|
|
3473
|
-
".env.example": ENV_EXAMPLE,
|
|
3474
|
-
"config.js": CONFIG_JS,
|
|
3475
|
-
"server.js": SERVER_JS,
|
|
3476
|
-
Dockerfile: DOCKERFILE,
|
|
3477
|
-
".dockerignore": DOCKERIGNORE,
|
|
3478
|
-
"docker-compose.yml": DOCKER_COMPOSE,
|
|
3479
|
-
"README.md": README_MD,
|
|
3480
|
-
"CONTRIBUTING.md": CONTRIBUTING_MD,
|
|
3481
|
-
"db/database.js": DATABASE_JS,
|
|
3482
|
-
"db/migrations/001_initial.js": MIGRATION_001,
|
|
3483
|
-
"db/repositories/userRepository.js": USER_REPOSITORY_JS,
|
|
3484
|
-
"db/repositories/itemRepository.js": ITEM_REPOSITORY_JS,
|
|
3485
|
-
"middleware/auth.js": AUTH_JS,
|
|
3486
|
-
"middleware/errorHandler.js": ERROR_HANDLER_JS,
|
|
3487
|
-
"middleware/staticFiles.js": STATIC_FILES_JS,
|
|
3488
|
-
"middleware/validate.js": VALIDATE_JS,
|
|
3489
|
-
"routes/auth.js": ROUTES_AUTH_JS,
|
|
3490
|
-
"routes/items.js": ROUTES_ITEMS_JS,
|
|
3491
|
-
"public/index.html": INDEX_HTML,
|
|
3492
|
-
"public/styles.css": STYLES_CSS,
|
|
3493
|
-
"public/lib/api.js": API_JS,
|
|
3494
|
-
"public/store/AuthContext.js": AUTH_CONTEXT_JS,
|
|
3495
|
-
"public/store/AppContext.js": APP_CONTEXT_JS,
|
|
3496
|
-
"public/components/Toast.js": TOAST_JS,
|
|
3497
|
-
"public/components/Navbar.js": NAVBAR_JS,
|
|
3498
|
-
"public/components/ItemCard.js": ITEM_CARD_JS,
|
|
3499
|
-
"public/components/ItemForm.js": ITEM_FORM_JS,
|
|
3500
|
-
"public/components/StatsChart.js": STATS_CHART_JS,
|
|
3501
|
-
"public/pages/WelcomePage.js": WELCOME_PAGE_JS,
|
|
3502
|
-
"public/pages/LoginPage.js": LOGIN_PAGE_JS,
|
|
3503
|
-
"public/pages/RegisterPage.js": REGISTER_PAGE_JS,
|
|
3504
|
-
"public/pages/DashboardPage.js": DASHBOARD_PAGE_JS,
|
|
3505
|
-
"public/App.js": APP_JS,
|
|
3506
|
-
} as Record<string, string>,
|
|
3507
|
-
};
|