create-ereo 0.1.32 → 0.1.35
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -14
- package/dist/index.js +1926 -33
- package/package.json +4 -4
package/dist/index.js
CHANGED
|
@@ -47,14 +47,20 @@ function printHelp() {
|
|
|
47
47
|
bunx create-ereo@latest <project-name> [options]
|
|
48
48
|
|
|
49
49
|
\x1B[1mOptions:\x1B[0m
|
|
50
|
-
-t, --template <name> Template to use (minimal, default, tailwind)
|
|
50
|
+
-t, --template <name> Template to use (minimal, default, tailwind, tasks)
|
|
51
51
|
--no-typescript Use JavaScript instead of TypeScript
|
|
52
52
|
--no-git Skip git initialization
|
|
53
53
|
--no-install Skip package installation
|
|
54
54
|
--trace Include @ereo/trace for full-stack observability
|
|
55
55
|
|
|
56
|
+
\x1B[1mTemplates:\x1B[0m
|
|
57
|
+
tailwind (default) Landing page with blog, Tailwind CSS styling
|
|
58
|
+
minimal Bare-bones starting point
|
|
59
|
+
tasks Full-stack CRUD app with auth + SQLite database
|
|
60
|
+
|
|
56
61
|
\x1B[1mExamples:\x1B[0m
|
|
57
62
|
bunx create-ereo@latest my-app
|
|
63
|
+
bunx create-ereo@latest my-app --template tasks
|
|
58
64
|
bunx create-ereo@latest my-app --template minimal
|
|
59
65
|
bunx create-ereo@latest my-app --no-typescript
|
|
60
66
|
`);
|
|
@@ -75,8 +81,8 @@ function parseArgs(args) {
|
|
|
75
81
|
process.exit(1);
|
|
76
82
|
}
|
|
77
83
|
const tmpl = args[++i];
|
|
78
|
-
if (tmpl !== "minimal" && tmpl !== "default" && tmpl !== "tailwind") {
|
|
79
|
-
console.error(` \x1B[31m\u2717\x1B[0m Unknown template "${tmpl}". Valid options: minimal, default, tailwind
|
|
84
|
+
if (tmpl !== "minimal" && tmpl !== "default" && tmpl !== "tailwind" && tmpl !== "tasks") {
|
|
85
|
+
console.error(` \x1B[31m\u2717\x1B[0m Unknown template "${tmpl}". Valid options: minimal, default, tailwind, tasks
|
|
80
86
|
`);
|
|
81
87
|
process.exit(1);
|
|
82
88
|
}
|
|
@@ -111,25 +117,25 @@ COPY package.json bun.lockb* ./
|
|
|
111
117
|
RUN bun install --production --ignore-scripts
|
|
112
118
|
|
|
113
119
|
# ---- Stage 3: Production image ----
|
|
114
|
-
FROM oven/bun:1-
|
|
120
|
+
FROM oven/bun:1-alpine AS runner
|
|
115
121
|
WORKDIR /app
|
|
116
122
|
|
|
117
123
|
ENV NODE_ENV=production
|
|
118
124
|
|
|
119
125
|
# Non-root user for security
|
|
120
|
-
RUN
|
|
121
|
-
|
|
126
|
+
RUN addgroup -S -g 1001 ereo && \\
|
|
127
|
+
adduser -S -u 1001 -G ereo -H ereo
|
|
122
128
|
|
|
123
129
|
# Copy production node_modules
|
|
124
130
|
COPY --from=deps --chown=ereo:ereo /app/node_modules ./node_modules
|
|
125
131
|
|
|
126
132
|
# Copy build output and runtime files
|
|
127
|
-
COPY --from=builder --chown=ereo:ereo /app/.ereo
|
|
128
|
-
COPY --from=builder --chown=ereo:ereo /app/app
|
|
129
|
-
COPY --from=builder --chown=ereo:ereo /app/public
|
|
130
|
-
COPY --from=builder --chown=ereo:ereo /app/package.json
|
|
131
|
-
COPY --from=builder --chown=ereo:ereo /app/ereo.config.*
|
|
132
|
-
COPY --from=builder --chown=ereo:ereo /app/tsconfig.*
|
|
133
|
+
COPY --from=builder --chown=ereo:ereo /app/.ereo ./.ereo
|
|
134
|
+
COPY --from=builder --chown=ereo:ereo /app/app ./app
|
|
135
|
+
COPY --from=builder --chown=ereo:ereo /app/public ./public
|
|
136
|
+
COPY --from=builder --chown=ereo:ereo /app/package.json ./
|
|
137
|
+
COPY --from=builder --chown=ereo:ereo /app/ereo.config.* ./
|
|
138
|
+
COPY --from=builder --chown=ereo:ereo /app/tsconfig.* ./
|
|
133
139
|
|
|
134
140
|
USER ereo
|
|
135
141
|
|
|
@@ -318,8 +324,8 @@ export default function HomePage() {
|
|
|
318
324
|
<h1><span className="gradient-text">EreoJS</span></h1>
|
|
319
325
|
<p>A React fullstack framework built on Bun. Fast server-side rendering, file-based routing, and islands architecture.</p>
|
|
320
326
|
<div className="btn-group">
|
|
321
|
-
<a href="https://
|
|
322
|
-
<a href="https://github.com/
|
|
327
|
+
<a href="https://ereojs.github.io/ereoJS/" className="cta-btn cta-btn-primary">Get Started</a>
|
|
328
|
+
<a href="https://github.com/ereoJS/ereoJS" className="cta-btn cta-btn-secondary">
|
|
323
329
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z"/></svg>
|
|
324
330
|
GitHub
|
|
325
331
|
</a>
|
|
@@ -392,7 +398,7 @@ export default function HomePage() {
|
|
|
392
398
|
|
|
393
399
|
{/* Footer */}
|
|
394
400
|
<footer className="site-footer">
|
|
395
|
-
<p>Built with EreoJS — <a href="https://
|
|
401
|
+
<p>Built with EreoJS — <a href="https://ereojs.github.io/ereoJS/">Docs</a> · <a href="https://github.com/ereoJS/ereoJS">GitHub</a></p>
|
|
396
402
|
</footer>
|
|
397
403
|
</>
|
|
398
404
|
);
|
|
@@ -453,40 +459,44 @@ async function generateTailwindProject(projectDir, projectName, typescript, trac
|
|
|
453
459
|
"@ereo/data": EREO_VERSION,
|
|
454
460
|
"@ereo/cli": EREO_VERSION,
|
|
455
461
|
"@ereo/runtime-bun": EREO_VERSION,
|
|
456
|
-
"@ereo/plugin-tailwind": EREO_VERSION,
|
|
457
462
|
...trace ? { "@ereo/trace": EREO_VERSION } : {},
|
|
458
463
|
react: "^18.2.0",
|
|
459
464
|
"react-dom": "^18.2.0"
|
|
460
465
|
},
|
|
461
466
|
devDependencies: {
|
|
467
|
+
"@ereo/bundler": EREO_VERSION,
|
|
462
468
|
"@ereo/testing": EREO_VERSION,
|
|
463
469
|
"@ereo/dev-inspector": EREO_VERSION,
|
|
470
|
+
"@ereo/plugin-tailwind": EREO_VERSION,
|
|
471
|
+
tailwindcss: "^3.4.0",
|
|
464
472
|
...ts ? {
|
|
465
473
|
"@types/bun": "^1.1.0",
|
|
466
474
|
"@types/react": "^18.2.0",
|
|
467
475
|
"@types/react-dom": "^18.2.0",
|
|
468
476
|
typescript: "^5.4.0"
|
|
469
|
-
} : {}
|
|
470
|
-
tailwindcss: "^3.4.0"
|
|
477
|
+
} : {}
|
|
471
478
|
}
|
|
472
479
|
};
|
|
473
480
|
await Bun.write(join(projectDir, "package.json"), JSON.stringify(packageJson, null, 2));
|
|
474
481
|
const ereoConfig = `
|
|
475
482
|
import { defineConfig } from '@ereo/core';
|
|
476
|
-
|
|
483
|
+
|
|
484
|
+
const plugins = [];
|
|
485
|
+
|
|
486
|
+
// Tailwind is a dev/build dependency \u2014 skip in production
|
|
487
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
488
|
+
const { default: tailwind } = await import('@ereo/plugin-tailwind');
|
|
489
|
+
plugins.push(tailwind());
|
|
490
|
+
}
|
|
477
491
|
|
|
478
492
|
export default defineConfig({
|
|
479
493
|
server: {
|
|
480
494
|
port: 3000,
|
|
481
|
-
// Enable development features
|
|
482
|
-
development: process.env.NODE_ENV !== 'production',
|
|
483
495
|
},
|
|
484
496
|
build: {
|
|
485
497
|
target: 'bun',
|
|
486
498
|
},
|
|
487
|
-
plugins
|
|
488
|
-
tailwind(),
|
|
489
|
-
],
|
|
499
|
+
plugins,
|
|
490
500
|
});
|
|
491
501
|
`.trim();
|
|
492
502
|
await Bun.write(join(projectDir, `ereo.config.${ts ? "ts" : "js"}`), ereoConfig);
|
|
@@ -980,10 +990,10 @@ export function Footer() {
|
|
|
980
990
|
<span>Built with EreoJS</span>
|
|
981
991
|
</div>
|
|
982
992
|
<div className="flex items-center gap-6 text-sm text-gray-500 dark:text-gray-500">
|
|
983
|
-
<a href="https://github.com/
|
|
993
|
+
<a href="https://github.com/ereoJS/ereoJS" target="_blank" rel="noopener" className="hover:text-primary-600">
|
|
984
994
|
GitHub
|
|
985
995
|
</a>
|
|
986
|
-
<a href="https://
|
|
996
|
+
<a href="https://ereojs.github.io/ereoJS/" target="_blank" rel="noopener" className="hover:text-primary-600">
|
|
987
997
|
Documentation
|
|
988
998
|
</a>
|
|
989
999
|
<span>© {currentYear}</span>
|
|
@@ -1157,7 +1167,7 @@ export default function HomePage({ loaderData }${ts ? ": HomePageProps" : ""}) {
|
|
|
1157
1167
|
|
|
1158
1168
|
{/* CTA Buttons */}
|
|
1159
1169
|
<div className="flex flex-wrap gap-4 justify-center mt-10 opacity-0 animate-slide-up delay-400">
|
|
1160
|
-
<a href="https://
|
|
1170
|
+
<a href="https://ereojs.github.io/ereoJS/" className="btn btn-primary text-base px-6 py-3">
|
|
1161
1171
|
Get Started
|
|
1162
1172
|
</a>
|
|
1163
1173
|
<a href="/blog" className="btn btn-secondary text-base px-6 py-3">
|
|
@@ -1333,8 +1343,8 @@ export async function action({ request }) {
|
|
|
1333
1343
|
</div>
|
|
1334
1344
|
</div>
|
|
1335
1345
|
<div className="flex flex-wrap gap-4 justify-center">
|
|
1336
|
-
<a href="https://
|
|
1337
|
-
<a href="https://github.com/
|
|
1346
|
+
<a href="https://ereojs.github.io/ereoJS/" className="btn btn-primary text-base px-6 py-3">Documentation</a>
|
|
1347
|
+
<a href="https://github.com/ereoJS/ereoJS" target="_blank" rel="noopener" className="btn btn-secondary text-base px-6 py-3">GitHub</a>
|
|
1338
1348
|
</div>
|
|
1339
1349
|
</section>
|
|
1340
1350
|
</div>
|
|
@@ -1734,7 +1744,7 @@ export default function AboutPage() {
|
|
|
1734
1744
|
</p>
|
|
1735
1745
|
<div className="flex flex-wrap gap-4">
|
|
1736
1746
|
<a
|
|
1737
|
-
href="https://
|
|
1747
|
+
href="https://ereojs.github.io/ereoJS/"
|
|
1738
1748
|
target="_blank"
|
|
1739
1749
|
rel="noopener"
|
|
1740
1750
|
className="btn btn-primary"
|
|
@@ -1742,7 +1752,7 @@ export default function AboutPage() {
|
|
|
1742
1752
|
Documentation
|
|
1743
1753
|
</a>
|
|
1744
1754
|
<a
|
|
1745
|
-
href="https://github.com/
|
|
1755
|
+
href="https://github.com/ereoJS/ereoJS"
|
|
1746
1756
|
target="_blank"
|
|
1747
1757
|
rel="noopener"
|
|
1748
1758
|
className="btn btn-secondary"
|
|
@@ -1834,7 +1844,7 @@ NODE_ENV=development
|
|
|
1834
1844
|
# API_KEY=`);
|
|
1835
1845
|
const readme = `# ${projectName}
|
|
1836
1846
|
|
|
1837
|
-
A modern web application built with [EreoJS](https://github.com/
|
|
1847
|
+
A modern web application built with [EreoJS](https://github.com/ereoJS/ereoJS) - a React fullstack framework powered by Bun.
|
|
1838
1848
|
|
|
1839
1849
|
## Features
|
|
1840
1850
|
|
|
@@ -1910,7 +1920,1888 @@ For production, alias \`@ereo/trace\` to \`@ereo/trace/noop\` in your bundler fo
|
|
|
1910
1920
|
|
|
1911
1921
|
` : ""}## Learn More
|
|
1912
1922
|
|
|
1913
|
-
- [EreoJS Documentation](https://
|
|
1923
|
+
- [EreoJS Documentation](https://ereojs.github.io/ereoJS/)
|
|
1924
|
+
- [Bun Documentation](https://bun.sh/docs)
|
|
1925
|
+
- [Tailwind CSS](https://tailwindcss.com/docs)
|
|
1926
|
+
`;
|
|
1927
|
+
await Bun.write(join(projectDir, "README.md"), readme);
|
|
1928
|
+
}
|
|
1929
|
+
async function generateTasksProject(projectDir, projectName, typescript, trace = false) {
|
|
1930
|
+
const ext = typescript ? "tsx" : "jsx";
|
|
1931
|
+
const ts = typescript;
|
|
1932
|
+
await mkdir(projectDir, { recursive: true });
|
|
1933
|
+
await mkdir(join(projectDir, "app/routes/(auth)"), { recursive: true });
|
|
1934
|
+
await mkdir(join(projectDir, "app/routes/tasks"), { recursive: true });
|
|
1935
|
+
await mkdir(join(projectDir, "app/components"), { recursive: true });
|
|
1936
|
+
await mkdir(join(projectDir, "app/lib"), { recursive: true });
|
|
1937
|
+
await mkdir(join(projectDir, "public"), { recursive: true });
|
|
1938
|
+
await mkdir(join(projectDir, "data"), { recursive: true });
|
|
1939
|
+
const packageJson = {
|
|
1940
|
+
name: projectName,
|
|
1941
|
+
version: "0.1.0",
|
|
1942
|
+
type: "module",
|
|
1943
|
+
scripts: {
|
|
1944
|
+
dev: trace ? "ereo dev --trace" : "ereo dev",
|
|
1945
|
+
build: "ereo build",
|
|
1946
|
+
start: "ereo start",
|
|
1947
|
+
test: "bun test",
|
|
1948
|
+
typecheck: "tsc --noEmit"
|
|
1949
|
+
},
|
|
1950
|
+
dependencies: {
|
|
1951
|
+
"@ereo/core": EREO_VERSION,
|
|
1952
|
+
"@ereo/router": EREO_VERSION,
|
|
1953
|
+
"@ereo/server": EREO_VERSION,
|
|
1954
|
+
"@ereo/client": EREO_VERSION,
|
|
1955
|
+
"@ereo/data": EREO_VERSION,
|
|
1956
|
+
"@ereo/cli": EREO_VERSION,
|
|
1957
|
+
"@ereo/auth": EREO_VERSION,
|
|
1958
|
+
"@ereo/runtime-bun": EREO_VERSION,
|
|
1959
|
+
...trace ? { "@ereo/trace": EREO_VERSION } : {},
|
|
1960
|
+
react: "^18.2.0",
|
|
1961
|
+
"react-dom": "^18.2.0"
|
|
1962
|
+
},
|
|
1963
|
+
devDependencies: {
|
|
1964
|
+
"@ereo/bundler": EREO_VERSION,
|
|
1965
|
+
"@ereo/testing": EREO_VERSION,
|
|
1966
|
+
"@ereo/dev-inspector": EREO_VERSION,
|
|
1967
|
+
"@ereo/plugin-tailwind": EREO_VERSION,
|
|
1968
|
+
tailwindcss: "^3.4.0",
|
|
1969
|
+
...ts ? {
|
|
1970
|
+
"@types/bun": "^1.1.0",
|
|
1971
|
+
"@types/react": "^18.2.0",
|
|
1972
|
+
"@types/react-dom": "^18.2.0",
|
|
1973
|
+
typescript: "^5.4.0"
|
|
1974
|
+
} : {}
|
|
1975
|
+
}
|
|
1976
|
+
};
|
|
1977
|
+
await Bun.write(join(projectDir, "package.json"), JSON.stringify(packageJson, null, 2));
|
|
1978
|
+
const ereoConfig = `
|
|
1979
|
+
import { defineConfig } from '@ereo/core';
|
|
1980
|
+
import { createAuthPlugin, credentials } from '@ereo/auth';
|
|
1981
|
+
|
|
1982
|
+
const plugins${ts ? ": any[]" : ""} = [];
|
|
1983
|
+
|
|
1984
|
+
// Tailwind CSS \u2014 dev/build only
|
|
1985
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
1986
|
+
const { default: tailwind } = await import('@ereo/plugin-tailwind');
|
|
1987
|
+
plugins.push(tailwind());
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
// Database & auth helpers
|
|
1991
|
+
const { findUserByEmail, verifyPassword } = await import('./app/lib/db${ts ? "" : ".js"}');
|
|
1992
|
+
|
|
1993
|
+
// Authentication plugin \u2014 email/password with JWT sessions
|
|
1994
|
+
plugins.push(
|
|
1995
|
+
createAuthPlugin({
|
|
1996
|
+
session: {
|
|
1997
|
+
strategy: 'jwt',
|
|
1998
|
+
secret: process.env.AUTH_SECRET || 'dev-only-please-change-in-production',
|
|
1999
|
+
maxAge: 7 * 24 * 60 * 60, // 7 days
|
|
2000
|
+
},
|
|
2001
|
+
providers: [
|
|
2002
|
+
credentials({
|
|
2003
|
+
authorize: async (creds${ts ? ": Record<string, unknown>" : ""}) => {
|
|
2004
|
+
const email = creds.email as string;
|
|
2005
|
+
const password = creds.password as string;
|
|
2006
|
+
if (!email || !password) return null;
|
|
2007
|
+
|
|
2008
|
+
const user = findUserByEmail(email);
|
|
2009
|
+
if (!user) return null;
|
|
2010
|
+
|
|
2011
|
+
const valid = await verifyPassword(password, user.password_hash);
|
|
2012
|
+
if (!valid) return null;
|
|
2013
|
+
|
|
2014
|
+
return { id: String(user.id), email: user.email, name: user.name };
|
|
2015
|
+
},
|
|
2016
|
+
}),
|
|
2017
|
+
],
|
|
2018
|
+
cookie: {
|
|
2019
|
+
name: 'ereo.session',
|
|
2020
|
+
secure: process.env.NODE_ENV === 'production',
|
|
2021
|
+
httpOnly: true,
|
|
2022
|
+
sameSite: 'lax',
|
|
2023
|
+
},
|
|
2024
|
+
})
|
|
2025
|
+
);
|
|
2026
|
+
|
|
2027
|
+
export default defineConfig({
|
|
2028
|
+
server: {
|
|
2029
|
+
port: 3000,
|
|
2030
|
+
},
|
|
2031
|
+
build: {
|
|
2032
|
+
target: 'bun',
|
|
2033
|
+
},
|
|
2034
|
+
plugins,
|
|
2035
|
+
});
|
|
2036
|
+
`.trim();
|
|
2037
|
+
await Bun.write(join(projectDir, `ereo.config.${ts ? "ts" : "js"}`), ereoConfig);
|
|
2038
|
+
if (ts) {
|
|
2039
|
+
const tsconfig = {
|
|
2040
|
+
compilerOptions: {
|
|
2041
|
+
target: "ESNext",
|
|
2042
|
+
module: "ESNext",
|
|
2043
|
+
moduleResolution: "bundler",
|
|
2044
|
+
jsx: "react-jsx",
|
|
2045
|
+
strict: true,
|
|
2046
|
+
esModuleInterop: true,
|
|
2047
|
+
skipLibCheck: true,
|
|
2048
|
+
forceConsistentCasingInFileNames: true,
|
|
2049
|
+
types: ["bun-types"],
|
|
2050
|
+
paths: {
|
|
2051
|
+
"~/*": ["./app/*"]
|
|
2052
|
+
}
|
|
2053
|
+
},
|
|
2054
|
+
include: ["app/**/*", "*.config.ts"]
|
|
2055
|
+
};
|
|
2056
|
+
await Bun.write(join(projectDir, "tsconfig.json"), JSON.stringify(tsconfig, null, 2));
|
|
2057
|
+
}
|
|
2058
|
+
const tailwindConfig = `
|
|
2059
|
+
/** @type {import('tailwindcss').Config} */
|
|
2060
|
+
export default {
|
|
2061
|
+
content: ['./app/**/*.{js,ts,jsx,tsx}'],
|
|
2062
|
+
darkMode: 'class',
|
|
2063
|
+
theme: {
|
|
2064
|
+
extend: {
|
|
2065
|
+
fontFamily: {
|
|
2066
|
+
sans: ['system-ui', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'sans-serif'],
|
|
2067
|
+
mono: ['ui-monospace', 'SFMono-Regular', 'SF Mono', 'Menlo', 'Consolas', 'monospace'],
|
|
2068
|
+
},
|
|
2069
|
+
colors: {
|
|
2070
|
+
primary: {
|
|
2071
|
+
50: '#eef2ff',
|
|
2072
|
+
100: '#e0e7ff',
|
|
2073
|
+
200: '#c7d2fe',
|
|
2074
|
+
300: '#a5b4fc',
|
|
2075
|
+
400: '#818cf8',
|
|
2076
|
+
500: '#6366f1',
|
|
2077
|
+
600: '#4f46e5',
|
|
2078
|
+
700: '#4338ca',
|
|
2079
|
+
800: '#3730a3',
|
|
2080
|
+
900: '#312e81',
|
|
2081
|
+
950: '#1e1b4b',
|
|
2082
|
+
},
|
|
2083
|
+
},
|
|
2084
|
+
animation: {
|
|
2085
|
+
'fade-in': 'fadeIn 0.5s ease forwards',
|
|
2086
|
+
'slide-up': 'slideUp 0.6s ease forwards',
|
|
2087
|
+
},
|
|
2088
|
+
keyframes: {
|
|
2089
|
+
fadeIn: {
|
|
2090
|
+
'0%': { opacity: '0' },
|
|
2091
|
+
'100%': { opacity: '1' },
|
|
2092
|
+
},
|
|
2093
|
+
slideUp: {
|
|
2094
|
+
'0%': { opacity: '0', transform: 'translateY(20px)' },
|
|
2095
|
+
'100%': { opacity: '1', transform: 'translateY(0)' },
|
|
2096
|
+
},
|
|
2097
|
+
},
|
|
2098
|
+
},
|
|
2099
|
+
},
|
|
2100
|
+
plugins: [],
|
|
2101
|
+
};
|
|
2102
|
+
`.trim();
|
|
2103
|
+
await Bun.write(join(projectDir, "tailwind.config.js"), tailwindConfig);
|
|
2104
|
+
const styles = `
|
|
2105
|
+
@tailwind base;
|
|
2106
|
+
@tailwind components;
|
|
2107
|
+
@tailwind utilities;
|
|
2108
|
+
|
|
2109
|
+
@layer base {
|
|
2110
|
+
body {
|
|
2111
|
+
@apply antialiased font-sans;
|
|
2112
|
+
}
|
|
2113
|
+
}
|
|
2114
|
+
|
|
2115
|
+
@layer components {
|
|
2116
|
+
.btn {
|
|
2117
|
+
@apply px-4 py-2 rounded-lg font-medium transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 inline-flex items-center justify-center gap-2;
|
|
2118
|
+
}
|
|
2119
|
+
.btn-primary {
|
|
2120
|
+
@apply bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500;
|
|
2121
|
+
}
|
|
2122
|
+
.btn-secondary {
|
|
2123
|
+
@apply bg-gray-200 text-gray-900 hover:bg-gray-300 focus:ring-gray-500 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600;
|
|
2124
|
+
}
|
|
2125
|
+
.btn-danger {
|
|
2126
|
+
@apply bg-red-600 text-white hover:bg-red-700 focus:ring-red-500;
|
|
2127
|
+
}
|
|
2128
|
+
.btn-ghost {
|
|
2129
|
+
@apply bg-transparent text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800 focus:ring-gray-500;
|
|
2130
|
+
}
|
|
2131
|
+
.btn-sm {
|
|
2132
|
+
@apply px-3 py-1.5 text-sm;
|
|
2133
|
+
}
|
|
2134
|
+
.input {
|
|
2135
|
+
@apply w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-gray-800 dark:border-gray-600 dark:text-white;
|
|
2136
|
+
}
|
|
2137
|
+
.label {
|
|
2138
|
+
@apply block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5;
|
|
2139
|
+
}
|
|
2140
|
+
.card {
|
|
2141
|
+
@apply bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6;
|
|
2142
|
+
}
|
|
2143
|
+
.badge {
|
|
2144
|
+
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
|
|
2145
|
+
}
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
@layer utilities {
|
|
2149
|
+
.delay-100 { animation-delay: 100ms; }
|
|
2150
|
+
.delay-200 { animation-delay: 200ms; }
|
|
2151
|
+
.delay-300 { animation-delay: 300ms; }
|
|
2152
|
+
}
|
|
2153
|
+
`.trim();
|
|
2154
|
+
await Bun.write(join(projectDir, "app/styles.css"), styles);
|
|
2155
|
+
const dbModule = `
|
|
2156
|
+
import { Database } from 'bun:sqlite';
|
|
2157
|
+
import { join } from 'node:path';
|
|
2158
|
+
|
|
2159
|
+
${ts ? `export interface User {
|
|
2160
|
+
id: number;
|
|
2161
|
+
email: string;
|
|
2162
|
+
name: string;
|
|
2163
|
+
password_hash: string;
|
|
2164
|
+
created_at: string;
|
|
2165
|
+
}
|
|
2166
|
+
|
|
2167
|
+
export interface Task {
|
|
2168
|
+
id: number;
|
|
2169
|
+
user_id: number;
|
|
2170
|
+
title: string;
|
|
2171
|
+
description: string;
|
|
2172
|
+
status: 'todo' | 'in_progress' | 'done';
|
|
2173
|
+
priority: 'low' | 'medium' | 'high';
|
|
2174
|
+
created_at: string;
|
|
2175
|
+
updated_at: string;
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2178
|
+
export interface TaskStats {
|
|
2179
|
+
todo: number;
|
|
2180
|
+
in_progress: number;
|
|
2181
|
+
done: number;
|
|
2182
|
+
total: number;
|
|
2183
|
+
}
|
|
2184
|
+
` : ""}
|
|
2185
|
+
/**
|
|
2186
|
+
* SQLite database with production-ready configuration.
|
|
2187
|
+
*
|
|
2188
|
+
* The database file is stored in the /data directory so it can be
|
|
2189
|
+
* easily mounted as a Docker volume for persistence.
|
|
2190
|
+
*/
|
|
2191
|
+
const DB_PATH = join(import.meta.dir, '../../data/app.db');
|
|
2192
|
+
|
|
2193
|
+
const db = new Database(DB_PATH, { create: true });
|
|
2194
|
+
|
|
2195
|
+
// Production-ready PRAGMA settings
|
|
2196
|
+
db.exec('PRAGMA journal_mode = WAL'); // Better concurrent reads
|
|
2197
|
+
db.exec('PRAGMA synchronous = NORMAL'); // Safe with WAL, much faster
|
|
2198
|
+
db.exec('PRAGMA foreign_keys = ON'); // Enforce referential integrity
|
|
2199
|
+
db.exec('PRAGMA cache_size = -64000'); // ~64MB cache
|
|
2200
|
+
db.exec('PRAGMA busy_timeout = 5000'); // Wait 5s on lock contention
|
|
2201
|
+
|
|
2202
|
+
// \u2500\u2500 Schema migrations \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
2203
|
+
db.exec(\`
|
|
2204
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
2205
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
2206
|
+
email TEXT UNIQUE NOT NULL,
|
|
2207
|
+
name TEXT NOT NULL,
|
|
2208
|
+
password_hash TEXT NOT NULL,
|
|
2209
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
2210
|
+
);
|
|
2211
|
+
|
|
2212
|
+
CREATE TABLE IF NOT EXISTS tasks (
|
|
2213
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
2214
|
+
user_id INTEGER NOT NULL,
|
|
2215
|
+
title TEXT NOT NULL,
|
|
2216
|
+
description TEXT NOT NULL DEFAULT '',
|
|
2217
|
+
status TEXT NOT NULL DEFAULT 'todo'
|
|
2218
|
+
CHECK (status IN ('todo', 'in_progress', 'done')),
|
|
2219
|
+
priority TEXT NOT NULL DEFAULT 'medium'
|
|
2220
|
+
CHECK (priority IN ('low', 'medium', 'high')),
|
|
2221
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
2222
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
2223
|
+
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
2224
|
+
);
|
|
2225
|
+
|
|
2226
|
+
CREATE INDEX IF NOT EXISTS idx_tasks_user_id ON tasks(user_id);
|
|
2227
|
+
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(user_id, status);
|
|
2228
|
+
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
|
2229
|
+
\`);
|
|
2230
|
+
|
|
2231
|
+
// \u2500\u2500 User operations \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
2232
|
+
|
|
2233
|
+
export function findUserByEmail(email${ts ? ": string" : ""})${ts ? ": User | null" : ""} {
|
|
2234
|
+
return db.prepare('SELECT * FROM users WHERE email = ?').get(email)${ts ? " as User | null" : ""};
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
export function findUserById(id${ts ? ": number" : ""})${ts ? ": User | null" : ""} {
|
|
2238
|
+
return db.prepare('SELECT * FROM users WHERE id = ?').get(id)${ts ? " as User | null" : ""};
|
|
2239
|
+
}
|
|
2240
|
+
|
|
2241
|
+
export function createUser(email${ts ? ": string" : ""}, name${ts ? ": string" : ""}, passwordHash${ts ? ": string" : ""})${ts ? ": User" : ""} {
|
|
2242
|
+
return db.prepare(
|
|
2243
|
+
'INSERT INTO users (email, name, password_hash) VALUES (?, ?, ?) RETURNING *'
|
|
2244
|
+
).get(email, name, passwordHash)${ts ? " as User" : ""};
|
|
2245
|
+
}
|
|
2246
|
+
|
|
2247
|
+
export function emailExists(email${ts ? ": string" : ""})${ts ? ": boolean" : ""} {
|
|
2248
|
+
const row = db.prepare('SELECT 1 FROM users WHERE email = ?').get(email);
|
|
2249
|
+
return row !== null;
|
|
2250
|
+
}
|
|
2251
|
+
|
|
2252
|
+
// \u2500\u2500 Task operations \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
2253
|
+
|
|
2254
|
+
export function getTasksByUser(
|
|
2255
|
+
userId${ts ? ": number" : ""},
|
|
2256
|
+
status${ts ? "?: string" : ""}
|
|
2257
|
+
)${ts ? ": Task[]" : ""} {
|
|
2258
|
+
if (status && status !== 'all') {
|
|
2259
|
+
return db.prepare(
|
|
2260
|
+
\`SELECT * FROM tasks WHERE user_id = ? AND status = ?
|
|
2261
|
+
ORDER BY CASE priority WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 END,
|
|
2262
|
+
created_at DESC\`
|
|
2263
|
+
).all(userId, status)${ts ? " as Task[]" : ""};
|
|
2264
|
+
}
|
|
2265
|
+
return db.prepare(
|
|
2266
|
+
\`SELECT * FROM tasks WHERE user_id = ?
|
|
2267
|
+
ORDER BY CASE priority WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 END,
|
|
2268
|
+
created_at DESC\`
|
|
2269
|
+
).all(userId)${ts ? " as Task[]" : ""};
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
export function getTaskById(id${ts ? ": number" : ""}, userId${ts ? ": number" : ""})${ts ? ": Task | null" : ""} {
|
|
2273
|
+
return db.prepare(
|
|
2274
|
+
'SELECT * FROM tasks WHERE id = ? AND user_id = ?'
|
|
2275
|
+
).get(id, userId)${ts ? " as Task | null" : ""};
|
|
2276
|
+
}
|
|
2277
|
+
|
|
2278
|
+
export function createTask(
|
|
2279
|
+
userId${ts ? ": number" : ""},
|
|
2280
|
+
title${ts ? ": string" : ""},
|
|
2281
|
+
description${ts ? ": string" : ""},
|
|
2282
|
+
status${ts ? ": string" : ""},
|
|
2283
|
+
priority${ts ? ": string" : ""}
|
|
2284
|
+
)${ts ? ": Task" : ""} {
|
|
2285
|
+
return db.prepare(
|
|
2286
|
+
'INSERT INTO tasks (user_id, title, description, status, priority) VALUES (?, ?, ?, ?, ?) RETURNING *'
|
|
2287
|
+
).get(userId, title, description, status, priority)${ts ? " as Task" : ""};
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
export function updateTask(
|
|
2291
|
+
id${ts ? ": number" : ""},
|
|
2292
|
+
userId${ts ? ": number" : ""},
|
|
2293
|
+
title${ts ? ": string" : ""},
|
|
2294
|
+
description${ts ? ": string" : ""},
|
|
2295
|
+
status${ts ? ": string" : ""},
|
|
2296
|
+
priority${ts ? ": string" : ""}
|
|
2297
|
+
)${ts ? ": Task | null" : ""} {
|
|
2298
|
+
return db.prepare(
|
|
2299
|
+
"UPDATE tasks SET title = ?, description = ?, status = ?, priority = ?, updated_at = datetime('now') WHERE id = ? AND user_id = ? RETURNING *"
|
|
2300
|
+
).get(title, description, status, priority, id, userId)${ts ? " as Task | null" : ""};
|
|
2301
|
+
}
|
|
2302
|
+
|
|
2303
|
+
export function deleteTask(id${ts ? ": number" : ""}, userId${ts ? ": number" : ""})${ts ? ": boolean" : ""} {
|
|
2304
|
+
const result = db.prepare('DELETE FROM tasks WHERE id = ? AND user_id = ?').run(id, userId);
|
|
2305
|
+
return result.changes > 0;
|
|
2306
|
+
}
|
|
2307
|
+
|
|
2308
|
+
export function getTaskStats(userId${ts ? ": number" : ""})${ts ? ": TaskStats" : ""} {
|
|
2309
|
+
const rows = db.prepare(
|
|
2310
|
+
'SELECT status, COUNT(*) as count FROM tasks WHERE user_id = ? GROUP BY status'
|
|
2311
|
+
).all(userId)${ts ? " as { status: string; count: number }[]" : ""};
|
|
2312
|
+
|
|
2313
|
+
const stats${ts ? ": TaskStats" : ""} = { todo: 0, in_progress: 0, done: 0, total: 0 };
|
|
2314
|
+
for (const row of rows) {
|
|
2315
|
+
stats[row.status${ts ? " as keyof TaskStats" : ""}] = row.count;
|
|
2316
|
+
stats.total += row.count;
|
|
2317
|
+
}
|
|
2318
|
+
return stats;
|
|
2319
|
+
}
|
|
2320
|
+
|
|
2321
|
+
// \u2500\u2500 Password hashing \u2014 Bun built-in argon2id \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
2322
|
+
|
|
2323
|
+
export async function hashPassword(password${ts ? ": string" : ""})${ts ? ": Promise<string>" : ""} {
|
|
2324
|
+
return Bun.password.hash(password, { algorithm: 'argon2id' });
|
|
2325
|
+
}
|
|
2326
|
+
|
|
2327
|
+
export async function verifyPassword(password${ts ? ": string" : ""}, hash${ts ? ": string" : ""})${ts ? ": Promise<boolean>" : ""} {
|
|
2328
|
+
return Bun.password.verify(password, hash);
|
|
2329
|
+
}
|
|
2330
|
+
|
|
2331
|
+
export default db;
|
|
2332
|
+
`.trim();
|
|
2333
|
+
await Bun.write(join(projectDir, `app/lib/db.${ts ? "ts" : "js"}`), dbModule);
|
|
2334
|
+
if (ts) {
|
|
2335
|
+
const types = `
|
|
2336
|
+
/**
|
|
2337
|
+
* Shared types for the application.
|
|
2338
|
+
*/
|
|
2339
|
+
|
|
2340
|
+
export type { User, Task, TaskStats } from './db';
|
|
2341
|
+
|
|
2342
|
+
export interface ActionResult<T = unknown> {
|
|
2343
|
+
success: boolean;
|
|
2344
|
+
data?: T;
|
|
2345
|
+
error?: string;
|
|
2346
|
+
errors?: Record<string, string>;
|
|
2347
|
+
}
|
|
2348
|
+
`.trim();
|
|
2349
|
+
await Bun.write(join(projectDir, "app/lib/types.ts"), types);
|
|
2350
|
+
}
|
|
2351
|
+
const navigation = `
|
|
2352
|
+
${ts ? `interface NavigationProps {
|
|
2353
|
+
user?: { name: string; email: string } | null;
|
|
2354
|
+
}
|
|
2355
|
+
` : ""}
|
|
2356
|
+
export function Navigation({ user }${ts ? ": NavigationProps" : ""}) {
|
|
2357
|
+
return (
|
|
2358
|
+
<nav className="bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800 sticky top-0 z-50">
|
|
2359
|
+
<div className="max-w-5xl mx-auto px-4">
|
|
2360
|
+
<div className="flex items-center justify-between h-16">
|
|
2361
|
+
{/* Logo */}
|
|
2362
|
+
<a href={user ? '/tasks' : '/'} className="flex items-center gap-2 group">
|
|
2363
|
+
<svg width="28" height="28" viewBox="0 0 80 80" fill="none" xmlns="http://www.w3.org/2000/svg" className="transition-transform group-hover:scale-110">
|
|
2364
|
+
<path d="M40 8L72 24V56L40 72L8 56V24L40 8Z" stroke="url(#nav-grad)" strokeWidth="3" fill="none" />
|
|
2365
|
+
<path d="M40 28L52 34V46L40 52L28 46V34L40 28Z" fill="url(#nav-grad)" />
|
|
2366
|
+
<defs>
|
|
2367
|
+
<linearGradient id="nav-grad" x1="8" y1="8" x2="72" y2="72">
|
|
2368
|
+
<stop stopColor="#6366f1" />
|
|
2369
|
+
<stop offset="1" stopColor="#a855f7" />
|
|
2370
|
+
</linearGradient>
|
|
2371
|
+
</defs>
|
|
2372
|
+
</svg>
|
|
2373
|
+
<span className="font-bold text-xl">${projectName}</span>
|
|
2374
|
+
</a>
|
|
2375
|
+
|
|
2376
|
+
{/* Desktop */}
|
|
2377
|
+
<div className="hidden md:flex items-center gap-4">
|
|
2378
|
+
{user ? (
|
|
2379
|
+
<>
|
|
2380
|
+
<a href="/tasks" className="text-gray-600 dark:text-gray-300 hover:text-primary-600 transition-colors">
|
|
2381
|
+
Tasks
|
|
2382
|
+
</a>
|
|
2383
|
+
<a href="/tasks/new" className="btn btn-primary btn-sm">
|
|
2384
|
+
New Task
|
|
2385
|
+
</a>
|
|
2386
|
+
<div className="relative group">
|
|
2387
|
+
<button className="flex items-center gap-2 px-3 py-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors">
|
|
2388
|
+
<div className="w-8 h-8 rounded-full bg-primary-100 dark:bg-primary-900 flex items-center justify-center text-primary-700 dark:text-primary-300 font-medium text-sm">
|
|
2389
|
+
{user.name.charAt(0).toUpperCase()}
|
|
2390
|
+
</div>
|
|
2391
|
+
<span className="text-sm text-gray-700 dark:text-gray-300">{user.name}</span>
|
|
2392
|
+
</button>
|
|
2393
|
+
<div className="absolute right-0 mt-0 w-48 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 py-1 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-150">
|
|
2394
|
+
<div className="px-4 py-2 border-b border-gray-200 dark:border-gray-700">
|
|
2395
|
+
<p className="text-sm font-medium text-gray-900 dark:text-white">{user.name}</p>
|
|
2396
|
+
<p className="text-xs text-gray-500 dark:text-gray-400">{user.email}</p>
|
|
2397
|
+
</div>
|
|
2398
|
+
<form method="POST" action="/logout">
|
|
2399
|
+
<button
|
|
2400
|
+
type="submit"
|
|
2401
|
+
className="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-gray-50 dark:hover:bg-gray-700"
|
|
2402
|
+
>
|
|
2403
|
+
Sign out
|
|
2404
|
+
</button>
|
|
2405
|
+
</form>
|
|
2406
|
+
</div>
|
|
2407
|
+
</div>
|
|
2408
|
+
</>
|
|
2409
|
+
) : (
|
|
2410
|
+
<>
|
|
2411
|
+
<a href="/login" className="text-gray-600 dark:text-gray-300 hover:text-primary-600 transition-colors">
|
|
2412
|
+
Sign in
|
|
2413
|
+
</a>
|
|
2414
|
+
<a href="/register" className="btn btn-primary btn-sm">
|
|
2415
|
+
Get Started
|
|
2416
|
+
</a>
|
|
2417
|
+
</>
|
|
2418
|
+
)}
|
|
2419
|
+
</div>
|
|
2420
|
+
|
|
2421
|
+
{/* Mobile */}
|
|
2422
|
+
<div className="md:hidden flex items-center gap-3">
|
|
2423
|
+
{user ? (
|
|
2424
|
+
<>
|
|
2425
|
+
<a href="/tasks/new" className="btn btn-primary btn-sm">New</a>
|
|
2426
|
+
<form method="POST" action="/logout">
|
|
2427
|
+
<button type="submit" className="text-sm text-red-600 hover:text-red-700">Sign out</button>
|
|
2428
|
+
</form>
|
|
2429
|
+
</>
|
|
2430
|
+
) : (
|
|
2431
|
+
<>
|
|
2432
|
+
<a href="/login" className="text-gray-600 dark:text-gray-300 hover:text-primary-600 transition-colors">
|
|
2433
|
+
Sign in
|
|
2434
|
+
</a>
|
|
2435
|
+
<a href="/register" className="btn btn-primary btn-sm">
|
|
2436
|
+
Get Started
|
|
2437
|
+
</a>
|
|
2438
|
+
</>
|
|
2439
|
+
)}
|
|
2440
|
+
</div>
|
|
2441
|
+
</div>
|
|
2442
|
+
</div>
|
|
2443
|
+
</nav>
|
|
2444
|
+
);
|
|
2445
|
+
}
|
|
2446
|
+
`.trim();
|
|
2447
|
+
await Bun.write(join(projectDir, `app/components/Navigation.${ext}`), navigation);
|
|
2448
|
+
const footer = `
|
|
2449
|
+
export function Footer() {
|
|
2450
|
+
const currentYear = new Date().getFullYear();
|
|
2451
|
+
|
|
2452
|
+
return (
|
|
2453
|
+
<footer className="bg-gray-50 dark:bg-gray-900 border-t border-gray-200 dark:border-gray-800 mt-auto">
|
|
2454
|
+
<div className="max-w-5xl mx-auto px-4 py-6">
|
|
2455
|
+
<div className="flex flex-col md:flex-row items-center justify-between gap-4">
|
|
2456
|
+
<div className="flex items-center gap-2 text-gray-500 dark:text-gray-400 text-sm">
|
|
2457
|
+
<span className="text-lg">⬡</span>
|
|
2458
|
+
<span>Built with EreoJS</span>
|
|
2459
|
+
</div>
|
|
2460
|
+
<div className="flex items-center gap-6 text-sm text-gray-500 dark:text-gray-500">
|
|
2461
|
+
<a href="https://ereojs.github.io/ereoJS/" target="_blank" rel="noopener" className="hover:text-primary-600 transition-colors">
|
|
2462
|
+
Docs
|
|
2463
|
+
</a>
|
|
2464
|
+
<a href="https://github.com/ereoJS/ereoJS" target="_blank" rel="noopener" className="hover:text-primary-600 transition-colors">
|
|
2465
|
+
GitHub
|
|
2466
|
+
</a>
|
|
2467
|
+
<span>© {currentYear}</span>
|
|
2468
|
+
</div>
|
|
2469
|
+
</div>
|
|
2470
|
+
</div>
|
|
2471
|
+
</footer>
|
|
2472
|
+
);
|
|
2473
|
+
}
|
|
2474
|
+
`.trim();
|
|
2475
|
+
await Bun.write(join(projectDir, `app/components/Footer.${ext}`), footer);
|
|
2476
|
+
const taskCard = `
|
|
2477
|
+
${ts ? `interface TaskCardProps {
|
|
2478
|
+
task: {
|
|
2479
|
+
id: number;
|
|
2480
|
+
title: string;
|
|
2481
|
+
description: string;
|
|
2482
|
+
status: 'todo' | 'in_progress' | 'done';
|
|
2483
|
+
priority: 'low' | 'medium' | 'high';
|
|
2484
|
+
created_at: string;
|
|
2485
|
+
updated_at: string;
|
|
2486
|
+
};
|
|
2487
|
+
}
|
|
2488
|
+
` : ""}
|
|
2489
|
+
const statusConfig${ts ? ": Record<string, { label: string; color: string }>" : ""} = {
|
|
2490
|
+
todo: { label: 'To Do', color: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300' },
|
|
2491
|
+
in_progress: { label: 'In Progress', color: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300' },
|
|
2492
|
+
done: { label: 'Done', color: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300' },
|
|
2493
|
+
};
|
|
2494
|
+
|
|
2495
|
+
const priorityConfig${ts ? ": Record<string, { label: string; color: string; icon: string }>" : ""} = {
|
|
2496
|
+
high: { label: 'High', color: 'text-red-600 dark:text-red-400', icon: '!!!' },
|
|
2497
|
+
medium: { label: 'Medium', color: 'text-yellow-600 dark:text-yellow-400', icon: '!!' },
|
|
2498
|
+
low: { label: 'Low', color: 'text-gray-400 dark:text-gray-500', icon: '!' },
|
|
2499
|
+
};
|
|
2500
|
+
|
|
2501
|
+
export function TaskCard({ task }${ts ? ": TaskCardProps" : ""}) {
|
|
2502
|
+
const status = statusConfig[task.status] || statusConfig.todo;
|
|
2503
|
+
const priority = priorityConfig[task.priority] || priorityConfig.medium;
|
|
2504
|
+
|
|
2505
|
+
return (
|
|
2506
|
+
<a
|
|
2507
|
+
href={\`/tasks/\${task.id}\`}
|
|
2508
|
+
className="card hover:shadow-md hover:border-primary-300 dark:hover:border-primary-700 transition-all group block"
|
|
2509
|
+
>
|
|
2510
|
+
<div className="flex items-start justify-between gap-4">
|
|
2511
|
+
<div className="flex-1 min-w-0">
|
|
2512
|
+
<div className="flex items-center gap-2 mb-1">
|
|
2513
|
+
<span className={\`badge \${status.color}\`}>{status.label}</span>
|
|
2514
|
+
<span className={\`text-xs font-mono font-bold \${priority.color}\`}>
|
|
2515
|
+
{priority.icon}
|
|
2516
|
+
</span>
|
|
2517
|
+
</div>
|
|
2518
|
+
<h3 className="font-semibold text-gray-900 dark:text-white group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors truncate">
|
|
2519
|
+
{task.title}
|
|
2520
|
+
</h3>
|
|
2521
|
+
{task.description && (
|
|
2522
|
+
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1 line-clamp-2">
|
|
2523
|
+
{task.description}
|
|
2524
|
+
</p>
|
|
2525
|
+
)}
|
|
2526
|
+
</div>
|
|
2527
|
+
<svg className="w-5 h-5 text-gray-400 group-hover:text-primary-500 transition-colors flex-shrink-0 mt-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
2528
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
|
2529
|
+
</svg>
|
|
2530
|
+
</div>
|
|
2531
|
+
<div className="mt-3 flex items-center gap-4 text-xs text-gray-400 dark:text-gray-500">
|
|
2532
|
+
<span>Created {new Date(task.created_at).toLocaleDateString()}</span>
|
|
2533
|
+
{task.updated_at !== task.created_at && (
|
|
2534
|
+
<span>Updated {new Date(task.updated_at).toLocaleDateString()}</span>
|
|
2535
|
+
)}
|
|
2536
|
+
</div>
|
|
2537
|
+
</a>
|
|
2538
|
+
);
|
|
2539
|
+
}
|
|
2540
|
+
`.trim();
|
|
2541
|
+
await Bun.write(join(projectDir, `app/components/TaskCard.${ext}`), taskCard);
|
|
2542
|
+
const rootLayout = `
|
|
2543
|
+
import { Navigation } from '~/components/Navigation';
|
|
2544
|
+
import { Footer } from '~/components/Footer';
|
|
2545
|
+
import { useAuth } from '@ereo/auth';
|
|
2546
|
+
|
|
2547
|
+
${ts ? `interface RootLayoutProps {
|
|
2548
|
+
children: React.ReactNode;
|
|
2549
|
+
context: any;
|
|
2550
|
+
}
|
|
2551
|
+
` : ""}
|
|
2552
|
+
export async function loader({ context }${ts ? ": { context: any }" : ""}) {
|
|
2553
|
+
let user = null;
|
|
2554
|
+
try {
|
|
2555
|
+
const auth = useAuth(context);
|
|
2556
|
+
if (auth.isAuthenticated()) {
|
|
2557
|
+
user = auth.getUser();
|
|
2558
|
+
}
|
|
2559
|
+
} catch {
|
|
2560
|
+
// Not authenticated
|
|
2561
|
+
}
|
|
2562
|
+
return { user };
|
|
2563
|
+
}
|
|
2564
|
+
|
|
2565
|
+
export default function RootLayout({ children, loaderData }${ts ? ": RootLayoutProps & { loaderData: { user: any } }" : ""}) {
|
|
2566
|
+
return (
|
|
2567
|
+
<html lang="en">
|
|
2568
|
+
<head>
|
|
2569
|
+
<meta charSet="utf-8" />
|
|
2570
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
2571
|
+
<meta name="description" content="${projectName} \u2014 A task management app built with EreoJS" />
|
|
2572
|
+
<title>${projectName}</title>
|
|
2573
|
+
<link rel="stylesheet" href="/__tailwind.css" />
|
|
2574
|
+
</head>
|
|
2575
|
+
<body className="min-h-screen flex flex-col bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white">
|
|
2576
|
+
<Navigation user={loaderData?.user} />
|
|
2577
|
+
<main className="flex-1">
|
|
2578
|
+
{children}
|
|
2579
|
+
</main>
|
|
2580
|
+
<Footer />
|
|
2581
|
+
</body>
|
|
2582
|
+
</html>
|
|
2583
|
+
);
|
|
2584
|
+
}
|
|
2585
|
+
`.trim();
|
|
2586
|
+
await Bun.write(join(projectDir, `app/routes/_layout.${ext}`), rootLayout);
|
|
2587
|
+
const indexPage = `
|
|
2588
|
+
import { useAuth } from '@ereo/auth';
|
|
2589
|
+
|
|
2590
|
+
export async function loader({ context }${ts ? ": { context: any }" : ""}) {
|
|
2591
|
+
try {
|
|
2592
|
+
const auth = useAuth(context);
|
|
2593
|
+
if (auth.isAuthenticated()) {
|
|
2594
|
+
return new Response(null, {
|
|
2595
|
+
status: 302,
|
|
2596
|
+
headers: { Location: '/tasks' },
|
|
2597
|
+
});
|
|
2598
|
+
}
|
|
2599
|
+
} catch {
|
|
2600
|
+
// Not authenticated \u2014 show landing page
|
|
2601
|
+
}
|
|
2602
|
+
return {};
|
|
2603
|
+
}
|
|
2604
|
+
|
|
2605
|
+
export default function LandingPage() {
|
|
2606
|
+
return (
|
|
2607
|
+
<div>
|
|
2608
|
+
{/* Hero Section */}
|
|
2609
|
+
<section className="relative py-24 sm:py-32 px-4 overflow-hidden">
|
|
2610
|
+
<div className="absolute inset-0 bg-gradient-to-b from-primary-50/50 via-transparent to-transparent dark:from-primary-950/20" />
|
|
2611
|
+
<div className="relative max-w-4xl mx-auto text-center">
|
|
2612
|
+
<div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-primary-100 dark:bg-primary-900/40 text-primary-700 dark:text-primary-300 text-sm font-medium mb-6 opacity-0 animate-slide-up">
|
|
2613
|
+
Built with EreoJS + SQLite
|
|
2614
|
+
</div>
|
|
2615
|
+
|
|
2616
|
+
<h1 className="text-5xl sm:text-6xl font-extrabold tracking-tight mb-6 opacity-0 animate-slide-up delay-100">
|
|
2617
|
+
Organize your work,{' '}
|
|
2618
|
+
<span className="bg-clip-text text-transparent bg-gradient-to-r from-primary-500 to-purple-500">
|
|
2619
|
+
ship faster
|
|
2620
|
+
</span>
|
|
2621
|
+
</h1>
|
|
2622
|
+
|
|
2623
|
+
<p className="text-lg sm:text-xl text-gray-600 dark:text-gray-400 max-w-2xl mx-auto mb-10 opacity-0 animate-slide-up delay-200">
|
|
2624
|
+
A full-stack task manager showcasing authentication, SQLite database,
|
|
2625
|
+
and CRUD operations \u2014 all powered by EreoJS and Bun.
|
|
2626
|
+
</p>
|
|
2627
|
+
|
|
2628
|
+
<div className="flex flex-wrap gap-4 justify-center opacity-0 animate-slide-up delay-300">
|
|
2629
|
+
<a href="/register" className="btn btn-primary text-base px-6 py-3">
|
|
2630
|
+
Get Started Free
|
|
2631
|
+
</a>
|
|
2632
|
+
<a href="/login" className="btn btn-secondary text-base px-6 py-3">
|
|
2633
|
+
Sign In
|
|
2634
|
+
</a>
|
|
2635
|
+
</div>
|
|
2636
|
+
</div>
|
|
2637
|
+
</section>
|
|
2638
|
+
|
|
2639
|
+
{/* Features */}
|
|
2640
|
+
<section className="py-20 px-4">
|
|
2641
|
+
<div className="max-w-5xl mx-auto">
|
|
2642
|
+
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
2643
|
+
{[
|
|
2644
|
+
{
|
|
2645
|
+
title: 'Email & Password Auth',
|
|
2646
|
+
desc: 'Secure authentication with argon2id password hashing, JWT sessions, and protected routes.',
|
|
2647
|
+
icon: 'M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z',
|
|
2648
|
+
},
|
|
2649
|
+
{
|
|
2650
|
+
title: 'SQLite + WAL Mode',
|
|
2651
|
+
desc: 'Production-ready SQLite with Write-Ahead Logging, prepared statements, and automatic migrations.',
|
|
2652
|
+
icon: 'M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4',
|
|
2653
|
+
},
|
|
2654
|
+
{
|
|
2655
|
+
title: 'Full CRUD Operations',
|
|
2656
|
+
desc: 'Create, read, update, and delete tasks with server-side validation and error handling.',
|
|
2657
|
+
icon: 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2',
|
|
2658
|
+
},
|
|
2659
|
+
{
|
|
2660
|
+
title: 'Server-Side Rendering',
|
|
2661
|
+
desc: 'Fast initial loads with SSR. Data fetched via loaders, mutations via actions.',
|
|
2662
|
+
icon: 'M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2',
|
|
2663
|
+
},
|
|
2664
|
+
{
|
|
2665
|
+
title: 'File-Based Routing',
|
|
2666
|
+
desc: 'Routes map to files. Dynamic segments, layouts, route groups, and error boundaries.',
|
|
2667
|
+
icon: 'M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z',
|
|
2668
|
+
},
|
|
2669
|
+
{
|
|
2670
|
+
title: 'Production Ready',
|
|
2671
|
+
desc: 'Docker support, environment configuration, secure cookies, and proper error handling.',
|
|
2672
|
+
icon: 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z',
|
|
2673
|
+
},
|
|
2674
|
+
].map((feature) => (
|
|
2675
|
+
<div key={feature.title} className="card hover:shadow-md transition-shadow">
|
|
2676
|
+
<div className="w-10 h-10 rounded-lg flex items-center justify-center mb-3 bg-primary-100 dark:bg-primary-900/40">
|
|
2677
|
+
<svg className="w-5 h-5 text-primary-600 dark:text-primary-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
|
|
2678
|
+
<path strokeLinecap="round" strokeLinejoin="round" d={feature.icon} />
|
|
2679
|
+
</svg>
|
|
2680
|
+
</div>
|
|
2681
|
+
<h3 className="font-semibold mb-1">{feature.title}</h3>
|
|
2682
|
+
<p className="text-sm text-gray-600 dark:text-gray-400">{feature.desc}</p>
|
|
2683
|
+
</div>
|
|
2684
|
+
))}
|
|
2685
|
+
</div>
|
|
2686
|
+
</div>
|
|
2687
|
+
</section>
|
|
2688
|
+
|
|
2689
|
+
{/* Code Preview */}
|
|
2690
|
+
<section className="py-20 px-4 bg-white dark:bg-gray-800/50">
|
|
2691
|
+
<div className="max-w-5xl mx-auto">
|
|
2692
|
+
<div className="text-center mb-12">
|
|
2693
|
+
<h2 className="text-3xl font-bold mb-3">Simple, Powerful Patterns</h2>
|
|
2694
|
+
<p className="text-gray-600 dark:text-gray-400">Loaders fetch data. Actions handle mutations. It just works.</p>
|
|
2695
|
+
</div>
|
|
2696
|
+
<div className="grid md:grid-cols-2 gap-6">
|
|
2697
|
+
<div className="rounded-xl overflow-hidden" style={{ background: '#1e1e2e' }}>
|
|
2698
|
+
<div className="flex items-center gap-1.5 px-4 py-3" style={{ background: 'rgba(255,255,255,0.05)' }}>
|
|
2699
|
+
<span className="w-3 h-3 rounded-full bg-red-500" />
|
|
2700
|
+
<span className="w-3 h-3 rounded-full bg-yellow-500" />
|
|
2701
|
+
<span className="w-3 h-3 rounded-full bg-green-500" />
|
|
2702
|
+
<span className="ml-3 text-xs text-gray-500 font-mono">routes/tasks/index.tsx</span>
|
|
2703
|
+
</div>
|
|
2704
|
+
<pre className="px-5 py-4 font-mono text-sm text-primary-300 leading-relaxed overflow-x-auto">
|
|
2705
|
+
{\`// Server loader \u2014 fetches data
|
|
2706
|
+
export async function loader({ context }) {
|
|
2707
|
+
const auth = useAuth(context);
|
|
2708
|
+
const user = auth.getUser();
|
|
2709
|
+
const tasks = getTasksByUser(user.id);
|
|
2710
|
+
return { tasks };
|
|
2711
|
+
}
|
|
2712
|
+
|
|
2713
|
+
// Component renders with data
|
|
2714
|
+
export default function Tasks({ loaderData }) {
|
|
2715
|
+
return (
|
|
2716
|
+
<div>
|
|
2717
|
+
{loaderData.tasks.map(task => (
|
|
2718
|
+
<TaskCard key={task.id} task={task} />
|
|
2719
|
+
))}
|
|
2720
|
+
</div>
|
|
2721
|
+
);
|
|
2722
|
+
}\`}
|
|
2723
|
+
</pre>
|
|
2724
|
+
</div>
|
|
2725
|
+
<div className="rounded-xl overflow-hidden" style={{ background: '#1e1e2e' }}>
|
|
2726
|
+
<div className="flex items-center gap-1.5 px-4 py-3" style={{ background: 'rgba(255,255,255,0.05)' }}>
|
|
2727
|
+
<span className="w-3 h-3 rounded-full bg-red-500" />
|
|
2728
|
+
<span className="w-3 h-3 rounded-full bg-yellow-500" />
|
|
2729
|
+
<span className="w-3 h-3 rounded-full bg-green-500" />
|
|
2730
|
+
<span className="ml-3 text-xs text-gray-500 font-mono">routes/tasks/new.tsx</span>
|
|
2731
|
+
</div>
|
|
2732
|
+
<pre className="px-5 py-4 font-mono text-sm text-primary-300 leading-relaxed overflow-x-auto">
|
|
2733
|
+
{\`// Server action \u2014 handles mutations
|
|
2734
|
+
export async function action({ request, context }) {
|
|
2735
|
+
const auth = useAuth(context);
|
|
2736
|
+
const user = auth.getUser();
|
|
2737
|
+
const form = await request.formData();
|
|
2738
|
+
|
|
2739
|
+
const task = createTask(
|
|
2740
|
+
user.id,
|
|
2741
|
+
form.get('title'),
|
|
2742
|
+
form.get('description'),
|
|
2743
|
+
'todo',
|
|
2744
|
+
form.get('priority')
|
|
2745
|
+
);
|
|
2746
|
+
|
|
2747
|
+
return Response.redirect('/tasks');
|
|
2748
|
+
}\`}
|
|
2749
|
+
</pre>
|
|
2750
|
+
</div>
|
|
2751
|
+
</div>
|
|
2752
|
+
</div>
|
|
2753
|
+
</section>
|
|
2754
|
+
|
|
2755
|
+
{/* CTA */}
|
|
2756
|
+
<section className="py-24 px-4 text-center">
|
|
2757
|
+
<h2 className="text-3xl font-bold mb-4">Ready to get organized?</h2>
|
|
2758
|
+
<p className="text-gray-600 dark:text-gray-400 mb-8">Create your account and start managing tasks in seconds.</p>
|
|
2759
|
+
<a href="/register" className="btn btn-primary text-base px-8 py-3">
|
|
2760
|
+
Create Free Account
|
|
2761
|
+
</a>
|
|
2762
|
+
</section>
|
|
2763
|
+
</div>
|
|
2764
|
+
);
|
|
2765
|
+
}
|
|
2766
|
+
`.trim();
|
|
2767
|
+
await Bun.write(join(projectDir, `app/routes/index.${ext}`), indexPage);
|
|
2768
|
+
const loginPage = `
|
|
2769
|
+
import { useAuth } from '@ereo/auth';
|
|
2770
|
+
|
|
2771
|
+
export async function loader({ context }${ts ? ": { context: any }" : ""}) {
|
|
2772
|
+
try {
|
|
2773
|
+
const auth = useAuth(context);
|
|
2774
|
+
if (auth.isAuthenticated()) {
|
|
2775
|
+
return new Response(null, { status: 302, headers: { Location: '/tasks' } });
|
|
2776
|
+
}
|
|
2777
|
+
} catch {}
|
|
2778
|
+
return {};
|
|
2779
|
+
}
|
|
2780
|
+
|
|
2781
|
+
export async function action({ request, context }${ts ? ": { request: Request; context: any }" : ""}) {
|
|
2782
|
+
const formData = await request.formData();
|
|
2783
|
+
const email = formData.get('email')${ts ? " as string" : ""};
|
|
2784
|
+
const password = formData.get('password')${ts ? " as string" : ""};
|
|
2785
|
+
|
|
2786
|
+
const errors${ts ? ": Record<string, string>" : ""} = {};
|
|
2787
|
+
if (!email) errors.email = 'Email is required';
|
|
2788
|
+
if (!password) errors.password = 'Password is required';
|
|
2789
|
+
|
|
2790
|
+
if (Object.keys(errors).length > 0) {
|
|
2791
|
+
return { success: false, errors };
|
|
2792
|
+
}
|
|
2793
|
+
|
|
2794
|
+
try {
|
|
2795
|
+
const auth = useAuth(context);
|
|
2796
|
+
await auth.signIn('credentials', { email, password });
|
|
2797
|
+
|
|
2798
|
+
return new Response(null, {
|
|
2799
|
+
status: 302,
|
|
2800
|
+
headers: {
|
|
2801
|
+
Location: '/tasks',
|
|
2802
|
+
'Set-Cookie': auth.getCookieHeader() || '',
|
|
2803
|
+
},
|
|
2804
|
+
});
|
|
2805
|
+
} catch {
|
|
2806
|
+
return { success: false, errors: { form: 'Invalid email or password' } };
|
|
2807
|
+
}
|
|
2808
|
+
}
|
|
2809
|
+
|
|
2810
|
+
${ts ? `interface LoginPageProps {
|
|
2811
|
+
actionData?: {
|
|
2812
|
+
success: boolean;
|
|
2813
|
+
errors?: Record<string, string>;
|
|
2814
|
+
};
|
|
2815
|
+
}
|
|
2816
|
+
` : ""}
|
|
2817
|
+
export default function LoginPage({ actionData }${ts ? ": LoginPageProps" : ""}) {
|
|
2818
|
+
return (
|
|
2819
|
+
<div className="min-h-[80vh] flex items-center justify-center px-4">
|
|
2820
|
+
<div className="w-full max-w-md">
|
|
2821
|
+
<div className="text-center mb-8">
|
|
2822
|
+
<h1 className="text-3xl font-bold">Welcome back</h1>
|
|
2823
|
+
<p className="text-gray-600 dark:text-gray-400 mt-2">
|
|
2824
|
+
Sign in to manage your tasks
|
|
2825
|
+
</p>
|
|
2826
|
+
</div>
|
|
2827
|
+
|
|
2828
|
+
<div className="card">
|
|
2829
|
+
{actionData?.errors?.form && (
|
|
2830
|
+
<div className="mb-4 p-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800">
|
|
2831
|
+
<p className="text-sm text-red-700 dark:text-red-300">{actionData.errors.form}</p>
|
|
2832
|
+
</div>
|
|
2833
|
+
)}
|
|
2834
|
+
|
|
2835
|
+
<form method="POST" className="space-y-4">
|
|
2836
|
+
<div>
|
|
2837
|
+
<label htmlFor="email" className="label">Email</label>
|
|
2838
|
+
<input
|
|
2839
|
+
type="email"
|
|
2840
|
+
id="email"
|
|
2841
|
+
name="email"
|
|
2842
|
+
required
|
|
2843
|
+
autoComplete="email"
|
|
2844
|
+
className="input"
|
|
2845
|
+
placeholder="you@example.com"
|
|
2846
|
+
/>
|
|
2847
|
+
{actionData?.errors?.email && (
|
|
2848
|
+
<p className="mt-1 text-sm text-red-600">{actionData.errors.email}</p>
|
|
2849
|
+
)}
|
|
2850
|
+
</div>
|
|
2851
|
+
|
|
2852
|
+
<div>
|
|
2853
|
+
<label htmlFor="password" className="label">Password</label>
|
|
2854
|
+
<input
|
|
2855
|
+
type="password"
|
|
2856
|
+
id="password"
|
|
2857
|
+
name="password"
|
|
2858
|
+
required
|
|
2859
|
+
autoComplete="current-password"
|
|
2860
|
+
className="input"
|
|
2861
|
+
placeholder="Your password"
|
|
2862
|
+
/>
|
|
2863
|
+
{actionData?.errors?.password && (
|
|
2864
|
+
<p className="mt-1 text-sm text-red-600">{actionData.errors.password}</p>
|
|
2865
|
+
)}
|
|
2866
|
+
</div>
|
|
2867
|
+
|
|
2868
|
+
<button
|
|
2869
|
+
type="submit"
|
|
2870
|
+
className="btn btn-primary w-full py-2.5"
|
|
2871
|
+
>
|
|
2872
|
+
Sign in
|
|
2873
|
+
</button>
|
|
2874
|
+
</form>
|
|
2875
|
+
|
|
2876
|
+
<div className="mt-6 text-center text-sm text-gray-600 dark:text-gray-400">
|
|
2877
|
+
Don't have an account?{' '}
|
|
2878
|
+
<a href="/register" className="text-primary-600 hover:underline font-medium">
|
|
2879
|
+
Create one
|
|
2880
|
+
</a>
|
|
2881
|
+
</div>
|
|
2882
|
+
</div>
|
|
2883
|
+
</div>
|
|
2884
|
+
</div>
|
|
2885
|
+
);
|
|
2886
|
+
}
|
|
2887
|
+
`.trim();
|
|
2888
|
+
await Bun.write(join(projectDir, `app/routes/(auth)/login.${ext}`), loginPage);
|
|
2889
|
+
const registerPage = `
|
|
2890
|
+
import { useAuth } from '@ereo/auth';
|
|
2891
|
+
import { emailExists, createUser, hashPassword } from '~/lib/db';
|
|
2892
|
+
|
|
2893
|
+
export async function loader({ context }${ts ? ": { context: any }" : ""}) {
|
|
2894
|
+
try {
|
|
2895
|
+
const auth = useAuth(context);
|
|
2896
|
+
if (auth.isAuthenticated()) {
|
|
2897
|
+
return new Response(null, { status: 302, headers: { Location: '/tasks' } });
|
|
2898
|
+
}
|
|
2899
|
+
} catch {}
|
|
2900
|
+
return {};
|
|
2901
|
+
}
|
|
2902
|
+
|
|
2903
|
+
export async function action({ request, context }${ts ? ": { request: Request; context: any }" : ""}) {
|
|
2904
|
+
const formData = await request.formData();
|
|
2905
|
+
const name = (formData.get('name')${ts ? " as string" : ""} || '').trim();
|
|
2906
|
+
const email = (formData.get('email')${ts ? " as string" : ""} || '').trim().toLowerCase();
|
|
2907
|
+
const password = formData.get('password')${ts ? " as string" : ""} || '';
|
|
2908
|
+
const confirmPassword = formData.get('confirmPassword')${ts ? " as string" : ""} || '';
|
|
2909
|
+
|
|
2910
|
+
// Validate
|
|
2911
|
+
const errors${ts ? ": Record<string, string>" : ""} = {};
|
|
2912
|
+
if (!name || name.length < 2) errors.name = 'Name must be at least 2 characters';
|
|
2913
|
+
if (!email || !email.includes('@')) errors.email = 'Please enter a valid email';
|
|
2914
|
+
if (!password || password.length < 8) errors.password = 'Password must be at least 8 characters';
|
|
2915
|
+
if (password !== confirmPassword) errors.confirmPassword = 'Passwords do not match';
|
|
2916
|
+
|
|
2917
|
+
if (Object.keys(errors).length > 0) {
|
|
2918
|
+
return { success: false, errors };
|
|
2919
|
+
}
|
|
2920
|
+
|
|
2921
|
+
// Check if email already exists
|
|
2922
|
+
if (emailExists(email)) {
|
|
2923
|
+
return { success: false, errors: { email: 'An account with this email already exists' } };
|
|
2924
|
+
}
|
|
2925
|
+
|
|
2926
|
+
// Create user
|
|
2927
|
+
const passwordHash = await hashPassword(password);
|
|
2928
|
+
createUser(email, name, passwordHash);
|
|
2929
|
+
|
|
2930
|
+
// Sign in the new user
|
|
2931
|
+
try {
|
|
2932
|
+
const auth = useAuth(context);
|
|
2933
|
+
await auth.signIn('credentials', { email, password });
|
|
2934
|
+
|
|
2935
|
+
return new Response(null, {
|
|
2936
|
+
status: 302,
|
|
2937
|
+
headers: {
|
|
2938
|
+
Location: '/tasks',
|
|
2939
|
+
'Set-Cookie': auth.getCookieHeader() || '',
|
|
2940
|
+
},
|
|
2941
|
+
});
|
|
2942
|
+
} catch {
|
|
2943
|
+
// Account created but auto-login failed \u2014 redirect to login
|
|
2944
|
+
return new Response(null, { status: 302, headers: { Location: '/login' } });
|
|
2945
|
+
}
|
|
2946
|
+
}
|
|
2947
|
+
|
|
2948
|
+
${ts ? `interface RegisterPageProps {
|
|
2949
|
+
actionData?: {
|
|
2950
|
+
success: boolean;
|
|
2951
|
+
errors?: Record<string, string>;
|
|
2952
|
+
};
|
|
2953
|
+
}
|
|
2954
|
+
` : ""}
|
|
2955
|
+
export default function RegisterPage({ actionData }${ts ? ": RegisterPageProps" : ""}) {
|
|
2956
|
+
return (
|
|
2957
|
+
<div className="min-h-[80vh] flex items-center justify-center px-4">
|
|
2958
|
+
<div className="w-full max-w-md">
|
|
2959
|
+
<div className="text-center mb-8">
|
|
2960
|
+
<h1 className="text-3xl font-bold">Create your account</h1>
|
|
2961
|
+
<p className="text-gray-600 dark:text-gray-400 mt-2">
|
|
2962
|
+
Start organizing your tasks today
|
|
2963
|
+
</p>
|
|
2964
|
+
</div>
|
|
2965
|
+
|
|
2966
|
+
<div className="card">
|
|
2967
|
+
<form method="POST" className="space-y-4">
|
|
2968
|
+
<div>
|
|
2969
|
+
<label htmlFor="name" className="label">Name</label>
|
|
2970
|
+
<input
|
|
2971
|
+
type="text"
|
|
2972
|
+
id="name"
|
|
2973
|
+
name="name"
|
|
2974
|
+
required
|
|
2975
|
+
autoComplete="name"
|
|
2976
|
+
className="input"
|
|
2977
|
+
placeholder="Your name"
|
|
2978
|
+
/>
|
|
2979
|
+
{actionData?.errors?.name && (
|
|
2980
|
+
<p className="mt-1 text-sm text-red-600">{actionData.errors.name}</p>
|
|
2981
|
+
)}
|
|
2982
|
+
</div>
|
|
2983
|
+
|
|
2984
|
+
<div>
|
|
2985
|
+
<label htmlFor="email" className="label">Email</label>
|
|
2986
|
+
<input
|
|
2987
|
+
type="email"
|
|
2988
|
+
id="email"
|
|
2989
|
+
name="email"
|
|
2990
|
+
required
|
|
2991
|
+
autoComplete="email"
|
|
2992
|
+
className="input"
|
|
2993
|
+
placeholder="you@example.com"
|
|
2994
|
+
/>
|
|
2995
|
+
{actionData?.errors?.email && (
|
|
2996
|
+
<p className="mt-1 text-sm text-red-600">{actionData.errors.email}</p>
|
|
2997
|
+
)}
|
|
2998
|
+
</div>
|
|
2999
|
+
|
|
3000
|
+
<div>
|
|
3001
|
+
<label htmlFor="password" className="label">Password</label>
|
|
3002
|
+
<input
|
|
3003
|
+
type="password"
|
|
3004
|
+
id="password"
|
|
3005
|
+
name="password"
|
|
3006
|
+
required
|
|
3007
|
+
autoComplete="new-password"
|
|
3008
|
+
minLength={8}
|
|
3009
|
+
className="input"
|
|
3010
|
+
placeholder="At least 8 characters"
|
|
3011
|
+
/>
|
|
3012
|
+
{actionData?.errors?.password && (
|
|
3013
|
+
<p className="mt-1 text-sm text-red-600">{actionData.errors.password}</p>
|
|
3014
|
+
)}
|
|
3015
|
+
</div>
|
|
3016
|
+
|
|
3017
|
+
<div>
|
|
3018
|
+
<label htmlFor="confirmPassword" className="label">Confirm Password</label>
|
|
3019
|
+
<input
|
|
3020
|
+
type="password"
|
|
3021
|
+
id="confirmPassword"
|
|
3022
|
+
name="confirmPassword"
|
|
3023
|
+
required
|
|
3024
|
+
autoComplete="new-password"
|
|
3025
|
+
className="input"
|
|
3026
|
+
placeholder="Repeat your password"
|
|
3027
|
+
/>
|
|
3028
|
+
{actionData?.errors?.confirmPassword && (
|
|
3029
|
+
<p className="mt-1 text-sm text-red-600">{actionData.errors.confirmPassword}</p>
|
|
3030
|
+
)}
|
|
3031
|
+
</div>
|
|
3032
|
+
|
|
3033
|
+
<button
|
|
3034
|
+
type="submit"
|
|
3035
|
+
className="btn btn-primary w-full py-2.5"
|
|
3036
|
+
>
|
|
3037
|
+
Create account
|
|
3038
|
+
</button>
|
|
3039
|
+
</form>
|
|
3040
|
+
|
|
3041
|
+
<div className="mt-6 text-center text-sm text-gray-600 dark:text-gray-400">
|
|
3042
|
+
Already have an account?{' '}
|
|
3043
|
+
<a href="/login" className="text-primary-600 hover:underline font-medium">
|
|
3044
|
+
Sign in
|
|
3045
|
+
</a>
|
|
3046
|
+
</div>
|
|
3047
|
+
</div>
|
|
3048
|
+
</div>
|
|
3049
|
+
</div>
|
|
3050
|
+
);
|
|
3051
|
+
}
|
|
3052
|
+
`.trim();
|
|
3053
|
+
await Bun.write(join(projectDir, `app/routes/(auth)/register.${ext}`), registerPage);
|
|
3054
|
+
const logoutRoute = `
|
|
3055
|
+
import { useAuth } from '@ereo/auth';
|
|
3056
|
+
|
|
3057
|
+
export async function action({ context }${ts ? ": { context: any }" : ""}) {
|
|
3058
|
+
try {
|
|
3059
|
+
const auth = useAuth(context);
|
|
3060
|
+
await auth.signOut();
|
|
3061
|
+
} catch {
|
|
3062
|
+
// Already signed out
|
|
3063
|
+
}
|
|
3064
|
+
|
|
3065
|
+
return new Response(null, {
|
|
3066
|
+
status: 302,
|
|
3067
|
+
headers: { Location: '/' },
|
|
3068
|
+
});
|
|
3069
|
+
}
|
|
3070
|
+
|
|
3071
|
+
export default function LogoutPage() {
|
|
3072
|
+
return (
|
|
3073
|
+
<div className="min-h-[80vh] flex items-center justify-center">
|
|
3074
|
+
<p className="text-gray-500">Signing out...</p>
|
|
3075
|
+
</div>
|
|
3076
|
+
);
|
|
3077
|
+
}
|
|
3078
|
+
`.trim();
|
|
3079
|
+
await Bun.write(join(projectDir, `app/routes/logout.${ext}`), logoutRoute);
|
|
3080
|
+
const tasksIndex = `
|
|
3081
|
+
import { useAuth, requireAuth } from '@ereo/auth';
|
|
3082
|
+
import { getTasksByUser, getTaskStats } from '~/lib/db';
|
|
3083
|
+
import { TaskCard } from '~/components/TaskCard';
|
|
3084
|
+
|
|
3085
|
+
export const config = { ...requireAuth({ redirect: '/login' }) };
|
|
3086
|
+
|
|
3087
|
+
export async function loader({ request, context }${ts ? ": { request: Request; context: any }" : ""}) {
|
|
3088
|
+
const auth = useAuth(context);
|
|
3089
|
+
if (!auth.isAuthenticated()) {
|
|
3090
|
+
return new Response(null, { status: 302, headers: { Location: '/login' } });
|
|
3091
|
+
}
|
|
3092
|
+
const user = auth.getUser()${ts ? "!" : ""};
|
|
3093
|
+
const userId = Number(user.id);
|
|
3094
|
+
|
|
3095
|
+
// Read filter from URL search params
|
|
3096
|
+
const url = new URL(request.url);
|
|
3097
|
+
const status = url.searchParams.get('status') || 'all';
|
|
3098
|
+
|
|
3099
|
+
const tasks = getTasksByUser(userId, status);
|
|
3100
|
+
const stats = getTaskStats(userId);
|
|
3101
|
+
|
|
3102
|
+
return { tasks, stats, currentFilter: status };
|
|
3103
|
+
}
|
|
3104
|
+
|
|
3105
|
+
${ts ? `interface TasksPageProps {
|
|
3106
|
+
loaderData: {
|
|
3107
|
+
tasks: Array<{
|
|
3108
|
+
id: number;
|
|
3109
|
+
title: string;
|
|
3110
|
+
description: string;
|
|
3111
|
+
status: 'todo' | 'in_progress' | 'done';
|
|
3112
|
+
priority: 'low' | 'medium' | 'high';
|
|
3113
|
+
created_at: string;
|
|
3114
|
+
updated_at: string;
|
|
3115
|
+
}>;
|
|
3116
|
+
stats: { todo: number; in_progress: number; done: number; total: number };
|
|
3117
|
+
currentFilter: string;
|
|
3118
|
+
};
|
|
3119
|
+
}
|
|
3120
|
+
` : ""}
|
|
3121
|
+
export default function TasksPage({ loaderData }${ts ? ": TasksPageProps" : ""}) {
|
|
3122
|
+
const { tasks, stats, currentFilter } = loaderData;
|
|
3123
|
+
|
|
3124
|
+
const filters = [
|
|
3125
|
+
{ value: 'all', label: 'All', count: stats.total },
|
|
3126
|
+
{ value: 'todo', label: 'To Do', count: stats.todo },
|
|
3127
|
+
{ value: 'in_progress', label: 'In Progress', count: stats.in_progress },
|
|
3128
|
+
{ value: 'done', label: 'Done', count: stats.done },
|
|
3129
|
+
];
|
|
3130
|
+
|
|
3131
|
+
return (
|
|
3132
|
+
<div className="max-w-5xl mx-auto px-4 py-8">
|
|
3133
|
+
{/* Header */}
|
|
3134
|
+
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-8">
|
|
3135
|
+
<div>
|
|
3136
|
+
<h1 className="text-3xl font-bold">Tasks</h1>
|
|
3137
|
+
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
|
3138
|
+
{stats.total === 0
|
|
3139
|
+
? 'No tasks yet. Create your first one!'
|
|
3140
|
+
: \`\${stats.total} task\${stats.total === 1 ? '' : 's'} total, \${stats.done} completed\`}
|
|
3141
|
+
</p>
|
|
3142
|
+
</div>
|
|
3143
|
+
<a href="/tasks/new" className="btn btn-primary">
|
|
3144
|
+
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
3145
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
|
3146
|
+
</svg>
|
|
3147
|
+
New Task
|
|
3148
|
+
</a>
|
|
3149
|
+
</div>
|
|
3150
|
+
|
|
3151
|
+
{/* Stats Cards */}
|
|
3152
|
+
{stats.total > 0 && (
|
|
3153
|
+
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
|
3154
|
+
<div className="card py-4">
|
|
3155
|
+
<div className="text-sm text-gray-500 dark:text-gray-400">Total</div>
|
|
3156
|
+
<div className="text-3xl font-bold mt-1">{stats.total}</div>
|
|
3157
|
+
</div>
|
|
3158
|
+
<div className="card py-4">
|
|
3159
|
+
<div className="text-sm text-gray-500 dark:text-gray-400">To Do</div>
|
|
3160
|
+
<div className="text-3xl font-bold mt-1 text-gray-600 dark:text-gray-300">{stats.todo}</div>
|
|
3161
|
+
</div>
|
|
3162
|
+
<div className="card py-4">
|
|
3163
|
+
<div className="text-sm text-blue-500">In Progress</div>
|
|
3164
|
+
<div className="text-3xl font-bold mt-1 text-blue-600 dark:text-blue-400">{stats.in_progress}</div>
|
|
3165
|
+
</div>
|
|
3166
|
+
<div className="card py-4">
|
|
3167
|
+
<div className="text-sm text-green-500">Done</div>
|
|
3168
|
+
<div className="text-3xl font-bold mt-1 text-green-600 dark:text-green-400">{stats.done}</div>
|
|
3169
|
+
</div>
|
|
3170
|
+
</div>
|
|
3171
|
+
)}
|
|
3172
|
+
|
|
3173
|
+
{/* Filter Tabs */}
|
|
3174
|
+
{stats.total > 0 && (
|
|
3175
|
+
<div className="flex gap-2 mb-6 overflow-x-auto pb-2">
|
|
3176
|
+
{filters.map((filter) => (
|
|
3177
|
+
<a
|
|
3178
|
+
key={filter.value}
|
|
3179
|
+
href={\`/tasks\${filter.value === 'all' ? '' : \`?status=\${filter.value}\`}\`}
|
|
3180
|
+
className={\`btn btn-sm whitespace-nowrap \${
|
|
3181
|
+
currentFilter === filter.value
|
|
3182
|
+
? 'btn-primary'
|
|
3183
|
+
: 'btn-ghost'
|
|
3184
|
+
}\`}
|
|
3185
|
+
>
|
|
3186
|
+
{filter.label}
|
|
3187
|
+
<span className={\`ml-1.5 px-1.5 py-0.5 rounded-full text-xs \${
|
|
3188
|
+
currentFilter === filter.value
|
|
3189
|
+
? 'bg-white/20'
|
|
3190
|
+
: 'bg-gray-200 dark:bg-gray-700'
|
|
3191
|
+
}\`}>
|
|
3192
|
+
{filter.count}
|
|
3193
|
+
</span>
|
|
3194
|
+
</a>
|
|
3195
|
+
))}
|
|
3196
|
+
</div>
|
|
3197
|
+
)}
|
|
3198
|
+
|
|
3199
|
+
{/* Task List */}
|
|
3200
|
+
{tasks.length > 0 ? (
|
|
3201
|
+
<div className="space-y-3">
|
|
3202
|
+
{tasks.map((task) => (
|
|
3203
|
+
<TaskCard key={task.id} task={task} />
|
|
3204
|
+
))}
|
|
3205
|
+
</div>
|
|
3206
|
+
) : stats.total > 0 ? (
|
|
3207
|
+
<div className="card text-center py-12">
|
|
3208
|
+
<p className="text-gray-500 dark:text-gray-400 mb-4">No tasks match the current filter.</p>
|
|
3209
|
+
<a href="/tasks" className="btn btn-secondary btn-sm">Clear filter</a>
|
|
3210
|
+
</div>
|
|
3211
|
+
) : (
|
|
3212
|
+
<div className="card text-center py-16">
|
|
3213
|
+
<svg className="w-16 h-16 mx-auto text-gray-300 dark:text-gray-600 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
3214
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
|
|
3215
|
+
</svg>
|
|
3216
|
+
<h2 className="text-xl font-semibold mb-2">No tasks yet</h2>
|
|
3217
|
+
<p className="text-gray-500 dark:text-gray-400 mb-6">Create your first task to get started.</p>
|
|
3218
|
+
<a href="/tasks/new" className="btn btn-primary">
|
|
3219
|
+
Create your first task
|
|
3220
|
+
</a>
|
|
3221
|
+
</div>
|
|
3222
|
+
)}
|
|
3223
|
+
</div>
|
|
3224
|
+
);
|
|
3225
|
+
}
|
|
3226
|
+
`.trim();
|
|
3227
|
+
await Bun.write(join(projectDir, `app/routes/tasks/index.${ext}`), tasksIndex);
|
|
3228
|
+
const newTaskPage = `
|
|
3229
|
+
import { useAuth, requireAuth } from '@ereo/auth';
|
|
3230
|
+
import { createTask } from '~/lib/db';
|
|
3231
|
+
|
|
3232
|
+
export const config = { ...requireAuth({ redirect: '/login' }) };
|
|
3233
|
+
|
|
3234
|
+
export async function action({ request, context }${ts ? ": { request: Request; context: any }" : ""}) {
|
|
3235
|
+
const auth = useAuth(context);
|
|
3236
|
+
if (!auth.isAuthenticated()) {
|
|
3237
|
+
return new Response(null, { status: 302, headers: { Location: '/login' } });
|
|
3238
|
+
}
|
|
3239
|
+
const user = auth.getUser()${ts ? "!" : ""};
|
|
3240
|
+
const userId = Number(user.id);
|
|
3241
|
+
|
|
3242
|
+
const formData = await request.formData();
|
|
3243
|
+
const title = (formData.get('title')${ts ? " as string" : ""} || '').trim();
|
|
3244
|
+
const description = (formData.get('description')${ts ? " as string" : ""} || '').trim();
|
|
3245
|
+
const priority = formData.get('priority')${ts ? " as string" : ""} || 'medium';
|
|
3246
|
+
|
|
3247
|
+
// Validate
|
|
3248
|
+
const errors${ts ? ": Record<string, string>" : ""} = {};
|
|
3249
|
+
if (!title || title.length < 1) errors.title = 'Title is required';
|
|
3250
|
+
if (title.length > 200) errors.title = 'Title must be under 200 characters';
|
|
3251
|
+
if (!['low', 'medium', 'high'].includes(priority)) errors.priority = 'Invalid priority';
|
|
3252
|
+
|
|
3253
|
+
if (Object.keys(errors).length > 0) {
|
|
3254
|
+
return { success: false, errors };
|
|
3255
|
+
}
|
|
3256
|
+
|
|
3257
|
+
createTask(userId, title, description, 'todo', priority);
|
|
3258
|
+
|
|
3259
|
+
return new Response(null, {
|
|
3260
|
+
status: 302,
|
|
3261
|
+
headers: { Location: '/tasks' },
|
|
3262
|
+
});
|
|
3263
|
+
}
|
|
3264
|
+
|
|
3265
|
+
${ts ? `interface NewTaskPageProps {
|
|
3266
|
+
actionData?: {
|
|
3267
|
+
success: boolean;
|
|
3268
|
+
errors?: Record<string, string>;
|
|
3269
|
+
};
|
|
3270
|
+
}
|
|
3271
|
+
` : ""}
|
|
3272
|
+
export default function NewTaskPage({ actionData }${ts ? ": NewTaskPageProps" : ""}) {
|
|
3273
|
+
return (
|
|
3274
|
+
<div className="max-w-2xl mx-auto px-4 py-8">
|
|
3275
|
+
<div className="mb-8">
|
|
3276
|
+
<a href="/tasks" className="text-sm text-gray-500 hover:text-primary-600 transition-colors">
|
|
3277
|
+
← Back to tasks
|
|
3278
|
+
</a>
|
|
3279
|
+
<h1 className="text-3xl font-bold mt-2">New Task</h1>
|
|
3280
|
+
</div>
|
|
3281
|
+
|
|
3282
|
+
<div className="card">
|
|
3283
|
+
<form method="POST" className="space-y-5">
|
|
3284
|
+
<div>
|
|
3285
|
+
<label htmlFor="title" className="label">Title</label>
|
|
3286
|
+
<input
|
|
3287
|
+
type="text"
|
|
3288
|
+
id="title"
|
|
3289
|
+
name="title"
|
|
3290
|
+
required
|
|
3291
|
+
maxLength={200}
|
|
3292
|
+
className="input"
|
|
3293
|
+
placeholder="What needs to be done?"
|
|
3294
|
+
autoFocus
|
|
3295
|
+
/>
|
|
3296
|
+
{actionData?.errors?.title && (
|
|
3297
|
+
<p className="mt-1 text-sm text-red-600">{actionData.errors.title}</p>
|
|
3298
|
+
)}
|
|
3299
|
+
</div>
|
|
3300
|
+
|
|
3301
|
+
<div>
|
|
3302
|
+
<label htmlFor="description" className="label">
|
|
3303
|
+
Description <span className="text-gray-400 font-normal">(optional)</span>
|
|
3304
|
+
</label>
|
|
3305
|
+
<textarea
|
|
3306
|
+
id="description"
|
|
3307
|
+
name="description"
|
|
3308
|
+
rows={4}
|
|
3309
|
+
className="input"
|
|
3310
|
+
placeholder="Add more details about this task..."
|
|
3311
|
+
/>
|
|
3312
|
+
</div>
|
|
3313
|
+
|
|
3314
|
+
<div>
|
|
3315
|
+
<label htmlFor="priority" className="label">Priority</label>
|
|
3316
|
+
<select id="priority" name="priority" className="input" defaultValue="medium">
|
|
3317
|
+
<option value="low">Low</option>
|
|
3318
|
+
<option value="medium">Medium</option>
|
|
3319
|
+
<option value="high">High</option>
|
|
3320
|
+
</select>
|
|
3321
|
+
{actionData?.errors?.priority && (
|
|
3322
|
+
<p className="mt-1 text-sm text-red-600">{actionData.errors.priority}</p>
|
|
3323
|
+
)}
|
|
3324
|
+
</div>
|
|
3325
|
+
|
|
3326
|
+
<div className="flex gap-3 pt-2">
|
|
3327
|
+
<button
|
|
3328
|
+
type="submit"
|
|
3329
|
+
className="btn btn-primary"
|
|
3330
|
+
>
|
|
3331
|
+
Create Task
|
|
3332
|
+
</button>
|
|
3333
|
+
<a href="/tasks" className="btn btn-secondary">Cancel</a>
|
|
3334
|
+
</div>
|
|
3335
|
+
</form>
|
|
3336
|
+
</div>
|
|
3337
|
+
</div>
|
|
3338
|
+
);
|
|
3339
|
+
}
|
|
3340
|
+
`.trim();
|
|
3341
|
+
await Bun.write(join(projectDir, `app/routes/tasks/new.${ext}`), newTaskPage);
|
|
3342
|
+
const taskDetailPage = `
|
|
3343
|
+
import { useAuth, requireAuth } from '@ereo/auth';
|
|
3344
|
+
import { getTaskById, updateTask, deleteTask } from '~/lib/db';
|
|
3345
|
+
|
|
3346
|
+
export const config = { ...requireAuth({ redirect: '/login' }) };
|
|
3347
|
+
|
|
3348
|
+
export async function loader({ params, context }${ts ? ": { params: { id: string }; context: any }" : ""}) {
|
|
3349
|
+
const auth = useAuth(context);
|
|
3350
|
+
if (!auth.isAuthenticated()) {
|
|
3351
|
+
return new Response(null, { status: 302, headers: { Location: '/login' } });
|
|
3352
|
+
}
|
|
3353
|
+
const user = auth.getUser()${ts ? "!" : ""};
|
|
3354
|
+
const userId = Number(user.id);
|
|
3355
|
+
|
|
3356
|
+
const task = getTaskById(Number(params.id), userId);
|
|
3357
|
+
|
|
3358
|
+
if (!task) {
|
|
3359
|
+
throw new Response('Task not found', { status: 404 });
|
|
3360
|
+
}
|
|
3361
|
+
|
|
3362
|
+
return { task };
|
|
3363
|
+
}
|
|
3364
|
+
|
|
3365
|
+
export async function action({ request, params, context }${ts ? ": { request: Request; params: { id: string }; context: any }" : ""}) {
|
|
3366
|
+
const auth = useAuth(context);
|
|
3367
|
+
if (!auth.isAuthenticated()) {
|
|
3368
|
+
return new Response(null, { status: 302, headers: { Location: '/login' } });
|
|
3369
|
+
}
|
|
3370
|
+
const user = auth.getUser()${ts ? "!" : ""};
|
|
3371
|
+
const userId = Number(user.id);
|
|
3372
|
+
const taskId = Number(params.id);
|
|
3373
|
+
|
|
3374
|
+
const formData = await request.formData();
|
|
3375
|
+
const intent = formData.get('_intent')${ts ? " as string" : ""};
|
|
3376
|
+
|
|
3377
|
+
// Handle delete
|
|
3378
|
+
if (intent === 'delete') {
|
|
3379
|
+
deleteTask(taskId, userId);
|
|
3380
|
+
return new Response(null, {
|
|
3381
|
+
status: 302,
|
|
3382
|
+
headers: { Location: '/tasks' },
|
|
3383
|
+
});
|
|
3384
|
+
}
|
|
3385
|
+
|
|
3386
|
+
// Handle status quick-toggle
|
|
3387
|
+
if (intent === 'toggle-status') {
|
|
3388
|
+
const task = getTaskById(taskId, userId);
|
|
3389
|
+
if (!task) throw new Response('Task not found', { status: 404 });
|
|
3390
|
+
|
|
3391
|
+
const nextStatus${ts ? ": Record<string, string>" : ""} = {
|
|
3392
|
+
todo: 'in_progress',
|
|
3393
|
+
in_progress: 'done',
|
|
3394
|
+
done: 'todo',
|
|
3395
|
+
};
|
|
3396
|
+
|
|
3397
|
+
updateTask(taskId, userId, task.title, task.description, nextStatus[task.status] || 'todo', task.priority);
|
|
3398
|
+
return new Response(null, {
|
|
3399
|
+
status: 302,
|
|
3400
|
+
headers: { Location: \`/tasks/\${taskId}\` },
|
|
3401
|
+
});
|
|
3402
|
+
}
|
|
3403
|
+
|
|
3404
|
+
// Handle update
|
|
3405
|
+
const title = (formData.get('title')${ts ? " as string" : ""} || '').trim();
|
|
3406
|
+
const description = (formData.get('description')${ts ? " as string" : ""} || '').trim();
|
|
3407
|
+
const status = formData.get('status')${ts ? " as string" : ""} || 'todo';
|
|
3408
|
+
const priority = formData.get('priority')${ts ? " as string" : ""} || 'medium';
|
|
3409
|
+
|
|
3410
|
+
const errors${ts ? ": Record<string, string>" : ""} = {};
|
|
3411
|
+
if (!title || title.length < 1) errors.title = 'Title is required';
|
|
3412
|
+
if (title.length > 200) errors.title = 'Title must be under 200 characters';
|
|
3413
|
+
if (!['todo', 'in_progress', 'done'].includes(status)) errors.status = 'Invalid status';
|
|
3414
|
+
if (!['low', 'medium', 'high'].includes(priority)) errors.priority = 'Invalid priority';
|
|
3415
|
+
|
|
3416
|
+
if (Object.keys(errors).length > 0) {
|
|
3417
|
+
return { success: false, errors };
|
|
3418
|
+
}
|
|
3419
|
+
|
|
3420
|
+
const updated = updateTask(taskId, userId, title, description, status, priority);
|
|
3421
|
+
|
|
3422
|
+
if (!updated) {
|
|
3423
|
+
return { success: false, errors: { form: 'Task not found' } };
|
|
3424
|
+
}
|
|
3425
|
+
|
|
3426
|
+
return new Response(null, {
|
|
3427
|
+
status: 302,
|
|
3428
|
+
headers: { Location: '/tasks' },
|
|
3429
|
+
});
|
|
3430
|
+
}
|
|
3431
|
+
|
|
3432
|
+
${ts ? `interface TaskDetailProps {
|
|
3433
|
+
loaderData: {
|
|
3434
|
+
task: {
|
|
3435
|
+
id: number;
|
|
3436
|
+
title: string;
|
|
3437
|
+
description: string;
|
|
3438
|
+
status: 'todo' | 'in_progress' | 'done';
|
|
3439
|
+
priority: 'low' | 'medium' | 'high';
|
|
3440
|
+
created_at: string;
|
|
3441
|
+
updated_at: string;
|
|
3442
|
+
};
|
|
3443
|
+
};
|
|
3444
|
+
actionData?: {
|
|
3445
|
+
success: boolean;
|
|
3446
|
+
message?: string;
|
|
3447
|
+
errors?: Record<string, string>;
|
|
3448
|
+
};
|
|
3449
|
+
}
|
|
3450
|
+
` : ""}
|
|
3451
|
+
export default function TaskDetailPage({ loaderData, actionData }${ts ? ": TaskDetailProps" : ""}) {
|
|
3452
|
+
const { task } = loaderData;
|
|
3453
|
+
|
|
3454
|
+
const statusLabels${ts ? ": Record<string, string>" : ""} = {
|
|
3455
|
+
todo: 'To Do',
|
|
3456
|
+
in_progress: 'In Progress',
|
|
3457
|
+
done: 'Done',
|
|
3458
|
+
};
|
|
3459
|
+
|
|
3460
|
+
return (
|
|
3461
|
+
<div className="max-w-2xl mx-auto px-4 py-8">
|
|
3462
|
+
<div className="mb-8 flex items-center justify-between">
|
|
3463
|
+
<div>
|
|
3464
|
+
<a href="/tasks" className="text-sm text-gray-500 hover:text-primary-600 transition-colors">
|
|
3465
|
+
← Back to tasks
|
|
3466
|
+
</a>
|
|
3467
|
+
<h1 className="text-3xl font-bold mt-2">Edit Task</h1>
|
|
3468
|
+
</div>
|
|
3469
|
+
|
|
3470
|
+
{/* Quick status toggle */}
|
|
3471
|
+
<form method="POST">
|
|
3472
|
+
<input type="hidden" name="_intent" value="toggle-status" />
|
|
3473
|
+
<button type="submit" className="btn btn-secondary btn-sm">
|
|
3474
|
+
Move to {statusLabels[task.status === 'todo' ? 'in_progress' : task.status === 'in_progress' ? 'done' : 'todo']}
|
|
3475
|
+
</button>
|
|
3476
|
+
</form>
|
|
3477
|
+
</div>
|
|
3478
|
+
|
|
3479
|
+
{actionData?.errors?.form && (
|
|
3480
|
+
<div className="mb-6 p-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800">
|
|
3481
|
+
<p className="text-sm text-red-700 dark:text-red-300">{actionData.errors.form}</p>
|
|
3482
|
+
</div>
|
|
3483
|
+
)}
|
|
3484
|
+
|
|
3485
|
+
<div className="card">
|
|
3486
|
+
<form method="POST" className="space-y-5">
|
|
3487
|
+
<div>
|
|
3488
|
+
<label htmlFor="title" className="label">Title</label>
|
|
3489
|
+
<input
|
|
3490
|
+
type="text"
|
|
3491
|
+
id="title"
|
|
3492
|
+
name="title"
|
|
3493
|
+
required
|
|
3494
|
+
maxLength={200}
|
|
3495
|
+
className="input"
|
|
3496
|
+
defaultValue={task.title}
|
|
3497
|
+
/>
|
|
3498
|
+
{actionData?.errors?.title && (
|
|
3499
|
+
<p className="mt-1 text-sm text-red-600">{actionData.errors.title}</p>
|
|
3500
|
+
)}
|
|
3501
|
+
</div>
|
|
3502
|
+
|
|
3503
|
+
<div>
|
|
3504
|
+
<label htmlFor="description" className="label">
|
|
3505
|
+
Description <span className="text-gray-400 font-normal">(optional)</span>
|
|
3506
|
+
</label>
|
|
3507
|
+
<textarea
|
|
3508
|
+
id="description"
|
|
3509
|
+
name="description"
|
|
3510
|
+
rows={4}
|
|
3511
|
+
className="input"
|
|
3512
|
+
defaultValue={task.description}
|
|
3513
|
+
/>
|
|
3514
|
+
</div>
|
|
3515
|
+
|
|
3516
|
+
<div className="grid grid-cols-2 gap-4">
|
|
3517
|
+
<div>
|
|
3518
|
+
<label htmlFor="status" className="label">Status</label>
|
|
3519
|
+
<select id="status" name="status" className="input" defaultValue={task.status}>
|
|
3520
|
+
<option value="todo">To Do</option>
|
|
3521
|
+
<option value="in_progress">In Progress</option>
|
|
3522
|
+
<option value="done">Done</option>
|
|
3523
|
+
</select>
|
|
3524
|
+
</div>
|
|
3525
|
+
|
|
3526
|
+
<div>
|
|
3527
|
+
<label htmlFor="priority" className="label">Priority</label>
|
|
3528
|
+
<select id="priority" name="priority" className="input" defaultValue={task.priority}>
|
|
3529
|
+
<option value="low">Low</option>
|
|
3530
|
+
<option value="medium">Medium</option>
|
|
3531
|
+
<option value="high">High</option>
|
|
3532
|
+
</select>
|
|
3533
|
+
</div>
|
|
3534
|
+
</div>
|
|
3535
|
+
|
|
3536
|
+
<div className="text-xs text-gray-400 dark:text-gray-500">
|
|
3537
|
+
Created {new Date(task.created_at).toLocaleString()}
|
|
3538
|
+
{task.updated_at !== task.created_at && (
|
|
3539
|
+
<> • Updated {new Date(task.updated_at).toLocaleString()}</>
|
|
3540
|
+
)}
|
|
3541
|
+
</div>
|
|
3542
|
+
|
|
3543
|
+
<div className="flex items-center gap-3 pt-2">
|
|
3544
|
+
<button
|
|
3545
|
+
type="submit"
|
|
3546
|
+
className="btn btn-primary"
|
|
3547
|
+
>
|
|
3548
|
+
Save Changes
|
|
3549
|
+
</button>
|
|
3550
|
+
<a href="/tasks" className="btn btn-secondary">Cancel</a>
|
|
3551
|
+
</div>
|
|
3552
|
+
</form>
|
|
3553
|
+
</div>
|
|
3554
|
+
|
|
3555
|
+
{/* Delete section - pure form, no client JS needed */}
|
|
3556
|
+
<div className="mt-6 card border-red-200 dark:border-red-800">
|
|
3557
|
+
<h3 className="text-sm font-semibold text-red-600 dark:text-red-400 mb-2">Danger Zone</h3>
|
|
3558
|
+
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
|
3559
|
+
Permanently delete this task. This action cannot be undone.
|
|
3560
|
+
</p>
|
|
3561
|
+
<form method="POST">
|
|
3562
|
+
<input type="hidden" name="_intent" value="delete" />
|
|
3563
|
+
<button type="submit" className="btn btn-danger btn-sm">
|
|
3564
|
+
Delete Task
|
|
3565
|
+
</button>
|
|
3566
|
+
</form>
|
|
3567
|
+
</div>
|
|
3568
|
+
</div>
|
|
3569
|
+
);
|
|
3570
|
+
}
|
|
3571
|
+
|
|
3572
|
+
export function ErrorBoundary({ error }${ts ? ": { error: Error }" : ""}) {
|
|
3573
|
+
return (
|
|
3574
|
+
<div className="max-w-2xl mx-auto px-4 py-16 text-center">
|
|
3575
|
+
<h1 className="text-4xl font-bold mb-4">Task Not Found</h1>
|
|
3576
|
+
<p className="text-gray-600 dark:text-gray-400 mb-8">
|
|
3577
|
+
This task doesn't exist or you don't have access to it.
|
|
3578
|
+
</p>
|
|
3579
|
+
<a href="/tasks" className="btn btn-primary">
|
|
3580
|
+
Back to Tasks
|
|
3581
|
+
</a>
|
|
3582
|
+
</div>
|
|
3583
|
+
);
|
|
3584
|
+
}
|
|
3585
|
+
`.trim();
|
|
3586
|
+
await Bun.write(join(projectDir, `app/routes/tasks/[id].${ext}`), taskDetailPage);
|
|
3587
|
+
const errorPage = `
|
|
3588
|
+
${ts ? `interface ErrorPageProps {
|
|
3589
|
+
error: Error;
|
|
3590
|
+
}
|
|
3591
|
+
` : ""}
|
|
3592
|
+
export default function ErrorPage({ error }${ts ? ": ErrorPageProps" : ""}) {
|
|
3593
|
+
return (
|
|
3594
|
+
<div className="min-h-[80vh] flex items-center justify-center p-4">
|
|
3595
|
+
<div className="text-center">
|
|
3596
|
+
<h1 className="text-4xl font-bold mb-4">Something went wrong</h1>
|
|
3597
|
+
<p className="text-gray-600 dark:text-gray-400 mb-8 max-w-md">
|
|
3598
|
+
{error?.message || 'An unexpected error occurred. Please try again.'}
|
|
3599
|
+
</p>
|
|
3600
|
+
<a href="/" className="btn btn-primary">
|
|
3601
|
+
Go Home
|
|
3602
|
+
</a>
|
|
3603
|
+
</div>
|
|
3604
|
+
</div>
|
|
3605
|
+
);
|
|
3606
|
+
}
|
|
3607
|
+
`.trim();
|
|
3608
|
+
await Bun.write(join(projectDir, `app/routes/_error.${ext}`), errorPage);
|
|
3609
|
+
const notFoundPage = `
|
|
3610
|
+
export default function NotFoundPage() {
|
|
3611
|
+
return (
|
|
3612
|
+
<div className="min-h-[80vh] flex items-center justify-center p-4">
|
|
3613
|
+
<div className="text-center">
|
|
3614
|
+
<div className="text-8xl font-bold text-gray-200 dark:text-gray-700 mb-4">404</div>
|
|
3615
|
+
<h1 className="text-4xl font-bold mb-4">Page Not Found</h1>
|
|
3616
|
+
<p className="text-gray-600 dark:text-gray-400 mb-8">
|
|
3617
|
+
The page you're looking for doesn't exist.
|
|
3618
|
+
</p>
|
|
3619
|
+
<a href="/" className="btn btn-primary">
|
|
3620
|
+
Go Home
|
|
3621
|
+
</a>
|
|
3622
|
+
</div>
|
|
3623
|
+
</div>
|
|
3624
|
+
);
|
|
3625
|
+
}
|
|
3626
|
+
`.trim();
|
|
3627
|
+
await Bun.write(join(projectDir, `app/routes/_404.${ext}`), notFoundPage);
|
|
3628
|
+
await Bun.write(join(projectDir, ".gitignore"), `node_modules
|
|
3629
|
+
.ereo
|
|
3630
|
+
dist
|
|
3631
|
+
*.log
|
|
3632
|
+
.DS_Store
|
|
3633
|
+
.env
|
|
3634
|
+
.env.local
|
|
3635
|
+
.env.*.local
|
|
3636
|
+
data/*.db
|
|
3637
|
+
data/*.db-wal
|
|
3638
|
+
data/*.db-shm`);
|
|
3639
|
+
await Bun.write(join(projectDir, ".env.example"), `# Environment Variables
|
|
3640
|
+
# Copy this file to .env and update the values
|
|
3641
|
+
|
|
3642
|
+
# Node environment
|
|
3643
|
+
NODE_ENV=development
|
|
3644
|
+
|
|
3645
|
+
# Server port (optional, defaults to 3000)
|
|
3646
|
+
# PORT=3000
|
|
3647
|
+
|
|
3648
|
+
# Auth secret \u2014 REQUIRED for production!
|
|
3649
|
+
# Generate one with: openssl rand -base64 32
|
|
3650
|
+
AUTH_SECRET=change-me-in-production`);
|
|
3651
|
+
await Bun.write(join(projectDir, ".env"), `NODE_ENV=development
|
|
3652
|
+
AUTH_SECRET=dev-secret-not-for-production`);
|
|
3653
|
+
await Bun.write(join(projectDir, "data/.gitkeep"), "");
|
|
3654
|
+
const dockerfile = `# ---- Stage 1: Install all deps + build ----
|
|
3655
|
+
FROM oven/bun:1-slim AS builder
|
|
3656
|
+
WORKDIR /app
|
|
3657
|
+
COPY package.json bun.lockb* ./
|
|
3658
|
+
RUN bun install --ignore-scripts
|
|
3659
|
+
COPY . .
|
|
3660
|
+
RUN bun run build
|
|
3661
|
+
|
|
3662
|
+
# ---- Stage 2: Production dependencies only ----
|
|
3663
|
+
FROM oven/bun:1-slim AS deps
|
|
3664
|
+
WORKDIR /app
|
|
3665
|
+
COPY package.json bun.lockb* ./
|
|
3666
|
+
RUN bun install --production --ignore-scripts
|
|
3667
|
+
|
|
3668
|
+
# ---- Stage 3: Production image ----
|
|
3669
|
+
FROM oven/bun:1-alpine AS runner
|
|
3670
|
+
WORKDIR /app
|
|
3671
|
+
|
|
3672
|
+
ENV NODE_ENV=production
|
|
3673
|
+
|
|
3674
|
+
# Non-root user for security
|
|
3675
|
+
RUN addgroup -S -g 1001 ereo && \\
|
|
3676
|
+
adduser -S -u 1001 -G ereo -H ereo
|
|
3677
|
+
|
|
3678
|
+
# Copy production node_modules
|
|
3679
|
+
COPY --from=deps --chown=ereo:ereo /app/node_modules ./node_modules
|
|
3680
|
+
|
|
3681
|
+
# Copy build output and runtime files
|
|
3682
|
+
COPY --from=builder --chown=ereo:ereo /app/.ereo ./.ereo
|
|
3683
|
+
COPY --from=builder --chown=ereo:ereo /app/app ./app
|
|
3684
|
+
COPY --from=builder --chown=ereo:ereo /app/public ./public
|
|
3685
|
+
COPY --from=builder --chown=ereo:ereo /app/package.json ./
|
|
3686
|
+
COPY --from=builder --chown=ereo:ereo /app/ereo.config.* ./
|
|
3687
|
+
COPY --from=builder --chown=ereo:ereo /app/tsconfig.* ./
|
|
3688
|
+
|
|
3689
|
+
# Create data directory for SQLite with proper permissions
|
|
3690
|
+
RUN mkdir -p /app/data && chown -R ereo:ereo /app/data
|
|
3691
|
+
|
|
3692
|
+
USER ereo
|
|
3693
|
+
|
|
3694
|
+
# Mount a volume for persistent SQLite data
|
|
3695
|
+
VOLUME ["/app/data"]
|
|
3696
|
+
|
|
3697
|
+
EXPOSE 3000
|
|
3698
|
+
|
|
3699
|
+
CMD ["bun", "run", "start"]
|
|
3700
|
+
`;
|
|
3701
|
+
await Bun.write(join(projectDir, "Dockerfile"), dockerfile);
|
|
3702
|
+
await Bun.write(join(projectDir, ".dockerignore"), generateDockerignore());
|
|
3703
|
+
const readme = `# ${projectName}
|
|
3704
|
+
|
|
3705
|
+
A full-stack task management app built with [EreoJS](https://github.com/ereoJS/ereoJS) \u2014 a React fullstack framework powered by Bun.
|
|
3706
|
+
|
|
3707
|
+
## Features
|
|
3708
|
+
|
|
3709
|
+
- **Email & Password Authentication** \u2014 Secure sign up/sign in with argon2id hashing and JWT sessions
|
|
3710
|
+
- **SQLite Database** \u2014 Production-ready with WAL mode, foreign keys, and automatic migrations
|
|
3711
|
+
- **Full CRUD** \u2014 Create, read, update, and delete tasks with server-side validation
|
|
3712
|
+
- **Server-Side Rendering** \u2014 Fast initial loads with data fetched via loaders
|
|
3713
|
+
- **File-Based Routing** \u2014 Routes map to files with layouts, dynamic segments, and error boundaries
|
|
3714
|
+
- **Protected Routes** \u2014 Auth middleware redirects unauthenticated users to login
|
|
3715
|
+
- **Tailwind CSS** \u2014 Utility-first styling with dark mode support
|
|
3716
|
+
- **Docker Ready** \u2014 Multi-stage Dockerfile with SQLite volume persistence${trace ? `
|
|
3717
|
+
- **Full-Stack Tracing** \u2014 Request-level observability with \`@ereo/trace\`` : ""}
|
|
3718
|
+
|
|
3719
|
+
## Getting Started
|
|
3720
|
+
|
|
3721
|
+
\`\`\`bash
|
|
3722
|
+
# Install dependencies
|
|
3723
|
+
bun install
|
|
3724
|
+
|
|
3725
|
+
# Start development server${trace ? " (tracing enabled)" : ""}
|
|
3726
|
+
bun run dev
|
|
3727
|
+
|
|
3728
|
+
# Open http://localhost:3000
|
|
3729
|
+
\`\`\`
|
|
3730
|
+
|
|
3731
|
+
Create an account at http://localhost:3000/register and start managing tasks!
|
|
3732
|
+
|
|
3733
|
+
## Project Structure
|
|
3734
|
+
|
|
3735
|
+
\`\`\`
|
|
3736
|
+
app/
|
|
3737
|
+
\u251C\u2500\u2500 components/
|
|
3738
|
+
\u2502 \u251C\u2500\u2500 Navigation.tsx # Auth-aware navigation
|
|
3739
|
+
\u2502 \u251C\u2500\u2500 Footer.tsx
|
|
3740
|
+
\u2502 \u2514\u2500\u2500 TaskCard.tsx # Task list item
|
|
3741
|
+
\u251C\u2500\u2500 lib/
|
|
3742
|
+
\u2502 \u251C\u2500\u2500 db.ts # SQLite database, schema & queries${ts ? `
|
|
3743
|
+
\u2502 \u2514\u2500\u2500 types.ts # Shared TypeScript types` : ""}
|
|
3744
|
+
\u251C\u2500\u2500 routes/
|
|
3745
|
+
\u2502 \u251C\u2500\u2500 _layout.tsx # Root layout with auth context
|
|
3746
|
+
\u2502 \u251C\u2500\u2500 _error.tsx # Error boundary
|
|
3747
|
+
\u2502 \u251C\u2500\u2500 _404.tsx # Not found page
|
|
3748
|
+
\u2502 \u251C\u2500\u2500 index.tsx # Landing page
|
|
3749
|
+
\u2502 \u251C\u2500\u2500 logout.tsx # Logout action
|
|
3750
|
+
\u2502 \u251C\u2500\u2500 (auth)/
|
|
3751
|
+
\u2502 \u2502 \u251C\u2500\u2500 login.tsx # Sign in
|
|
3752
|
+
\u2502 \u2502 \u2514\u2500\u2500 register.tsx # Sign up
|
|
3753
|
+
\u2502 \u2514\u2500\u2500 tasks/
|
|
3754
|
+
\u2502 \u251C\u2500\u2500 index.tsx # Task list with filters
|
|
3755
|
+
\u2502 \u251C\u2500\u2500 new.tsx # Create task form
|
|
3756
|
+
\u2502 \u2514\u2500\u2500 [id].tsx # View/edit/delete task
|
|
3757
|
+
\u2514\u2500\u2500 styles.css # Tailwind directives
|
|
3758
|
+
data/
|
|
3759
|
+
\u2514\u2500\u2500 app.db # SQLite database (auto-created)
|
|
3760
|
+
\`\`\`
|
|
3761
|
+
|
|
3762
|
+
## Scripts
|
|
3763
|
+
|
|
3764
|
+
- \`bun run dev\` \u2014 Start development server
|
|
3765
|
+
- \`bun run build\` \u2014 Build for production
|
|
3766
|
+
- \`bun run start\` \u2014 Start production server
|
|
3767
|
+
- \`bun test\` \u2014 Run tests
|
|
3768
|
+
- \`bun run typecheck\` \u2014 TypeScript type checking
|
|
3769
|
+
|
|
3770
|
+
## Environment Variables
|
|
3771
|
+
|
|
3772
|
+
| Variable | Required | Default | Description |
|
|
3773
|
+
|----------|----------|---------|-------------|
|
|
3774
|
+
| \`AUTH_SECRET\` | **Yes** (production) | dev secret | JWT signing secret |
|
|
3775
|
+
| \`NODE_ENV\` | No | development | Environment |
|
|
3776
|
+
| \`PORT\` | No | 3000 | Server port |
|
|
3777
|
+
|
|
3778
|
+
Generate a production secret:
|
|
3779
|
+
|
|
3780
|
+
\`\`\`bash
|
|
3781
|
+
openssl rand -base64 32
|
|
3782
|
+
\`\`\`
|
|
3783
|
+
|
|
3784
|
+
## Docker
|
|
3785
|
+
|
|
3786
|
+
\`\`\`bash
|
|
3787
|
+
# Build image
|
|
3788
|
+
docker build -t ${projectName} .
|
|
3789
|
+
|
|
3790
|
+
# Run with persistent SQLite data
|
|
3791
|
+
docker run -p 3000:3000 -v ${projectName}-data:/app/data -e AUTH_SECRET=your-secret ${projectName}
|
|
3792
|
+
\`\`\`
|
|
3793
|
+
|
|
3794
|
+
${trace ? `## Tracing
|
|
3795
|
+
|
|
3796
|
+
This project includes \`@ereo/trace\` for full-stack observability.
|
|
3797
|
+
|
|
3798
|
+
- **CLI Reporter** \u2014 Live tree view in your terminal
|
|
3799
|
+
- **Trace Viewer** \u2014 http://localhost:3000/__ereo/traces
|
|
3800
|
+
- **Production** \u2014 Alias \`@ereo/trace\` to \`@ereo/trace/noop\` (592 bytes, zero cost)
|
|
3801
|
+
|
|
3802
|
+
` : ""}## Learn More
|
|
3803
|
+
|
|
3804
|
+
- [EreoJS Documentation](https://ereojs.github.io/ereoJS/)
|
|
1914
3805
|
- [Bun Documentation](https://bun.sh/docs)
|
|
1915
3806
|
- [Tailwind CSS](https://tailwindcss.com/docs)
|
|
1916
3807
|
`;
|
|
@@ -1920,6 +3811,8 @@ async function generateProject(projectDir, projectName, options) {
|
|
|
1920
3811
|
const { template, typescript, trace } = options;
|
|
1921
3812
|
if (template === "minimal") {
|
|
1922
3813
|
await generateMinimalProject(projectDir, projectName, typescript, trace);
|
|
3814
|
+
} else if (template === "tasks") {
|
|
3815
|
+
await generateTasksProject(projectDir, projectName, typescript, trace);
|
|
1923
3816
|
} else {
|
|
1924
3817
|
await generateTailwindProject(projectDir, projectName, typescript, trace);
|
|
1925
3818
|
}
|