A little app to serve my photography from my personal website

add view module things

ericwood.org eb73c897 696d7f3e

Waiting for spindle ...
+402 -232
assets/fonts/.DS_Store

This is a binary file and will not be displayed.

assets/photos/index.css src/views/photos/index/style.css
assets/photos/index.js src/views/photos/index/script.js
assets/photos/show.css src/views/photos/show/style.css
+1
src/main.rs
··· 25 25 use tower_http::{services::ServeDir, set_header::SetResponseHeaderLayer}; 26 26 mod config; 27 27 use config::Config; 28 + mod views; 28 29 29 30 use crate::blog::BlogPost; 30 31
+7 -10
src/routes/blog/index.rs
··· 1 - use crate::{AppState, Response, templates::render}; 1 + use crate::{ 2 + AppState, Response, 3 + views::{View, blog::BlogIndex}, 4 + }; 2 5 use axum::{extract::State, response::Html}; 3 - use minijinja::context; 4 6 use std::sync::Arc; 5 7 6 8 pub async fn index(State(state): State<Arc<AppState>>) -> Response { 7 9 let slugs: Vec<&String> = state.blog_slugs.keys().collect(); 8 - let rendered = render( 9 - &state.reloader, 10 - "blog/index", 11 - context! { 12 - slugs 13 - }, 14 - )?; 10 + let view = BlogIndex::new(slugs); 11 + let html = view.render(&state.reloader)?; 15 12 16 - Ok(Html(rendered)) 13 + Ok(Html(html)) 17 14 }
+5 -12
src/routes/blog/show.rs
··· 1 - use crate::{AppState, Response, app_error::AppError, blog::render_post, templates::render}; 1 + use crate::views::{View, blog::BlogShow}; 2 + use crate::{AppState, Response, app_error::AppError}; 2 3 use axum::{ 3 4 extract::{Path, State}, 4 5 response::Html, 5 6 }; 6 - use minijinja::context; 7 7 use std::{fs::read_to_string, sync::Arc}; 8 8 9 9 pub async fn show(Path(slug): Path<String>, State(state): State<Arc<AppState>>) -> Response { 10 10 let post = state.blog_slugs.get(&slug).ok_or(AppError::NotFound)?; 11 - let body = render_post(&post.file_path)?; 12 11 13 12 if state.config.is_prod() { 14 13 let html = read_to_string(&post.cache_path)?; 15 14 return Ok(Html(html)); 16 15 } 17 16 18 - let rendered = render( 19 - &state.reloader, 20 - "blog/show", 21 - context! { 22 - post, 23 - body 24 - }, 25 - )?; 17 + let view = BlogShow::new(post); 18 + let html = view.render(&state.reloader)?; 26 19 27 - Ok(Html(rendered)) 20 + Ok(Html(html)) 28 21 }
+7 -185
src/routes/photos/index.rs
··· 1 - use std::{collections::HashSet, sync::Arc}; 1 + use std::sync::Arc; 2 2 3 3 use axum::{extract::State, response::Html}; 4 4 use axum_extra::extract::Query; 5 - use minijinja::context; 6 - use serde::{self, Deserialize, Serialize}; 7 5 use serde_valid::Validate; 8 6 9 7 use crate::{ 10 8 AppState, Response, 11 - db::{self, Pagination as QueryPagination, PhotoQuery, Sort, SortDirection, SortField}, 12 - models::Tag, 13 - templates::render, 9 + db::{self, Pagination as QueryPagination, PhotoQuery, Sort}, 10 + views::{self, View, photos::IndexParams}, 14 11 }; 15 12 16 - #[derive(Serialize)] 17 - struct Pagination { 18 - page: u32, 19 - num_pages: u32, 20 - prev_query: Option<String>, 21 - next_query: Option<String>, 22 - } 23 - 24 - #[derive(Serialize)] 25 - struct SelectedTag { 26 - name: String, 27 - action: String, 28 - } 29 - 30 - #[derive(Serialize)] 31 - struct SelectableTag { 32 - tag: Tag, 33 - action: String, 34 - } 35 - 36 - #[derive(Deserialize, Serialize, Clone, Validate)] 37 - pub struct IndexParams { 38 - #[validate(minimum = 1)] 39 - page: Option<u32>, 40 - 41 - #[validate(minimum = 1)] 42 - #[validate(maximum = 100)] 43 - limit: Option<u32>, 44 - 45 - tags: Option<Vec<String>>, 46 - sort: Option<SortField>, 47 - dir: Option<SortDirection>, 48 - } 49 - 50 - impl Default for IndexParams { 51 - fn default() -> Self { 52 - Self { 53 - page: Some(1), 54 - limit: Some(30), 55 - tags: Some(vec![]), 56 - sort: Some(SortField::TakenAt), 57 - dir: Some(SortDirection::Desc), 58 - } 59 - } 60 - } 61 - 62 - static SORT_FIELDS: [SortField; 2] = [SortField::TakenAt, SortField::CreatedAt]; 63 - 64 13 pub async fn index(query: Query<IndexParams>, State(state): State<Arc<AppState>>) -> Response { 65 14 query.validate()?; 66 15 let query = query.0; ··· 84 33 ) 85 34 .await?; 86 35 87 - let sort_dir = query.dir; 88 - let sort_field = query.sort; 89 - let sort_link = sort_link(&query, dir)?; 90 - 91 - let num_pages = (total_photos as f32 / limit as f32).ceil() as u32; 92 - let pagination = get_pagination(&query, num_pages)?; 93 - 94 - let all_tags = db::get_tags(&state.photos_db_pool, &query.tags).await?; 95 - let (current_tags, tags) = process_tags(&all_tags, &query)?; 96 - 97 - let rendered = render( 98 - &state.reloader, 99 - "photos/index", 100 - context! { 101 - photos, 102 - tags, 103 - current_tags, 104 - sort_dir, 105 - sort_field, 106 - pagination, 107 - sort_fields => SORT_FIELDS, 108 - sort_link, 109 - }, 110 - )?; 111 - 112 - Ok(Html(rendered)) 113 - } 114 - 115 - fn process_tags( 116 - all_tags: &[Tag], 117 - query: &IndexParams, 118 - ) -> anyhow::Result<(Vec<SelectedTag>, Vec<SelectableTag>)> { 119 - let selected = query.tags.clone().unwrap_or_default(); 120 - let tags = tag_difference(all_tags, &selected); 121 - let selected_tags: Vec<SelectedTag> = selected 122 - .iter() 123 - .enumerate() 124 - .map(|(i, tag)| { 125 - let tags_removed = selected 126 - .iter() 127 - .enumerate() 128 - .filter_map(|(j, s)| if i == j { None } else { Some(s.clone()) }) 129 - .collect(); 130 - let action = serde_html_form::to_string(IndexParams { 131 - tags: Some(tags_removed), 132 - page: None, 133 - ..*query 134 - })?; 135 - 136 - Ok(SelectedTag { 137 - name: tag.clone(), 138 - action, 139 - }) 140 - }) 141 - .collect::<anyhow::Result<Vec<SelectedTag>>>()?; 142 - 143 - let selectable_tags: Vec<SelectableTag> = tags 144 - .iter() 145 - .map(|tag| { 146 - let mut tags_added = selected.clone(); 147 - tags_added.push(tag.name.clone()); 148 - let action = serde_html_form::to_string(IndexParams { 149 - tags: Some(tags_added), 150 - page: None, 151 - ..*query 152 - })?; 153 - 154 - Ok(SelectableTag { 155 - tag: tag.clone(), 156 - action, 157 - }) 158 - }) 159 - .collect::<anyhow::Result<Vec<SelectableTag>>>()?; 160 - 161 - Ok((selected_tags, selectable_tags)) 162 - } 163 - 164 - fn tag_difference(tags: &[Tag], selected: &[String]) -> Vec<Tag> { 165 - let selected: HashSet<&String> = HashSet::from_iter(selected.iter()); 166 - 167 - tags.iter() 168 - .filter(|tag| !selected.contains(&tag.name)) 169 - .cloned() 170 - .collect() 171 - } 172 - 173 - fn get_pagination(query: &IndexParams, num_pages: u32) -> anyhow::Result<Pagination> { 174 - let page = query.page.unwrap_or(1); 175 - let prev_query = if page > 1 { 176 - let prev_page = if page == 1 { None } else { Some(page - 1) }; 177 - Some(serde_html_form::to_string(IndexParams { 178 - page: prev_page, 179 - tags: query.tags.clone(), 180 - ..*query 181 - })?) 182 - } else { 183 - None 184 - }; 185 - 186 - let next_query = if page < num_pages { 187 - let next_page = if page < num_pages { 188 - Some(page + 1) 189 - } else { 190 - None 191 - }; 192 - Some(serde_html_form::to_string(IndexParams { 193 - page: next_page, 194 - tags: query.tags.clone(), 195 - ..*query 196 - })?) 197 - } else { 198 - None 199 - }; 200 - 201 - Ok(Pagination { 202 - page, 203 - num_pages, 204 - prev_query, 205 - next_query, 206 - }) 207 - } 36 + let tags = db::get_tags(&state.photos_db_pool, &query.tags).await?; 208 37 209 - fn sort_link(query: &IndexParams, dir: SortDirection) -> anyhow::Result<String> { 210 - let new_dir = match dir { 211 - SortDirection::Asc => SortDirection::Desc, 212 - SortDirection::Desc => SortDirection::Asc, 213 - }; 38 + let view = views::photos::PhotosIndex::new(query, photos, tags, total_photos); 39 + let html = view.render(&state.reloader)?; 214 40 215 - Ok(serde_html_form::to_string(IndexParams { 216 - dir: Some(new_dir), 217 - tags: query.tags.clone(), 218 - ..*query 219 - })?) 41 + Ok(Html(html)) 220 42 }
+7 -17
src/routes/photos/show.rs
··· 1 - use crate::{AppError, AppState, Response, db, templates::render}; 1 + use crate::{ 2 + AppError, AppState, Response, db, 3 + views::{View, photos::PhotosShow}, 4 + }; 2 5 use axum::{ 3 6 extract::{Path, State}, 4 7 response::Html, 5 8 }; 6 - use minijinja::context; 7 9 use std::sync::Arc; 8 10 9 11 pub async fn show(Path(id): Path<String>, State(state): State<Arc<AppState>>) -> Response { ··· 15 17 })?; 16 18 17 19 let tags = db::get_photo_tags(&state.photos_db_pool, &id).await?; 18 - let aperture = format!("{:.1}", photo.aperture); 19 - let focal_length = format!("{:.0}", photo.focal_length); 20 - let shutter_speed = format!("1/{:.0}s", 1.0 / photo.shutter_speed); 21 20 22 - let rendered = render( 23 - &state.reloader, 24 - "photos/show", 25 - context! { 26 - aperture, 27 - focal_length, 28 - shutter_speed, 29 - photo, 30 - tags, 31 - }, 32 - )?; 21 + let view = PhotosShow::new(photo, tags); 22 + let html = view.render(&state.reloader)?; 33 23 34 - Ok(Html(rendered)) 24 + Ok(Html(html)) 35 25 }
+24 -2
src/templates.rs
··· 22 22 notifier.watch_path(template_path, true); 23 23 } 24 24 env.add_function("url_escape", url_escape); 25 + env.add_function("inline_style", inline_style); 26 + env.add_function("inline_script", inline_script); 25 27 env.add_global( 26 28 "nav_links", 27 29 Value::from_serialize([ ··· 52 54 } 53 55 54 56 fn loader(name: &str) -> Result<Option<String>, Error> { 55 - let root_path = Path::new("templates"); 56 - let template_path = root_path.join(format!("{name}.jinja")); 57 + let is_view = name.starts_with("views"); 58 + let has_extension = name.ends_with(".jinja"); 59 + let root_path = Path::new(if is_view { "src" } else { "templates" }); 60 + 61 + let mut template_path = root_path.join(name); 62 + if is_view && !has_extension { 63 + template_path.push("template") 64 + } 65 + if !has_extension { 66 + template_path.add_extension("jinja"); 67 + } 68 + 57 69 let template = read_to_string(&template_path).map_err(|_| { 58 70 Error::new( 59 71 ErrorKind::TemplateNotFound, ··· 78 90 fn url_escape(input: String) -> String { 79 91 urlencoding::encode(&input).into_owned() 80 92 } 93 + 94 + fn inline_style(path: String) -> String { 95 + let styles = read_to_string(path).expect("unable to locate stylesheet"); 96 + format!("<style type=\"text/css\">\n{styles}\n</style>") 97 + } 98 + 99 + fn inline_script(path: String) -> String { 100 + let script = read_to_string(path).expect("unable to locate script"); 101 + format!("<script type=\"text/javascript\">\n{script}\n</script>") 102 + }
+28
src/views/blog/index/mod.rs
··· 1 + use minijinja::context; 2 + use minijinja_autoreload::AutoReloader; 3 + 4 + use crate::{templates::render, views::View}; 5 + 6 + pub struct BlogIndex<'a> { 7 + slugs: Vec<&'a String>, 8 + } 9 + 10 + impl<'a> BlogIndex<'a> { 11 + pub fn new(slugs: Vec<&'a String>) -> Self { 12 + Self { slugs } 13 + } 14 + } 15 + 16 + impl<'a> View for BlogIndex<'a> { 17 + fn render(&self, reloader: &AutoReloader) -> anyhow::Result<String> { 18 + let html = render( 19 + reloader, 20 + "views/blog/index", 21 + context! { 22 + slugs => self.slugs 23 + }, 24 + )?; 25 + 26 + Ok(html) 27 + } 28 + }
+5
src/views/blog/mod.rs
··· 1 + mod index; 2 + mod show; 3 + 4 + pub use index::BlogIndex; 5 + pub use show::BlogShow;
+34
src/views/blog/show/mod.rs
··· 1 + use minijinja::context; 2 + use minijinja_autoreload::AutoReloader; 3 + 4 + use crate::{ 5 + blog::{BlogPost, render_post}, 6 + templates::render, 7 + views::View, 8 + }; 9 + 10 + pub struct BlogShow<'a> { 11 + post: &'a BlogPost, 12 + } 13 + 14 + impl<'a> BlogShow<'a> { 15 + pub fn new(post: &'a BlogPost) -> Self { 16 + Self { post } 17 + } 18 + } 19 + 20 + impl<'a> View for BlogShow<'a> { 21 + fn render(&self, reloader: &AutoReloader) -> anyhow::Result<String> { 22 + let body = render_post(&self.post.file_path)?; 23 + let html = render( 24 + reloader, 25 + "views/blog/show", 26 + context! { 27 + post => self.post, 28 + body, 29 + }, 30 + )?; 31 + 32 + Ok(html) 33 + } 34 + }
+18
src/views/mod.rs
··· 1 + use minijinja_autoreload::AutoReloader; 2 + 3 + pub mod blog; 4 + pub mod photos; 5 + 6 + pub trait View { 7 + fn render(&self, reloader: &AutoReloader) -> anyhow::Result<String>; 8 + } 9 + 10 + // 11 + // views 12 + // - photos 13 + // - show 14 + // - show.jinja 15 + // - show.rs 16 + // - show.js 17 + // - show.css 18 + // - index
+216
src/views/photos/index/mod.rs
··· 1 + use std::collections::HashSet; 2 + 3 + use minijinja::context; 4 + use minijinja_autoreload::AutoReloader; 5 + use serde::{Deserialize, Serialize}; 6 + use serde_valid::Validate; 7 + 8 + use crate::{ 9 + Photo, 10 + db::{SortDirection, SortField}, 11 + models::Tag, 12 + templates::render, 13 + views::View, 14 + }; 15 + 16 + #[derive(Serialize)] 17 + struct SelectedTag { 18 + name: String, 19 + action: String, 20 + } 21 + 22 + #[derive(Serialize)] 23 + struct SelectableTag { 24 + tag: Tag, 25 + action: String, 26 + } 27 + 28 + #[derive(Serialize)] 29 + struct Pagination { 30 + page: u32, 31 + num_pages: u32, 32 + prev_query: Option<String>, 33 + next_query: Option<String>, 34 + } 35 + 36 + static SORT_FIELDS: [SortField; 2] = [SortField::TakenAt, SortField::CreatedAt]; 37 + 38 + #[derive(Deserialize, Serialize, Clone, Validate)] 39 + pub struct IndexParams { 40 + #[validate(minimum = 1)] 41 + pub page: Option<u32>, 42 + 43 + #[validate(minimum = 1)] 44 + #[validate(maximum = 100)] 45 + pub limit: Option<u32>, 46 + 47 + pub tags: Option<Vec<String>>, 48 + pub sort: Option<SortField>, 49 + pub dir: Option<SortDirection>, 50 + } 51 + 52 + impl Default for IndexParams { 53 + fn default() -> Self { 54 + Self { 55 + page: Some(1), 56 + limit: Some(30), 57 + tags: Some(vec![]), 58 + sort: Some(SortField::TakenAt), 59 + dir: Some(SortDirection::Desc), 60 + } 61 + } 62 + } 63 + 64 + pub struct PhotosIndex { 65 + query: IndexParams, 66 + photos: Vec<Photo>, 67 + total_photos: u32, 68 + tags: Vec<Tag>, 69 + } 70 + 71 + impl PhotosIndex { 72 + pub fn new(query: IndexParams, photos: Vec<Photo>, tags: Vec<Tag>, total_photos: u32) -> Self { 73 + Self { 74 + query, 75 + photos, 76 + total_photos, 77 + tags, 78 + } 79 + } 80 + 81 + fn process_tags(&self) -> anyhow::Result<(Vec<SelectedTag>, Vec<SelectableTag>)> { 82 + let selected_tag_strings = self.query.tags.clone().unwrap_or_default(); 83 + let tags = tag_difference(&self.tags, &selected_tag_strings); 84 + let selected_tags: Vec<SelectedTag> = selected_tag_strings 85 + .iter() 86 + .enumerate() 87 + .map(|(i, tag)| { 88 + let tags_removed = selected_tag_strings 89 + .iter() 90 + .enumerate() 91 + .filter_map(|(j, s)| if i == j { None } else { Some(s.clone()) }) 92 + .collect(); 93 + let action = serde_html_form::to_string(IndexParams { 94 + tags: Some(tags_removed), 95 + page: None, 96 + ..self.query 97 + })?; 98 + 99 + Ok(SelectedTag { 100 + name: tag.clone(), 101 + action, 102 + }) 103 + }) 104 + .collect::<anyhow::Result<Vec<SelectedTag>>>()?; 105 + 106 + let selectable_tags: Vec<SelectableTag> = tags 107 + .iter() 108 + .map(|tag| { 109 + let mut tags_added = selected_tag_strings.clone(); 110 + tags_added.push(tag.name.clone()); 111 + let action = serde_html_form::to_string(IndexParams { 112 + tags: Some(tags_added), 113 + page: None, 114 + ..self.query 115 + })?; 116 + 117 + Ok(SelectableTag { 118 + tag: tag.clone(), 119 + action, 120 + }) 121 + }) 122 + .collect::<anyhow::Result<Vec<SelectableTag>>>()?; 123 + 124 + Ok((selected_tags, selectable_tags)) 125 + } 126 + 127 + fn get_pagination(&self, limit: u32) -> anyhow::Result<Pagination> { 128 + let num_pages = (self.total_photos as f32 / limit as f32).ceil() as u32; 129 + let page = self.query.page.unwrap_or(1); 130 + let prev_query = if page > 1 { 131 + let prev_page = if page == 1 { None } else { Some(page - 1) }; 132 + Some(serde_html_form::to_string(IndexParams { 133 + page: prev_page, 134 + tags: self.query.tags.clone(), 135 + ..self.query 136 + })?) 137 + } else { 138 + None 139 + }; 140 + 141 + let next_query = if page < num_pages { 142 + let next_page = if page < num_pages { 143 + Some(page + 1) 144 + } else { 145 + None 146 + }; 147 + Some(serde_html_form::to_string(IndexParams { 148 + page: next_page, 149 + tags: self.query.tags.clone(), 150 + ..self.query 151 + })?) 152 + } else { 153 + None 154 + }; 155 + 156 + Ok(Pagination { 157 + page, 158 + num_pages, 159 + prev_query, 160 + next_query, 161 + }) 162 + } 163 + 164 + fn sort_link(&self, dir: SortDirection) -> anyhow::Result<String> { 165 + let new_dir = match dir { 166 + SortDirection::Asc => SortDirection::Desc, 167 + SortDirection::Desc => SortDirection::Asc, 168 + }; 169 + 170 + Ok(serde_html_form::to_string(IndexParams { 171 + dir: Some(new_dir), 172 + tags: self.query.tags.clone(), 173 + ..self.query 174 + })?) 175 + } 176 + } 177 + 178 + impl View for PhotosIndex { 179 + fn render(&self, reloader: &AutoReloader) -> anyhow::Result<String> { 180 + let default = IndexParams::default(); 181 + let limit = self.query.limit.unwrap_or(default.limit.unwrap()); 182 + let sort_dir = self.query.dir.unwrap_or(default.dir.unwrap()); 183 + let sort_field = self.query.sort.unwrap_or(default.sort.unwrap()); 184 + 185 + let (current_tags, tags) = self.process_tags()?; 186 + 187 + let pagination = self.get_pagination(limit)?; 188 + let sort_link = self.sort_link(sort_dir)?; 189 + 190 + let html = render( 191 + reloader, 192 + "views/photos/index", 193 + context! { 194 + photos => self.photos, 195 + tags, 196 + current_tags, 197 + sort_dir, 198 + sort_field, 199 + pagination, 200 + sort_fields => SORT_FIELDS, 201 + sort_link, 202 + }, 203 + )?; 204 + 205 + Ok(html) 206 + } 207 + } 208 + 209 + fn tag_difference(tags: &[Tag], selected: &[String]) -> Vec<Tag> { 210 + let selected: HashSet<&String> = HashSet::from_iter(selected.iter()); 211 + 212 + tags.iter() 213 + .filter(|tag| !selected.contains(&tag.name)) 214 + .cloned() 215 + .collect() 216 + }
+5
src/views/photos/mod.rs
··· 1 + mod index; 2 + mod show; 3 + 4 + pub use index::{IndexParams, PhotosIndex}; 5 + pub use show::PhotosShow;
+37
src/views/photos/show/mod.rs
··· 1 + use minijinja::context; 2 + use minijinja_autoreload::AutoReloader; 3 + 4 + use crate::{Photo, models::Tag, templates::render, views::View}; 5 + 6 + pub struct PhotosShow { 7 + photo: Photo, 8 + tags: Vec<Tag>, 9 + } 10 + 11 + impl PhotosShow { 12 + pub fn new(photo: Photo, tags: Vec<Tag>) -> Self { 13 + Self { photo, tags } 14 + } 15 + } 16 + 17 + impl View for PhotosShow { 18 + fn render(&self, reloader: &AutoReloader) -> anyhow::Result<String> { 19 + let aperture = format!("{:.1}", self.photo.aperture); 20 + let focal_length = format!("{:.0}", self.photo.focal_length); 21 + let shutter_speed = format!("1/{:.0}s", 1.0 / self.photo.shutter_speed); 22 + 23 + let html = render( 24 + reloader, 25 + "views/photos/show", 26 + context! { 27 + photo => self.photo, 28 + tags => self.tags, 29 + aperture, 30 + focal_length, 31 + shutter_speed, 32 + }, 33 + )?; 34 + 35 + Ok(html) 36 + } 37 + }
templates/blog/index.jinja src/views/blog/index/template.jinja
templates/blog/show.jinja src/views/blog/show/template.jinja
+1 -3
templates/layout.jinja
··· 82 82 } 83 83 } 84 84 </style> 85 - <title> 86 - {% block title %}{% endblock %} 87 - </title> 85 + <title>{% block title %}{% endblock %}</title> 88 86 {% block head %}{% endblock %} 89 87 </head> 90 88 <body>
+4 -2
templates/photos/index.jinja src/views/photos/index/template.jinja
··· 1 1 {% extends "layout" %} 2 2 {% set active_page = 'photos' %} 3 3 {% block title %}Photos{% endblock %} 4 - {% block head %}<link rel="stylesheet" href="/photos/assets/photos/index.css" />{% endblock %} 4 + {% block head %} 5 + {{ inline_style("src/views/photos/index/style.css") }} 6 + {% endblock %} 5 7 {% block body %} 6 8 <div class="photos__layout"> 7 9 <div class="photos__mobile-nav"> ··· 98 100 <path id="close-icon" d="M20.7457 3.32851C20.3552 2.93798 19.722 2.93798 19.3315 3.32851L12.0371 10.6229L4.74275 3.32851C4.35223 2.93798 3.71906 2.93798 3.32854 3.32851C2.93801 3.71903 2.93801 4.3522 3.32854 4.74272L10.6229 12.0371L3.32856 19.3314C2.93803 19.722 2.93803 20.3551 3.32856 20.7457C3.71908 21.1362 4.35225 21.1362 4.74277 20.7457L12.0371 13.4513L19.3315 20.7457C19.722 21.1362 20.3552 21.1362 20.7457 20.7457C21.1362 20.3551 21.1362 19.722 20.7457 19.3315L13.4513 12.0371L20.7457 4.74272C21.1362 4.3522 21.1362 3.71903 20.7457 3.32851Z" fill="currentColor"/> 99 101 </defs> 100 102 </svg> 101 - <script src="/photos/assets/photos/index.js" type="text/javascript"></script> 103 + {{ inline_script("src/views/photos/index/script.js") }} 102 104 {% endblock %}
+3 -1
templates/photos/show.jinja src/views/photos/show/template.jinja
··· 1 1 {% extends "layout" %} 2 2 {% set active_page = 'photos' %} 3 3 {% block title %}Photos / View{% endblock %} 4 - {% block head %}<link rel="stylesheet" href="/photos/assets/photos/show.css" />{% endblock %} 4 + {% block head %} 5 + {{ inline_style("src/views/photos/show/style.css") }} 6 + {% endblock %} 5 7 {% block body %} 6 8 <div class="photo__container"> 7 9 <div class="photo__image">