Add user pages

rust
Elnu 1 year ago
parent e729eee137
commit 5aa4c4d203

@ -87,7 +87,7 @@ async fn rocket() -> Result<Rocket<Ignite>, rocket::Error> {
.manage(load_database()) .manage(load_database())
.mount( .mount(
"/", "/",
routes![get_challenge, get_guilds, login, post_login, success, logout, testing], routes![get_challenge, get_guilds, get_user, login, post_login, success, logout, testing],
) )
.mount("/css", FileServer::from(relative!("styles/css"))) .mount("/css", FileServer::from(relative!("styles/css")))
.mount("/", FileServer::from(relative!("assets")).rank(2)) .mount("/", FileServer::from(relative!("assets")).rank(2))

@ -139,7 +139,7 @@ impl Database {
}, },
}; };
} }
for submission in legacy.parse().unwrap() { for submission in legacy.parse(n as u32).unwrap() {
conn.execute( conn.execute(
"INSERT INTO Submission(author_id, timestamp, image, challenge) VALUES (?1, ?2, ?3, ?4)", "INSERT INTO Submission(author_id, timestamp, image, challenge) VALUES (?1, ?2, ?3, ?4)",
params![ params![
@ -181,6 +181,40 @@ impl Database {
author_id: row.get(0)?, author_id: row.get(0)?,
timestamp: row.get(1)?, timestamp: row.get(1)?,
image: row.get(2)?, image: row.get(2)?,
challenge,
})
})?
.collect::<std::result::Result<Vec<Submission>, rusqlite::Error>>()?)
}
pub fn get_user_by_name(&self, name: &str) -> Result<Option<User>> {
Ok(self.conn()?
.prepare("SELECT id, discriminator, avatar, deleted FROM User where name = ?1")?
.query_row(params![name], |row| {
Ok(Some(User {
id: row.get(0)?,
name: name.to_owned(),
discriminator: row.get(1)?,
avatar: row.get(2)?,
deleted: row.get(3)?,
}))
})?)
}
pub fn get_submissions_by_user_name(&self, name: &str) -> Result<Vec<Submission>> {
Ok(self.conn()?
.prepare("
SELECT author_id, timestamp, image, challenge
FROM Submission s
JOIN User u ON s.author_id = u.id
WHERE u.name = ?1",
)?
.query_map(params![name], |row| {
Ok(Submission {
author_id: row.get(0)?,
timestamp: row.get(1)?,
image: row.get(2)?,
challenge: row.get(3)?,
}) })
})? })?
.collect::<std::result::Result<Vec<Submission>, rusqlite::Error>>()?) .collect::<std::result::Result<Vec<Submission>, rusqlite::Error>>()?)

@ -26,7 +26,7 @@ pub enum LegacySubmissionParseError {
} }
impl LegacySubmission { impl LegacySubmission {
pub fn parse(&self) -> Result<Vec<Submission>, LegacySubmissionParseError> { pub fn parse(&self, challenge: u32) -> Result<Vec<Submission>, LegacySubmissionParseError> {
let author_id = self.id; let author_id = self.id;
Ok(self.images Ok(self.images
.iter() .iter()
@ -53,6 +53,7 @@ impl LegacySubmission {
})?)() })?)()
}, },
image: image.clone(), image: image.clone(),
challenge,
} }
}) })
.collect()) .collect())

@ -15,4 +15,7 @@ pub struct Submission {
// Image path relative to submission folder. // Image path relative to submission folder.
// Thumbnail image path will be determined from this. // Thumbnail image path will be determined from this.
pub image: String, pub image: String,
// Not necessary for challenge pages,
// but needed for user profile pages
pub challenge: u32,
} }

@ -81,7 +81,7 @@ impl User {
"https://cdn.discordapp.com/avatars/{}/{}.{}?size=1024", "https://cdn.discordapp.com/avatars/{}/{}.{}?size=1024",
self.id, self.id,
avatar, avatar,
if avatar.starts_with("a_") { "gif "} else { "webp" } if avatar.starts_with("a_") { "gif" } else { "webp" }
), ),
// Archived user or user with no avatar, calculate default avatar // Archived user or user with no avatar, calculate default avatar
// https://www.reddit.com/r/discordapp/comments/au6v4e/comment/eh61dm6/ // https://www.reddit.com/r/discordapp/comments/au6v4e/comment/eh61dm6/

