Add Sortable to pools

This commit is contained in:
Bad Manners 2025-04-11 20:58:55 -03:00
parent 2b6b1f30f4
commit 239258e324
5 changed files with 141 additions and 29 deletions

View file

@ -4,7 +4,6 @@ Sam's small image board. Currently a WIP.
## TODO
- [ ] Pools
- [ ] Config
- [ ] Video support
- [ ] Cleanup/fixup background tasks

View file

@ -26,7 +26,7 @@ use crate::views::{
add_post_source, add_post_to_pool, change_pool_visibility, create_pool, delete_post,
edit_post_details, get_full_media, get_media, get_pools, get_pools_page, index, login, logout,
post_details, posts, posts_page, remove_field, remove_pool_post, search_tags, select_tag,
submit_post_details, upload, view_pool, view_post,
sort_pool, submit_post_details, upload, view_pool, view_post,
};
pub(crate) const NEGATIVE_PREFIX: &str = "-";
@ -94,6 +94,7 @@ pub async fn get_router(db: DatabaseConnection, files_dir: &str) -> Result<Route
.route("/pool/{pool_id}", get(view_pool))
.route("/pool/{pool_id}/public", put(change_pool_visibility))
.route("/pool/{pool_id}/post", post(add_post_to_pool))
.route("/pool/{pool_id}/sort", put(sort_pool))
.route("/pool_post/{pool_post_id}", delete(remove_pool_post))
// Search routes
.route("/posts", get(posts))

View file

