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.
Files changed (30) hide show
  1. package/package.json +1 -1
  2. package/src/cli/add.ts +29 -75
  3. package/src/cli/create.ts +32 -5
  4. package/src/cli/feat.ts +55 -93
  5. package/src/cli/registry.ts +168 -0
  6. package/src/core/cookies.ts +38 -15
  7. package/src/core/hooks.ts +2 -0
  8. package/src/core/html.ts +19 -8
  9. package/src/core/renderer.ts +2 -2
  10. package/src/core/server.ts +11 -3
  11. package/templates/drizzle/package.json +3 -9
  12. package/templates/drizzle/template.json +3 -0
  13. package/templates/drizzle/drizzle.config.ts +0 -10
  14. package/templates/drizzle/src/features/drizzle/index.ts +0 -15
  15. package/templates/drizzle/src/features/drizzle/migrations/.gitkeep +0 -0
  16. package/templates/drizzle/src/features/drizzle/schemas.ts +0 -1
  17. package/templates/drizzle/src/features/drizzle/seeds/001_initial_todos.ts +0 -11
  18. package/templates/drizzle/src/features/drizzle/seeds/runner.ts +0 -80
  19. package/templates/drizzle/src/features/todo/index.ts +0 -3
  20. package/templates/drizzle/src/features/todo/queries.ts +0 -36
  21. package/templates/drizzle/src/features/todo/schemas/todo.table.ts +0 -9
  22. package/templates/drizzle/src/features/todo/types.ts +0 -5
  23. package/templates/drizzle/src/lib/components/todo/index.ts +0 -3
  24. package/templates/drizzle/src/lib/components/todo/todo-form.svelte +0 -23
  25. package/templates/drizzle/src/lib/components/todo/todo-item.svelte +0 -63
  26. package/templates/drizzle/src/lib/components/todo/todo-list.svelte +0 -21
  27. package/templates/drizzle/src/routes/api/todos/+server.ts +0 -18
  28. package/templates/drizzle/src/routes/api/todos/[id]/+server.ts +0 -42
  29. package/templates/drizzle/src/routes/todos/+page.server.ts +0 -52
  30. package/templates/drizzle/src/routes/todos/+page.svelte +0 -39
@@ -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
- const path = options?.path ?? "/";
51
- if (UNSAFE_COOKIE_VALUE.test(path)) throw new Error(`Invalid cookie path: ${path}`);
52
- header += `; Path=${path}`;
53
- if (options?.domain) {
54
- if (UNSAFE_COOKIE_VALUE.test(options.domain)) throw new Error(`Invalid cookie domain: ${options.domain}`);
55
- header += `; Domain=${options.domain}`;
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 (options?.maxAge != null) header += `; Max-Age=${options.maxAge}`;
58
- if (options?.expires) header += `; Expires=${options.expires.toUTCString()}`;
59
- if (options?.httpOnly) header += "; HttpOnly";
60
- if (options?.secure) header += "; Secure";
61
- if (options?.sameSite) {
62
- if (!VALID_SAMESITE.has(options.sameSite)) throw new Error(`Invalid cookie sameSite: ${options.sameSite}`);
63
- header += `; SameSite=${options.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, "", { path: options?.path, domain: options?.domain, maxAge: 0 });
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
- let _shellOpen: string | null = null;
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
- if (_shellOpen) return _shellOpen;
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
- _shellOpen = `<!DOCTYPE html>\n<html lang="en">\n<head>\n` +
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
- return _shellOpen;
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.
@@ -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)));
@@ -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) return compress(JSON.stringify({ pageData: {}, layoutData: [] }), "application/json", request);
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
- return compress(JSON.stringify({ ...data, metadata }), "application/json", request);
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
  }
@@ -0,0 +1,3 @@
1
+ {
2
+ "features": ["todo"]
3
+ }
@@ -1,10 +0,0 @@
1
- import { defineConfig } from "drizzle-kit";
2
-
3
- export default defineConfig({
4
- schema: "./src/features/drizzle/schemas.ts",
5
- out: "./src/features/drizzle/migrations",
6
- dialect: "postgresql",
7
- dbCredentials: {
8
- url: process.env.DATABASE_URL!,
9
- },
10
- });
@@ -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;
@@ -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,3 +0,0 @@
1
- export * from "./schemas/todo.table";
2
- export * from "./types";
3
- export * as todoQueries from "./queries";
@@ -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,5 +0,0 @@
1
- import type { InferSelectModel, InferInsertModel } from "drizzle-orm";
2
- import type { todos } from "./schemas/todo.table";
3
-
4
- export type Todo = InferSelectModel<typeof todos>;
5
- export type NewTodo = InferInsertModel<typeof todos>;
@@ -1,3 +0,0 @@
1
- export { default as TodoForm } from "./todo-form.svelte";
2
- export { default as TodoItem } from "./todo-item.svelte";
3
- export { default as TodoList } from "./todo-list.svelte";
@@ -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
- }