@ -20,7 +20,7 @@ pub async fn get_challenge(
) -> Template { ) -> Template {
let (submissions, users) = database.get_challenge_user_data(challenge).unwrap(); let (submissions, users) = database.get_challenge_user_data(challenge).unwrap();
Template::render( Template::render(
"index", "challenge",
context! { context! {
challenge, challenge,
submissions, submissions,

@ -0,0 +1,35 @@
use std::ops::Deref;
use rocket::{http::CookieJar, State};
use rocket_dyn_templates::{context, Template};
use crate::{
cookies::LANG_COOKIE,
i18n::DEFAULT as DEFAULT_LANG,
models::{Settings, SessionUser, Database},
utils::AcceptLanguage,
};
#[get("/users/<user>")]
pub async fn get_user(
user: String,
cookies: &CookieJar<'_>,
settings: &State<Settings>,
database: &State<Database>,
accept_language: AcceptLanguage,
) -> Template {
Template::render(
"user",
context! {
profile_user: database.get_user_by_name(&user).unwrap(),
submissions: database.get_submissions_by_user_name(&user).unwrap(),
settings: settings.deref(),
lang: cookies
.get(LANG_COOKIE)
.map(|cookie| vec![cookie.value().to_owned()])
.or(accept_language.0)
.unwrap_or_else(|| vec![DEFAULT_LANG.to_owned()]),
user: SessionUser::get(cookies).await.unwrap(),
},
)
}

@ -6,6 +6,10 @@ pub use _get_challenge::get_challenge;
mod _get_guilds; mod _get_guilds;
pub use _get_guilds::get_guilds; pub use _get_guilds::get_guilds;
#[path = "get_user.rs"]
mod _get_user;
pub use _get_user::get_user;
#[path = "login.rs"] #[path = "login.rs"]
mod _login; mod _login;
pub use _login::login; pub use _login::login;

@ -0,0 +1,72 @@
<!DOCTYPE html>
<html>
<head>
<title>{% block title %}Tegaki Tuesday{% endblock %}</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,400;0,700;1,400&display=swap" rel="stylesheet">
<link href="/fonts/K-Gothic/stylesheet.css" rel="stylesheet">
<link href="/css/style.css" rel="stylesheet">
{% block head %}{% endblock head %}
</head>
<body>
{% if user %}
{% include "modal" %}
{% endif %}
<div id="content">
<nav>{% block nav %}{% include "nav" %}{% endblock %}</nav>
<div id="challenge">
<div>{% block content %}{% endblock %}</div>
<footer>
{% block content_copyright %}
Copyright {% include "copyright-years" %} Tegaki Tuesday. All rights reserved. 字ちゃん mascot art by <a href="https://twitter.com/bellumela" target="_blank">@bellumela</a>.
{% endblock %}
</footer>
</div>
<div id="submissions">
<script>
const submissionModal = image => {
const dialog = document.createElement("dialog");
dialog.style.padding = "0";
const img = document.createElement("img");
img.src = image.src;
const defaultZoom = "75vh";
img.style.height = defaultZoom;
img.style.cursor = "zoom-in";
img.style.display = "block";
img.style.margin = "0";
img.onclick = event => img.style.height = img.style.height ? "" : defaultZoom;
dialog.appendChild(img);
dialog.addEventListener("close", event => document.body.removeChild(dialog));
document.body.appendChild(dialog);
dialog.showModal();
};
</script>
<div>
{% for submission in submissions %}
{% if users %}
{% set author = users[submission.author_id] %}
{% else %}
{% set author = profile_user %}
{% endif %}
<figure>
<img src="/{{ submission.challenge }}/{{ submission.image }}" alt="{{ author.username }}'s submission" onclick="submissionModal('{{ submission.image }}')">
<figcaption>
{% if profile_user %}
<a href="/{{ submission.challenge }}">#{{ submission.challenge }}</a>
{% else %}
{% if not author.deleted %}<a href="/users/{{ author.name }}" target="_blank">{% endif %}{{ author.username }}{% if author.deleted %} (deleted account){% else %}</a>{% endif %}
{% endif %}
</figcaption>
</figure>
{% endfor %}
</div>
<footer>
{% block submissions_copyright %}
Submissions are copyright {% include "copyright-years" %} their respective submitters, and are licensed under the <a href="https://creativecommons.org/licenses/by-sa/4.0/" target="_blank">Attribution-ShareAlike 4.0 International (CC BY-SA 4.0)</a> license.
{% endblock %}
</footer>
</div>
</div>
</body>
</html>

@ -0,0 +1,55 @@
{% extends "base" %}
{% block content %}
<h1>Welcome to Tegaki Tuesday #{{ challenge }}!</h1>
{% if content.japanese %}
<div lang="ja">
<script>
let kyujitai = false;
function kyujitaiToggle() {
document.querySelectorAll("[data-kyujitai]").forEach(segment => {
let newText = segment.getAttribute("data-kyujitai");
segment.setAttribute("data-kyujitai", segment.firstChild.data);
segment.firstChild.data = newText;
})
kyujitai = !kyujitai;
}
</script>
<button onclick="kyujitaiToggle(); this.innerHTML = kyujitai ? '新字体' : '旧字体'">旧字体</button>
{% for line in content.japanese %}
{% for subline in line %}
<p>
{%- for word in subline -%}
{%- if word.dictionary -%}
<a href="{{ word.dictionary }}" target="_blank"{% if word.pos %} class="{{ word.pos }}" title="{{ word.pos }}"{% endif %}>
{%- endif -%}
{% for segment in word.text -%}
<ruby{% if segment.kyujitai %} data-kyujitai="{{ segment.kyujitai }}"{% endif %}>{{ segment.kanji }}<rp>(</rp><rt>{{ segment.furigana | safe }}</rt><rp>)</rp></ruby>
{%- endfor -%}
{%- if word.dictionary -%}
</a>
{%- endif -%}
{%- endfor -%}
</p>
{% endfor %}
{% endfor %}
</div>
{% endif %}
{{ content.text | safe }}
{% if content.translation %}
<p>
Translation
{% if content.translation.author %}by {{ content.translation.author }}{% endif %}
{% if content.translation.site %} via
{% if content.translation.site.link %}
<a href="{{ content.translation.site.link }}" target="_blank">{{ content.translation.site.name }}</a>
{% else %}
{{ content.translation.site.name }}
{% endif %}
{%- endif -%}
</p>
{%- endif -%}
{%- if content.suggester -%}
<p><em>This challenge was suggested by <strong>{{ content.suggester }}</strong> using the <code>-h suggest</code> command.</code></em></p>
{%- endif -%}
{% endblock %}

@ -1,159 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Tegaki Tuesday</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,400;0,700;1,400&display=swap" rel="stylesheet">
<link href="/fonts/K-Gothic/stylesheet.css" rel="stylesheet">
<link href="/css/style.css" rel="stylesheet">
</head>
<body>
{% if user %}
{% include "modal" %}
{% endif %}
<div id="content">
<nav>
<a class="link" href="/{{ challenge - 1 }}"><svg class="svg-inline" aria-hidden="true" focusable="false" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><path fill="currentColor" d="M41.4 233.4c-12.5 12.5-12.5 32.8 0 45.3l192 192c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L109.3 256 278.6 86.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-192 192z"></path></svg></a>
<input type="number" min="1" value="{{ challenge }}" autocomplete="false">
<a class="link" href="/{{ challenge + 1 }}"><svg class="svg-inline" aria-hidden="true" focusable="false" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><path fill="currentColor" d="M342.6 233.4c12.5 12.5 12.5 32.8 0 45.3l-192 192c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3L274.7 256 105.4 86.6c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0l192 192z"></path></svg></a>
{% if content.song %}
<div>
<span>{% if content.song.japanese %}{{ content.song.japanese | furigana | safe }}{% else %}{{ content.song.english }}{% endif %}</span>
</div>
{% endif %}
{% if user %}
<div class="dropdown right">
<a href="/users/{{ user.id }}" class="link">
<span>{{ user.username }}</span> <img src="{{ user.avatar }}">
</a>
<nav class="dropdown-content">
<span class="link" onclick="showServers()">
<span>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="icon">
<path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" />
</svg>
Join servers
</span>
</span>
<a href="/logout" class="link">
<span>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="icon">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" />
</svg>
Log out
</span>
</a>
</nav>
</div>
{% else%}
<a href="/login" class="link right">Log in</a>
{% endif %}
<div class="dropdown">
<span class="link">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="icon">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 21l5.25-11.25L21 21m-9-3h7.5M3 5.621a48.474 48.474 0 016-.371m0 0c1.12 0 2.233.038 3.334.114M9 5.25V3m3.334 2.364C11.176 10.658 7.69 15.08 3 17.502m9.334-12.138c.896.061 1.785.147 2.666.257m-4.589 8.495a18.023 18.023 0 01-3.827-5.802" />
</svg>
</span>
<nav class="dropdown-content">
{% set langs = 0 | langs %}
{% for other_lang in langs %}
{% if other_lang.code in lang %}{% continue %}{% endif %}
<span class="link" onclick="document.cookie = 'lang={{ other_lang.code }}; expires=Tue, 19 Jan 2038 03:14:07 UTC; SameSite=Strict; path=/'; location.reload()">{{ other_lang.name }}</span>
{% endfor %}
</nav>
</a>
</nav>
<div id="challenge">
<div>
<h1>Welcome to Tegaki Tuesday #{{ challenge }}!</h1>
{% if content.japanese %}
<div lang="ja">
<script>
let kyujitai = false;
function kyujitaiToggle() {
document.querySelectorAll("[data-kyujitai]").forEach(segment => {
let newText = segment.getAttribute("data-kyujitai");
segment.setAttribute("data-kyujitai", segment.firstChild.data);
segment.firstChild.data = newText;
})
kyujitai = !kyujitai;
}
</script>
<button onclick="kyujitaiToggle(); this.innerHTML = kyujitai ? '新字体' : '旧字体'">旧字体</button>
{% for line in content.japanese %}
{% for subline in line %}
<p>
{%- for word in subline -%}
{%- if word.dictionary -%}
<a href="{{ word.dictionary }}" target="_blank"{% if word.pos %} class="{{ word.pos }}" title="{{ word.pos }}"{% endif %}>
{%- endif -%}
{% for segment in word.text -%}
<ruby{% if segment.kyujitai %} data-kyujitai="{{ segment.kyujitai }}"{% endif %}>{{ segment.kanji }}<rp>(</rp><rt>{{ segment.furigana | safe }}</rt><rp>)</rp></ruby>
{%- endfor -%}
{%- if word.dictionary -%}
</a>
{%- endif -%}
{%- endfor -%}
</p>
{% endfor %}
{% endfor %}
</div>
{% endif %}
{{ content.text | safe }}
{% if content.translation %}
<p>
Translation
{% if content.translation.author %}by {{ content.translation.author }}{% endif %}
{% if content.translation.site %} via
{% if content.translation.site.link %}
<a href="{{ content.translation.site.link }}" target="_blank">{{ content.translation.site.name }}</a>
{% else %}
{{ content.translation.site.name }}
{% endif %}
{%- endif -%}
</p>
{%- endif -%}
{%- if content.suggester -%}
<p><em>This challenge was suggested by <strong>{{ content.suggester }}</strong> using the <code>-h suggest</code> command.</code></em></p>
{%- endif -%}
</div>
<footer>
Copyright {% include "copyright-years" %} Tegaki Tuesday. All rights reserved. 字ちゃん mascot art by <a href="https://twitter.com/bellumela" target="_blank">@bellumela</a>.
</footer>
</div>
<div id="submissions">
<script>
const submissionModal = image => {
const dialog = document.createElement("dialog");
dialog.style.padding = "0";
const img = document.createElement("img");
img.src = "/{{ challenge }}/" + image;
const defaultZoom = "75vh";
img.style.height = defaultZoom;
img.style.cursor = "zoom-in";
img.style.display = "block";
img.style.margin = "0";
img.onclick = event => img.style.height = img.style.height ? "" : defaultZoom;
dialog.appendChild(img);
dialog.addEventListener("close", event => document.body.removeChild(dialog));
document.body.appendChild(dialog);
dialog.showModal();
};
</script>
<div>
{% for submission in submissions %}
{% set author = users[submission.author_id] %}
<figure>
<img src="/{{ challenge }}/{{ submission.image }}" alt="{{ author.username }}'s submission" onclick="submissionModal('{{ submission.image }}')">
<figcaption>{% if not author.deleted %}<a href="https://discord.com/users/{{ author.id }}" target="_blank">{% endif %}{{ author.username }}{% if author.deleted %} (deleted account){% else %}</a>{% endif %}</figcaption>
</figure>
{% endfor %}
</div>
<footer>
Submissions are copyright {% include "copyright-years" %} their respective submitters, and are licensed under the <a href="https://creativecommons.org/licenses/by-sa/4.0/" target="_blank">Attribution-ShareAlike 4.0 International (CC BY-SA 4.0)</a> license.
</footer>
</div>
</div>
</body>
</html>

@ -0,0 +1,51 @@
{% if challenge %}
<a class="link" href="/{{ challenge - 1 }}"><svg class="svg-inline" aria-hidden="true" focusable="false" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><path fill="currentColor" d="M41.4 233.4c-12.5 12.5-12.5 32.8 0 45.3l192 192c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L109.3 256 278.6 86.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-192 192z"></path></svg></a>
<input type="number" min="1" value="{{ challenge }}" autocomplete="false">
<a class="link" href="/{{ challenge + 1 }}"><svg class="svg-inline" aria-hidden="true" focusable="false" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><path fill="currentColor" d="M342.6 233.4c12.5 12.5 12.5 32.8 0 45.3l-192 192c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3L274.7 256 105.4 86.6c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0l192 192z"></path></svg></a>
{% endif %}
{% if content.song %}
<div>
<span>{% if content.song.japanese %}{{ content.song.japanese | furigana | safe }}{% else %}{{ content.song.english }}{% endif %}</span>
</div>
{% endif %}
{% if user %}
<div class="dropdown right">
<a href="/users/{{ user.id }}" class="link">
<span>{{ user.username }}</span> <img src="{{ user.avatar }}">
</a>
<nav class="dropdown-content">
<span class="link" onclick="showServers()">
<span>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="icon">
<path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" />
</svg>
Join servers
</span>
</span>
<a href="/logout" class="link">
<span>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="icon">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" />
</svg>
Log out
</span>
</a>
</nav>
</div>
{% else%}
<a href="/login" class="link right">Log in</a>
{% endif %}
<div class="dropdown">
<span class="link">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="icon">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 21l5.25-11.25L21 21m-9-3h7.5M3 5.621a48.474 48.474 0 016-.371m0 0c1.12 0 2.233.038 3.334.114M9 5.25V3m3.334 2.364C11.176 10.658 7.69 15.08 3 17.502m9.334-12.138c.896.061 1.785.147 2.666.257m-4.589 8.495a18.023 18.023 0 01-3.827-5.802" />
</svg>
</span>
<nav class="dropdown-content">
{% set langs = 0 | langs %}
{% for other_lang in langs %}
{% if other_lang.code in lang %}{% continue %}{% endif %}
<span class="link" onclick="document.cookie = 'lang={{ other_lang.code }}; expires=Tue, 19 Jan 2038 03:14:07 UTC; SameSite=Strict; path=/'; location.reload()">{{ other_lang.name }}</span>
{% endfor %}
</nav>
</a>

@ -0,0 +1,9 @@
{% extends "base" %}
{% block content %}
<div style="text-align: center">
<img src="{{ profile_user.avatar }}" alt="{{ profile_user.username }}" style="border-radius: 100%">
<h1><a href="https://discord.com/users/{{ profile_user.id }}">{{ profile_user.username }}</a>'s submissions</h1>
{{ submissions | length }} images
</div>
{% endblock %}
Loading…
Cancel
Save