A little app to serve my photography from my personal website

allow multiple tags

+183 -62
+40 -1
Cargo.lock
··· 112 112 ] 113 113 114 114 [[package]] 115 + name = "axum-extra" 116 + version = "0.10.3" 117 + source = "registry+https://github.com/rust-lang/crates.io-index" 118 + checksum = "9963ff19f40c6102c76756ef0a46004c0d58957d87259fc9208ff8441c12ab96" 119 + dependencies = [ 120 + "axum", 121 + "axum-core", 122 + "bytes", 123 + "form_urlencoded", 124 + "futures-util", 125 + "http", 126 + "http-body", 127 + "http-body-util", 128 + "mime", 129 + "pin-project-lite", 130 + "rustversion", 131 + "serde_core", 132 + "serde_html_form", 133 + "serde_path_to_error", 134 + "tower-layer", 135 + "tower-service", 136 + "tracing", 137 + ] 138 + 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 + "axum-extra", 1069 1095 "chrono", 1070 1096 "dotenv", 1071 1097 "minijinja", 1072 1098 "serde", 1073 - "serde_urlencoded", 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 + ] 1298 + 1299 + [[package]] 1300 + name = "serde_html_form" 1301 + version = "0.2.8" 1302 + source = "registry+https://github.com/rust-lang/crates.io-index" 1303 + checksum = "b2f2d7ff8a2140333718bb329f5c40fc5f0865b84c426183ce14c97d2ab8154f" 1304 + dependencies = [ 1305 + "form_urlencoded", 1306 + "indexmap", 1307 + "itoa", 1308 + "ryu", 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 + 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 - serde_urlencoded = "0.7.1" 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 - pub tag: Option<String>, 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 - let photos = build_photo_query(&pq, false) 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 - let (count,): (u32,) = build_photo_query(&pq, true) 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 - fn build_photo_query(pq: &PhotoQuery, count: bool) -> QueryBuilder<Sqlite> { 97 + fn build_photo_query(pq: &PhotoQuery) -> QueryBuilder<Sqlite> { 98 98 let offset = pq.pagination.limit * (pq.pagination.page - 1); 99 99 100 - let select = if count { 101 - "SELECT COUNT(*) FROM photos " 102 - } else { 103 - "SELECT * FROM photos " 104 - }; 105 - let mut query = QueryBuilder::new(select); 100 + let mut query = QueryBuilder::new("SELECT photos.* FROM photos"); 106 101 107 - if let Some(tag) = pq.tag.clone() { 102 + if !pq.tags.is_empty() { 103 + query.push(", photo_tags WHERE photo_tags.tag IN ("); 104 + let mut separated = query.separated(", "); 105 + for tag in pq.tags.iter().cloned() { 106 + separated.push_bind(tag); 107 + } 108 + separated.push_unseparated(")"); 108 109 query 109 - .push("JOIN photo_tags ON photo_tags.tag = ") 110 - .push_bind(tag) 111 - .push(" AND photo_tags.photo_id = photos.id "); 110 + .push(" AND photo_tags.photo_id = photos.id") 111 + .push(" GROUP BY photos.id HAVING COUNT(photo_tags.photo_id)=") 112 + .push_bind(pq.tags.len() as u32); 112 113 }; 113 114 114 - if !count { 115 - let sort_sql = pq.sort.to_sql(); 116 - query.push(format!("ORDER BY {sort_sql}")); 115 + let sort_sql = pq.sort.to_sql(); 116 + query.push(format!(" ORDER BY {sort_sql}")); 117 + 118 + query 119 + .push(" LIMIT ") 120 + .push_bind(pq.pagination.limit) 121 + .push(" OFFSET ") 122 + .push_bind(offset); 123 + 124 + query 125 + } 126 + 127 + fn build_photo_count_query(pq: &PhotoQuery) -> QueryBuilder<Sqlite> { 128 + if pq.tags.is_empty() { 129 + return QueryBuilder::new("SELECT COUNT(*) FROM photos"); 130 + } 117 131 118 - query 119 - .push(" LIMIT ") 120 - .push_bind(pq.pagination.limit) 121 - .push(" OFFSET ") 122 - .push_bind(offset); 132 + let mut query = 133 + QueryBuilder::new("SELECT COUNT(*) FROM (SELECT 1 FROM photo_tags WHERE tag IN ("); 134 + let mut separated = query.separated(", "); 135 + for tag in pq.tags.iter().cloned() { 136 + separated.push_bind(tag); 123 137 } 138 + separated.push_unseparated(")"); 139 + query 140 + .push(" GROUP BY photo_id HAVING COUNT(photo_id)=") 141 + .push_bind(pq.tags.len() as u32) 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 - #[derive(FromRow, Serialize)] 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 - use std::sync::Arc; 1 + use std::{collections::HashSet, sync::Arc}; 2 2 3 3 use axum::{ 4 - extract::{Path, Query, State}, 4 + extract::{Path, State}, 5 5 response::Html, 6 6 }; 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 + models::Tag, 13 15 }; 14 16 15 17 #[derive(Serialize)] 16 - pub struct Pagination { 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 + #[derive(Serialize)] 26 + struct SelectedTag { 27 + tag: String, 28 + action: String, 29 + } 30 + 31 + #[derive(Serialize)] 32 + struct SelectableTag { 33 + tag: Tag, 34 + action: String, 35 + } 36 + 23 37 #[derive(Deserialize, Serialize, Clone)] 24 38 pub struct IndexParams { 25 39 page: Option<u32>, 26 40 limit: Option<u32>, 27 - tag: Option<String>, 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 - tag: None, 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 - let page = query.page.unwrap_or(1); 52 - let limit = query.limit.unwrap_or(10); 53 - let tag = query.tag.clone(); 54 - let sort = query.sort.unwrap_or(SortField::TakenAt); 55 - let dir = query.dir.unwrap_or(SortDirection::Desc); 65 + let default = IndexParams::default(); 66 + let page = query.page.unwrap_or(default.page.unwrap()); 67 + let limit = query.limit.unwrap_or(default.limit.unwrap()); 68 + let tags = query.tags.clone().unwrap_or_default(); 69 + let sort = query.sort.unwrap_or(default.sort.unwrap()); 70 + let dir = query.dir.unwrap_or(default.dir.unwrap()); 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 - tag: tag.to_owned(), 80 + tags: tags.clone(), 65 81 }, 66 82 ) 67 83 .await?; 68 84 69 - let current_tag = tag.clone(); 70 - let mut tags = db::get_tags(&state.pool).await?; 71 - if let Some(tag) = tag { 72 - tags.retain(|t| t.name != tag); 73 - } 74 - 75 85 let sort_dir = query.dir; 76 86 let sort_field = query.sort; 87 + 88 + let num_pages = (total_photos as f32 / limit as f32).ceil() as u32; 89 + let pagination = get_pagination(&query, num_pages)?; 90 + 91 + let all_tags = db::get_tags(&state.pool).await?; 92 + let (current_tags, tags) = process_tags(&all_tags, &query)?; 93 + 77 94 let template = state.template_env.get_template("photos/index")?; 78 - let num_pages = total_photos / limit; 79 - let pagination = get_pagination(query, num_pages)?; 80 95 let rendered = template.render(context! { 81 96 photos => photos, 82 97 tags => tags, 83 - current_tag => current_tag, 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 - fn get_pagination(query: IndexParams, num_pages: u32) -> anyhow::Result<Pagination> { 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 - Some(serde_urlencoded::to_string(IndexParams { 125 + Some(serde_html_form::to_string(IndexParams { 111 126 page: prev_page, 112 - tag: query.tag.clone(), 113 - ..query 127 + tags: query.tags.clone(), 128 + ..*query 114 129 })?) 115 130 } else { 116 131 None ··· 122 137 } else { 123 138 None 124 139 }; 125 - Some(serde_urlencoded::to_string(IndexParams { 140 + Some(serde_html_form::to_string(IndexParams { 126 141 page: next_page, 127 - tag: query.tag.clone(), 128 - ..query 142 + tags: query.tags.clone(), 143 + ..*query 129 144 })?) 130 145 } else { 131 146 None ··· 138 153 next_query, 139 154 }) 140 155 } 156 + 157 + fn process_tags( 158 + all_tags: &[Tag], 159 + query: &IndexParams, 160 + ) -> anyhow::Result<(Vec<SelectedTag>, Vec<SelectableTag>)> { 161 + let selected = query.tags.clone().unwrap_or_default(); 162 + let tags = tag_difference(all_tags, &selected); 163 + let selected_tags: Vec<SelectedTag> = selected 164 + .iter() 165 + .map(|tag| { 166 + let tags_removed = selected.iter().filter(|i| *i != tag).cloned().collect(); 167 + let action = serde_html_form::to_string(IndexParams { 168 + tags: Some(tags_removed), 169 + ..*query 170 + })?; 171 + 172 + Ok(SelectedTag { 173 + tag: tag.clone(), 174 + action, 175 + }) 176 + }) 177 + .collect::<anyhow::Result<Vec<SelectedTag>>>()?; 178 + 179 + let selectable_tags: Vec<SelectableTag> = tags 180 + .iter() 181 + .map(|tag| { 182 + let mut tags_added = selected.clone(); 183 + tags_added.push(tag.name.clone()); 184 + let action = serde_html_form::to_string(IndexParams { 185 + tags: Some(tags_added), 186 + ..*query 187 + })?; 188 + 189 + Ok(SelectableTag { 190 + tag: tag.clone(), 191 + action, 192 + }) 193 + }) 194 + .collect::<anyhow::Result<Vec<SelectableTag>>>()?; 195 + 196 + Ok((selected_tags, selectable_tags)) 197 + } 198 + 199 + fn tag_difference(tags: &[Tag], selected: &[String]) -> Vec<Tag> { 200 + let selected: HashSet<String> = HashSet::from_iter(selected.iter().cloned()); 201 + 202 + tags.iter() 203 + .filter(|tag| !selected.contains(&tag.name)) 204 + .cloned() 205 + .collect() 206 + }
+6 -10
templates/photos/index.jinja
··· 4 4 <nav> 5 5 {{ dir }} 6 6 <ul> 7 - {% if current_tag %} 8 - <li>{{ current_tag }} <a href="/">🅇</a></li> 9 - {% endif %} 7 + {% for tag in current_tags %} 8 + <li>{{ tag.tag }} <a href="/?{{ tag.action }}">🅇</a></li> 9 + {% endfor %} 10 10 {% for tag in tags %} 11 11 <li> 12 - {% if current_tag == tag.name -%} 13 - {{ tag.name }} ({{ tag.count }}) 14 - {% else -%} 15 - <a href="?tag={{ tag.name }}"> 16 - {{ tag.name }} ({{ tag.count }}) 17 - </a> 18 - {% endif %} 12 + <a href="?{{ tag.action }}"> 13 + {{ tag.tag.name }} ({{ tag.tag.count }}) 14 + </a> 19 15 </li> 20 16 {% endfor %} 21 17 </ul>