add: followers/following ui

This commit is contained in:
trisua 2025-03-31 22:35:11 -04:00
parent 17564ede49
commit e183a01887
13 changed files with 469 additions and 86 deletions

View file

@ -42,6 +42,8 @@ pub const AUTH_REGISTER: &str = include_str!("./public/html/auth/register.html")
pub const PROFILE_BASE: &str = include_str!("./public/html/profile/base.html");
pub const PROFILE_POSTS: &str = include_str!("./public/html/profile/posts.html");
pub const PROFILE_SETTINGS: &str = include_str!("./public/html/profile/settings.html");
pub const PROFILE_FOLLOWING: &str = include_str!("./public/html/profile/following.html");
pub const PROFILE_FOLLOWERS: &str = include_str!("./public/html/profile/followers.html");
pub const COMMUNITIES_LIST: &str = include_str!("./public/html/communities/list.html");
pub const COMMUNITIES_BASE: &str = include_str!("./public/html/communities/base.html");
@ -159,6 +161,8 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD {
write_template!(html_path->"profile/base.html"(crate::assets::PROFILE_BASE) -d "profile" --config=config);
write_template!(html_path->"profile/posts.html"(crate::assets::PROFILE_POSTS) --config=config);
write_template!(html_path->"profile/settings.html"(crate::assets::PROFILE_SETTINGS) --config=config);
write_template!(html_path->"profile/following.html"(crate::assets::PROFILE_FOLLOWING) --config=config);
write_template!(html_path->"profile/followers.html"(crate::assets::PROFILE_FOLLOWERS) --config=config);
write_template!(html_path->"communities/list.html"(crate::assets::COMMUNITIES_LIST) -d "communities" --config=config);
write_template!(html_path->"communities/base.html"(crate::assets::COMMUNITIES_BASE) --config=config);

View file

@ -226,6 +226,10 @@ a {
color: var(--color-link);
}
a.flush {
color: inherit;
}
a:hover {
text-decoration: underline;
}
@ -633,6 +637,7 @@ nav .button:not(.inner *) {
transition:
opacity 0.15s,
transform 0.15s;
font-size: 0.95rem;
}
nav button:not(.inner *):hover,

View file

@ -87,94 +87,107 @@ community %}
{% endif %}
</button>
{%- endmacro %} {% macro post(post, owner, secondary=false, community=false,
show_community=true) -%}
<div class="card flex flex-col gap-2 {% if secondary %}secondary{% endif %}">
<div class="w-full flex gap-2">
<a href="/user/{{ owner.username }}">
{{ components::avatar(username=post.owner, size="52px",
selector_type="id") }}
</a>
<div class="flex flex-col w-full gap-1">
<div class="flex flex-wrap gap-2 items-center">
<a href="/user/{{ owner.username }}"
>{{ components::username(user=owner) }}</a
>
<span class="fade date">{{ post.created }}</span>
{% if show_community %}
<a href="/api/v1/communities/find/{{ post.community }}">
<!-- prettier-ignore -->
{% if community %}
{{ components::community_avatar(id=post.community,
community=community) }}
{% else %}
{{ components::community_avatar(id=post.community) }}
{% endif %}
</a>
{% endif %}
</div>
<span id="post-content:{{ post.id }}"
>{{ post.content|markdown|safe }}</span
>
</div>
</div>
<div class="flex justify-between items-center gap-2 w-full">
{% if user %}
<div
class="flex gap-1 reactions_box"
hook="check_reactions"
hook-arg:id="{{ post.id }}"
show_community=true) -%} {% if community and show_community %}
<div class="card-nest">
<div class="card small">
<a
href="/api/v1/communities/find/{{ post.community }}"
class="flush flex gap-1 items-center"
>
{{ components::likes(id=post.id, asset_type="Post",
likes=post.likes, dislikes=post.dislikes) }}
</div>
{% else %}
<div></div>
{% endif %}
<div class="flex gap-1 buttons_box">
<a href="/post/{{ post.id }}" class="button camo small">
{{ icon "message-circle" }}
<span>{{ post.comment_count }}</span>
{{ components::community_avatar(id=post.community,
community=community) }}
<b>{{ community.title }}</b>
</a>
</div>
{% endif %}
<div
class="card flex flex-col gap-2 {% if secondary %}secondary{% endif %}"
>
<div class="w-full flex gap-2">
<a href="/@{{ owner.username }}">
{{ components::avatar(username=post.owner, size="52px",
selector_type="id") }}
</a>
<a
href="/post/{{ post.id }}"
class="button camo small"
target="_blank"
>
{{ icon "external-link" }}
</a>
{% if user %} {% if (user.id == post.owner) or is_helper %}
<div class="dropdown">
<button
class="camo small"
onclick="trigger('atto::hooks::dropdown', [event])"
exclude="dropdown"
>
{{ icon "ellipsis" }}
</button>
<div class="inner">
<button
class="red"
onclick="trigger('me::remove_post', ['{{ post.id }}'])"
<div class="flex flex-col w-full gap-1">
<div class="flex flex-wrap gap-2 items-center">
<a href="/@{{ owner.username }}"
>{{ components::username(user=owner) }}</a
>
{{ icon "trash" }}
<span>{{ text "general:action.delete" }}</span>
</button>
<span class="fade date">{{ post.created }}</span>
{% if show_community %}
<a href="/api/v1/communities/find/{{ post.community }}">
<!-- prettier-ignore -->
{% if not community %}
{{ components::community_avatar(id=post.community) }}
{% endif %}
</a>
{% endif %}
</div>
<span id="post-content:{{ post.id }}"
>{{ post.content|markdown|safe }}</span
>
</div>
</div>
<div class="flex justify-between items-center gap-2 w-full">
{% if user %}
<div
class="flex gap-1 reactions_box"
hook="check_reactions"
hook-arg:id="{{ post.id }}"
>
{{ components::likes(id=post.id, asset_type="Post",
likes=post.likes, dislikes=post.dislikes) }}
</div>
{% else %}
<div></div>
{% endif %}
<div class="flex gap-1 buttons_box">
<a href="/post/{{ post.id }}" class="button camo small">
{{ icon "message-circle" }}
<span>{{ post.comment_count }}</span>
</a>
<a
href="/post/{{ post.id }}"
class="button camo small"
target="_blank"
>
{{ icon "external-link" }}
</a>
{% if user %} {% if (user.id == post.owner) or is_helper %}
<div class="dropdown">
<button
class="camo small"
onclick="trigger('atto::hooks::dropdown', [event])"
exclude="dropdown"
>
{{ icon "ellipsis" }}
</button>
<div class="inner">
<button
class="red"
onclick="trigger('me::remove_post', ['{{ post.id }}'])"
>
{{ icon "trash" }}
<span>{{ text "general:action.delete" }}</span>
</button>
</div>
</div>
{% endif %} {% endif %}
</div>
{% endif %} {% endif %}
</div>
</div>
{% if community and show_community %}
</div>
{%- endmacro %} {% macro notification(notification) -%}
{% endif %} {%- endmacro %} {% macro notification(notification) -%}
<div class="w-full card-nest">
<div class="card small notif_title flex items-center">
{% if not notification.read %}
@ -222,4 +235,15 @@ show_community=true) -%}
</div>
</div>
</div>
{%- endmacro %} {% macro user_card(user) -%}
<a
class="card secondary w-full flex items-center gap-4"
href="/@{{ user.username }}"
>
{{ components::avatar(username=user.username, size="48px") }}
<div class="flex flex-col">
<h3>{{ components::username(user=user) }}</h3>
<span class="fade">{{ user.username }}</span>
</div>
</a>
{%- endmacro %}

