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