an attempt at a lightweight photo/album viewer

pretty powerful relation functionality from drizzle

+76 -33
+2 -2
server/deno.json
··· 11 11 "drizzle-orm": "npm:drizzle-orm@^1.0.0-beta.6-c513c71", 12 12 "drizzle-zod": "npm:drizzle-zod@^0.8.3", 13 13 "eslint": "npm:eslint@^9.39.2", 14 - "hono": "npm:hono@^4.11.0", 14 + "hono": "npm:hono@^4.11.3", 15 15 "hono-pino": "npm:hono-pino@^0.10.3", 16 16 "libsql": "npm:libsql@^0.5.22", 17 17 "pino": "npm:pino@^10.1.0", ··· 22 22 "zod": "npm:zod@^4.1.13" 23 23 }, 24 24 "tasks": { 25 - "generate": "deno run runtimes/generate.ts", 25 + "generate": "mkdir build; deno run runtimes/generate.ts", 26 26 "dev": "deno run $(deno task allow-native-sql) --allow-read=.env --allow-net=0.0.0.0:8000 --allow-sys=hostname --allow-env=LIBSQL_JS_DEV,CI,TERM,NODE_V8_COVERAGE runtimes/dev.ts", 27 27 "serve": "deno run $(deno task allow-native-sql) --allow-read=.env --allow-net=0.0.0.0:8000 --allow-env=LIBSQL_JS_DEV runtimes/main.ts", 28 28 "lint": "eslint .",
+13 -13
server/deno.lock
··· 4 4 "jsr:@std/dotenv@*": "0.225.5", 5 5 "jsr:@std/dotenv@~0.225.5": "0.225.5", 6 6 "npm:@antfu/eslint-config@^6.6.1": "6.6.1_eslint@9.39.2_@typescript-eslint+parser@8.49.0__eslint@9.39.2__typescript@5.9.3_typescript@5.9.3_@typescript-eslint+eslint-plugin@8.49.0__@typescript-eslint+parser@8.49.0___eslint@9.39.2___typescript@5.9.3__eslint@9.39.2__typescript@5.9.3_@stylistic+eslint-plugin@5.6.1__eslint@9.39.2_vue-eslint-parser@10.2.0__eslint@9.39.2", 7 - "npm:@hono/zod-openapi@^1.1.5": "1.1.5_hono@4.11.0_zod@4.1.13", 7 + "npm:@hono/zod-openapi@^1.1.5": "1.1.5_hono@4.11.3_zod@4.1.13", 8 8 "npm:@libsql/client@~0.15.15": "0.15.15", 9 - "npm:@scalar/hono-api-reference@~0.9.28": "0.9.28_hono@4.11.0", 9 + "npm:@scalar/hono-api-reference@~0.9.28": "0.9.28_hono@4.11.3", 10 10 "npm:@types/deno@^2.5.0": "2.5.0", 11 11 "npm:drizzle-orm@^1.0.0-beta.6-c513c71": "1.0.0-beta.6-c513c71_@libsql+client@0.15.15_@types+mssql@9.1.8_mssql@11.0.1_postgres@3.4.7", 12 12 "npm:drizzle-zod@~0.8.3": "0.8.3_drizzle-orm@1.0.0-beta.6-c513c71__@libsql+client@0.15.15__@types+mssql@9.1.8__mssql@11.0.1__postgres@3.4.7_zod@4.1.13_@libsql+client@0.15.15_postgres@3.4.7", 13 13 "npm:eslint@^9.39.2": "9.39.2", 14 - "npm:hono-pino@~0.10.3": "0.10.3_hono@4.11.0_pino@10.1.0", 15 - "npm:hono@^4.11.0": "4.11.0", 14 + "npm:hono-pino@~0.10.3": "0.10.3_hono@4.11.3_pino@10.1.0", 15 + "npm:hono@^4.11.3": "4.11.3", 16 16 "npm:libsql@~0.5.22": "0.5.22", 17 17 "npm:pino-pretty@^13.1.3": "13.1.3", 18 18 "npm:pino@^10.1.0": "10.1.0", 19 19 "npm:postgres@^3.4.7": "3.4.7", 20 - "npm:stoker@^2.0.1": "2.0.1_@hono+zod-openapi@1.1.5__hono@4.11.0__zod@4.1.13_hono@4.11.0_zod@4.1.13", 20 + "npm:stoker@^2.0.1": "2.0.1_@hono+zod-openapi@1.1.5__hono@4.11.3__zod@4.1.13_hono@4.11.3_zod@4.1.13", 21 21 "npm:typescript@^5.9.3": "5.9.3", 22 22 "npm:zod@^4.1.13": "4.1.13" 23 23 }, ··· 387 387 "levn" 388 388 ] 389 389 }, 390 - "@hono/zod-openapi@1.1.5_hono@4.11.0_zod@4.1.13": { 390 + "@hono/zod-openapi@1.1.5_hono@4.11.3_zod@4.1.13": { 391 391 "integrity": "sha512-EAnY6ad4yt/MUKHx716BEGGOXSl5d0/FOLozOYB/pmSEFq07qrzefKFtBEMAgd3hlpJXjH+4lwgTtlAo+BGBgQ==", 392 392 "dependencies": [ 393 393 "@asteasolutions/zod-to-openapi", ··· 397 397 "zod" 398 398 ] 399 399 }, 400 - "@hono/zod-validator@0.7.5_hono@4.11.0_zod@4.1.13": { 400 + "@hono/zod-validator@0.7.5_hono@4.11.3_zod@4.1.13": { 401 401 "integrity": "sha512-n4l4hutkfYU07PzRUHBOVzUEn38VSfrh+UVE5d0w4lyfWDOEhzxIupqo5iakRiJL44c3vTuFJBvcmUl8b9agIA==", 402 402 "dependencies": [ 403 403 "hono", ··· 521 521 "@scalar/types" 522 522 ] 523 523 }, 524 - "@scalar/hono-api-reference@0.9.28_hono@4.11.0": { 524 + "@scalar/hono-api-reference@0.9.28_hono@4.11.3": { 525 525 "integrity": "sha512-RVY55Rpcy9/irv0SMSxuSlQ6wDyuP+iTDmTz/d5tGv/qqo8vEOJMdDRNftMUqdtqiZUAE8fXJnuDCTJ80ZztAQ==", 526 526 "dependencies": [ 527 527 "@scalar/core", ··· 1523 1523 "help-me@5.0.0": { 1524 1524 "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==" 1525 1525 }, 1526 - "hono-pino@0.10.3_hono@4.11.0_pino@10.1.0": { 1526 + "hono-pino@0.10.3_hono@4.11.3_pino@10.1.0": { 1527 1527 "integrity": "sha512-n0RNPIFOoq25Fg8b4D5gus4sVqI0z+8I17ibl96+p43d07UnZ0EMM/It0qSgfc7UtaC+XP5FkFmRHwBp6owsNA==", 1528 1528 "dependencies": [ 1529 1529 "defu", ··· 1531 1531 "pino" 1532 1532 ] 1533 1533 }, 1534 - "hono@4.11.0": { 1535 - "integrity": "sha512-Jg8uZzN2ul8/qlyid5FO8O624F3AK0wKtkgoeEON1qBum1rM1itYBxoMKu/1SPJC7F1+xlIZsJMmc4HHhJ1AWg==" 1534 + "hono@4.11.3": { 1535 + "integrity": "sha512-PmQi306+M/ct/m5s66Hrg+adPnkD5jiO6IjA7WhWw0gSBSo1EcRegwuI1deZ+wd5pzCGynCcn2DprnE4/yEV4w==" 1536 1536 }, 1537 1537 "html-entities@2.6.0": { 1538 1538 "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==" ··· 2554 2554 "sprintf-js@1.1.3": { 2555 2555 "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==" 2556 2556 }, 2557 - "stoker@2.0.1_@hono+zod-openapi@1.1.5__hono@4.11.0__zod@4.1.13_hono@4.11.0_zod@4.1.13": { 2557 + "stoker@2.0.1_@hono+zod-openapi@1.1.5__hono@4.11.3__zod@4.1.13_hono@4.11.3_zod@4.1.13": { 2558 2558 "integrity": "sha512-liSQNnJmn8fWSEan7sVaFe6iSHuN3X02fDGLS6snwW+FUuKi5HmKUHm3P+Kzr5xiDPqRpmSTtmGEBbSL9H2zkQ==", 2559 2559 "dependencies": [ 2560 2560 "@hono/zod-openapi", ··· 2804 2804 "npm:drizzle-zod@~0.8.3", 2805 2805 "npm:eslint@^9.39.2", 2806 2806 "npm:hono-pino@~0.10.3", 2807 - "npm:hono@^4.11.0", 2807 + "npm:hono@^4.11.3", 2808 2808 "npm:libsql@~0.5.22", 2809 2809 "npm:pino-pretty@^13.1.3", 2810 2810 "npm:pino@^10.1.0",
+12 -2
server/runtimes/dev.ts
··· 1 + import openapiConfig from '@/openapi.config.ts' 2 + import { PhotoAPI } from '@/src/app.ts' 1 3 import type { AppBindings } from '@/types.ts' 2 4 import { OpenAPIHono } from '@hono/zod-openapi' 3 5 import { Scalar } from '@scalar/hono-api-reference' 4 6 import { pinoLogger } from 'hono-pino' 7 + import { cors } from 'hono/cors' 5 8 import pino from 'pino' 6 9 import pretty from 'pino-pretty' 7 10 import defaultHook from 'stoker/openapi/default-hook' 8 - import openapiConfig from '@/openapi.config.ts' 9 - import { PhotoAPI } from '@/src/app.ts' 10 11 11 12 function devTools(app: OpenAPIHono<AppBindings>) { 12 13 app.use(pinoLogger({ 13 14 pino: pino(pretty()), 14 15 })) 16 + 17 + app.use( 18 + '*', // This applies the middleware to all routes 19 + cors({ 20 + origin: ['http://localhost:8300'], // Add your frontend development URLs 21 + allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], // Explicitly allow necessary methods 22 + credentials: true, // Allow cookies/credentials if needed 23 + }) 24 + ); 15 25 16 26 app.doc('/doc', openapiConfig) 17 27
+8 -1
server/src/albums-segmented.ts
··· 10 10 import createErrorSchema from "stoker/openapi/schemas/create-error-schema"; 11 11 import type { Passthrough } from "./app.ts"; 12 12 import { albumToPhoto, photo } from "./db/schema/index.ts"; 13 + import { photoMetadata } from "./db/schema/photo.ts"; 13 14 14 15 export const routes: Array<Passthrough> = [ 15 16 (app) => ··· 64 65 65 66 const createdPhotos = await db.insert(photo).values(imgs).returning(); 66 67 68 + await db.insert(photoMetadata).values(createdPhotos.map((r, index) => { 69 + return { 70 + photoId: r.id, 71 + ...segment.images[index].metadata 72 + } 73 + })) 74 + 67 75 await db.insert(segmentToPhoto).values(createdPhotos.map((p) => { 68 76 return { 69 77 segmentId: createdSegment.id, ··· 86 94 const segmentedAlbumWithNewPhotosWBluntGeo = z.object({ 87 95 album: createInsertSchema(albumTb).omit({ 88 96 id: true, 89 - cover: true, 90 97 }), 91 98 sections: z.array(z.object({ 92 99 segments: z.array(z.object({
+22 -8
server/src/albums.ts
··· 9 9 import createErrorSchema from "stoker/openapi/schemas/create-error-schema"; 10 10 import IdParamsSchema from "stoker/openapi/schemas/id-params"; 11 11 import type { App, Passthrough } from "./app.ts"; 12 - import { albumToPhoto, photo } from "./db/schema/index.ts"; 12 + import { albumSection, albumSegment, albumToPhoto, photo } from "./db/schema/index.ts"; 13 + import { photoMetadata } from "./db/schema/photo.ts"; 13 14 14 15 export const routes: Array<Passthrough> = [ 15 16 (app) => ··· 33 34 }, 34 35 responses: { 35 36 [HttpStatusCodes.OK]: jsonContent( 36 - createSelectSchema(albumTb), 37 + createSelectSchema(albumTb).extend({ 38 + sections: z.array(createSelectSchema(albumSection).pick({ 39 + id: true 40 + }).extend({ 41 + segments: z.array(createSelectSchema(albumSegment).extend({ 42 + images: z.array(createSelectSchema(photo).extend({ 43 + metadata: createSelectSchema(photoMetadata) 44 + })) 45 + })) 46 + })) 47 + }), 37 48 "an album", 38 49 ), 39 - [HttpStatusCodes.UNPROCESSABLE_ENTITY]: jsonContent( 40 - createErrorSchema(createSelectSchema(albumTb)), 41 - "validation errors", 42 - ), 43 50 }, 44 51 }), 45 52 async (c) => { ··· 55 62 id: true, 56 63 }, 57 64 with: { 58 - segments: true, 65 + segments: { 66 + with: { 67 + images: { 68 + with: { 69 + metadata: true 70 + } 71 + } 72 + } 73 + } 59 74 }, 60 75 }, 61 76 }, ··· 173 188 contents: z.array( 174 189 createInsertSchema(photo).omit({ 175 190 id: true, 176 - takenWith: true, 177 191 }), 178 192 ), 179 193 });
+14 -2
server/src/db/schema/index.ts
··· 1 1 import { defineRelations } from "drizzle-orm"; 2 - import { album, albumSection, albumSegment } from "./album.ts"; 3 - import { photo } from './photo.ts'; 2 + import { album, albumSection, albumSegment, segmentToPhoto } from "./album.ts"; 3 + import { photo, photoMetadata } from './photo.ts'; 4 4 5 5 export const relations = defineRelations({ 6 + photoMetadata, 6 7 album, 7 8 albumSection, 8 9 albumSegment, 10 + segmentToPhoto, 9 11 photo 10 12 }, (r) => ({ 13 + photo: { 14 + metadata: r.one.photoMetadata({ 15 + from: r.photo.id, 16 + to: r.photoMetadata.photoId 17 + }) 18 + }, 11 19 albumSegment: { 12 20 parent: r.one.albumSection({ 13 21 from: r.albumSegment.sectionId, 14 22 to: r.albumSection.id 23 + }), 24 + images: r.many.photo({ 25 + from: r.albumSegment.id.through(r.segmentToPhoto.segmentId), 26 + to: r.photo.id.through(r.segmentToPhoto.photoId) 15 27 }) 16 28 }, 17 29 albumSection: {
+5 -5
server/src/db/schema/photo.ts
··· 1 - import { defineRelations } from "drizzle-orm"; 2 1 import { int, integer, real, sqliteTable, sqliteView, text } from 'drizzle-orm/sqlite-core'; 3 2 4 3 export const photo = sqliteTable('photo', { ··· 6 5 fileName: text().notNull(), 7 6 dateCreated: int().notNull(), 8 7 dateModified: int().notNull(), 8 + }) 9 + 10 + export const photoMetadata = sqliteTable('photoMetadata', { 11 + photoId: integer({ mode: 'number' }).notNull().references(() => photo.id).primaryKey(), 9 12 lat: real(), 10 13 lon: real(), 11 14 width: int().notNull(), ··· 18 21 const timeline = sqliteView("timeline_view").as((qb) => qb 19 22 .select() 20 23 .from(photo) 21 - ); 22 - 23 - 24 - export const relations = defineRelations({ photo }) 24 + );