View file

@ -65,7 +65,7 @@ show_lhs=true) -%}
<div class="inner">
<b class="title">{{ user.username }}</b>
<a href="/user/{{ user.username }}">
<a href="/@{{ user.username }}">
{{ icon "circle-user-round" }}
<span>{{ text "auth:link.my_profile" }}</span>
</a>

View file

@ -34,14 +34,14 @@
<div class="card flex flex-col gap-2" id="social">
<div class="w-full flex">
<a
href="/user/{{ profile.username }}/followers"
href="/@{{ profile.username }}/followers"
class="w-full flex justify-center items-center gap-2"
>
<h4>{{ profile.follower_count }}</h4>
<span>{{ text "auth:label.followers" }}</span>
</a>
<a
href="/user/{{ profile.username }}/following"
href="/@{{ profile.username }}/following"
class="w-full flex justify-center items-center gap-2"
>
<h4>{{ profile.following_count }}</h4>

View file

@ -0,0 +1,22 @@
{% import "macros.html" as macros %} {% extends "profile/base.html" %} {% block
content %}
<div class="card-nest">
<div class="card small flex gap-2 items-center">
{{ icon "users-round" }}
<span>{{ text "auth:label.followers" }}</span>
</div>
<div class="card flex flex-col gap-4">
<!-- prettier-ignore -->
{% for item in list %}
<div class="card-nest">
<div class="card small">
Since <span class="date">{{ item[0].created }}</span>
</div>
{{ components::user_card(user=item[1]) }}
</div>
{% endfor %}
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,22 @@
{% import "macros.html" as macros %} {% extends "profile/base.html" %} {% block
content %}
<div class="card-nest">
<div class="card small flex gap-2 items-center">
{{ icon "users-round" }}
<span>{{ text "auth:label.following" }}</span>
</div>
<div class="card flex flex-col gap-4">
<!-- prettier-ignore -->
{% for item in list %}
<div class="card-nest">
<div class="card small">
Since <span class="date">{{ item[0].created }}</span>
</div>
{{ components::user_card(user=item[1]) }}
</div>
{% endfor %}
</div>
</div>
{% endblock %}

