Parent and child posts

This commit is contained in:
Bad Manners 2025-04-08 23:10:10 -03:00
parent 04f888c323
commit 54379b98e0
16 changed files with 334 additions and 58 deletions

View file

@ -4,8 +4,11 @@ Sam's small image board. Currently a WIP.
## TODO
- [ ] Parent posts (including tags and stuff)
- [ ] Edit tags in the middle of input
- [ ] Pools
- [ ] Video support
- [ ] Cleanup/fixup background tasks
- [ ] CSS
- [ ] CLI, env vars, logging...

View file

@ -32,6 +32,29 @@ impl MigrationTrait for Migration {
)
.await?;
manager
.create_table(
Table::create()
.table(SameyTag::Table)
.if_not_exists()
.col(pk_auto(SameyTag::Id))
.col(string_len(SameyTag::Name, 100))
.col(string_len_uniq(SameyTag::NormalizedName, 100))
.to_owned(),
)
.await?;
manager
.create_table(
Table::create()
.table(SameyPool::Table)
.if_not_exists()
.col(pk_auto(SameyPool::Id))
.col(string_len_uniq(SameyPool::Name, 100))
.to_owned(),
)
.await?;
manager
.create_table(
Table::create()
@ -98,18 +121,6 @@ impl MigrationTrait for Migration {
)
.await?;
manager
.create_table(
Table::create()
.table(SameyTag::Table)
.if_not_exists()
.col(pk_auto(SameyTag::Id))
.col(string_len(SameyTag::Name, 100))
.col(string_len_uniq(SameyTag::NormalizedName, 100))
.to_owned(),
)
.await?;
manager
.create_table(
Table::create()
@ -142,6 +153,39 @@ impl MigrationTrait for Migration {
)
.await?;
manager
.create_table(
Table::create()
.table(SameyPoolPost::Table)
.if_not_exists()
.col(pk_auto(SameyPoolPost::Id))
.col(integer(SameyPoolPost::PoolId))
.col(integer(SameyPoolPost::PostId))
.col(float(SameyPoolPost::Position))
.foreign_key(
ForeignKeyCreateStatement::new()
.name("fk-samey_pool_post-samey_pool-pool_id")
.from(SameyPoolPost::Table, SameyPoolPost::PoolId)
.to(SameyPool::Table, SameyPool::Id)
.on_delete(ForeignKeyAction::Cascade),
)
.foreign_key(
ForeignKeyCreateStatement::new()
.name("fk-samey_pool_post-samey_post-post_id")
.from(SameyPoolPost::Table, SameyPoolPost::PostId)
.to(SameyPost::Table, SameyPost::Id)
.on_delete(ForeignKeyAction::Cascade),
)
.index(
Index::create()
.unique()
.col(SameyPoolPost::PoolId)
.col(SameyPoolPost::PostId),
)
.to_owned(),
)
.await?;
Ok(())
}
@ -149,11 +193,11 @@ impl MigrationTrait for Migration {
// Replace the sample below with your own migration scripts
manager
.drop_table(Table::drop().table(SameyTagPost::Table).to_owned())
.drop_table(Table::drop().table(SameyPoolPost::Table).to_owned())
.await?;
manager
.drop_table(Table::drop().table(SameyTag::Table).to_owned())
.drop_table(Table::drop().table(SameyTagPost::Table).to_owned())
.await?;
manager
@ -164,6 +208,14 @@ impl MigrationTrait for Migration {
.drop_table(Table::drop().table(SameyPost::Table).to_owned())
.await?;
manager
.drop_table(Table::drop().table(SameyPool::Table).to_owned())
.await?;
manager
.drop_table(Table::drop().table(SameyTag::Table).to_owned())
.await?;
manager
.drop_table(Table::drop().table(SameyUser::Table).to_owned())
.await?;
@ -255,3 +307,21 @@ enum SameyTagPost {
TagId,
PostId,
}
#[derive(DeriveIden)]
enum SameyPool {
#[sea_orm(iden = "samey_pool")]
Table,
Id,
Name,
}
#[derive(DeriveIden)]
enum SameyPoolPost {
#[sea_orm(iden = "samey_pool_post")]
Table,
Id,
PoolId,
PostId,
Position,
}

View file

@ -123,7 +123,7 @@ impl SessionStore for SessionStorage {
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
)),
expiry_date: Set(record.expiry_date.unix_timestamp().to_string()),
expiry_date: Set(record.expiry_date.unix_timestamp()),
..Default::default()
})
.exec(&self.db)
@ -141,7 +141,7 @@ impl SessionStore for SessionStorage {
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
)),
expiry_date: Set(record.expiry_date.unix_timestamp().to_string()),
expiry_date: Set(record.expiry_date.unix_timestamp()),
..Default::default()
})
.filter(samey_session::Column::SessionId.eq(record.id.to_string()))
@ -175,20 +175,9 @@ impl SessionStore for SessionStorage {
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
expiry_date: match session.expiry_date.parse() {
Ok(timestamp) => {
OffsetDateTime::from_unix_timestamp(timestamp).map_err(|_| {
session_store::Error::Backend(
"Invalid timestamp for expiry date".into(),
)
})?
}
Err(_) => {
return Err(session_store::Error::Backend(
"Failed to parse session expiry date".into(),
));
}
},
expiry_date: OffsetDateTime::from_unix_timestamp(session.expiry_date).map_err(
|_| session_store::Error::Backend("Invalid timestamp for expiry date".into()),
)?,
},
None => return Ok(None),
};

