an attempt at a lightweight photo/album viewer

initial frontend album card display

+533 -139
+133
frontend/dist/album-cards.css
··· 1 + /* Container styling */ 2 + .album-grid { 3 + display: grid; 4 + gap: 16px; 5 + padding: 16px; 6 + max-width: 1200px; 7 + margin: 0 auto; 8 + /* Default: Mobile 2 columns */ 9 + grid-template-columns: repeat(2, 1fr); 10 + } 11 + 12 + /* Desktop: Auto-adjust columns based on card width */ 13 + @media (min-width: 768px) { 14 + .album-grid { 15 + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); 16 + gap: 24px; 17 + } 18 + } 19 + 20 + /* Card Styling */ 21 + .album-card { 22 + position: relative; 23 + background: #fff; 24 + border-radius: 8px; 25 + overflow: hidden; 26 + box-shadow: 0 4px 6px rgba(0,0,0,0.1); 27 + display: flex; 28 + flex-direction: column; 29 + cursor: pointer; 30 + } 31 + 32 + .album-cover { 33 + width: 100%; 34 + aspect-ratio: 1 / 1; /* Keeps images square */ 35 + object-fit: cover; 36 + display: block; 37 + } 38 + 39 + .album-info { 40 + padding: 12px; 41 + } 42 + 43 + .album-title { 44 + margin: 0; 45 + font-size: 1rem; 46 + font-weight: 600; 47 + color: #333; 48 + /* Basic line clamping for long titles */ 49 + -webkit-line-clamp: 2; 50 + -webkit-box-orient: vertical; 51 + overflow: hidden; 52 + } 53 + 54 + .edit-input { 55 + width: 80%; /* Leave room for icons */ 56 + font-size: 1rem; 57 + padding: 4px; 58 + border: 1px solid #007bff; 59 + border-radius: 4px; 60 + outline: none; 61 + } 62 + 63 + /* Icon Buttons */ 64 + .action-area { 65 + position: absolute; 66 + bottom: 12px; 67 + right: 12px; 68 + display: flex; 69 + gap: 8px; 70 + } 71 + 72 + .icon-btn { 73 + background: #eee; 74 + border: none; 75 + border-radius: 50%; 76 + width: 28px; 77 + height: 28px; 78 + cursor: pointer; 79 + display: flex; 80 + align-items: center; 81 + justify-content: center; 82 + font-weight: bold; 83 + } 84 + 85 + .btn-tick { background: #d4edda; color: #155724; } 86 + .btn-cross { background: #f8d7da; color: #721c24; } 87 + 88 + .menu-container { 89 + position: absolute; 90 + bottom: 8px; 91 + right: 8px; 92 + } 93 + 94 + .menu-trigger { 95 + background: rgba(255, 255, 255, 0.9); 96 + border: none; 97 + border-radius: 50%; 98 + width: 32px; 99 + height: 32px; 100 + font-size: 20px; 101 + cursor: pointer; 102 + display: flex; 103 + align-items: center; 104 + justify-content: center; 105 + box-shadow: 0 2px 4px rgba(0,0,0,0.2); 106 + } 107 + 108 + .menu-dropdown { 109 + display: none; 110 + position: absolute; 111 + bottom: 100%; 112 + right: 0; 113 + background: white; 114 + min-width: 120px; 115 + box-shadow: 0 8px 16px rgba(0,0,0,0.2); 116 + border-radius: 4px; 117 + z-index: 10; 118 + margin-bottom: 8px; 119 + } 120 + 121 + .menu-dropdown.show { 122 + display: block; 123 + } 124 + 125 + .menu-item { 126 + padding: 12px; 127 + font-size: 14px; 128 + color: #333; 129 + } 130 + 131 + .menu-item:hover { 132 + background-color: #f5f5f5; 133 + }
+28
frontend/dist/album.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 + <title>Gallery</title> 7 + <link href="styles.css" rel="stylesheet"> 8 + <link href="scrobbler.css" rel="stylesheet"> 9 + </head> 10 + <body> 11 + <header> 12 + <h1 id="album-title"></h1> 13 + </header> 14 + <div id="grid" class="scrubbable-grid"></div> 15 + <div id="scrobbler-container"> 16 + <div id="scrobbler-track"></div> 17 + <div id="scrobbler-handle-container"> 18 + <div id="scrobbler-handle">↕&nbsp;</div> 19 + </div> 20 + <div id="scrobbler-date"></div> 21 + <div id="scrobbler-section-title"></div> 22 + </div> 23 + <script type="module"> 24 + import { init } from './app.js'; 25 + init(); 26 + </script> 27 + </body> 28 + </html>
+9 -15
frontend/dist/index.html
··· 3 3 <head> 4 4 <meta charset="UTF-8"> 5 5 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 - <title>Gallery</title> 7 - <link href="styles.css" rel="stylesheet"> 8 - <link href="scrobbler.css" rel="stylesheet"> 6 + <title>Albums</title> 7 + <link rel="stylesheet" href="album-cards.css"> 9 8 </head> 10 9 <body> 11 10 <header> 12 - <h1 id="album-title"></h1> 11 + <h1>Albums</h1> 13 12 </header> 14 - <div id="grid" class="scrubbable-grid"></div> 15 - <div id="scrobbler-container"> 16 - <div id="scrobbler-track"></div> 17 - <div id="scrobbler-handle-container"> 18 - <div id="scrobbler-handle">↕&nbsp;</div> 19 - </div> 20 - <div id="scrobbler-date"></div> 21 - <div id="scrobbler-section-title"></div> 22 - </div> 13 + 14 + <main id="album-grid" class="album-grid"> 15 + <p class="loading">Loading albums...</p> 16 + </main> 23 17 <script type="module"> 24 - import { init } from './app.js'; 25 - init(); 18 + import { albumList } from './app.js'; 19 + albumList(); 26 20 </script> 27 21 </body> 28 22 </html>
+4 -2
frontend/esb/build.mjs
··· 20 20 define['process.env.NODE_ENV'] = JSON.stringify(env); 21 21 22 22 const buildOptions = { 23 - entryPoints: ['src/index.ts'], 23 + entryPoints: [ 24 + 'src/index.ts' 25 + ], 24 26 bundle: true, 25 27 format: "esm", 26 28 outfile: 'dist/app.js', ··· 64 66 const { hosts, port } = await context.serve({ 65 67 servedir: './dist', 66 68 host: '127.0.0.1', 67 - port: 8000, 69 + port: 7999, 68 70 }); 69 71 console.log(`Dev server listening at http://${hosts}:${port}`); 70 72 const proxyPort = process.env.FRONTEND_DEV_PORT;
-1
frontend/esb/httptext.mjs
··· 111 111 112 112 // Verify the cached file's integrity 113 113 if (cachedHash === checksum) { 114 - console.log(`Loaded from cache: ${url}`); 115 114 return { contents: cachedContents, loader: 'text' }; 116 115 } else { 117 116 console.warn(`Cache invalid for: ${url} (checksum mismatch). Re-downloading...`);
+13 -4
frontend/esb/liveserver.mjs
··· 1 1 import { createServer, request } from "http"; 2 2 3 3 const clients = []; 4 + var tx = false 4 5 5 6 const lrPlug = { 6 7 name: 'live-reload', 7 8 setup(build) { 8 - build.onEnd(result => { 9 + build.onStart(result => { 10 + if (tx) { 11 + return 12 + } 9 13 clients.forEach((res) => res.write("data: update\n\n")); 10 14 clients.length = 0; 11 15 }); ··· 23 27 Connection: "keep-alive", 24 28 }) 25 29 ); 30 + tx = true; 26 31 const path = ~url.split("/").pop().indexOf(".") ? url : `/index.html`; 32 + const jsReloadCode = 33 + ' (() => new EventSource("/esbuild").onmessage = () => location.reload())();'; 27 34 req.pipe( 28 35 request( 29 36 { hostname: fromHost, port: fromPort, path, method, headers }, 30 37 (prxRes) => { 31 - if (url === "/app.js") { 38 + if (url.startsWith("/app.js")) { 32 39 33 - const jsReloadCode = 34 - ' (() => new EventSource("/esbuild").onmessage = () => location.reload())();'; 35 40 36 41 const newHeaders = { 37 42 ...prxRes.headers, ··· 50 55 res.writeHead(prxRes.statusCode, newHeaders); 51 56 } 52 57 prxRes.pipe(res, { end: true }); 58 + prxRes.on('end', () => { 59 + // probably because of the proxy there is a race between build finishing and the browser getting the esbuild reload trigger 60 + tx = false 61 + }); 53 62 } 54 63 ), 55 64 { end: true }
+173
frontend/src/albums.ts
··· 1 + import { CopyPartyImage } from "./copyparty/copy-party-image.js"; 2 + 3 + export default async function list() { 4 + const metaAPI = process.env.METADATA_API 5 + const albumsUrl = `${metaAPI}/albums` 6 + const updateAlbumUrl = `${metaAPI}/album` 7 + 8 + // Initialize the gallery 9 + const gallery = new AlbumGallery("album-grid", updateAlbumUrl); 10 + gallery.init(albumsUrl); 11 + } 12 + interface Album { 13 + id: number | null; 14 + title: string; 15 + year: number | null; 16 + cover: string; 17 + slug: string; 18 + } 19 + 20 + interface Album { 21 + id: number | null; 22 + title: string; 23 + year: number | null; 24 + cover: string; 25 + slug: string; 26 + } 27 + 28 + class AlbumGallery { 29 + private container: HTMLElement | null; 30 + 31 + private updateUrl: string 32 + 33 + constructor(containerId: string, updateUrl: string) { 34 + this.container = document.getElementById(containerId); 35 + this.updateUrl = updateUrl 36 + } 37 + 38 + async init(apiUrl: string): Promise<void> { 39 + try { 40 + const response = await fetch(apiUrl); 41 + const albums: Album[] = await response.json(); 42 + 43 + if (this.container) { 44 + this.container.innerHTML = ""; 45 + albums.forEach(albumData => { 46 + const card = new AlbumCard(albumData, this.updateAlbumApi.bind(this)); 47 + this.container?.appendChild(card.getElement()); 48 + }); 49 + } 50 + } catch (error) { 51 + console.error("Initialization failed", error); 52 + } 53 + } 54 + 55 + /** 56 + * API Call Wrapper 57 + * Passed to child components to handle persistence 58 + */ 59 + private async updateAlbumApi(id: number | null, newTitle: string): Promise<string> { 60 + const response = await fetch(`${this.updateUrl}/${id}`, { 61 + method: 'PATCH', 62 + headers: { 'Content-Type': 'application/json' }, 63 + body: JSON.stringify({ title: newTitle }) 64 + }); 65 + 66 + if (!response.ok) throw new Error("Update failed"); 67 + 68 + const data = await response.json(); 69 + return data.title; // Return the server-confirmed title 70 + } 71 + } 72 + 73 + export class AlbumCard { 74 + private element: HTMLElement; 75 + private isEditing: boolean = false; 76 + private originalTitle: string; 77 + 78 + constructor(private data: Album, private onUpdate: (id: number | null, newTitle: string) => Promise<string>) { 79 + this.originalTitle = data.title; 80 + this.element = document.createElement('div'); 81 + this.element.className = 'album-card'; 82 + this.render(); 83 + } 84 + 85 + public getElement(): HTMLElement { 86 + return this.element; 87 + } 88 + 89 + private render(): void { 90 + const cover = new CopyPartyImage(this.data.cover) 91 + this.element.innerHTML = ` 92 + <img class="album-cover" src="${cover.thumbnail()}" alt="${this.data.title}" loading="lazy"> 93 + <div class="album-title-container"> 94 + ${this.isEditing 95 + ? `<input type="text" class="edit-input" value="${this.data.title}">` 96 + : `<h3 class="album-title">${this.data.title}</h3>`} 97 + </div> 98 + <div class="action-area"> 99 + ${this.renderActions()} 100 + </div> 101 + `; 102 + 103 + this.attachEventListeners(); 104 + } 105 + 106 + private renderActions(): string { 107 + if (this.isEditing) { 108 + return ` 109 + <button class="icon-btn btn-tick" title="Save">✓</button> 110 + <button class="icon-btn btn-cross" title="Cancel">✕</button> 111 + `; 112 + } 113 + return ` 114 + <button class="icon-btn menu-trigger">⋮</button> 115 + <div class="menu-dropdown"> 116 + <div class="menu-item js-rename">Rename Album</div> 117 + </div> 118 + `; 119 + } 120 + 121 + private attachEventListeners(): void { 122 + // Card Navigation 123 + this.element.onclick = () => { 124 + if (!this.isEditing) window.location.href = `/album.html?album=${this.data.slug}`; 125 + }; 126 + 127 + // Stop propagation for all buttons and inputs 128 + this.element.querySelectorAll('button, input, .menu-dropdown').forEach(el => { 129 + el.addEventListener('click', (e) => e.stopPropagation()); 130 + }); 131 + 132 + if (this.isEditing) { 133 + const input = this.element.querySelector('.edit-input') as HTMLInputElement; 134 + const tick = this.element.querySelector('.btn-tick') as HTMLButtonElement; 135 + const cross = this.element.querySelector('.btn-cross') as HTMLButtonElement; 136 + 137 + input.focus(); 138 + 139 + tick.onclick = async () => { 140 + const newTitle = input.value; 141 + tick.disabled = true; // Prevent double submission 142 + try { 143 + const updatedTitle = await this.onUpdate(this.data.id, newTitle); 144 + this.data.title = updatedTitle; 145 + this.isEditing = false; 146 + this.render(); 147 + } catch (err) { 148 + alert("Failed to update title"); 149 + tick.disabled = false; 150 + } 151 + }; 152 + 153 + cross.onclick = () => { 154 + this.isEditing = false; 155 + this.render(); 156 + }; 157 + } else { 158 + const trigger = this.element.querySelector('.menu-trigger') as HTMLButtonElement; 159 + const menu = this.element.querySelector('.menu-dropdown') as HTMLElement; 160 + const renameBtn = this.element.querySelector('.js-rename') as HTMLElement; 161 + 162 + trigger.onclick = (e) => { 163 + e.stopPropagation(); 164 + menu.classList.toggle('show'); 165 + }; 166 + 167 + renameBtn.onclick = () => { 168 + this.isEditing = true; 169 + this.render(); 170 + }; 171 + } 172 + } 173 + }
+19
frontend/src/copyparty/copy-party-image.ts
··· 1 + import type { ImageUrlProvider } from "../image-url-provider.js" 2 + 3 + export class CopyPartyImage implements ImageUrlProvider { 4 + private THUMB_URL_PARAMS = "th=wf3&cache=i&_=1liSY&raster" 5 + 6 + private baseUrl: string 7 + 8 + constructor(baseUrl: string) { 9 + this.baseUrl = baseUrl 10 + } 11 + 12 + thumbnail(): string { 13 + return `${this.baseUrl}?${this.THUMB_URL_PARAMS}` 14 + } 15 + 16 + img(): string { 17 + return `${this.baseUrl}` 18 + } 19 + }
+48 -49
frontend/src/gallery.mjs
··· 18 18 const albumUrl = `${apiBase}/photos/${album}`; 19 19 20 20 let thumbUrlParams = "th=wf3&cache=i&_=1liSY&raster" 21 - let sectionStore = fetch(`${albumUrl}/store.geo.json`).then(res => res.json()); 21 + let sectionStore = () => fetch(`${albumUrl}/store.geo.json`).then(res => res.json()); 22 22 let regionStore = `${apiBase}/${process.env.GEO_API_ENDPOINT}` 23 23 const geo = new Geo(regionStore); 24 24 ··· 26 26 27 27 28 28 function getSections() { 29 - return sectionStore.then(delay(50 + Math.random() * 500)).then(store => { 29 + return sectionStore().then(delay(50 + Math.random() * 500)).then(store => { 30 30 return store.map(section => { 31 31 return { sectionId: section.sectionId, totalImages: section.totalImages }; 32 32 }); ··· 34 34 } 35 35 36 36 function getSegments(sectionId) { 37 - return sectionStore.then(delay(50 + Math.random() * 500)).then(store => { 37 + return sectionStore().then(delay(50 + Math.random() * 500)).then(store => { 38 38 return store.find(section => section.sectionId == sectionId).segments 39 39 }); 40 40 } ··· 118 118 }; 119 119 120 120 121 - sectionStore.then(store => { 121 + sectionStore().then(store => { 122 122 window.allSections = store.map(section => ({ sectionId: section.sectionId, totalImages: section.totalImages })); 123 123 populateGrid(document.getElementById("grid"), store); 124 124 }); ··· 130 130 document.title = albumTitle; 131 131 document.getElementById('album-title').textContent = albumTitle; 132 132 } 133 - 134 - window.onload = loadUi; 135 - window.onresize = loadUi; 136 133 137 134 function populateGrid(gridNode, store) { 138 135 const sectionsHtml = window.allSections.map(getDetachedSectionHtml).join("\n"); ··· 172 169 return height; 173 170 } 174 171 175 - document.addEventListener('DOMContentLoaded', () => { 176 - const scrobblerContainer = document.getElementById('scrobbler-container'); 177 - const scrobblerHandle = document.getElementById('scrobbler-handle-container'); 178 - const scrobblerSectionTitle = document.getElementById('scrobbler-section-title'); 179 - const grid = document.getElementById('grid'); 172 + export function register() { 173 + document.addEventListener('DOMContentLoaded', () => { 174 + const scrobblerContainer = document.getElementById('scrobbler-container'); 175 + const scrobblerHandle = document.getElementById('scrobbler-handle-container'); 176 + const scrobblerSectionTitle = document.getElementById('scrobbler-section-title'); 177 + const grid = document.getElementById('grid'); 180 178 181 - let allSegments = []; 182 - let minTimestamp, maxTimestamp; 183 - let segmentOffsets = new Map(); 179 + let allSegments = []; 180 + let minTimestamp, maxTimestamp; 181 + let segmentOffsets = new Map(); 184 182 185 - const scrobblerCtrl = new Scrobbler(grid, scrobblerContainer, scrobblerHandle, scrobblerSectionTitle); 186 - scrobblerCtrl.register(); 183 + const scrobblerCtrl = new Scrobbler(grid, scrobblerContainer, scrobblerHandle, scrobblerSectionTitle); 184 + scrobblerCtrl.register(); 187 185 188 - grid.addEventListener('grid-populated', (e) => { 189 - const store = e.detail.store; 190 - allSegments = store.flatMap(section => 191 - section.segments.map(segment => ({...segment, sectionId: section.sectionId})) 192 - ).sort((a, b) => new Date(a.images[0].timestamp) - new Date(b.images[0].timestamp)); 193 - minTimestamp = new Date(allSegments[0].images[0].timestamp); 194 - maxTimestamp = new Date(allSegments[allSegments.length - 1].images[0].timestamp); 195 - }); 196 - grid.addEventListener('section-populated', (e) => { 197 - const section = e.detail.sectionDiv; 198 - const segments = section.querySelectorAll('.segment'); 186 + grid.addEventListener('grid-populated', (e) => { 187 + const store = e.detail.store; 188 + allSegments = store.flatMap(section => 189 + section.segments.map(segment => ({...segment, sectionId: section.sectionId})) 190 + ).sort((a, b) => new Date(a.images[0].timestamp) - new Date(b.images[0].timestamp)); 191 + minTimestamp = new Date(allSegments[0].images[0].timestamp); 192 + maxTimestamp = new Date(allSegments[allSegments.length - 1].images[0].timestamp); 193 + }); 194 + grid.addEventListener('section-populated', (e) => { 195 + const section = e.detail.sectionDiv; 196 + const segments = section.querySelectorAll('.segment'); 199 197 200 - segments.forEach(s => { 201 - segmentOffsets.set(s.id, s.offsetTop); 202 - }); 203 - scrobblerCtrl.updateGalleryMeta(allSegments, segmentOffsets, minTimestamp, maxTimestamp); 198 + segments.forEach(s => { 199 + segmentOffsets.set(s.id, s.offsetTop); 200 + }); 201 + scrobblerCtrl.updateGalleryMeta(allSegments, segmentOffsets, minTimestamp, maxTimestamp); 204 202 205 - segments.forEach(s => { 206 - const tooltipText = s.dataset.title; 207 - const tooltip = document.createElement('div'); 208 - tooltip.innerHTML = tooltipText; 209 - tooltip.setAttribute('class', 'scrobbler-tooltip'); 210 - scrobblerContainer.appendChild(tooltip); 211 - scrobblerCtrl.registerTooltip(s, tooltip); 212 - }); 203 + segments.forEach(s => { 204 + const tooltipText = s.dataset.title; 205 + const tooltip = document.createElement('div'); 206 + tooltip.innerHTML = tooltipText; 207 + tooltip.setAttribute('class', 'scrobbler-tooltip'); 208 + scrobblerContainer.appendChild(tooltip); 209 + scrobblerCtrl.registerTooltip(s, tooltip); 210 + }); 213 211 214 - const regionTags = [...segments].flatMap(s => s.dataset.regions).flatMap(rs => rs.split(',')).filter(r => r.length !== 0) 215 - geo.load(regionTags).then(_ => { 216 - [...segments].forEach(s => { 217 - s.dataset.regions.split(',').forEach(r => { 218 - s.children[0].innerHTML += ` ${geo.localRegionDesc(r)}, ${geo.countryName(r)}`; 219 - }) 220 - }) 212 + const regionTags = [...segments].flatMap(s => s.dataset.regions).flatMap(rs => rs.split(',')).filter(r => r.length !== 0) 213 + geo.load(regionTags).then(_ => { 214 + [...segments].forEach(s => { 215 + s.dataset.regions.split(',').forEach(r => { 216 + s.children[0].innerHTML += ` ${geo.localRegionDesc(r)}, ${geo.countryName(r)}`; 217 + }) 218 + }) 219 + }); 221 220 }); 222 - }); 223 221 224 222 225 - }); 223 + }); 224 + }
+5
frontend/src/image-url-provider.ts
··· 1 + export interface ImageUrlProvider { 2 + thumbnail(): string, 3 + img(): string 4 + 5 + }
+11 -2
frontend/src/index.ts
··· 1 - import { loadUi } from './gallery.mjs' 1 + import { register, loadUi } from './gallery.mjs' 2 + 3 + import listAlbums from './albums.js' 2 4 3 5 function init() { 6 + window.onload = loadUi; 7 + window.onresize = loadUi; 8 + register(); 4 9 loadUi(); 5 10 } 6 11 7 - export { init }; 12 + function albumList() { 13 + listAlbums() 14 + } 15 + 16 + export { init, albumList };
+2 -1
preprocessing/savetobackendblunt.py
··· 21 21 client = Client(base_url="http://adams-laptop:8000") 22 22 album=dict() 23 23 album['album'] = dict() 24 - album['album']['title'] = 'a test' 24 + album['album']['title'] = args.target_directory 25 + album['album']['slug'] = args.target_directory 25 26 album['album']['year'] = 2025 26 27 album['sections'] = store_data 27 28 data = PostSegmentedAlbumWithNewPhotosWBluntGeoBody.from_dict(album)
+1 -1
server/runtimes/dev.ts
··· 18 18 '*', // This applies the middleware to all routes 19 19 cors({ 20 20 origin: ['http://localhost:8300'], // Add your frontend development URLs 21 - allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], // Explicitly allow necessary methods 21 + allowMethods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'], // Explicitly allow necessary methods 22 22 credentials: true, // Allow cookies/credentials if needed 23 23 }) 24 24 );
+85 -63
server/src/albums.ts
··· 2 2 import db from "@/src/db/index.ts"; 3 3 import { album as albumTb } from "@/src/db/schema/album.ts"; 4 4 import { createRoute, z } from "@hono/zod-openapi"; 5 - import { createSelectSchema } from "drizzle-zod"; 5 + import { eq } from "drizzle-orm/sql"; 6 + import { createSelectSchema, createUpdateSchema } from "drizzle-zod"; 6 7 import * as HttpStatusCodes from "stoker/http-status-codes"; 7 8 import jsonContent from "stoker/openapi/helpers/json-content"; 8 9 import jsonContentRequired from "stoker/openapi/helpers/json-content-required"; ··· 13 14 import { photoMetadata } from "./db/schema/photo.ts"; 14 15 15 16 export const routes: Array<Passthrough> = [ 16 - (app) => 17 - app.openapi( 18 - createRoute({ 19 - path: "/albums", 20 - method: "get", 21 - responses: { 22 - [HttpStatusCodes.OK]: jsonContent(listOf, "all albums"), 23 - }, 24 - }), 25 - async (c) => c.json(await db.query.album.findMany()), 26 - ), 27 - (app) => 28 - app.openapi( 29 - createRoute({ 30 - path: "/album/{id}", 31 - method: "get", 32 - request: { 33 - params: IdParamsSchema, 34 - }, 35 - responses: { 36 - [HttpStatusCodes.OK]: jsonContent( 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 - })) 17 + (app) => app.openapi( 18 + createRoute({ 19 + path: "/albums", 20 + method: "get", 21 + responses: { 22 + [HttpStatusCodes.OK]: jsonContent(listOf, "all albums"), 23 + }, 24 + }), 25 + async (c) => c.json(await db.query.album.findMany()), 26 + ), 27 + (app) => app.openapi(createRoute({ 28 + path: "/album/{id}", 29 + method: "get", 30 + request: { 31 + params: IdParamsSchema, 32 + }, 33 + responses: { 34 + [HttpStatusCodes.OK]: jsonContent( 35 + createSelectSchema(albumTb).extend({ 36 + sections: z.array(createSelectSchema(albumSection).pick({ 37 + id: true 38 + }).extend({ 39 + segments: z.array(createSelectSchema(albumSegment).extend({ 40 + images: z.array(createSelectSchema(photo).extend({ 41 + metadata: createSelectSchema(photoMetadata) 45 42 })) 46 43 })) 47 - }), 48 - "an album", 49 - ), 50 - }, 51 - }), 52 - async (c) => { 53 - const params = c.req.valid("param"); 54 - return c.json( 55 - await db.query.album.findFirst({ 56 - where: { 57 - id: params.id, 58 - }, 59 - with: { 60 - sections: { 61 - columns: { 62 - id: true, 63 - }, 64 - with: { 65 - segments: { 66 - with: { 67 - images: { 68 - with: { 69 - metadata: true 70 - } 71 - } 72 - } 73 - } 74 - }, 75 - }, 76 - }, 44 + })) 77 45 }), 78 - HttpStatusCodes.OK, 79 - ); 46 + "an album", 47 + ), 80 48 }, 81 - ), 49 + }), 50 + async (c) => { 51 + const params = c.req.valid("param"); 52 + return c.json( 53 + await db.query.album.findFirst({ 54 + where: { 55 + id: params.id, 56 + }, 57 + with: { 58 + sections: { 59 + columns: { id: true, }, 60 + with: { segments: { with: { images: { with: { metadata: true } } } } }, 61 + }, 62 + }, 63 + }), 64 + HttpStatusCodes.OK, 65 + ); 66 + }), 82 67 createAlbum, 68 + updateAlbum, 83 69 createBatchPhotosInAlbum, 84 70 ]; 85 71 86 72 const listOf = z.array(createSelectSchema(albumTb, {})); 73 + 74 + function updateAlbum(app: App) { 75 + return app.openapi( 76 + createRoute({ 77 + path: "/album/{id}", 78 + method: "patch", 79 + request: { 80 + params: IdParamsSchema, 81 + body: jsonContentRequired( 82 + createUpdateSchema(albumTb).omit({ 83 + id: true 84 + }), 85 + "album to update", 86 + ), 87 + }, 88 + responses: { 89 + [HttpStatusCodes.OK]: jsonContent( 90 + createSelectSchema(albumTb), 91 + "updated album", 92 + ), 93 + [HttpStatusCodes.UNPROCESSABLE_ENTITY]: jsonContent( 94 + createErrorSchema(createUpdateSchema(albumTb).omit({ 95 + id: true 96 + })), 97 + "validation errors", 98 + ), 99 + }, 100 + }), 101 + async (c) => { 102 + const params = c.req.valid("param"); 103 + const album = c.req.valid("json"); 104 + const [created] = await db.update(albumTb).set(album).where(eq(albumTb.id, params.id)).returning() 105 + return c.json(created, HttpStatusCodes.OK); 106 + }, 107 + ); 108 + } 87 109 88 110 function createAlbum(app: App) { 89 111 return app.openapi(
+2 -1
server/src/db/schema/album.ts
··· 2 2 import { photo } from './photo.ts'; 3 3 4 4 export const album = sqliteTable('album', { 5 - id: int('id', { mode: 'number' }).primaryKey({ autoIncrement: true }), 5 + id: int('id', { mode: 'number' }).notNull().primaryKey({ autoIncrement: true }), 6 + slug: text().notNull(), 6 7 title: text().notNull(), 7 8 year: int().notNull(), 8 9 cover: text(),