tangled
alpha
login
or
join now
ericwood.org
/
photos-site
1
fork
atom
A little app to serve my photography from my personal website
1
fork
atom
overview
issues
pulls
pipelines
allow multiple tags
ericwood.org
5 months ago
431de761
3de747de
+183
-62
6 changed files
expand all
collapse all
unified
split
Cargo.lock
Cargo.toml
src
db.rs
models.rs
routes
photos.rs
templates
photos
index.jinja
+40
-1
Cargo.lock
···
112
112
]
113
113
114
114
[[package]]
115
115
+
name = "axum-extra"
116
116
+
version = "0.10.3"
117
117
+
source = "registry+https://github.com/rust-lang/crates.io-index"
118
118
+
checksum = "9963ff19f40c6102c76756ef0a46004c0d58957d87259fc9208ff8441c12ab96"
119
119
+
dependencies = [
120
120
+
"axum",
121
121
+
"axum-core",
122
122
+
"bytes",
123
123
+
"form_urlencoded",
124
124
+
"futures-util",
125
125
+
"http",
126
126
+
"http-body",
127
127
+
"http-body-util",
128
128
+
"mime",
129
129
+
"pin-project-lite",
130
130
+
"rustversion",
131
131
+
"serde_core",
132
132
+
"serde_html_form",
133
133
+
"serde_path_to_error",
134
134
+
"tower-layer",
135
135
+
"tower-service",
136
136
+
"tracing",
137
137
+
]
138
138
+
139
139
+
[[package]]
115
140
name = "backtrace"
116
141
version = "0.3.76"
117
142
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1066
1091
dependencies = [
1067
1092
"anyhow",
1068
1093
"axum",
1094
1094
+
"axum-extra",
1069
1095
"chrono",
1070
1096
"dotenv",
1071
1097
"minijinja",
1072
1098
"serde",
1073
1073
-
"serde_urlencoded",
1099
1099
+
"serde_html_form",
1074
1100
"sqlx",
1075
1101
"tokio",
1076
1102
"tower-http",
···
1268
1294
"proc-macro2",
1269
1295
"quote",
1270
1296
"syn",
1297
1297
+
]
1298
1298
+
1299
1299
+
[[package]]
1300
1300
+
name = "serde_html_form"
1301
1301
+
version = "0.2.8"
1302
1302
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1303
1303
+
checksum = "b2f2d7ff8a2140333718bb329f5c40fc5f0865b84c426183ce14c97d2ab8154f"
1304
1304
+
dependencies = [
1305
1305
+
"form_urlencoded",
1306
1306
+
"indexmap",
1307
1307
+
"itoa",
1308
1308
+
"ryu",
1309
1309
+
"serde_core",
1271
1310
]
1272
1311
1273
1312
[[package]]
+2
-1
Cargo.toml
···
6
6
[dependencies]
7
7
anyhow = "1.0.100"
8
8
axum = "0.8.6"
9
9
+
axum-extra = { version = "0.10.3", features = ["query"] }
9
10
chrono = "0.4.42"
10
11
dotenv = "0.15.0"
11
12
minijinja = { version = "2.12.0", features = ["loader"] }
12
13
serde = "1.0.228"
13
13
-
serde_urlencoded = "0.7.1"
14
14
+
serde_html_form = "0.2.8"
14
15
sqlx = { version = "0.8.6", features = ["runtime-tokio", "sqlite"] }
15
16
tokio = { version = "1.47.1", features = ["rt-multi-thread"] }
16
17
tower-http = { version = "0.6.6", features = ["fs"] }
+41
-22
src/db.rs
···
75
75
pub struct PhotoQuery {
76
76
pub sort: Sort,
77
77
pub pagination: Pagination,
78
78
-
pub tag: Option<String>,
78
78
+
pub tags: Vec<String>,
79
79
}
80
80
81
81
pub async fn get_photos(pool: &SqlitePool, pq: PhotoQuery) -> anyhow::Result<(u32, Vec<Photo>)> {
82
82
-
let photos = build_photo_query(&pq, false)
82
82
+
let photos = build_photo_query(&pq)
83
83
.build_query_as()
84
84
.fetch_all(pool)
85
85
.await?;
86
86
87
87
// I like how convenient `QueryAs` is but it doesn't make it easy for me to select a COUNT for
88
88
// the results on top of it so whatever! One more query!!
89
89
-
let (count,): (u32,) = build_photo_query(&pq, true)
89
89
+
let (count,): (u32,) = build_photo_count_query(&pq)
90
90
.build_query_as()
91
91
.fetch_one(pool)
92
92
.await?;
···
94
94
Ok((count, photos))
95
95
}
96
96
97
97
-
fn build_photo_query(pq: &PhotoQuery, count: bool) -> QueryBuilder<Sqlite> {
97
97
+
fn build_photo_query(pq: &PhotoQuery) -> QueryBuilder<Sqlite> {
98
98
let offset = pq.pagination.limit * (pq.pagination.page - 1);
99
99
100
100
-
let select = if count {
101
101
-
"SELECT COUNT(*) FROM photos "
102
102
-
} else {
103
103
-
"SELECT * FROM photos "
104
104
-
};
105
105
-
let mut query = QueryBuilder::new(select);
100
100
+
let mut query = QueryBuilder::new("SELECT photos.* FROM photos");
106
101
107
107
-
if let Some(tag) = pq.tag.clone() {
102
102
+
if !pq.tags.is_empty() {
103
103
+
query.push(", photo_tags WHERE photo_tags.tag IN (");
104
104
+
let mut separated = query.separated(", ");
105
105
+
for tag in pq.tags.iter().cloned() {
106
106
+
separated.push_bind(tag);
107
107
+
}
108
108
+
separated.push_unseparated(")");
108
109
query
109
109
-
.push("JOIN photo_tags ON photo_tags.tag = ")
110
110
-
.push_bind(tag)
111
111
-
.push(" AND photo_tags.photo_id = photos.id ");
110
110
+
.push(" AND photo_tags.photo_id = photos.id")
111
111
+
.push(" GROUP BY photos.id HAVING COUNT(photo_tags.photo_id)=")
112
112
+
.push_bind(pq.tags.len() as u32);
112
113
};
113
114
114
114
-
if !count {
115
115
-
let sort_sql = pq.sort.to_sql();
116
116
-
query.push(format!("ORDER BY {sort_sql}"));
115
115
+
let sort_sql = pq.sort.to_sql();
116
116
+
query.push(format!(" ORDER BY {sort_sql}"));
117
117
+
118
118
+
query
119
119
+
.push(" LIMIT ")
120
120
+
.push_bind(pq.pagination.limit)
121
121
+
.push(" OFFSET ")
122
122
+
.push_bind(offset);
123
123
+
124
124
+
query
125
125
+
}
126
126
+
127
127
+
fn build_photo_count_query(pq: &PhotoQuery) -> QueryBuilder<Sqlite> {
128
128
+
if pq.tags.is_empty() {
129
129
+
return QueryBuilder::new("SELECT COUNT(*) FROM photos");
130
130
+
}
117
131
118
118
-
query
119
119
-
.push(" LIMIT ")
120
120
-
.push_bind(pq.pagination.limit)
121
121
-
.push(" OFFSET ")
122
122
-
.push_bind(offset);
132
132
+
let mut query =
133
133
+
QueryBuilder::new("SELECT COUNT(*) FROM (SELECT 1 FROM photo_tags WHERE tag IN (");
134
134
+
let mut separated = query.separated(", ");
135
135
+
for tag in pq.tags.iter().cloned() {
136
136
+
separated.push_bind(tag);
123
137
}
138
138
+
separated.push_unseparated(")");
139
139
+
query
140
140
+
.push(" GROUP BY photo_id HAVING COUNT(photo_id)=")
141
141
+
.push_bind(pq.tags.len() as u32)
142
142
+
.push(")");
124
143
125
144
query
126
145
}
+1
-1
src/models.rs
···
16
16
pub created_at: DateTime,
17
17
}
18
18
19
19
-
#[derive(FromRow, Serialize)]
19
19
+
#[derive(FromRow, Serialize, Clone)]
20
20
pub struct Tag {
21
21
pub name: String,
22
22
pub count: u32,
+93
-27
src/routes/photos.rs
···
1
1
-
use std::sync::Arc;
1
1
+
use std::{collections::HashSet, sync::Arc};
2
2
3
3
use axum::{
4
4
-
extract::{Path, Query, State},
4
4
+
extract::{Path, State},
5
5
response::Html,
6
6
};
7
7
+
use axum_extra::extract::Query;
7
8
use minijinja::context;
8
9
use serde::{self, Deserialize, Serialize};
9
10
10
11
use crate::{
11
12
AppError, AppState,
12
13
db::{self, Pagination as QueryPagination, PhotoQuery, Sort, SortDirection, SortField},
14
14
+
models::Tag,
13
15
};
14
16
15
17
#[derive(Serialize)]
16
16
-
pub struct Pagination {
18
18
+
struct Pagination {
17
19
page: u32,
18
20
num_pages: u32,
19
21
prev_query: Option<String>,
20
22
next_query: Option<String>,
21
23
}
22
24
25
25
+
#[derive(Serialize)]
26
26
+
struct SelectedTag {
27
27
+
tag: String,
28
28
+
action: String,
29
29
+
}
30
30
+
31
31
+
#[derive(Serialize)]
32
32
+
struct SelectableTag {
33
33
+
tag: Tag,
34
34
+
action: String,
35
35
+
}
36
36
+
23
37
#[derive(Deserialize, Serialize, Clone)]
24
38
pub struct IndexParams {
25
39
page: Option<u32>,
26
40
limit: Option<u32>,
27
27
-
tag: Option<String>,
41
41
+
tags: Option<Vec<String>>,
28
42
sort: Option<SortField>,
29
43
dir: Option<SortDirection>,
30
44
}
···
34
48
Self {
35
49
page: Some(1),
36
50
limit: Some(10),
37
37
-
tag: None,
51
51
+
tags: Some(vec![]),
38
52
sort: Some(SortField::TakenAt),
39
53
dir: Some(SortDirection::Desc),
40
54
}
···
48
62
State(state): State<Arc<AppState>>,
49
63
) -> Result<Html<String>, AppError> {
50
64
let query = query.0;
51
51
-
let page = query.page.unwrap_or(1);
52
52
-
let limit = query.limit.unwrap_or(10);
53
53
-
let tag = query.tag.clone();
54
54
-
let sort = query.sort.unwrap_or(SortField::TakenAt);
55
55
-
let dir = query.dir.unwrap_or(SortDirection::Desc);
65
65
+
let default = IndexParams::default();
66
66
+
let page = query.page.unwrap_or(default.page.unwrap());
67
67
+
let limit = query.limit.unwrap_or(default.limit.unwrap());
68
68
+
let tags = query.tags.clone().unwrap_or_default();
69
69
+
let sort = query.sort.unwrap_or(default.sort.unwrap());
70
70
+
let dir = query.dir.unwrap_or(default.dir.unwrap());
71
71
+
56
72
let (total_photos, photos) = db::get_photos(
57
73
&state.pool,
58
74
PhotoQuery {
···
61
77
direction: dir,
62
78
},
63
79
pagination: QueryPagination { limit, page },
64
64
-
tag: tag.to_owned(),
80
80
+
tags: tags.clone(),
65
81
},
66
82
)
67
83
.await?;
68
84
69
69
-
let current_tag = tag.clone();
70
70
-
let mut tags = db::get_tags(&state.pool).await?;
71
71
-
if let Some(tag) = tag {
72
72
-
tags.retain(|t| t.name != tag);
73
73
-
}
74
74
-
75
85
let sort_dir = query.dir;
76
86
let sort_field = query.sort;
87
87
+
88
88
+
let num_pages = (total_photos as f32 / limit as f32).ceil() as u32;
89
89
+
let pagination = get_pagination(&query, num_pages)?;
90
90
+
91
91
+
let all_tags = db::get_tags(&state.pool).await?;
92
92
+
let (current_tags, tags) = process_tags(&all_tags, &query)?;
93
93
+
77
94
let template = state.template_env.get_template("photos/index")?;
78
78
-
let num_pages = total_photos / limit;
79
79
-
let pagination = get_pagination(query, num_pages)?;
80
95
let rendered = template.render(context! {
81
96
photos => photos,
82
97
tags => tags,
83
83
-
current_tag => current_tag,
98
98
+
current_tags => current_tags,
84
99
sort_dir => sort_dir,
85
100
sort_field => sort_field,
86
101
sort_fields => SORT_FIELDS,
···
103
118
Ok(Html(rendered))
104
119
}
105
120
106
106
-
fn get_pagination(query: IndexParams, num_pages: u32) -> anyhow::Result<Pagination> {
121
121
+
fn get_pagination(query: &IndexParams, num_pages: u32) -> anyhow::Result<Pagination> {
107
122
let page = query.page.unwrap_or(1);
108
123
let prev_query = if page > 1 {
109
124
let prev_page = if page == 1 { None } else { Some(page - 1) };
110
110
-
Some(serde_urlencoded::to_string(IndexParams {
125
125
+
Some(serde_html_form::to_string(IndexParams {
111
126
page: prev_page,
112
112
-
tag: query.tag.clone(),
113
113
-
..query
127
127
+
tags: query.tags.clone(),
128
128
+
..*query
114
129
})?)
115
130
} else {
116
131
None
···
122
137
} else {
123
138
None
124
139
};
125
125
-
Some(serde_urlencoded::to_string(IndexParams {
140
140
+
Some(serde_html_form::to_string(IndexParams {
126
141
page: next_page,
127
127
-
tag: query.tag.clone(),
128
128
-
..query
142
142
+
tags: query.tags.clone(),
143
143
+
..*query
129
144
})?)
130
145
} else {
131
146
None
···
138
153
next_query,
139
154
})
140
155
}
156
156
+
157
157
+
fn process_tags(
158
158
+
all_tags: &[Tag],
159
159
+
query: &IndexParams,
160
160
+
) -> anyhow::Result<(Vec<SelectedTag>, Vec<SelectableTag>)> {
161
161
+
let selected = query.tags.clone().unwrap_or_default();
162
162
+
let tags = tag_difference(all_tags, &selected);
163
163
+
let selected_tags: Vec<SelectedTag> = selected
164
164
+
.iter()
165
165
+
.map(|tag| {
166
166
+
let tags_removed = selected.iter().filter(|i| *i != tag).cloned().collect();
167
167
+
let action = serde_html_form::to_string(IndexParams {
168
168
+
tags: Some(tags_removed),
169
169
+
..*query
170
170
+
})?;
171
171
+
172
172
+
Ok(SelectedTag {
173
173
+
tag: tag.clone(),
174
174
+
action,
175
175
+
})
176
176
+
})
177
177
+
.collect::<anyhow::Result<Vec<SelectedTag>>>()?;
178
178
+
179
179
+
let selectable_tags: Vec<SelectableTag> = tags
180
180
+
.iter()
181
181
+
.map(|tag| {
182
182
+
let mut tags_added = selected.clone();
183
183
+
tags_added.push(tag.name.clone());
184
184
+
let action = serde_html_form::to_string(IndexParams {
185
185
+
tags: Some(tags_added),
186
186
+
..*query
187
187
+
})?;
188
188
+
189
189
+
Ok(SelectableTag {
190
190
+
tag: tag.clone(),
191
191
+
action,
192
192
+
})
193
193
+
})
194
194
+
.collect::<anyhow::Result<Vec<SelectableTag>>>()?;
195
195
+
196
196
+
Ok((selected_tags, selectable_tags))
197
197
+
}
198
198
+
199
199
+
fn tag_difference(tags: &[Tag], selected: &[String]) -> Vec<Tag> {
200
200
+
let selected: HashSet<String> = HashSet::from_iter(selected.iter().cloned());
201
201
+
202
202
+
tags.iter()
203
203
+
.filter(|tag| !selected.contains(&tag.name))
204
204
+
.cloned()
205
205
+
.collect()
206
206
+
}
+6
-10
templates/photos/index.jinja
···
4
4
<nav>
5
5
{{ dir }}
6
6
<ul>
7
7
-
{% if current_tag %}
8
8
-
<li>{{ current_tag }} <a href="/">🅇</a></li>
9
9
-
{% endif %}
7
7
+
{% for tag in current_tags %}
8
8
+
<li>{{ tag.tag }} <a href="/?{{ tag.action }}">🅇</a></li>
9
9
+
{% endfor %}
10
10
{% for tag in tags %}
11
11
<li>
12
12
-
{% if current_tag == tag.name -%}
13
13
-
{{ tag.name }} ({{ tag.count }})
14
14
-
{% else -%}
15
15
-
<a href="?tag={{ tag.name }}">
16
16
-
{{ tag.name }} ({{ tag.count }})
17
17
-
</a>
18
18
-
{% endif %}
12
12
+
<a href="?{{ tag.action }}">
13
13
+
{{ tag.tag.name }} ({{ tag.tag.count }})
14
14
+
</a>
19
15
</li>
20
16
{% endfor %}
21
17
</ul>