View file

@ -2,6 +2,8 @@
pub mod prelude;
pub mod samey_pool;
pub mod samey_pool_post;
pub mod samey_post;
pub mod samey_post_source;
pub mod samey_session;

View file

@ -1,5 +1,7 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.8
pub use super::samey_pool::Entity as SameyPool;
pub use super::samey_pool_post::Entity as SameyPoolPost;
pub use super::samey_post::Entity as SameyPost;
pub use super::samey_post_source::Entity as SameyPostSource;
pub use super::samey_session::Entity as SameySession;

View file

@ -0,0 +1,26 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.8
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "samey_pool")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[sea_orm(unique)]
pub name: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::samey_pool_post::Entity")]
SameyPoolPost,
}
impl Related<super::samey_pool_post::Entity> for Entity {
fn to() -> RelationDef {
Relation::SameyPoolPost.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View file

@ -0,0 +1,48 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.8
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "samey_pool_post")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub pool_id: i32,
pub post_id: i32,
#[sea_orm(column_type = "Float")]
pub position: f32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::samey_pool::Entity",
from = "Column::PoolId",
to = "super::samey_pool::Column::Id",
on_update = "NoAction",
on_delete = "Cascade"
)]
SameyPool,
#[sea_orm(
belongs_to = "super::samey_post::Entity",
from = "Column::PostId",
to = "super::samey_post::Column::Id",
on_update = "NoAction",
on_delete = "Cascade"
)]
SameyPost,
}
impl Related<super::samey_pool::Entity> for Entity {
fn to() -> RelationDef {
Relation::SameyPool.def()
}
}
impl Related<super::samey_post::Entity> for Entity {
fn to() -> RelationDef {
Relation::SameyPost.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View file

@ -24,6 +24,8 @@ pub struct Model {
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::samey_pool_post::Entity")]
SameyPoolPost,
#[sea_orm(
belongs_to = "Entity",
from = "Column::ParentId",
@ -46,6 +48,12 @@ pub enum Relation {
SameyUser,
}
impl Related<super::samey_pool_post::Entity> for Entity {
fn to() -> RelationDef {
Relation::SameyPoolPost.def()
}
}
impl Related<super::samey_post_source::Entity> for Entity {
fn to() -> RelationDef {
Relation::SameyPostSource.def()

View file

@ -7,9 +7,10 @@ use sea_orm::entity::prelude::*;
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[sea_orm(unique)]
pub session_id: String,
pub data: Json,
pub expiry_date: String,
pub expiry_date: i64,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]

View file

@ -16,16 +16,17 @@ use crate::{
};
#[derive(Debug, FromQueryResult)]
pub(crate) struct SearchPost {
pub(crate) struct PostOverview {
pub(crate) id: i32,
pub(crate) thumbnail: String,
pub(crate) tags: String,
pub(crate) rating: String,
}
pub(crate) fn search_posts(
tags: Option<&Vec<&str>>,
user: Option<User>,
) -> Selector<SelectModel<SearchPost>> {
user: Option<&User>,
) -> Selector<SelectModel<PostOverview>> {
let mut include_tags = HashSet::<String>::new();
let mut exclude_tags = HashSet::<String>::new();
let mut include_ratings = HashSet::<String>::new();
@ -47,11 +48,12 @@ pub(crate) fn search_posts(
}
}
let mut query = if include_tags.is_empty() && exclude_tags.is_empty() {
let query = if include_tags.is_empty() && exclude_tags.is_empty() {
let mut query = SameyPost::find()
.select_only()
.column(samey_post::Column::Id)
.column(samey_post::Column::Thumbnail)
.column(samey_post::Column::Rating)
.column_as(
Expr::cust("GROUP_CONCAT(\"samey_tag\".\"name\", ' ')"),
"tags",
@ -73,6 +75,7 @@ pub(crate) fn search_posts(
.select_only()
.column(samey_post::Column::Id)
.column(samey_post::Column::Thumbnail)
.column(samey_post::Column::Rating)
.column_as(
Expr::cust("GROUP_CONCAT(\"samey_tag\".\"name\", ' ')"),
"tags",
@ -130,20 +133,10 @@ pub(crate) fn search_posts(
query
};
query = match user {
None => query.filter(samey_post::Column::IsPublic.into_simple_expr()),
Some(user) if !user.is_admin => query.filter(
Condition::any()
.add(samey_post::Column::IsPublic.into_simple_expr())
.add(samey_post::Column::UploaderId.eq(user.id)),
),
_ => query,
};
query
filter_by_user(query, user)
.group_by(samey_post::Column::Id)
.order_by_desc(samey_post::Column::Id)
.into_model::<SearchPost>()
.into_model::<PostOverview>()
}
pub(crate) fn get_tags_for_post(post_id: i32) -> Select<SameyTag> {
@ -152,3 +145,15 @@ pub(crate) fn get_tags_for_post(post_id: i32) -> Select<SameyTag> {
.filter(samey_tag_post::Column::PostId.eq(post_id))
.order_by_asc(samey_tag::Column::Name)
}
pub(crate) fn filter_by_user(query: Select<SameyPost>, user: Option<&User>) -> Select<SameyPost> {
match user {
None => query.filter(samey_post::Column::IsPublic.into_simple_expr()),
Some(user) if !user.is_admin => query.filter(
Condition::any()
.add(samey_post::Column::IsPublic.into_simple_expr())
.add(samey_post::Column::UploaderId.eq(user.id)),
),
_ => query,
}
}

View file

@ -30,7 +30,7 @@ use crate::{
samey_post, samey_post_source, samey_tag, samey_tag_post,
},
error::SameyError,
query::{SearchPost, get_tags_for_post, search_posts},
query::{PostOverview, filter_by_user, get_tags_for_post, search_posts},
};
const MAX_THUMBNAIL_DIMENSION: u32 = 192;
@ -350,7 +350,7 @@ pub(crate) async fn select_tag(
struct PostsTemplate<'a> {
tags: Option<Vec<&'a str>>,
tags_text: Option<String>,
posts: Vec<SearchPost>,
posts: Vec<PostOverview>,
page: u32,
page_count: u64,
}
@ -378,7 +378,7 @@ pub(crate) async fn posts_page(
.tags
.as_ref()
.map(|tags| tags.split_whitespace().collect::<Vec<_>>());
let pagination = search_posts(tags.as_ref(), auth_session.user).paginate(&db, 50);
let pagination = search_posts(tags.as_ref(), auth_session.user.as_ref()).paginate(&db, 50);
let page_count = pagination.num_pages().await?;
let posts = pagination.fetch_page(page.saturating_sub(1) as u64).await?;
let posts = posts
@ -386,7 +386,7 @@ pub(crate) async fn posts_page(
.map(|post| {
let mut tags_vec: Vec<_> = post.tags.split_ascii_whitespace().collect();
tags_vec.sort();
SearchPost {
PostOverview {
tags: tags_vec.into_iter().join(" "),
..post
}
@ -412,8 +412,11 @@ pub(crate) async fn posts_page(
struct ViewPostTemplate {
post: samey_post::Model,
tags: Vec<samey_tag::Model>,
tags_text: String,
sources: Vec<samey_post_source::Model>,
can_edit: bool,
parent_post: Option<PostOverview>,
children_posts: Vec<PostOverview>,
}
pub(crate) async fn view_post(
@ -421,18 +424,64 @@ pub(crate) async fn view_post(
auth_session: AuthSession,
Path(post_id): Path<u32>,
) -> Result<impl IntoResponse, SameyError> {
let tags = get_tags_for_post(post_id as i32).all(&db).await?;
let post_id = post_id as i32;
let tags = get_tags_for_post(post_id).all(&db).await?;
let tags_text = tags.iter().map(|tag| &tag.name).join(" ");
let sources = SameyPostSource::find()
.filter(samey_post_source::Column::PostId.eq(post_id))
.all(&db)
.await?;
let post = SameyPost::find_by_id(post_id as i32)
let post = SameyPost::find_by_id(post_id)
.one(&db)
.await?
.ok_or(SameyError::NotFound)?;
let parent_post = if let Some(parent_id) = post.parent_id {
match filter_by_user(SameyPost::find_by_id(parent_id), auth_session.user.as_ref())
.one(&db)
.await?
{
Some(parent_post) => Some(PostOverview {
id: parent_id,
thumbnail: parent_post.thumbnail,
tags: get_tags_for_post(post_id)
.all(&db)
.await?
.iter()
.map(|tag| &tag.name)
.join(" "),
rating: parent_post.rating,
}),
None => None,
}
} else {
None
};
let children_posts_models = filter_by_user(
SameyPost::find().filter(samey_post::Column::ParentId.eq(post_id)),
auth_session.user.as_ref(),
)
.all(&db)
.await?;
let mut children_posts = Vec::with_capacity(children_posts_models.capacity());
for child_post in children_posts_models.into_iter() {
children_posts.push(PostOverview {
id: child_post.id,
thumbnail: child_post.thumbnail,
tags: get_tags_for_post(child_post.id)
.all(&db)
.await?
.iter()
.map(|tag| &tag.name)
.join(" "),
rating: child_post.rating,
});
}
let can_edit = match auth_session.user {
None => false,
Some(user) => user.is_admin || post.uploader_id == user.id,
@ -446,8 +495,11 @@ pub(crate) async fn view_post(
ViewPostTemplate {
post,
tags,
tags_text,
sources,
can_edit,
parent_post,
children_posts,
}
.render()?,
))
@ -505,12 +557,14 @@ pub(crate) struct SubmitPostDetailsForm {
#[serde(rename = "source")]
sources: Option<Vec<String>>,
tags: String,
parent_post: String,
}
#[derive(Template)]
#[template(path = "submit_post_details.html")]
struct SubmitPostDetailsTemplate {
post: samey_post::Model,
parent_post: Option<PostOverview>,
sources: Vec<samey_post_source::Model>,
tags: Vec<samey_tag::Model>,
can_edit: bool,
@ -529,7 +583,7 @@ pub(crate) async fn submit_post_details(
.await?
.ok_or(SameyError::NotFound)?;
match auth_session.user {
match auth_session.user.as_ref() {
None => return Err(SameyError::Forbidden),
Some(user) => {
if !user.is_admin && post.uploader_id != user.id {
@ -546,6 +600,27 @@ pub(crate) async fn submit_post_details(
description if description.is_empty() => None,
description => Some(description.to_owned()),
};
let parent_post = if let Some(parent_id) = body.parent_post.trim().parse().ok() {
match filter_by_user(SameyPost::find_by_id(parent_id), auth_session.user.as_ref())
.one(&db)
.await?
{
Some(parent_post) => Some(PostOverview {
id: parent_id,
thumbnail: parent_post.thumbnail,
tags: get_tags_for_post(post_id)
.all(&db)
.await?
.iter()
.map(|tag| &tag.name)
.join(" "),
rating: parent_post.rating,
}),
None => None,
}
} else {
None
};
let is_public = body.is_public.is_some();
let post = SameyPost::update(samey_post::ActiveModel {
id: Set(post_id),
@ -553,6 +628,7 @@ pub(crate) async fn submit_post_details(
description: Set(description),
is_public: Set(is_public),
rating: Set(body.rating),
parent_id: Set(parent_post.as_ref().map(|post| post.id)),
..Default::default()
})
.exec(&db)
@ -622,6 +698,7 @@ pub(crate) async fn submit_post_details(
post,
sources,
tags: upload_tags,
parent_post,
can_edit: true,
}
.render()?,

View file

@ -46,6 +46,10 @@
</ul>
<button hx-post="/post_source" hx-target="#sources" hx-swap="beforeend">+</button>
</div>
<div>
<label>Parent post</label>
<input name="parent_post" type="text" value="{% if let Some(parent_id) = post.parent_id %}{{ parent_id }}{% endif %}" />
</div>
<div>
<button>Submit</button>
<button hx-get="/post_details/{{ post.id }}">Cancel</button>

View file

@ -20,7 +20,6 @@
hx-trigger="input changed delay:400ms"
hx-target="next .tags-autocomplete"
hx-on::after-settle="this.focus(); this.setSelectionRange(-1, -1);"
value=""
autofocus
/>
<ul class="tags-autocomplete" id="search-autocomplete"></ul>
@ -40,7 +39,6 @@
hx-trigger="input changed delay:400ms"
hx-target="next .tags-autocomplete"
hx-on::after-settle="this.focus(); this.setSelectionRange(-1, -1);"
value=""
/>
<ul class="tags-autocomplete" id="upload-autocomplete"></ul>
<input
@ -52,6 +50,13 @@
<button type="submit">Submit</button>
</form>
</article>
<article>
<h2>Create pool</h2>
<form method="post" action="/pool">
<input class="tags" type="text" id="pool-name" name="name" />
<button type="submit">Submit</button>
</form>
</article>
<article>
<a href="/logout">Log out</a>
</article>

View file

@ -46,6 +46,7 @@
title="{{ post.tags }}"
>
<img src="/files/{{ post.thumbnail }}" />
<div>{{ post.rating }}</div>
</a>
</li>
{% endfor %}

View file

@ -1,4 +1,14 @@
{% include "post_details.html" %}
{% include "post_details.html" %} {% if let Some(parent_post) = parent_post %}
<article id="parent-post" hx-swap-oob="outerHTML">
<h2>Parent</h2>
<a href="/view/{{ parent_post.id }}" title="{{ parent_post.tags }}">
<img src="/files/{{ parent_post.thumbnail }}" />
<div>{{ parent_post.rating }}</div>
</a>
</article>
{% else %}
<article id="parent-post" hx-swap-oob="outerHTML" hidden></article>
{% endif %}
<ul id="tags-list" hx-swap-oob="outerHTML">
{% for tag in tags %}
<li>

View file

@ -13,7 +13,7 @@
<meta property="og:image" content="/files/{{ post.media }}" />
<meta property="og:image:width" content="{{ post.width }}" />
<meta property="og:image:height" content="{{ post.height }}" />
<!-- <meta property="og:image:alt" content="TO-DO" /> -->
<meta property="og:image:alt" content="{{ tags_text }}" />
<meta
property="og:description"
content="{% if let Some(description) = post.description %}{{ description }}{% endif %}"
@ -34,6 +34,31 @@
<h2>Details</h2>
{% include "post_details.html" %}
</article>
{% if let Some(parent_post) = parent_post %}
<article id="parent-post">
<h2>Parent</h2>
<a href="/view/{{ parent_post.id }}" title="{{ parent_post.tags }}">
<img src="/files/{{ parent_post.thumbnail }}" />
<div>{{ parent_post.rating }}</div>
</a>
</article>
{% else %}
<article id="parent-post" hidden></article>
{% endif %} {% if !children_posts.is_empty() %}
<article>
<h2>Child posts</h2>
<ul>
{% for child_post in children_posts %}
<li>
<a href="/view/{{ child_post.id }}" title="{{ child_post.tags }}">
<img src="/files/{{ child_post.thumbnail }}" />
<div>{{ child_post.rating }}</div>
</a>
</li>
{% endfor %}
</ul>
</article>
{% endif %}
<article>
<h2>Tags</h2>
<ul id="tags-list">