robodev 0.21.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/package.json +1 -1
  2. package/src/emit-starter.test.ts +2 -2
  3. package/src/index.ts +14 -4
  4. package/src/print-live-url.test.ts +31 -0
  5. package/src/print-live-url.ts +6 -0
  6. package/templates/auth-chat/.rulesync/skills/media-feature/SKILL.md +6 -6
  7. package/templates/auth-chat/package.json +1 -1
  8. package/templates/backend/api/_lib.ts +40 -15
  9. package/templates/backend/api/planets/[id].ts +23 -8
  10. package/templates/backend/api/planets.ts +14 -9
  11. package/templates/backend/package.json +1 -1
  12. package/templates/empty/package.json +1 -1
  13. package/templates/space/.rulesync/skills/media-feature/SKILL.md +6 -6
  14. package/templates/space/api/_lib.ts +40 -15
  15. package/templates/space/api/planets/[id].ts +23 -8
  16. package/templates/space/api/planets.ts +14 -9
  17. package/templates/space/apps/fe/src/assets/locales/en/translation.json +1 -0
  18. package/templates/space/apps/fe/src/assets/locales/sl/translation.json +1 -0
  19. package/templates/space/apps/fe/src/components/features/planets/details/PlanetEditPage.tsx +69 -33
  20. package/templates/space/apps/fe/src/components/features/planets/list/PlanetCreateModal.tsx +23 -20
  21. package/templates/space/apps/fe/src/utils/planet-form.test.ts +68 -0
  22. package/templates/space/apps/fe/src/utils/planet-form.ts +49 -0
  23. package/templates/space/apps/fe/src/utils/planet-submit.ts +25 -0
  24. package/templates/space/openapi.json +14 -131
  25. package/templates/space/package.json +1 -1
  26. package/templates/backend/api/files/upload-request.ts +0 -48
  27. package/templates/backend/api/files/upload.ts +0 -29
  28. package/templates/space/api/files/upload-request.ts +0 -48
  29. package/templates/space/api/files/upload.ts +0 -29
  30. package/templates/space/apps/fe/src/utils/media-upload-headers.ts +0 -8
  31. package/templates/space/apps/fe/src/utils/media-upload.test.ts +0 -19
  32. package/templates/space/apps/fe/src/utils/media-upload.ts +0 -72
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "robodev",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "description": "CLI for Robodev Starbase — create, auth, link, and deploy hosted apps",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -84,8 +84,8 @@ test("collectFiles for space starter is Tiny APIs without apps/fe", async () =>
84
84
  assert.ok(paths.has("api/planets.ts"));
85
85
  assert.ok(paths.has("api/planets/paginate.ts"));
86
86
  assert.ok(paths.has("api/aliens/labels.ts"));
87
- assert.ok(paths.has("api/files/upload-request.ts"));
88
- assert.ok(paths.has("api/files/upload.ts"));
87
+ assert.ok(!paths.has("api/files/upload-request.ts"));
88
+ assert.ok(!paths.has("api/files/upload.ts"));
89
89
  assert.ok(!paths.has("index.html"));
90
90
  assert.ok(!paths.has("src/main.tsx"));
91
91
  assert.ok([...paths].every((path) => !path.startsWith("apps/")));
package/src/index.ts CHANGED
@@ -32,6 +32,7 @@ import {
32
32
  type Starter,
33
33
  } from "./emit-starter.js";
34
34
  import { parseCreateArgs } from "./parse-create-args.js";
35
+ import { printLiveUrl } from "./print-live-url.js";
35
36
 
36
37
  const execFileAsync = promisify(execFile);
37
38
 
@@ -230,7 +231,10 @@ async function collectDeployFiles(
230
231
  }
231
232
 
232
233
  /** Uploads schema, APIs, and frontend. Destructive schema changes need confirmation or `--force`. */
233
- async function runDeploy(forceFlag: boolean, options: DeployOptions = {}): Promise<void> {
234
+ async function runDeploy(
235
+ forceFlag: boolean,
236
+ options: DeployOptions = {},
237
+ ): Promise<string | undefined> {
234
238
  const root = options.root ?? process.cwd();
235
239
  const link = await readLink(root);
236
240
  if (!link) {
@@ -273,7 +277,7 @@ async function runDeploy(forceFlag: boolean, options: DeployOptions = {}): Promi
273
277
  for (const route of result.routes) {
274
278
  console.log(` ${route.method.toUpperCase()} ${route.path}`);
275
279
  }
276
- return;
280
+ return result.appUrl ?? result.apiUrl;
277
281
  } catch (error) {
278
282
  if (
279
283
  error instanceof ApiError &&
@@ -299,7 +303,7 @@ async function runDeploy(forceFlag: boolean, options: DeployOptions = {}): Promi
299
303
  const answer = await prompt("Apply destructive changes? (y/N) ");
300
304
  if (!/^y(es)?$/i.test(answer.trim())) {
301
305
  console.log("Deploy cancelled.");
302
- return;
306
+ return undefined;
303
307
  }
304
308
  force = true;
305
309
  continue;
@@ -512,7 +516,7 @@ async function runCreate(args: string[]): Promise<void> {
512
516
  skipFrontendBuild = true;
513
517
  }
514
518
  }
515
- await runDeploy(false, { root, frontendFiles, skipFrontendBuild });
519
+ const liveUrl = await runDeploy(false, { root, frontendFiles, skipFrontendBuild });
516
520
 
517
521
  try {
518
522
  await npmInstall(root);
@@ -529,6 +533,9 @@ async function runCreate(args: string[]): Promise<void> {
529
533
  } else {
530
534
  console.log("Run `npm install` in the project folder, then `npm run dev`.");
531
535
  }
536
+ if (liveUrl) {
537
+ printLiveUrl(liveUrl);
538
+ }
532
539
  return;
533
540
  }
534
541
 
@@ -553,6 +560,9 @@ async function runCreate(args: string[]): Promise<void> {
553
560
  }
554
561
  console.log(" npm run dev");
555
562
  }
563
+ if (liveUrl) {
564
+ printLiveUrl(liveUrl);
565
+ }
556
566
  }
557
567
 
558
568
  async function main(): Promise<void> {
@@ -0,0 +1,31 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import { printLiveUrl } from "./print-live-url.js";
4
+
5
+ test("printLiveUrl writes a blank line, Live label, and verbatim URL", () => {
6
+ const lines: string[] = [];
7
+ const original = console.log;
8
+ console.log = (...args: unknown[]) => {
9
+ lines.push(args.map(String).join(" "));
10
+ };
11
+ try {
12
+ printLiveUrl("https://prj_1.robodev.povio.dev");
13
+ } finally {
14
+ console.log = original;
15
+ }
16
+ assert.deepEqual(lines, ["", "Live", "https://prj_1.robodev.povio.dev"]);
17
+ });
18
+
19
+ test("printLiveUrl prints a local project host verbatim", () => {
20
+ const lines: string[] = [];
21
+ const original = console.log;
22
+ console.log = (...args: unknown[]) => {
23
+ lines.push(args.map(String).join(" "));
24
+ };
25
+ try {
26
+ printLiveUrl("http://prj_1.localhost:4000");
27
+ } finally {
28
+ console.log = original;
29
+ }
30
+ assert.deepEqual(lines, ["", "Live", "http://prj_1.localhost:4000"]);
31
+ });
@@ -0,0 +1,6 @@
1
+ /** Last create output: blank line, label, absolute project URL on its own line. */
2
+ export function printLiveUrl(url: string): void {
3
+ console.log("");
4
+ console.log("Live");
5
+ console.log(url);
6
+ }
@@ -10,12 +10,12 @@ codexcli:
10
10
 
11
11
  Preserve the upload contract:
12
12
 
13
- 1. Frontend selects a `File`.
14
- 2. Frontend calls `MediaQueries.useUploadRequest` with metadata only (`POST /api/files/upload-request`).
15
- 3. Frontend uploads bytes to the returned URL (`POST /api/files/upload` via Starbase `ctx.storage`).
16
- 4. Feature create/update submits only `image: { id }`.
17
- 5. Feature reads resolve that ID to `image: { id, url }`.
13
+ 1. Frontend selects a `File` and keeps it local until submit. Do not call `/api/files/*`.
14
+ 2. Feature create/update is one `multipart/form-data` request (`POST` / `PUT`) with text fields and an optional `file`.
15
+ 3. The handler validates text fields, optionally calls `savePlanetImage` (`ctx.storage.upload` public), and writes the feature row.
16
+ 4. Update may send `clearImage=true` when the user cleared an existing image and picked nothing new. A new `file` wins over `clearImage`.
17
+ 5. Feature reads resolve the stored media ID to `image: { id, url }`.
18
18
 
19
- Never send `File`, `Blob`, base64, or raw bytes in a feature create/update request.
19
+ Never use a standalone files API. File bytes stay on the device until submit.
20
20
 
21
21
  Media metadata lives in the `media` table in `database.ts`. Bytes live in Robodev storage. Planet images use resource name `planet-image`.
@@ -11,7 +11,7 @@
11
11
  "@robodev-ai/sdk": "^0.6.0"
12
12
  },
13
13
  "devDependencies": {
14
- "robodev": "^0.21.0",
14
+ "robodev": "^0.22.0",
15
15
  "rulesync": "^8.18.0",
16
16
  "typescript": "^5.9.2"
17
17
  }
@@ -1,5 +1,5 @@
1
1
  import { asc, eq, type InferSelectModel } from "@robodev-ai/sdk";
2
- import type { ApiHandlerContext } from "@robodev-ai/sdk";
2
+ import type { ApiHandlerContext, UploadedFile } from "@robodev-ai/sdk";
3
3
  import { aliens, media, planetLikes, planets } from "../database";
4
4
 
5
5
  export const PLANET_IMAGE_RESOURCE = "planet-image";
@@ -339,21 +339,46 @@ export async function validateAlien(
339
339
  return row ?? null;
340
340
  }
341
341
 
342
- export async function validateImage(
343
- db: ApiHandlerContext<unknown, unknown>["db"],
344
- image: { id: string } | null | undefined,
345
- userId: string,
346
- ) {
347
- if (!image) return null;
348
- const [row] = await db.select().from(media).where(eq(media.id, image.id)).limit(1);
349
- if (!row) return { error: fail(404, "Media not found") };
350
- if (row.userId !== userId) return { error: fail(403, "You can only use your own media") };
351
- if (row.resourceName !== PLANET_IMAGE_RESOURCE) {
352
- return { error: fail(400, "Media cannot be used as a planet image") };
342
+ const ALLOWED_PLANET_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp"]);
343
+ const MAX_PLANET_IMAGE_BYTES = 2 * 1024 * 1024;
344
+
345
+ export async function savePlanetImage(
346
+ ctx: ApiHandlerContext<unknown, unknown>,
347
+ file: UploadedFile,
348
+ ): Promise<{ id: string } | ReturnType<typeof fail>> {
349
+ const mimeType = file.mimetype || "application/octet-stream";
350
+ if (file.data.length > MAX_PLANET_IMAGE_BYTES) {
351
+ return fail(400, "File is too large");
352
+ }
353
+ if (!ALLOWED_PLANET_IMAGE_TYPES.has(mimeType)) {
354
+ return fail(400, "Unsupported image type");
353
355
  }
354
- if (!row.uploaded)
355
- return { error: fail(400, "Media must be uploaded before it can be attached") };
356
- return { id: row.id };
356
+
357
+ const fileName = (file.filename || "image").replaceAll("/", "-");
358
+ const key = `planet-images/${ctx.user!.id}/${crypto.randomUUID()}-${fileName}`;
359
+ const [row] = await ctx.db
360
+ .insert(media)
361
+ .values({
362
+ key,
363
+ userId: ctx.user!.id,
364
+ resourceName: PLANET_IMAGE_RESOURCE,
365
+ fileName,
366
+ fileSize: file.data.length,
367
+ mimeType,
368
+ })
369
+ .returning();
370
+
371
+ try {
372
+ await ctx.storage.upload(row!.key, file.data, {
373
+ public: true,
374
+ contentType: mimeType,
375
+ });
376
+ } catch (error) {
377
+ return fail(503, error instanceof Error ? error.message : "Storage is not configured");
378
+ }
379
+
380
+ await ctx.db.update(media).set({ uploaded: new Date() }).where(eq(media.id, row!.id));
381
+ return { id: row!.id };
357
382
  }
358
383
 
359
384
  export { aliens, media, planetLikes, planets };
@@ -1,12 +1,18 @@
1
1
  import { defineApi, eq, z } from "@robodev-ai/sdk";
2
- import { enrichOne, fail, findPlanet, planets, validateAlien, validateImage } from "../_lib";
2
+ import { enrichOne, fail, findPlanet, planets, savePlanetImage, validateAlien } from "../_lib";
3
+
4
+ const emptyToUndefined = (value: unknown) => (value === "" ? undefined : value);
3
5
 
4
6
  const planetBody = z.object({
5
7
  name: z.string().min(1),
6
- alienId: z.string().nullish(),
7
- discoveryDate: z.string().nullish(),
8
- description: z.string().nullish(),
9
- image: z.object({ id: z.string() }).nullish(),
8
+ alienId: z.preprocess(emptyToUndefined, z.string().optional()),
9
+ discoveryDate: z.preprocess(emptyToUndefined, z.string().optional()),
10
+ description: z.preprocess(emptyToUndefined, z.string().optional()),
11
+ clearImage: z.preprocess((value) => {
12
+ if (value === "true" || value === true) return true;
13
+ if (value === "false" || value === false || value === "" || value === undefined) return undefined;
14
+ return value;
15
+ }, z.boolean().optional()),
10
16
  });
11
17
 
12
18
  export const get = defineApi({
@@ -20,6 +26,7 @@ export const get = defineApi({
20
26
 
21
27
  export const put = defineApi({
22
28
  auth: "required",
29
+ multipart: true,
23
30
  body: planetBody,
24
31
  handler: async (ctx) => {
25
32
  const planet = await findPlanet(ctx.db, ctx.params.id);
@@ -30,8 +37,16 @@ export const put = defineApi({
30
37
  if (alienId && !(await validateAlien(ctx.db, alienId))) {
31
38
  return fail(404, "Alien not found");
32
39
  }
33
- const image = await validateImage(ctx.db, ctx.body.image, ctx.user!.id);
34
- if (image && "error" in image) return image.error;
40
+
41
+ const file = ctx.files?.find((f) => f.fieldname === "file") ?? ctx.files?.[0];
42
+ let imageId = planet.imageId ?? null;
43
+ if (file?.data.length) {
44
+ const saved = await savePlanetImage(ctx, file);
45
+ if (!("id" in saved)) return saved;
46
+ imageId = saved.id;
47
+ } else if (ctx.body.clearImage) {
48
+ imageId = null;
49
+ }
35
50
 
36
51
  const [updated] = await ctx.db
37
52
  .update(planets)
@@ -40,7 +55,7 @@ export const put = defineApi({
40
55
  alienId,
41
56
  discoveryDate: ctx.body.discoveryDate ? new Date(ctx.body.discoveryDate) : null,
42
57
  description: ctx.body.description ?? null,
43
- imageId: image?.id ?? null,
58
+ imageId,
44
59
  updatedAt: new Date(),
45
60
  })
46
61
  .where(eq(planets.id, planet.id))
@@ -8,19 +8,18 @@ import {
8
8
  loadLookups,
9
9
  parseFilter,
10
10
  planets,
11
+ savePlanetImage,
11
12
  sortPlanets,
12
13
  validateAlien,
13
- validateImage,
14
14
  } from "./_lib";
15
15
 
16
- const imageRequest = z.object({ id: z.string() }).nullish();
16
+ const emptyToUndefined = (value: unknown) => (value === "" ? undefined : value);
17
17
 
18
18
  const planetBody = z.object({
19
19
  name: z.string().min(1),
20
- alienId: z.string().nullish(),
21
- discoveryDate: z.string().nullish(),
22
- description: z.string().nullish(),
23
- image: imageRequest,
20
+ alienId: z.preprocess(emptyToUndefined, z.string().optional()),
21
+ discoveryDate: z.preprocess(emptyToUndefined, z.string().optional()),
22
+ description: z.preprocess(emptyToUndefined, z.string().optional()),
24
23
  });
25
24
 
26
25
  export const get = defineApi({
@@ -42,14 +41,20 @@ export const get = defineApi({
42
41
 
43
42
  export const post = defineApi({
44
43
  auth: "required",
44
+ multipart: true,
45
45
  body: planetBody,
46
46
  handler: async (ctx) => {
47
47
  const alienId = ctx.body.alienId || null;
48
48
  if (alienId && !(await validateAlien(ctx.db, alienId))) {
49
49
  return fail(404, "Alien not found");
50
50
  }
51
- const image = await validateImage(ctx.db, ctx.body.image, ctx.user!.id);
52
- if (image && "error" in image) return image.error;
51
+ const file = ctx.files?.find((f) => f.fieldname === "file") ?? ctx.files?.[0];
52
+ let imageId: string | null = null;
53
+ if (file?.data.length) {
54
+ const saved = await savePlanetImage(ctx, file);
55
+ if (!("id" in saved)) return saved;
56
+ imageId = saved.id;
57
+ }
53
58
 
54
59
  const [row] = await ctx.db
55
60
  .insert(planets)
@@ -60,7 +65,7 @@ export const post = defineApi({
60
65
  discoveryDate: ctx.body.discoveryDate ? new Date(ctx.body.discoveryDate) : null,
61
66
  name: ctx.body.name,
62
67
  description: ctx.body.description ?? null,
63
- imageId: image?.id ?? null,
68
+ imageId,
64
69
  })
65
70
  .returning();
66
71
  return enrichOne(ctx, row!, ctx.user!.id);
@@ -10,7 +10,7 @@
10
10
  "@robodev-ai/sdk": "^0.6.0"
11
11
  },
12
12
  "devDependencies": {
13
- "robodev": "^0.21.0",
13
+ "robodev": "^0.22.0",
14
14
  "typescript": "^5.9.2"
15
15
  }
16
16
  }
@@ -10,7 +10,7 @@
10
10
  "@robodev-ai/sdk": "^0.6.0"
11
11
  },
12
12
  "devDependencies": {
13
- "robodev": "^0.21.0",
13
+ "robodev": "^0.22.0",
14
14
  "typescript": "^5.9.2"
15
15
  }
16
16
  }
@@ -10,12 +10,12 @@ codexcli:
10
10
 
11
11
  Preserve the upload contract:
12
12
 
13
- 1. Frontend selects a `File`.
14
- 2. Frontend calls `MediaQueries.useUploadRequest` with metadata only (`POST /api/files/upload-request`).
15
- 3. Frontend uploads bytes to the returned URL (`POST /api/files/upload` via Starbase `ctx.storage`).
16
- 4. Feature create/update submits only `image: { id }`.
17
- 5. Feature reads resolve that ID to `image: { id, url }`.
13
+ 1. Frontend selects a `File` and keeps it local until submit. Do not call `/api/files/*`.
14
+ 2. Feature create/update is one `multipart/form-data` request (`POST` / `PUT`) with text fields and an optional `file`.
15
+ 3. The handler validates text fields, optionally calls `savePlanetImage` (`ctx.storage.upload` public), and writes the feature row.
16
+ 4. Update may send `clearImage=true` when the user cleared an existing image and picked nothing new. A new `file` wins over `clearImage`.
17
+ 5. Feature reads resolve the stored media ID to `image: { id, url }`.
18
18
 
19
- Never send `File`, `Blob`, base64, or raw bytes in a feature create/update request.
19
+ Never use a standalone files API. File bytes stay on the device until submit.
20
20
 
21
21
  Media metadata lives in the `media` table in `database.ts`. Bytes live in Robodev storage. Planet images use resource name `planet-image`.
@@ -1,5 +1,5 @@
1
1
  import { asc, eq, type InferSelectModel } from "@robodev-ai/sdk";
2
- import type { ApiHandlerContext } from "@robodev-ai/sdk";
2
+ import type { ApiHandlerContext, UploadedFile } from "@robodev-ai/sdk";
3
3
  import { aliens, media, planetLikes, planets } from "../database";
4
4
 
5
5
  export const PLANET_IMAGE_RESOURCE = "planet-image";
@@ -339,21 +339,46 @@ export async function validateAlien(
339
339
  return row ?? null;
340
340
  }
341
341
 
342
- export async function validateImage(
343
- db: ApiHandlerContext<unknown, unknown>["db"],
344
- image: { id: string } | null | undefined,
345
- userId: string,
346
- ) {
347
- if (!image) return null;
348
- const [row] = await db.select().from(media).where(eq(media.id, image.id)).limit(1);
349
- if (!row) return { error: fail(404, "Media not found") };
350
- if (row.userId !== userId) return { error: fail(403, "You can only use your own media") };
351
- if (row.resourceName !== PLANET_IMAGE_RESOURCE) {
352
- return { error: fail(400, "Media cannot be used as a planet image") };
342
+ const ALLOWED_PLANET_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp"]);
343
+ const MAX_PLANET_IMAGE_BYTES = 2 * 1024 * 1024;
344
+
345
+ export async function savePlanetImage(
346
+ ctx: ApiHandlerContext<unknown, unknown>,
347
+ file: UploadedFile,
348
+ ): Promise<{ id: string } | ReturnType<typeof fail>> {
349
+ const mimeType = file.mimetype || "application/octet-stream";
350
+ if (file.data.length > MAX_PLANET_IMAGE_BYTES) {
351
+ return fail(400, "File is too large");
352
+ }
353
+ if (!ALLOWED_PLANET_IMAGE_TYPES.has(mimeType)) {
354
+ return fail(400, "Unsupported image type");
353
355
  }
354
- if (!row.uploaded)
355
- return { error: fail(400, "Media must be uploaded before it can be attached") };
356
- return { id: row.id };
356
+
357
+ const fileName = (file.filename || "image").replaceAll("/", "-");
358
+ const key = `planet-images/${ctx.user!.id}/${crypto.randomUUID()}-${fileName}`;
359
+ const [row] = await ctx.db
360
+ .insert(media)
361
+ .values({
362
+ key,
363
+ userId: ctx.user!.id,
364
+ resourceName: PLANET_IMAGE_RESOURCE,
365
+ fileName,
366
+ fileSize: file.data.length,
367
+ mimeType,
368
+ })
369
+ .returning();
370
+
371
+ try {
372
+ await ctx.storage.upload(row!.key, file.data, {
373
+ public: true,
374
+ contentType: mimeType,
375
+ });
376
+ } catch (error) {
377
+ return fail(503, error instanceof Error ? error.message : "Storage is not configured");
378
+ }
379
+
380
+ await ctx.db.update(media).set({ uploaded: new Date() }).where(eq(media.id, row!.id));
381
+ return { id: row!.id };
357
382
  }
358
383
 
359
384
  export { aliens, media, planetLikes, planets };
@@ -1,12 +1,18 @@
1
1
  import { defineApi, eq, z } from "@robodev-ai/sdk";
2
- import { enrichOne, fail, findPlanet, planets, validateAlien, validateImage } from "../_lib";
2
+ import { enrichOne, fail, findPlanet, planets, savePlanetImage, validateAlien } from "../_lib";
3
+
4
+ const emptyToUndefined = (value: unknown) => (value === "" ? undefined : value);
3
5
 
4
6
  const planetBody = z.object({
5
7
  name: z.string().min(1),
6
- alienId: z.string().nullish(),
7
- discoveryDate: z.string().nullish(),
8
- description: z.string().nullish(),
9
- image: z.object({ id: z.string() }).nullish(),
8
+ alienId: z.preprocess(emptyToUndefined, z.string().optional()),
9
+ discoveryDate: z.preprocess(emptyToUndefined, z.string().optional()),
10
+ description: z.preprocess(emptyToUndefined, z.string().optional()),
11
+ clearImage: z.preprocess((value) => {
12
+ if (value === "true" || value === true) return true;
13
+ if (value === "false" || value === false || value === "" || value === undefined) return undefined;
14
+ return value;
15
+ }, z.boolean().optional()),
10
16
  });
11
17
 
12
18
  export const get = defineApi({
@@ -20,6 +26,7 @@ export const get = defineApi({
20
26
 
21
27
  export const put = defineApi({
22
28
  auth: "required",
29
+ multipart: true,
23
30
  body: planetBody,
24
31
  handler: async (ctx) => {
25
32
  const planet = await findPlanet(ctx.db, ctx.params.id);
@@ -30,8 +37,16 @@ export const put = defineApi({
30
37
  if (alienId && !(await validateAlien(ctx.db, alienId))) {
31
38
  return fail(404, "Alien not found");
32
39
  }
33
- const image = await validateImage(ctx.db, ctx.body.image, ctx.user!.id);
34
- if (image && "error" in image) return image.error;
40
+
41
+ const file = ctx.files?.find((f) => f.fieldname === "file") ?? ctx.files?.[0];
42
+ let imageId = planet.imageId ?? null;
43
+ if (file?.data.length) {
44
+ const saved = await savePlanetImage(ctx, file);
45
+ if (!("id" in saved)) return saved;
46
+ imageId = saved.id;
47
+ } else if (ctx.body.clearImage) {
48
+ imageId = null;
49
+ }
35
50
 
36
51
  const [updated] = await ctx.db
37
52
  .update(planets)
@@ -40,7 +55,7 @@ export const put = defineApi({
40
55
  alienId,
41
56
  discoveryDate: ctx.body.discoveryDate ? new Date(ctx.body.discoveryDate) : null,
42
57
  description: ctx.body.description ?? null,
43
- imageId: image?.id ?? null,
58
+ imageId,
44
59
  updatedAt: new Date(),
45
60
  })
46
61
  .where(eq(planets.id, planet.id))
@@ -8,19 +8,18 @@ import {
8
8
  loadLookups,
9
9
  parseFilter,
10
10
  planets,
11
+ savePlanetImage,
11
12
  sortPlanets,
12
13
  validateAlien,
13
- validateImage,
14
14
  } from "./_lib";
15
15
 
16
- const imageRequest = z.object({ id: z.string() }).nullish();
16
+ const emptyToUndefined = (value: unknown) => (value === "" ? undefined : value);
17
17
 
18
18
  const planetBody = z.object({
19
19
  name: z.string().min(1),
20
- alienId: z.string().nullish(),
21
- discoveryDate: z.string().nullish(),
22
- description: z.string().nullish(),
23
- image: imageRequest,
20
+ alienId: z.preprocess(emptyToUndefined, z.string().optional()),
21
+ discoveryDate: z.preprocess(emptyToUndefined, z.string().optional()),
22
+ description: z.preprocess(emptyToUndefined, z.string().optional()),
24
23
  });
25
24
 
26
25
  export const get = defineApi({
@@ -42,14 +41,20 @@ export const get = defineApi({
42
41
 
43
42
  export const post = defineApi({
44
43
  auth: "required",
44
+ multipart: true,
45
45
  body: planetBody,
46
46
  handler: async (ctx) => {
47
47
  const alienId = ctx.body.alienId || null;
48
48
  if (alienId && !(await validateAlien(ctx.db, alienId))) {
49
49
  return fail(404, "Alien not found");
50
50
  }
51
- const image = await validateImage(ctx.db, ctx.body.image, ctx.user!.id);
52
- if (image && "error" in image) return image.error;
51
+ const file = ctx.files?.find((f) => f.fieldname === "file") ?? ctx.files?.[0];
52
+ let imageId: string | null = null;
53
+ if (file?.data.length) {
54
+ const saved = await savePlanetImage(ctx, file);
55
+ if (!("id" in saved)) return saved;
56
+ imageId = saved.id;
57
+ }
53
58
 
54
59
  const [row] = await ctx.db
55
60
  .insert(planets)
@@ -60,7 +65,7 @@ export const post = defineApi({
60
65
  discoveryDate: ctx.body.discoveryDate ? new Date(ctx.body.discoveryDate) : null,
61
66
  name: ctx.body.name,
62
67
  description: ctx.body.description ?? null,
63
- imageId: image?.id ?? null,
68
+ imageId,
64
69
  })
65
70
  .returning();
66
71
  return enrichOne(ctx, row!, ctx.user!.id);
@@ -228,6 +228,7 @@
228
228
  "imageEmptyText": "No image",
229
229
  "imageUploadText": "Upload",
230
230
  "imageBrowseText": "Browse",
231
+ "imageRemove": "Remove image",
231
232
  "submit": "Save",
232
233
  "cancel": "Cancel",
233
234
  "success": "Planet updated",
@@ -228,6 +228,7 @@
228
228
  "imageEmptyText": "Brez slike",
229
229
  "imageUploadText": "Naloži",
230
230
  "imageBrowseText": "Brskaj",
231
+ "imageRemove": "Odstrani sliko",
231
232
  "submit": "Shrani",
232
233
  "cancel": "Prekliči",
233
234
  "success": "Planet posodobljen",