@ -512,9 +512,8 @@ struct ViewPoolTemplate {
pub(crate) async fn view_pool(
State(AppState { db, .. }): State<AppState>,
auth_session: AuthSession,
Path(pool_id): Path<u32>,
Path(pool_id): Path<i32>,
) -> Result<impl IntoResponse, SameyError> {
let pool_id = pool_id as i32;
let pool = SameyPool::find_by_id(pool_id)
.one(&db)
.await?
@ -551,10 +550,9 @@ pub(crate) struct ChangePoolVisibilityForm {
pub(crate) async fn change_pool_visibility(
State(AppState { db, .. }): State<AppState>,
auth_session: AuthSession,
Path(pool_id): Path<u32>,
Path(pool_id): Path<i32>,
Form(body): Form<ChangePoolVisibilityForm>,
) -> Result<impl IntoResponse, SameyError> {
let pool_id = pool_id as i32;
let pool = SameyPool::find_by_id(pool_id)
.one(&db)
.await?
@ -595,6 +593,7 @@ pub(crate) struct PoolWithMaxPosition {
#[derive(Template)]
#[template(path = "add_post_to_pool.html")]
struct AddPostToPoolTemplate {
pool: PoolWithMaxPosition,
posts: Vec<PoolPost>,
can_edit: bool,
}
@ -602,10 +601,10 @@ struct AddPostToPoolTemplate {
pub(crate) async fn add_post_to_pool(
State(AppState { db, .. }): State<AppState>,
auth_session: AuthSession,
Path(pool_id): Path<u32>,
Path(pool_id): Path<i32>,
Form(body): Form<AddPostToPoolForm>,
) -> Result<impl IntoResponse, SameyError> {
let pool = SameyPool::find_by_id(pool_id as i32)
let pool = SameyPool::find_by_id(pool_id)
.select_only()
.column(samey_pool::Column::Id)
.column(samey_pool::Column::UploaderId)
@ -637,7 +636,7 @@ pub(crate) async fn add_post_to_pool(
SameyPoolPost::insert(samey_pool_post::ActiveModel {
pool_id: Set(pool.id),
post_id: Set(post.id),
position: Set(pool.max_position.unwrap_or(-1.0).floor() + 1.0),
position: Set(pool.max_position.unwrap_or(0.0).floor() + 1.0),
..Default::default()
})
.exec(&db)
@ -649,6 +648,7 @@ pub(crate) async fn add_post_to_pool(
Ok(Html(
AddPostToPoolTemplate {
pool,
posts,
can_edit: true,
}
@ -659,9 +659,8 @@ pub(crate) async fn add_post_to_pool(
pub(crate) async fn remove_pool_post(
State(AppState { db, .. }): State<AppState>,
auth_session: AuthSession,
Path(pool_post_id): Path<u32>,
Path(pool_post_id): Path<i32>,
) -> Result<impl IntoResponse, SameyError> {
let pool_post_id = pool_post_id as i32;
let pool_post = SameyPoolPost::find_by_id(pool_post_id)
.one(&db)
.await?
@ -685,6 +684,85 @@ pub(crate) async fn remove_pool_post(
Ok("")
}
#[derive(Debug, Deserialize)]
pub(crate) struct SortPoolForm {
old_index: usize,
new_index: usize,
}
#[derive(Template)]
#[template(path = "pool_posts.html")]
struct PoolPostsTemplate {
pool: samey_pool::Model,
posts: Vec<PoolPost>,
can_edit: bool,
}
pub(crate) async fn sort_pool(
State(AppState { db, .. }): State<AppState>,
auth_session: AuthSession,
Path(pool_id): Path<i32>,
Form(body): Form<SortPoolForm>,
) -> Result<impl IntoResponse, SameyError> {
let pool = SameyPool::find_by_id(pool_id)
.one(&db)
.await?
.ok_or(SameyError::NotFound)?;
let can_edit = match auth_session.user.as_ref() {
None => false,
Some(user) => user.is_admin || pool.uploader_id == user.id,
};
if !can_edit {
return Err(SameyError::Forbidden);
}
if body.old_index != body.new_index {
let posts = get_posts_in_pool(pool_id, auth_session.user.as_ref())
.all(&db)
.await?;
let changed_post = posts.get(body.old_index).ok_or(SameyError::NotFound)?;
let min_index = if body.new_index < body.old_index {
body.new_index.checked_sub(1)
} else {
Some(body.new_index)
};
let max_index = if body.new_index == posts.len().saturating_sub(1) {
None
} else {
if body.new_index < body.old_index {
Some(body.new_index)
} else {
Some(body.new_index + 1)
}
};
let min = min_index.map(|index| posts[index].position).unwrap_or(0.0);
let max = max_index
.map(|index| posts[index].position)
.unwrap_or_else(|| posts.last().map(|post| post.position).unwrap_or(min) + 2.0);
SameyPoolPost::update(samey_pool_post::ActiveModel {
id: Set(changed_post.pool_post_id),
position: Set((min + max) / 2.0),
..Default::default()
})
.exec(&db)
.await?;
}
let posts = get_posts_in_pool(pool_id, auth_session.user.as_ref())
.all(&db)
.await?;
Ok(Html(
PoolPostsTemplate {
pool,
posts,
can_edit: true,
}
.render()?,
))
}
// Single post views
#[derive(Template)]
@ -702,9 +780,9 @@ struct ViewPostTemplate {
pub(crate) async fn view_post(
State(AppState { db, .. }): State<AppState>,
auth_session: AuthSession,
Path(post_id): Path<u32>,
Path(post_id): Path<i32>,
) -> Result<impl IntoResponse, SameyError> {
let post_id = post_id as i32;
let post_id = post_id;
let tags = get_tags_for_post(post_id).all(&db).await?;
let tags_text = tags.iter().map(|tag| &tag.name).join(" ");
@ -796,9 +874,8 @@ struct PostDetailsTemplate {
pub(crate) async fn post_details(
State(AppState { db, .. }): State<AppState>,
auth_session: AuthSession,
Path(post_id): Path<u32>,
Path(post_id): Path<i32>,
) -> Result<impl IntoResponse, SameyError> {
let post_id = post_id as i32;
let sources = SameyPostSource::find()
.filter(samey_post_source::Column::PostId.eq(post_id))
.all(&db)
@ -853,11 +930,9 @@ struct SubmitPostDetailsTemplate {
pub(crate) async fn submit_post_details(
State(AppState { db, .. }): State<AppState>,
auth_session: AuthSession,
Path(post_id): Path<u32>,
Path(post_id): Path<i32>,
Form(body): Form<SubmitPostDetailsForm>,
) -> Result<impl IntoResponse, SameyError> {
let post_id = post_id as i32;
let post = SameyPost::find_by_id(post_id)
.one(&db)
.await?
@ -1000,9 +1075,9 @@ struct EditDetailsTemplate {
pub(crate) async fn edit_post_details(
State(AppState { db, .. }): State<AppState>,
auth_session: AuthSession,
Path(post_id): Path<u32>,
Path(post_id): Path<i32>,
) -> Result<impl IntoResponse, SameyError> {
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)?;
@ -1026,7 +1101,7 @@ pub(crate) async fn edit_post_details(
})
.collect();
let tags = get_tags_for_post(post_id as i32)
let tags = get_tags_for_post(post_id)
.select_only()
.column(samey_tag::Column::Name)
.into_tuple::<String>()
@ -1072,9 +1147,9 @@ struct GetMediaTemplate {
pub(crate) async fn get_media(
State(AppState { db, .. }): State<AppState>,
auth_session: AuthSession,
Path(post_id): Path<u32>,
Path(post_id): Path<i32>,
) -> Result<impl IntoResponse, SameyError> {
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)?;
@ -1100,9 +1175,9 @@ struct GetFullMediaTemplate {
pub(crate) async fn get_full_media(
State(AppState { db, .. }): State<AppState>,
auth_session: AuthSession,
Path(post_id): Path<u32>,
Path(post_id): Path<i32>,
) -> Result<impl IntoResponse, SameyError> {
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)?;
@ -1122,9 +1197,9 @@ pub(crate) async fn get_full_media(
pub(crate) async fn delete_post(
State(AppState { db, files_dir }): State<AppState>,
auth_session: AuthSession,
Path(post_id): Path<u32>,
Path(post_id): Path<i32>,
) -> Result<impl IntoResponse, SameyError> {
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)?;

View file

@ -16,6 +16,36 @@
{% if let Some(post) = posts.first() %}
<meta property="og:image" content="/files/{{ post.thumbnail }}" />
<meta property="twitter:image:src" content="/files/{{ post.thumbnail }}" />
{% endif %} {% if can_edit %}
<script>
htmx.onLoad(function (content) {
var sortables = content.querySelectorAll(".sortable");
for (var i = 0; i < sortables.length; i++) {
var sortable = sortables[i];
var sortableInstance = new Sortable(sortable, {
animation: 150,
ghostClass: "blue-background-class",
// Make the `.htmx-indicator` unsortable
filter: ".htmx-indicator",
onMove: function (evt) {
return evt.related.className.indexOf("htmx-indicator") === -1;
},
// Disable sorting on the `end` event
onEnd: function (evt) {
console.log(evt);
this.option("disabled", true);
},
});
// Re-enable sorting on the `htmx:afterSwap` event
sortable.addEventListener("htmx:afterSwap", function () {
sortableInstance.option("disabled", false);
});
}
});
</script>
{% endif %}
</head>
<body>

View file

@ -2,9 +2,16 @@
{% if posts.is_empty() %}
<span>No posts in pool.</span>
{% else %}
<ul>
<ul
class="sortable"
hx-put="/pool/{{ pool.id }}/sort"
hx-trigger="end"
hx-vals="js:{old_index: event.oldIndex, new_index: event.newIndex}"
hx-target="#pool-posts"
hx-swap="outerHTML"
>
{% for post in posts %}
<li class="pool-post" data-position="{{ post.position }}">
<li class="pool-post">
<a href="/post/{{ post.id }}" title="{{ post.tags }}">
<img src="/files/{{ post.thumbnail }}" />
<div>{{ post.rating | upper }}</div>