bosia 0.1.6 → 0.1.9
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/package.json +1 -1
- package/src/cli/add.ts +29 -75
- package/src/cli/create.ts +32 -5
- package/src/cli/feat.ts +55 -93
- package/src/cli/registry.ts +168 -0
- package/src/core/cookies.ts +38 -15
- package/src/core/hooks.ts +2 -0
- package/src/core/html.ts +19 -8
- package/src/core/renderer.ts +2 -2
- package/src/core/server.ts +11 -3
- package/templates/drizzle/package.json +3 -9
- package/templates/drizzle/template.json +3 -0
- package/templates/drizzle/drizzle.config.ts +0 -10
- package/templates/drizzle/src/features/drizzle/index.ts +0 -15
- package/templates/drizzle/src/features/drizzle/migrations/.gitkeep +0 -0
- package/templates/drizzle/src/features/drizzle/schemas.ts +0 -1
- package/templates/drizzle/src/features/drizzle/seeds/001_initial_todos.ts +0 -11
- package/templates/drizzle/src/features/drizzle/seeds/runner.ts +0 -80
- package/templates/drizzle/src/features/todo/index.ts +0 -3
- package/templates/drizzle/src/features/todo/queries.ts +0 -36
- package/templates/drizzle/src/features/todo/schemas/todo.table.ts +0 -9
- package/templates/drizzle/src/features/todo/types.ts +0 -5
- package/templates/drizzle/src/lib/components/todo/index.ts +0 -3
- package/templates/drizzle/src/lib/components/todo/todo-form.svelte +0 -23
- package/templates/drizzle/src/lib/components/todo/todo-item.svelte +0 -63
- package/templates/drizzle/src/lib/components/todo/todo-list.svelte +0 -21
- package/templates/drizzle/src/routes/api/todos/+server.ts +0 -18
- package/templates/drizzle/src/routes/api/todos/[id]/+server.ts +0 -42
- package/templates/drizzle/src/routes/todos/+page.server.ts +0 -52
- package/templates/drizzle/src/routes/todos/+page.svelte +0 -39
package/src/core/cookies.ts
CHANGED
|
@@ -11,6 +11,15 @@ const VALID_SAMESITE = new Set(["Strict", "Lax", "None"]);
|
|
|
11
11
|
*/
|
|
12
12
|
const VALID_COOKIE_NAME = /^[!#$%&'*+\-.0-9A-Z^_`a-z|~]+$/;
|
|
13
13
|
|
|
14
|
+
// ─── Cookie Defaults ─────────────────────────────────────
|
|
15
|
+
/** Secure defaults matching SvelteKit conventions. */
|
|
16
|
+
const COOKIE_DEFAULTS: CookieOptions = {
|
|
17
|
+
path: "/",
|
|
18
|
+
httpOnly: true,
|
|
19
|
+
secure: true,
|
|
20
|
+
sameSite: "Lax",
|
|
21
|
+
};
|
|
22
|
+
|
|
14
23
|
// ─── Cookie Helpers ──────────────────────────────────────
|
|
15
24
|
|
|
16
25
|
function parseCookies(header: string): Record<string, string> {
|
|
@@ -31,42 +40,56 @@ function parseCookies(header: string): Record<string, string> {
|
|
|
31
40
|
export class CookieJar implements Cookies {
|
|
32
41
|
private _incoming: Record<string, string>;
|
|
33
42
|
private _outgoing: string[] = [];
|
|
43
|
+
private _defaults: CookieOptions;
|
|
44
|
+
private _accessed = false;
|
|
34
45
|
|
|
35
|
-
constructor(cookieHeader: string) {
|
|
46
|
+
constructor(cookieHeader: string, dev = false) {
|
|
36
47
|
this._incoming = parseCookies(cookieHeader);
|
|
48
|
+
// In dev mode, omit Secure — browsers reject Secure cookies over http://localhost
|
|
49
|
+
this._defaults = dev
|
|
50
|
+
? { ...COOKIE_DEFAULTS, secure: false }
|
|
51
|
+
: COOKIE_DEFAULTS;
|
|
37
52
|
}
|
|
38
53
|
|
|
39
54
|
get(name: string): string | undefined {
|
|
55
|
+
this._accessed = true;
|
|
40
56
|
return this._incoming[name];
|
|
41
57
|
}
|
|
42
58
|
|
|
43
59
|
getAll(): Record<string, string> {
|
|
60
|
+
this._accessed = true;
|
|
44
61
|
return { ...this._incoming };
|
|
45
62
|
}
|
|
46
63
|
|
|
64
|
+
get accessed(): boolean {
|
|
65
|
+
return this._accessed;
|
|
66
|
+
}
|
|
67
|
+
|
|
47
68
|
set(name: string, value: string, options?: CookieOptions): void {
|
|
48
69
|
if (!VALID_COOKIE_NAME.test(name)) throw new Error(`Invalid cookie name: ${name}`);
|
|
70
|
+
const opts = { ...this._defaults, ...options };
|
|
49
71
|
let header = `${name}=${encodeURIComponent(value)}`;
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
72
|
+
if (opts.path) {
|
|
73
|
+
if (UNSAFE_COOKIE_VALUE.test(opts.path)) throw new Error(`Invalid cookie path: ${opts.path}`);
|
|
74
|
+
header += `; Path=${opts.path}`;
|
|
75
|
+
}
|
|
76
|
+
if (opts.domain) {
|
|
77
|
+
if (UNSAFE_COOKIE_VALUE.test(opts.domain)) throw new Error(`Invalid cookie domain: ${opts.domain}`);
|
|
78
|
+
header += `; Domain=${opts.domain}`;
|
|
56
79
|
}
|
|
57
|
-
if (
|
|
58
|
-
if (
|
|
59
|
-
if (
|
|
60
|
-
if (
|
|
61
|
-
if (
|
|
62
|
-
if (!VALID_SAMESITE.has(
|
|
63
|
-
header += `; SameSite=${
|
|
80
|
+
if (opts.maxAge != null) header += `; Max-Age=${opts.maxAge}`;
|
|
81
|
+
if (opts.expires) header += `; Expires=${opts.expires.toUTCString()}`;
|
|
82
|
+
if (opts.httpOnly) header += "; HttpOnly";
|
|
83
|
+
if (opts.secure) header += "; Secure";
|
|
84
|
+
if (opts.sameSite) {
|
|
85
|
+
if (!VALID_SAMESITE.has(opts.sameSite)) throw new Error(`Invalid cookie sameSite: ${opts.sameSite}`);
|
|
86
|
+
header += `; SameSite=${opts.sameSite}`;
|
|
64
87
|
}
|
|
65
88
|
this._outgoing.push(header);
|
|
66
89
|
}
|
|
67
90
|
|
|
68
91
|
delete(name: string, options?: Pick<CookieOptions, "path" | "domain">): void {
|
|
69
|
-
this.set(name, "", {
|
|
92
|
+
this.set(name, "", { ...options, maxAge: 0 });
|
|
70
93
|
}
|
|
71
94
|
|
|
72
95
|
get outgoing(): readonly string[] {
|
package/src/core/hooks.ts
CHANGED
|
@@ -70,6 +70,8 @@ export type Metadata = {
|
|
|
70
70
|
title?: string;
|
|
71
71
|
description?: string;
|
|
72
72
|
meta?: Array<{ name?: string; property?: string; content: string }>;
|
|
73
|
+
lang?: string;
|
|
74
|
+
link?: Array<{ rel: string; href: string; hreflang?: string }>;
|
|
73
75
|
data?: Record<string, any>;
|
|
74
76
|
};
|
|
75
77
|
|
package/src/core/html.ts
CHANGED
|
@@ -64,6 +64,7 @@ export function buildHtml(
|
|
|
64
64
|
layoutData: any[],
|
|
65
65
|
csr = true,
|
|
66
66
|
formData: any = null,
|
|
67
|
+
lang?: string,
|
|
67
68
|
): string {
|
|
68
69
|
const cssLinks = (distManifest.css ?? [])
|
|
69
70
|
.map((f: string) => `<link rel="stylesheet" href="/dist/client/${f}">`)
|
|
@@ -87,7 +88,7 @@ export function buildHtml(
|
|
|
87
88
|
: "";
|
|
88
89
|
|
|
89
90
|
return `<!DOCTYPE html>
|
|
90
|
-
<html lang="en">
|
|
91
|
+
<html lang="${lang || "en"}">
|
|
91
92
|
<head>
|
|
92
93
|
<meta charset="UTF-8">
|
|
93
94
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
@@ -108,15 +109,17 @@ export function buildHtml(
|
|
|
108
109
|
|
|
109
110
|
import type { Metadata } from "./hooks.ts";
|
|
110
111
|
|
|
111
|
-
|
|
112
|
+
const _shellOpenCache = new Map<string, string>();
|
|
112
113
|
|
|
113
114
|
/** Chunk 1: everything from <!DOCTYPE> through CSS/modulepreload links (head still open) */
|
|
114
|
-
export function buildHtmlShellOpen(): string {
|
|
115
|
-
|
|
115
|
+
export function buildHtmlShellOpen(lang?: string): string {
|
|
116
|
+
const key = lang || "en";
|
|
117
|
+
const cached = _shellOpenCache.get(key);
|
|
118
|
+
if (cached) return cached;
|
|
116
119
|
const cssLinks = (distManifest.css ?? [])
|
|
117
120
|
.map((f: string) => `<link rel="stylesheet" href="/dist/client/${f}">`)
|
|
118
121
|
.join("\n ");
|
|
119
|
-
|
|
122
|
+
const result = `<!DOCTYPE html>\n<html lang="${key}">\n<head>\n` +
|
|
120
123
|
` <meta charset="UTF-8">\n` +
|
|
121
124
|
` <meta name="viewport" content="width=device-width, initial-scale=1.0">\n` +
|
|
122
125
|
` <link rel="icon" type="image/svg+xml" href="/favicon.svg">\n` +
|
|
@@ -124,7 +127,8 @@ export function buildHtmlShellOpen(): string {
|
|
|
124
127
|
` <link rel="stylesheet" href="/bosia-tw.css${cacheBust}">\n` +
|
|
125
128
|
` <script>try{var t=localStorage.getItem('theme');if(t==='dark'||(!t&&window.matchMedia('(prefers-color-scheme: dark)').matches))document.documentElement.classList.add('dark');else document.documentElement.classList.remove('dark')}catch(_){}</script>\n` +
|
|
126
129
|
` <link rel="modulepreload" href="/dist/client/${distManifest.entry}${cacheBust}">`;
|
|
127
|
-
|
|
130
|
+
_shellOpenCache.set(key, result);
|
|
131
|
+
return result;
|
|
128
132
|
}
|
|
129
133
|
|
|
130
134
|
const SPINNER = `<div id="__bs__"><style>` +
|
|
@@ -148,6 +152,13 @@ export function buildMetadataChunk(metadata: Metadata | null): string {
|
|
|
148
152
|
out += ` <meta ${attrs} content="${escapeAttr(m.content)}">\n`;
|
|
149
153
|
}
|
|
150
154
|
}
|
|
155
|
+
if (metadata.link) {
|
|
156
|
+
for (const l of metadata.link) {
|
|
157
|
+
let attrs = `rel="${escapeAttr(l.rel)}" href="${escapeAttr(l.href)}"`;
|
|
158
|
+
if (l.hreflang) attrs += ` hreflang="${escapeAttr(l.hreflang)}"`;
|
|
159
|
+
out += ` <link ${attrs}>\n`;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
151
162
|
} else {
|
|
152
163
|
out += ` <title>Bosia App</title>\n`;
|
|
153
164
|
}
|
|
@@ -192,8 +203,8 @@ export function buildHtmlTail(
|
|
|
192
203
|
|
|
193
204
|
// ─── Gzip Compression ────────────────────────────────────
|
|
194
205
|
|
|
195
|
-
export function compress(body: string, contentType: string, req: Request, status = 200): Response {
|
|
196
|
-
const headers: Record<string, string> = { "Content-Type": contentType, "Vary": "Accept-Encoding" };
|
|
206
|
+
export function compress(body: string, contentType: string, req: Request, status = 200, extraHeaders?: Record<string, string>): Response {
|
|
207
|
+
const headers: Record<string, string> = { "Content-Type": contentType, "Vary": "Accept-Encoding", ...extraHeaders };
|
|
197
208
|
const accept = req.headers.get("accept-encoding") ?? "";
|
|
198
209
|
// Skip compression in dev — the dev proxy's fetch() auto-decompresses gzip
|
|
199
210
|
// responses but keeps the Content-Encoding header, causing ERR_CONTENT_DECODING_FAILED.
|
package/src/core/renderer.ts
CHANGED
|
@@ -203,8 +203,8 @@ export async function renderSSRStream(
|
|
|
203
203
|
|
|
204
204
|
const stream = new ReadableStream<Uint8Array>({
|
|
205
205
|
async start(controller) {
|
|
206
|
-
// Chunk 1: head opening (CSS, modulepreload — cached)
|
|
207
|
-
controller.enqueue(enc.encode(buildHtmlShellOpen()));
|
|
206
|
+
// Chunk 1: head opening (CSS, modulepreload — cached per lang)
|
|
207
|
+
controller.enqueue(enc.encode(buildHtmlShellOpen(metadata?.lang)));
|
|
208
208
|
|
|
209
209
|
// Chunk 2: metadata tags, close </head>, open <body> + spinner
|
|
210
210
|
controller.enqueue(enc.encode(buildMetadataChunk(metadata)));
|
package/src/core/server.ts
CHANGED
|
@@ -131,7 +131,10 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
131
131
|
try {
|
|
132
132
|
const pageMatch = findMatch(serverRoutes, routeUrl.pathname);
|
|
133
133
|
const data = await loadRouteData(routeUrl, locals, request, cookies);
|
|
134
|
-
if (!data)
|
|
134
|
+
if (!data) {
|
|
135
|
+
const cc = (cookies as CookieJar).accessed ? "private, no-cache" : "public, max-age=0, must-revalidate";
|
|
136
|
+
return compress(JSON.stringify({ pageData: {}, layoutData: [] }), "application/json", request, 200, { "Cache-Control": cc });
|
|
137
|
+
}
|
|
135
138
|
|
|
136
139
|
// Include metadata for client-side title/description updates
|
|
137
140
|
let metadata = null;
|
|
@@ -142,7 +145,12 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
142
145
|
} catch { /* non-fatal */ }
|
|
143
146
|
}
|
|
144
147
|
|
|
145
|
-
|
|
148
|
+
const cacheControl = (cookies as CookieJar).accessed
|
|
149
|
+
? "private, no-cache"
|
|
150
|
+
: "public, max-age=0, must-revalidate";
|
|
151
|
+
const cacheHeaders = { "Cache-Control": cacheControl };
|
|
152
|
+
|
|
153
|
+
return compress(JSON.stringify({ ...data, metadata }), "application/json", request, 200, cacheHeaders);
|
|
146
154
|
} catch (err) {
|
|
147
155
|
if (err instanceof Redirect) {
|
|
148
156
|
return compress(JSON.stringify({ redirect: err.location, status: err.status }), "application/json", request);
|
|
@@ -328,7 +336,7 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
|
|
|
328
336
|
return Response.json({ error: "Forbidden", message: csrfError }, { status: 403 });
|
|
329
337
|
}
|
|
330
338
|
|
|
331
|
-
const cookieJar = new CookieJar(request.headers.get("cookie") ?? "");
|
|
339
|
+
const cookieJar = new CookieJar(request.headers.get("cookie") ?? "", isDev);
|
|
332
340
|
const event: RequestEvent = { request, url, locals: {}, params: {}, cookies: cookieJar };
|
|
333
341
|
const response = userHandle
|
|
334
342
|
? await userHandle({ event, resolve })
|
|
@@ -5,22 +5,16 @@
|
|
|
5
5
|
"scripts": {
|
|
6
6
|
"dev": "bosia dev",
|
|
7
7
|
"build": "bosia build",
|
|
8
|
-
"start": "bosia start"
|
|
9
|
-
"db:generate": "drizzle-kit generate",
|
|
10
|
-
"db:migrate": "drizzle-kit migrate",
|
|
11
|
-
"db:seed": "bun run src/features/drizzle/seeds/runner.ts"
|
|
8
|
+
"start": "bosia start"
|
|
12
9
|
},
|
|
13
10
|
"dependencies": {
|
|
14
11
|
"bosia": "^{{BOSIA_VERSION}}",
|
|
15
12
|
"svelte": "^5.20.0",
|
|
16
13
|
"clsx": "^2.1.1",
|
|
17
|
-
"tailwind-merge": "^3.5.0"
|
|
18
|
-
"drizzle-orm": "^0.44.0",
|
|
19
|
-
"postgres": "^3.4.5"
|
|
14
|
+
"tailwind-merge": "^3.5.0"
|
|
20
15
|
},
|
|
21
16
|
"devDependencies": {
|
|
22
17
|
"@types/bun": "latest",
|
|
23
|
-
"typescript": "^5"
|
|
24
|
-
"drizzle-kit": "^0.31.0"
|
|
18
|
+
"typescript": "^5"
|
|
25
19
|
}
|
|
26
20
|
}
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
import { drizzle } from "drizzle-orm/postgres-js";
|
|
2
|
-
import postgres from "postgres";
|
|
3
|
-
import * as schema from "./schemas";
|
|
4
|
-
|
|
5
|
-
const connectionString = process.env.DATABASE_URL;
|
|
6
|
-
|
|
7
|
-
if (!connectionString) {
|
|
8
|
-
console.warn("⚠️ DATABASE_URL is not set. Database queries will fail.");
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
const client = postgres(connectionString || "postgres://localhost/dummy");
|
|
12
|
-
|
|
13
|
-
export const db = drizzle(client, { schema });
|
|
14
|
-
|
|
15
|
-
export type Database = typeof db;
|
|
File without changes
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export * from "../todo/schemas/todo.table";
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
import type { Database } from "../index";
|
|
2
|
-
import { todos } from "../../todo/schemas/todo.table";
|
|
3
|
-
|
|
4
|
-
export async function seed(db: Database) {
|
|
5
|
-
await db.insert(todos).values([
|
|
6
|
-
{ title: "Learn Bosia framework" },
|
|
7
|
-
{ title: "Set up PostgreSQL with Drizzle" },
|
|
8
|
-
{ title: "Build a full-stack app", completed: true },
|
|
9
|
-
{ title: "Deploy to production" },
|
|
10
|
-
]);
|
|
11
|
-
}
|
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
import { sql } from "drizzle-orm";
|
|
2
|
-
import { db } from "../index";
|
|
3
|
-
|
|
4
|
-
// Seed tracking table in the "drizzle" schema (same schema Drizzle Kit uses for migrations)
|
|
5
|
-
const SEEDS_TABLE = "__bosia_seeds";
|
|
6
|
-
|
|
7
|
-
interface SeedModule {
|
|
8
|
-
seed: (db: typeof import("../index").db) => Promise<void>;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
async function ensureSeedsTable() {
|
|
12
|
-
await db.execute(sql`
|
|
13
|
-
CREATE SCHEMA IF NOT EXISTS drizzle
|
|
14
|
-
`);
|
|
15
|
-
await db.execute(sql`
|
|
16
|
-
CREATE TABLE IF NOT EXISTS drizzle.${sql.identifier(SEEDS_TABLE)} (
|
|
17
|
-
id SERIAL PRIMARY KEY,
|
|
18
|
-
name TEXT NOT NULL UNIQUE,
|
|
19
|
-
applied_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
20
|
-
)
|
|
21
|
-
`);
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
async function getAppliedSeeds(): Promise<Set<string>> {
|
|
25
|
-
const rows = await db.execute<{ name: string }>(
|
|
26
|
-
sql`SELECT name FROM drizzle.${sql.identifier(SEEDS_TABLE)} ORDER BY id`
|
|
27
|
-
);
|
|
28
|
-
return new Set(rows.map((r) => r.name));
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
async function markSeedApplied(name: string) {
|
|
32
|
-
await db.execute(
|
|
33
|
-
sql`INSERT INTO drizzle.${sql.identifier(SEEDS_TABLE)} (name) VALUES (${name})`
|
|
34
|
-
);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
async function run() {
|
|
38
|
-
console.log("Running seeds...\n");
|
|
39
|
-
|
|
40
|
-
await ensureSeedsTable();
|
|
41
|
-
const applied = await getAppliedSeeds();
|
|
42
|
-
|
|
43
|
-
// Discover seed files (*.ts, excluding runner.ts)
|
|
44
|
-
const glob = new Bun.Glob("*.ts");
|
|
45
|
-
const seedDir = import.meta.dir;
|
|
46
|
-
const files = Array.from(glob.scanSync(seedDir))
|
|
47
|
-
.filter((f) => f !== "runner.ts")
|
|
48
|
-
.sort();
|
|
49
|
-
|
|
50
|
-
let count = 0;
|
|
51
|
-
|
|
52
|
-
for (const file of files) {
|
|
53
|
-
const name = file.replace(/\.ts$/, "");
|
|
54
|
-
|
|
55
|
-
if (applied.has(name)) {
|
|
56
|
-
console.log(` skip ${name} (already applied)`);
|
|
57
|
-
continue;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
console.log(` seed ${name}`);
|
|
61
|
-
const mod: SeedModule = await import(`./${file}`);
|
|
62
|
-
|
|
63
|
-
if (typeof mod.seed !== "function") {
|
|
64
|
-
console.error(` ERROR ${file} does not export a seed() function, skipping`);
|
|
65
|
-
continue;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
await mod.seed(db);
|
|
69
|
-
await markSeedApplied(name);
|
|
70
|
-
count++;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
console.log(`\nDone. Applied ${count} seed(s).`);
|
|
74
|
-
process.exit(0);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
run().catch((err) => {
|
|
78
|
-
console.error("Seed runner failed:", err);
|
|
79
|
-
process.exit(1);
|
|
80
|
-
});
|
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
import { eq } from "drizzle-orm";
|
|
2
|
-
import { db } from "../drizzle";
|
|
3
|
-
import { todos } from "./schemas/todo.table";
|
|
4
|
-
import type { NewTodo } from "./types";
|
|
5
|
-
|
|
6
|
-
export function getAll() {
|
|
7
|
-
return db.query.todos.findMany({
|
|
8
|
-
orderBy: (t, { desc }) => [desc(t.createdAt)],
|
|
9
|
-
});
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export function getById(id: string) {
|
|
13
|
-
return db.query.todos.findFirst({
|
|
14
|
-
where: eq(todos.id, id),
|
|
15
|
-
});
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export function create(data: Pick<NewTodo, "title">) {
|
|
19
|
-
return db.insert(todos).values(data).returning();
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export function update(id: string, data: Partial<Pick<NewTodo, "title" | "completed">>) {
|
|
23
|
-
return db
|
|
24
|
-
.update(todos)
|
|
25
|
-
.set({ ...data, updatedAt: new Date() })
|
|
26
|
-
.where(eq(todos.id, id))
|
|
27
|
-
.returning();
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export function toggle(id: string, completed: boolean) {
|
|
31
|
-
return update(id, { completed });
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export function remove(id: string) {
|
|
35
|
-
return db.delete(todos).where(eq(todos.id, id)).returning();
|
|
36
|
-
}
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
import { pgTable, uuid, text, boolean, timestamp } from "drizzle-orm/pg-core";
|
|
2
|
-
|
|
3
|
-
export const todos = pgTable("todos", {
|
|
4
|
-
id: uuid("id").defaultRandom().primaryKey(),
|
|
5
|
-
title: text("title").notNull(),
|
|
6
|
-
completed: boolean("completed").notNull().default(false),
|
|
7
|
-
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
8
|
-
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
|
9
|
-
});
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
<script lang="ts">
|
|
2
|
-
let { error }: { error?: string } = $props();
|
|
3
|
-
</script>
|
|
4
|
-
|
|
5
|
-
<form method="POST" action="?/create" class="flex gap-2">
|
|
6
|
-
<input
|
|
7
|
-
name="title"
|
|
8
|
-
type="text"
|
|
9
|
-
placeholder="What needs to be done?"
|
|
10
|
-
required
|
|
11
|
-
class="flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
12
|
-
/>
|
|
13
|
-
<button
|
|
14
|
-
type="submit"
|
|
15
|
-
class="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
|
|
16
|
-
>
|
|
17
|
-
Add
|
|
18
|
-
</button>
|
|
19
|
-
</form>
|
|
20
|
-
|
|
21
|
-
{#if error}
|
|
22
|
-
<p class="mt-2 text-sm text-destructive">{error}</p>
|
|
23
|
-
{/if}
|
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
<script lang="ts">
|
|
2
|
-
import type { Todo } from "../../../features/todo/types";
|
|
3
|
-
|
|
4
|
-
let { todo }: { todo: Todo } = $props();
|
|
5
|
-
let editing = $state(false);
|
|
6
|
-
let editTitle = $state(todo.title);
|
|
7
|
-
</script>
|
|
8
|
-
|
|
9
|
-
<li class="group flex items-center gap-3 rounded-md border border-border px-4 py-3 transition-colors hover:bg-accent/50">
|
|
10
|
-
<!-- Toggle -->
|
|
11
|
-
<form method="POST" action="?/toggle">
|
|
12
|
-
<input type="hidden" name="id" value={todo.id} />
|
|
13
|
-
<input type="hidden" name="completed" value={String(!todo.completed)} />
|
|
14
|
-
<button
|
|
15
|
-
type="submit"
|
|
16
|
-
class="flex size-5 items-center justify-center rounded border border-input transition-colors hover:border-primary {todo.completed ? 'bg-primary border-primary' : ''}"
|
|
17
|
-
aria-label={todo.completed ? 'Mark incomplete' : 'Mark complete'}
|
|
18
|
-
>
|
|
19
|
-
{#if todo.completed}
|
|
20
|
-
<svg class="size-3 text-primary-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
|
|
21
|
-
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
|
22
|
-
</svg>
|
|
23
|
-
{/if}
|
|
24
|
-
</button>
|
|
25
|
-
</form>
|
|
26
|
-
|
|
27
|
-
<!-- Title / Edit -->
|
|
28
|
-
{#if editing}
|
|
29
|
-
<form method="POST" action="?/update" class="flex flex-1 gap-2" onsubmit={() => (editing = false)}>
|
|
30
|
-
<input type="hidden" name="id" value={todo.id} />
|
|
31
|
-
<input
|
|
32
|
-
name="title"
|
|
33
|
-
type="text"
|
|
34
|
-
bind:value={editTitle}
|
|
35
|
-
required
|
|
36
|
-
class="flex-1 rounded-md border border-input bg-background px-2 py-1 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
37
|
-
/>
|
|
38
|
-
<button type="submit" class="text-sm text-primary hover:underline">Save</button>
|
|
39
|
-
<button type="button" class="text-sm text-muted-foreground hover:underline" onclick={() => { editing = false; editTitle = todo.title; }}>Cancel</button>
|
|
40
|
-
</form>
|
|
41
|
-
{:else}
|
|
42
|
-
<span
|
|
43
|
-
class="flex-1 text-sm {todo.completed ? 'line-through text-muted-foreground' : ''}"
|
|
44
|
-
ondblclick={() => (editing = true)}
|
|
45
|
-
>
|
|
46
|
-
{todo.title}
|
|
47
|
-
</span>
|
|
48
|
-
{/if}
|
|
49
|
-
|
|
50
|
-
<!-- Delete -->
|
|
51
|
-
<form method="POST" action="?/delete">
|
|
52
|
-
<input type="hidden" name="id" value={todo.id} />
|
|
53
|
-
<button
|
|
54
|
-
type="submit"
|
|
55
|
-
class="text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100 hover:text-destructive"
|
|
56
|
-
aria-label="Delete todo"
|
|
57
|
-
>
|
|
58
|
-
<svg class="size-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
59
|
-
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
60
|
-
</svg>
|
|
61
|
-
</button>
|
|
62
|
-
</form>
|
|
63
|
-
</li>
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
<script lang="ts">
|
|
2
|
-
import type { Todo } from "../../../features/todo/types";
|
|
3
|
-
import TodoItem from "./todo-item.svelte";
|
|
4
|
-
|
|
5
|
-
let { todos }: { todos: Todo[] } = $props();
|
|
6
|
-
</script>
|
|
7
|
-
|
|
8
|
-
{#if todos.length === 0}
|
|
9
|
-
<div class="rounded-md border border-dashed border-border py-12 text-center">
|
|
10
|
-
<p class="text-muted-foreground">No todos yet. Add one above!</p>
|
|
11
|
-
</div>
|
|
12
|
-
{:else}
|
|
13
|
-
<ul class="space-y-2">
|
|
14
|
-
{#each todos as todo (todo.id)}
|
|
15
|
-
<TodoItem {todo} />
|
|
16
|
-
{/each}
|
|
17
|
-
</ul>
|
|
18
|
-
<p class="mt-3 text-xs text-muted-foreground">
|
|
19
|
-
{todos.filter(t => t.completed).length} of {todos.length} completed
|
|
20
|
-
</p>
|
|
21
|
-
{/if}
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
import type { RequestEvent } from "bosia";
|
|
2
|
-
import { todoQueries } from "../../../features/todo";
|
|
3
|
-
|
|
4
|
-
export async function GET() {
|
|
5
|
-
const todos = await todoQueries.getAll();
|
|
6
|
-
return Response.json(todos);
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export async function POST({ request }: RequestEvent) {
|
|
10
|
-
const body = await request.json().catch(() => null);
|
|
11
|
-
|
|
12
|
-
if (!body?.title?.trim()) {
|
|
13
|
-
return Response.json({ error: "Title is required" }, { status: 400 });
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
const [todo] = await todoQueries.create({ title: body.title.trim() });
|
|
17
|
-
return Response.json(todo, { status: 201 });
|
|
18
|
-
}
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
import type { RequestEvent } from "bosia";
|
|
2
|
-
import { todoQueries } from "../../../../features/todo";
|
|
3
|
-
|
|
4
|
-
export async function GET({ params }: RequestEvent) {
|
|
5
|
-
const todo = await todoQueries.getById(params.id);
|
|
6
|
-
|
|
7
|
-
if (!todo) {
|
|
8
|
-
return Response.json({ error: "Todo not found" }, { status: 404 });
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
return Response.json(todo);
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export async function PUT({ params, request }: RequestEvent) {
|
|
15
|
-
const body = await request.json().catch(() => null);
|
|
16
|
-
|
|
17
|
-
if (!body || (body.title !== undefined && !body.title?.trim())) {
|
|
18
|
-
return Response.json({ error: "Invalid body" }, { status: 400 });
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
const data: { title?: string; completed?: boolean } = {};
|
|
22
|
-
if (body.title !== undefined) data.title = body.title.trim();
|
|
23
|
-
if (body.completed !== undefined) data.completed = body.completed;
|
|
24
|
-
|
|
25
|
-
const [todo] = await todoQueries.update(params.id, data);
|
|
26
|
-
|
|
27
|
-
if (!todo) {
|
|
28
|
-
return Response.json({ error: "Todo not found" }, { status: 404 });
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
return Response.json(todo);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export async function DELETE({ params }: RequestEvent) {
|
|
35
|
-
const [todo] = await todoQueries.remove(params.id);
|
|
36
|
-
|
|
37
|
-
if (!todo) {
|
|
38
|
-
return Response.json({ error: "Todo not found" }, { status: 404 });
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
return Response.json(todo);
|
|
42
|
-
}
|