View file

@ -18,14 +18,15 @@ pub async fn redirect_from_id(
Extension(data): Extension<State>,
Path(id): Path<String>,
) -> impl IntoResponse {
match (data.read().await).0
match (data.read().await)
.0
.get_user_by_id(match id.parse::<usize>() {
Ok(id) => id,
Err(_) => return Redirect::to("/"),
})
.await
{
Ok(u) => Redirect::to(&format!("/user/{}", u.username)),
Ok(u) => Redirect::to(&format!("/@{}", u.username)),
Err(_) => Redirect::to("/"),
}
}

View file

@ -25,7 +25,9 @@ pub fn routes() -> Router {
.route("/auth/login", get(auth::login_request))
// profile
.route("/settings", get(profile::settings_request))
.route("/user/{username}", get(profile::posts_request))
.route("/@{username}", get(profile::posts_request))
.route("/@{username}/following", get(profile::following_request))
.route("/@{username}/followers", get(profile::followers_request))
// communities
.route("/communities", get(communities::list_request))
.route("/community/{title}", get(communities::feed_request))

View file

@ -66,7 +66,7 @@ pub fn profile_context(
context.insert("is_blocking", &is_blocking);
}
/// `/user/{username}`
/// `/@{username}`
pub async fn posts_request(
jar: CookieJar,
Path(username): Path<String>,
@ -189,3 +189,255 @@ pub async fn posts_request(
// return
Ok(Html(data.1.render("profile/posts.html", &context).unwrap()))
}
/// `/@{username}/following`
pub async fn following_request(
jar: CookieJar,
Path(username): Path<String>,
Query(props): Query<PaginatedQuery>,
Extension(data): Extension<State>,
) -> impl IntoResponse {
let data = data.read().await;
let user = get_user_from_token!(jar, data.0);
let other_user = match data.0.get_user_by_username(&username).await {
Ok(ua) => ua,
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
};
// check if we're blocked
if let Some(ref ua) = user {
if data
.0
.get_userblock_by_initiator_receiver(other_user.id, ua.id)
.await
.is_ok()
{
return Err(Html(
render_error(Error::NotAllowed, &jar, &data, &user).await,
));
}
}
// check for private profile
if other_user.settings.private_profile {
if let Some(ref ua) = user {
if ua.id != other_user.id {
if data
.0
.get_userfollow_by_initiator_receiver(other_user.id, ua.id)
.await
.is_err()
{
return Err(Html(
render_error(Error::NotAllowed, &jar, &data, &user).await,
));
}
}
} else {
return Err(Html(
render_error(Error::NotAllowed, &jar, &data, &user).await,
));
}
}
// fetch data
let list = match data
.0
.get_userfollows_by_initiator(other_user.id, 12, props.page)
.await
{
Ok(l) => match data.0.fill_userfollows_with_receiver(l).await {
Ok(l) => l,
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
},
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
};
let communities = match data.0.get_memberships_by_owner(other_user.id).await {
Ok(m) => match data.0.fill_communities(m).await {
Ok(m) => m,
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
},
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
};
// init context
let lang = get_lang!(jar, data.0);
let mut context = initial_context(&data.0.0, lang, &user).await;
let is_self = if let Some(ref ua) = user {
ua.id == other_user.id
} else {
false
};
let is_following = if let Some(ref ua) = user {
data.0
.get_userfollow_by_initiator_receiver(ua.id, other_user.id)
.await
.is_ok()
} else {
false
};
let is_following_you = if let Some(ref ua) = user {
data.0
.get_userfollow_by_receiver_initiator(ua.id, other_user.id)
.await
.is_ok()
} else {
false
};
let is_blocking = if let Some(ref ua) = user {
data.0
.get_userblock_by_initiator_receiver(ua.id, other_user.id)
.await
.is_ok()
} else {
false
};
context.insert("list", &list);
profile_context(
&mut context,
&other_user,
&communities,
is_self,
is_following,
is_following_you,
is_blocking,
);
// return
Ok(Html(
data.1.render("profile/following.html", &context).unwrap(),
))
}
/// `/@{username}/followers`
pub async fn followers_request(
jar: CookieJar,
Path(username): Path<String>,
Query(props): Query<PaginatedQuery>,
Extension(data): Extension<State>,
) -> impl IntoResponse {
let data = data.read().await;
let user = get_user_from_token!(jar, data.0);
let other_user = match data.0.get_user_by_username(&username).await {
Ok(ua) => ua,
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
};
// check if we're blocked
if let Some(ref ua) = user {
if data
.0
.get_userblock_by_initiator_receiver(other_user.id, ua.id)
.await
.is_ok()
{
return Err(Html(
render_error(Error::NotAllowed, &jar, &data, &user).await,
));
}
}
// check for private profile
if other_user.settings.private_profile {
if let Some(ref ua) = user {
if ua.id != other_user.id {
if data
.0
.get_userfollow_by_initiator_receiver(other_user.id, ua.id)
.await
.is_err()
{
return Err(Html(
render_error(Error::NotAllowed, &jar, &data, &user).await,
));
}
}
} else {
return Err(Html(
render_error(Error::NotAllowed, &jar, &data, &user).await,
));
}
}
// fetch data
let list = match data
.0
.get_userfollows_by_receiver(other_user.id, 12, props.page)
.await
{
Ok(l) => match data.0.fill_userfollows_with_initiator(l).await {
Ok(l) => l,
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
},
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
};
let communities = match data.0.get_memberships_by_owner(other_user.id).await {
Ok(m) => match data.0.fill_communities(m).await {
Ok(m) => m,
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
},
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
};
// init context
let lang = get_lang!(jar, data.0);
let mut context = initial_context(&data.0.0, lang, &user).await;
let is_self = if let Some(ref ua) = user {
ua.id == other_user.id
} else {
false
};
let is_following = if let Some(ref ua) = user {
data.0
.get_userfollow_by_initiator_receiver(ua.id, other_user.id)
.await
.is_ok()
} else {
false
};
let is_following_you = if let Some(ref ua) = user {
data.0
.get_userfollow_by_receiver_initiator(ua.id, other_user.id)
.await
.is_ok()
} else {
false
};
let is_blocking = if let Some(ref ua) = user {
data.0
.get_userblock_by_initiator_receiver(ua.id, other_user.id)
.await
.is_ok()
} else {
false
};
context.insert("list", &list);
profile_context(
&mut context,
&other_user,
&communities,
is_self,
is_following,
is_following_you,
is_blocking,
);
// return
Ok(Html(
data.1.render("profile/followers.html", &context).unwrap(),
))
}

View file

@ -137,6 +137,9 @@ pub struct Config {
/// version built with the server binary.
#[serde(default = "default_no_track")]
pub no_track: Vec<String>,
/// A list of usernames which cannot be used.
#[serde(default = "default_banned_usernames")]
pub banned_usernames: Vec<String>,
}
fn default_name() -> String {
@ -170,6 +173,19 @@ fn default_no_track() -> Vec<String> {
Vec::new()
}
fn default_banned_usernames() -> Vec<String> {
vec![
"admin".to_string(),
"owner".to_string(),
"moderator".to_string(),
"api".to_string(),
"communities".to_string(),
"notifs".to_string(),
"notification".to_string(),
"post".to_string(),
]
}
impl Default for Config {
fn default() -> Self {
Self {
@ -181,6 +197,7 @@ impl Default for Config {
security: default_security(),
dirs: default_dirs(),
no_track: default_no_track(),
banned_usernames: default_banned_usernames(),
}
}
}

View file

@ -84,6 +84,10 @@ impl DataManager {
return Err(Error::DataTooShort("password".to_string()));
}
if self.0.banned_usernames.contains(&data.username) {
return Err(Error::MiscError("This username cannot be used".to_string()));
}
// make sure username isn't taken
if self.get_user_by_username(&data.username).await.is_ok() {
return Err(Error::UsernameInUse);

View file

@ -145,6 +145,36 @@ impl DataManager {
Ok(res.unwrap())
}
/// Complete a vector of just userfollows with their receiver as well.
pub async fn fill_userfollows_with_receiver(
&self,
userfollows: Vec<UserFollow>,
) -> Result<Vec<(UserFollow, User)>> {
let mut out: Vec<(UserFollow, User)> = Vec::new();
for userfollow in userfollows {
let receiver = userfollow.receiver.clone();
out.push((userfollow, self.get_user_by_id(receiver).await?));
}
Ok(out)
}
/// Complete a vector of just userfollows with their initiator as well.
pub async fn fill_userfollows_with_initiator(
&self,
userfollows: Vec<UserFollow>,
) -> Result<Vec<(UserFollow, User)>> {
let mut out: Vec<(UserFollow, User)> = Vec::new();
for userfollow in userfollows {
let initiator = userfollow.initiator.clone();
out.push((userfollow, self.get_user_by_id(initiator).await?));
}
Ok(out)
}
/// Create a new user follow in the database.
///
/// # Arguments