diff --git a/README.md b/README.md index df39807..0e06eeb 100644 --- a/README.md +++ b/README.md @@ -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... diff --git a/migration/src/m20250405_000001_create_table.rs b/migration/src/m20250405_000001_create_table.rs index 16ae03c..97621fd 100644 --- a/migration/src/m20250405_000001_create_table.rs +++ b/migration/src/m20250405_000001_create_table.rs @@ -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, +} diff --git a/src/auth.rs b/src/auth.rs index b404a02..8fd6801 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -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), }; diff --git a/src/entities/mod.rs b/src/entities/mod.rs index aeea177..7c33cad 100644 --- a/src/entities/mod.rs +++ b/src/entities/mod.rs @@ -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; diff --git a/src/entities/prelude.rs b/src/entities/prelude.rs index f4c570f..b85b8eb 100644 --- a/src/entities/prelude.rs +++ b/src/entities/prelude.rs @@ -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; diff --git a/src/entities/samey_pool.rs b/src/entities/samey_pool.rs new file mode 100644 index 0000000..9b6663c --- /dev/null +++ b/src/entities/samey_pool.rs @@ -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 for Entity { + fn to() -> RelationDef { + Relation::SameyPoolPost.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/entities/samey_pool_post.rs b/src/entities/samey_pool_post.rs new file mode 100644 index 0000000..8720d82 --- /dev/null +++ b/src/entities/samey_pool_post.rs @@ -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 for Entity { + fn to() -> RelationDef { + Relation::SameyPool.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::SameyPost.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/entities/samey_post.rs b/src/entities/samey_post.rs index 2e6caef..b810ecd 100644 --- a/src/entities/samey_post.rs +++ b/src/entities/samey_post.rs @@ -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 for Entity { + fn to() -> RelationDef { + Relation::SameyPoolPost.def() + } +} + impl Related for Entity { fn to() -> RelationDef { Relation::SameyPostSource.def() diff --git a/src/entities/samey_session.rs b/src/entities/samey_session.rs index 6d21e92..0b93415 100644 --- a/src/entities/samey_session.rs +++ b/src/entities/samey_session.rs @@ -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)] diff --git a/src/query.rs b/src/query.rs index 2f81278..a58513d 100644 --- a/src/query.rs +++ b/src/query.rs @@ -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, -) -> Selector> { + user: Option<&User>, +) -> Selector> { let mut include_tags = HashSet::::new(); let mut exclude_tags = HashSet::::new(); let mut include_ratings = HashSet::::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::() + .into_model::() } pub(crate) fn get_tags_for_post(post_id: i32) -> Select { @@ -152,3 +145,15 @@ pub(crate) fn get_tags_for_post(post_id: i32) -> Select { .filter(samey_tag_post::Column::PostId.eq(post_id)) .order_by_asc(samey_tag::Column::Name) } + +pub(crate) fn filter_by_user(query: Select, user: Option<&User>) -> Select { + 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, + } +} diff --git a/src/views.rs b/src/views.rs index 6fc74dd..fa0a54a 100644 --- a/src/views.rs +++ b/src/views.rs @@ -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>, tags_text: Option, - posts: Vec, + posts: Vec, page: u32, page_count: u64, } @@ -378,7 +378,7 @@ pub(crate) async fn posts_page( .tags .as_ref() .map(|tags| tags.split_whitespace().collect::>()); - 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, + tags_text: String, sources: Vec, can_edit: bool, + parent_post: Option, + children_posts: Vec, } pub(crate) async fn view_post( @@ -421,18 +424,64 @@ pub(crate) async fn view_post( auth_session: AuthSession, Path(post_id): Path, ) -> Result { - 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>, tags: String, + parent_post: String, } #[derive(Template)] #[template(path = "submit_post_details.html")] struct SubmitPostDetailsTemplate { post: samey_post::Model, + parent_post: Option, sources: Vec, tags: Vec, 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()?, diff --git a/templates/edit_post_details.html b/templates/edit_post_details.html index 7ee3832..0ce7fc0 100644 --- a/templates/edit_post_details.html +++ b/templates/edit_post_details.html @@ -46,6 +46,10 @@ +
+ + +
diff --git a/templates/index.html b/templates/index.html index a4aafda..f2f5649 100644 --- a/templates/index.html +++ b/templates/index.html @@ -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 />
    @@ -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="" />
      Submit +
      +

      Create pool

      +
      + + +
      +
      diff --git a/templates/posts.html b/templates/posts.html index efe035d..150070e 100644 --- a/templates/posts.html +++ b/templates/posts.html @@ -46,6 +46,7 @@ title="{{ post.tags }}" > +
      {{ post.rating }}
      {% endfor %} diff --git a/templates/submit_post_details.html b/templates/submit_post_details.html index 7b5ec2f..df37412 100644 --- a/templates/submit_post_details.html +++ b/templates/submit_post_details.html @@ -1,4 +1,14 @@ -{% include "post_details.html" %} +{% include "post_details.html" %} {% if let Some(parent_post) = parent_post %} + +{% else %} + +{% endif %}
        {% for tag in tags %}
      • diff --git a/templates/view_post.html b/templates/view_post.html index b3ebef4..720ad0c 100644 --- a/templates/view_post.html +++ b/templates/view_post.html @@ -13,7 +13,7 @@ - + Details {% include "post_details.html" %} + {% if let Some(parent_post) = parent_post %} + + {% else %} + + {% endif %} {% if !children_posts.is_empty() %} + + {% endif %}

        Tags