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_BASE: &str = include_str!("./public/html/profile/base.html");
pub const PROFILE_POSTS: &str = include_str!("./public/html/profile/posts.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_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_LIST: &str = include_str!("./public/html/communities/list.html");
pub const COMMUNITIES_BASE: &str = include_str!("./public/html/communities/base.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/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/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/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/list.html"(crate::assets::COMMUNITIES_LIST) -d "communities" --config=config);
write_template!(html_path->"communities/base.html"(crate::assets::COMMUNITIES_BASE) --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); color: var(--color-link);
} }
a.flush {
color: inherit;
}
a:hover { a:hover {
text-decoration: underline; text-decoration: underline;
} }
@ -633,6 +637,7 @@ nav .button:not(.inner *) {
transition: transition:
opacity 0.15s, opacity 0.15s,
transform 0.15s; transform 0.15s;
font-size: 0.95rem;
} }
nav button:not(.inner *):hover, nav button:not(.inner *):hover,

View file

@ -87,17 +87,31 @@ community %}
{% endif %} {% endif %}
</button> </button>
{%- endmacro %} {% macro post(post, owner, secondary=false, community=false, {%- endmacro %} {% macro post(post, owner, secondary=false, community=false,
show_community=true) -%} show_community=true) -%} {% if community and show_community %}
<div class="card flex flex-col gap-2 {% if secondary %}secondary{% endif %}"> <div class="card-nest">
<div class="card small">
<a
href="/api/v1/communities/find/{{ post.community }}"
class="flush flex gap-1 items-center"
>
{{ 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"> <div class="w-full flex gap-2">
<a href="/user/{{ owner.username }}"> <a href="/@{{ owner.username }}">
{{ components::avatar(username=post.owner, size="52px", {{ components::avatar(username=post.owner, size="52px",
selector_type="id") }} selector_type="id") }}
</a> </a>
<div class="flex flex-col w-full gap-1"> <div class="flex flex-col w-full gap-1">
<div class="flex flex-wrap gap-2 items-center"> <div class="flex flex-wrap gap-2 items-center">
<a href="/user/{{ owner.username }}" <a href="/@{{ owner.username }}"
>{{ components::username(user=owner) }}</a >{{ components::username(user=owner) }}</a
> >
@ -106,10 +120,7 @@ show_community=true) -%}
{% if show_community %} {% if show_community %}
<a href="/api/v1/communities/find/{{ post.community }}"> <a href="/api/v1/communities/find/{{ post.community }}">
<!-- prettier-ignore --> <!-- prettier-ignore -->
{% if community %} {% if not community %}
{{ components::community_avatar(id=post.community,
community=community) }}
{% else %}
{{ components::community_avatar(id=post.community) }} {{ components::community_avatar(id=post.community) }}
{% endif %} {% endif %}
</a> </a>
@ -173,8 +184,10 @@ show_community=true) -%}
{% endif %} {% endif %} {% endif %} {% endif %}
</div> </div>
</div> </div>
</div>
{% if community and show_community %}
</div> </div>
{%- endmacro %} {% macro notification(notification) -%} {% endif %} {%- endmacro %} {% macro notification(notification) -%}
<div class="w-full card-nest"> <div class="w-full card-nest">
<div class="card small notif_title flex items-center"> <div class="card small notif_title flex items-center">
{% if not notification.read %} {% if not notification.read %}
@ -222,4 +235,15 @@ show_community=true) -%}
</div> </div>
</div> </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 %} {%- endmacro %}

View file

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

View file

@ -34,14 +34,14 @@
<div class="card flex flex-col gap-2" id="social"> <div class="card flex flex-col gap-2" id="social">
<div class="w-full flex"> <div class="w-full flex">
<a <a
href="/user/{{ profile.username }}/followers" href="/@{{ profile.username }}/followers"
class="w-full flex justify-center items-center gap-2" class="w-full flex justify-center items-center gap-2"
> >
<h4>{{ profile.follower_count }}</h4> <h4>{{ profile.follower_count }}</h4>
<span>{{ text "auth:label.followers" }}</span> <span>{{ text "auth:label.followers" }}</span>
</a> </a>
<a <a
href="/user/{{ profile.username }}/following" href="/@{{ profile.username }}/following"
class="w-full flex justify-center items-center gap-2" class="w-full flex justify-center items-center gap-2"
> >
<h4>{{ profile.following_count }}</h4> <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>, Extension(data): Extension<State>,
Path(id): Path<String>, Path(id): Path<String>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match (data.read().await).0 match (data.read().await)
.0
.get_user_by_id(match id.parse::<usize>() { .get_user_by_id(match id.parse::<usize>() {
Ok(id) => id, Ok(id) => id,
Err(_) => return Redirect::to("/"), Err(_) => return Redirect::to("/"),
}) })
.await .await
{ {
Ok(u) => Redirect::to(&format!("/user/{}", u.username)), Ok(u) => Redirect::to(&format!("/@{}", u.username)),
Err(_) => Redirect::to("/"), Err(_) => Redirect::to("/"),
} }
} }

View file

@ -25,7 +25,9 @@ pub fn routes() -> Router {
.route("/auth/login", get(auth::login_request)) .route("/auth/login", get(auth::login_request))
// profile // profile
.route("/settings", get(profile::settings_request)) .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 // communities
.route("/communities", get(communities::list_request)) .route("/communities", get(communities::list_request))
.route("/community/{title}", get(communities::feed_request)) .route("/community/{title}", get(communities::feed_request))

View file

@ -66,7 +66,7 @@ pub fn profile_context(
context.insert("is_blocking", &is_blocking); context.insert("is_blocking", &is_blocking);
} }
/// `/user/{username}` /// `/@{username}`
pub async fn posts_request( pub async fn posts_request(
jar: CookieJar, jar: CookieJar,
Path(username): Path<String>, Path(username): Path<String>,
@ -189,3 +189,255 @@ pub async fn posts_request(
// return // return
Ok(Html(data.1.render("profile/posts.html", &context).unwrap())) 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. /// version built with the server binary.
#[serde(default = "default_no_track")] #[serde(default = "default_no_track")]
pub no_track: Vec<String>, 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 { fn default_name() -> String {
@ -170,6 +173,19 @@ fn default_no_track() -> Vec<String> {
Vec::new() 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 { impl Default for Config {
fn default() -> Self { fn default() -> Self {
Self { Self {
@ -181,6 +197,7 @@ impl Default for Config {
security: default_security(), security: default_security(),
dirs: default_dirs(), dirs: default_dirs(),
no_track: default_no_track(), 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())); 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 // make sure username isn't taken
if self.get_user_by_username(&data.username).await.is_ok() { if self.get_user_by_username(&data.username).await.is_ok() {
return Err(Error::UsernameInUse); return Err(Error::UsernameInUse);

View file

@ -145,6 +145,36 @@ impl DataManager {
Ok(res.unwrap()) 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. /// Create a new user follow in the database.
/// ///
/// # Arguments /// # Arguments