ui: redesign from scratch

This commit is contained in:
4cuck authored and GitHub committed 2026-04-17 13:26:48 -04:00
1 parent 7321ee27bf
commit 1eb036d196
22 files changed
+1879 -936

No files matched your search

+2 -6
View File
@@ -843,11 +843,7 @@ impl ApiClient {
}
/// Translate a tweet using Kagi Translate API.
pub async fn kagi_translate(
&self,
tweet_id: &str,
kagi_token: &str,
) -> Result<Translation> {
pub async fn kagi_translate(&self, tweet_id: &str, kagi_token: &str) -> Result<Translation> {
use http_body_util::Full;
use hyper_rustls::HttpsConnectorBuilder;
use hyper_util::{
@@ -995,7 +991,7 @@ impl ApiClient {
tweet_id: tweet.id.to_string(),
color: String::new(),
});
if photos.len() >= 16 {
if photos.len() >= 10 {
return Ok(photos);
}
}
+2 -2
View File
@@ -155,10 +155,10 @@ async fn main() -> eyre::Result<()> {
HeaderValue::from_static(
"default-src 'none'; \
script-src 'self'; \
style-src 'self' 'unsafe-inline'; \
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; \
img-src 'self' data:; \
media-src 'self' blob:; \
font-src 'self'; \
font-src 'self' https://fonts.gstatic.com; \
connect-src 'self'; \
form-action 'self'; \
base-uri 'self'; \
+3 -5
View File
@@ -121,7 +121,8 @@ async fn status_media_redirect(Path((username, id)): Path<(String, String)>) ->
#[expect(
clippy::cognitive_complexity,
reason = "status handler has many conditional branches for caching, scrolling, pagination, and translation"
reason = "status handler has many conditional branches for caching, scrolling, pagination, and \
translation"
)]
async fn status(
State(state): State<AppState>,
@@ -534,10 +535,7 @@ async fn translate(
(!server_token.is_empty()).then(|| server_token.clone())
});
let result = state
.api
.translate_auto(&id, kagi_token.as_deref())
.await;
let result = state.api.translate_auto(&id, kagi_token.as_deref()).await;
match result {
Ok(tl) if !tl.text.is_empty() => {
+1 -1
View File
@@ -52,7 +52,7 @@ impl Prefs {
hide_pins: false,
hide_replies: false,
hide_community_notes: false,
square_avatars: false,
square_avatars: true,
use_twemoji: true,
sticky_nav: true,
proxy_media: true,
+29 -29
View File
@@ -266,41 +266,41 @@ pub struct TweetStats {
reason = "mirrors Twitter API tweet fields"
)]
pub struct Tweet {
pub id: i64,
pub thread_id: i64,
pub reply_id: i64,
pub user: User,
pub text: String,
pub id: i64,
pub thread_id: i64,
pub reply_id: i64,
pub user: User,
pub text: String,
#[serde(with = "time::serde::timestamp::option")]
pub time: Option<time::OffsetDateTime>,
pub reply: Vec<String>,
pub pinned: bool,
pub has_thread: bool,
pub available: bool,
pub tombstone: String,
pub location: String,
pub source: String,
pub stats: TweetStats,
pub retweet: Option<Box<Self>>,
pub attribution: Option<User>,
pub media_tags: Vec<User>,
pub quote: Option<Box<Self>>,
pub card: Option<Card>,
pub poll: Option<Poll>,
pub gif: Option<Gif>,
pub video: Option<Video>,
pub photos: Vec<Photo>,
pub time: Option<time::OffsetDateTime>,
pub reply: Vec<String>,
pub pinned: bool,
pub has_thread: bool,
pub available: bool,
pub tombstone: String,
pub location: String,
pub source: String,
pub stats: TweetStats,
pub retweet: Option<Box<Self>>,
pub attribution: Option<User>,
pub media_tags: Vec<User>,
pub quote: Option<Box<Self>>,
pub card: Option<Card>,
pub poll: Option<Poll>,
pub gif: Option<Gif>,
pub video: Option<Video>,
pub photos: Vec<Photo>,
/// Community note (Birdwatch) -- structured data, rendered in views.
pub note: Option<CommunityNote>,
pub note: Option<CommunityNote>,
/// Content disclosure labels (paid promotion, AI-generated media).
pub paid_promotion: bool,
pub ai_generated: bool,
pub paid_promotion: bool,
pub ai_generated: bool,
/// Edit history tweet IDs.
pub history: Vec<i64>,
pub history: Vec<i64>,
/// Text entities for expansion (mentions, hashtags, URLs).
pub entities: Vec<Entity>,
pub entities: Vec<Entity>,
/// BCP-47 language code from Twitter's language detection.
pub lang: String,
pub lang: String,
/// Whether Twitter considers this tweet translatable.
pub is_translatable: bool,
/// Pre-fetched translation for embeds (OG tags, `ActivityPub`).
+16 -6
View File
@@ -3,6 +3,7 @@ use std::sync::LazyLock;
use maud::{
DOCTYPE,
Markup,
PreEscaped,
html,
};
use regex::Regex;
@@ -10,7 +11,6 @@ use regex::Regex;
use crate::{
config::Config,
types::Prefs,
views::renderutils::icon,
};
pub const STYLE_CSS: &str = "/css/style.css";
@@ -249,13 +249,23 @@ pub fn render_navbar_full(config: &Config, rss: &str, canonical: &str, referer:
img class="site-logo" src="/logo.svg" alt=(format!("{} logo", config.server.title));
}
div class="nav-item right" {
(icon("search", "", "Search", "", "/search"))
a class="nav-icon" title="Search" href="/search" {
span class="material-symbols-outlined" { "search" }
}
@if config.config.enable_rss && !rss.is_empty() {
(icon("rss", "", "RSS Feed", "", rss))
a class="nav-icon" title="RSS Feed" href=(rss) {
span class="material-symbols-outlined" { "rss_feed" }
}
}
a class="nav-icon" title="Open in X" href=(&canonical) {
(PreEscaped(r#"<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-4.714-6.231-5.401 6.231H2.744l7.737-8.834L1.254 2.25H8.08l4.259 5.622L18.244 2.25zm-1.161 17.52h1.833L7.084 4.126H5.117L17.083 19.77z"/></svg>"#))
}
a class="nav-icon" title="About" href="/about" {
span class="material-symbols-outlined" { "info" }
}
a class="nav-icon" title="Preferences" href=(&settings_href) {
span class="material-symbols-outlined" { "settings" }
}
(icon("bird", "", "Open in X", "", &canonical))
(icon("info", "", "About", "", "/about"))
(icon("cog", "", "Preferences", "", &settings_href))
}
}
}
+77 -50
View File
@@ -79,74 +79,101 @@ pub fn render_preferences_form(
prefs_url: &str,
) -> Markup {
html! {
div class="overlay-panel" {
div class="overlay-panel preferences-panel" {
fieldset class="preferences" {
form method="post" action="/saveprefs" autocomplete="off" {
// Close button for animated panel
a href=(referer) class="preferences-close" {
span class="icon-close" {}
}
// Header with animation
div class="preferences-header" {
h2 { "Preferences" }
p { "Customize your teapot experience" }
}
form method="post" action="/saveprefs" autocomplete="off" class="preferences-form" id="saveprefs-form" {
input type="hidden" name="referer" value=(referer);
// Display section
legend { "Display" }
(gen_select("theme", "Theme", &prefs.theme,
&themes.iter().map(|theme| (theme.as_str(), theme.as_str())).collect::<Vec<_>>()))
(gen_checkbox("infiniteScroll", "Infinite scrolling (experimental, requires JavaScript)", prefs.infinite_scroll))
(gen_checkbox("stickyProfile", "Make profile sidebar stick to top", prefs.sticky_profile))
(gen_checkbox("stickyNav", "Keep navbar fixed to top", prefs.sticky_nav))
(gen_checkbox("bidiSupport", "Support bidirectional text (makes clicking on tweets harder)", prefs.bidi_support))
(gen_checkbox("hideTweetStats", "Hide tweet stats (replies, retweets, likes)", prefs.hide_tweet_stats))
(gen_checkbox("hideBanner", "Hide profile banner", prefs.hide_banner))
(gen_checkbox("hidePins", "Hide pinned tweets", prefs.hide_pins))
(gen_checkbox("hideReplies", "Hide tweet replies", prefs.hide_replies))
(gen_checkbox("hideCommunityNotes", "Hide community notes", prefs.hide_community_notes))
(gen_checkbox("squareAvatars", "Square profile pictures", prefs.square_avatars))
(gen_checkbox("useTwemoji", "Use Twitter emoji (Twemoji)", prefs.use_twemoji))
div class="pref-section" data-section="display" {
legend { "Display" }
div class="pref-grid" {
(gen_select("theme", "Theme", &prefs.theme, &themes.iter().map(|t| (t.as_str(), t.as_str())).collect::<Vec<_>>()))
(gen_checkbox("infiniteScroll", "Infinite scrolling (experimental, requires JavaScript)", prefs.infinite_scroll))
(gen_checkbox("stickyProfile", "Make profile sidebar stick to top", prefs.sticky_profile))
(gen_checkbox("stickyNav", "Keep navbar fixed to top", prefs.sticky_nav))
(gen_checkbox("bidiSupport", "Support bidirectional text", prefs.bidi_support))
(gen_checkbox("hideTweetStats", "Hide tweet stats", prefs.hide_tweet_stats))
(gen_checkbox("hideBanner", "Hide profile banner", prefs.hide_banner))
(gen_checkbox("hidePins", "Hide pinned tweets", prefs.hide_pins))
(gen_checkbox("hideReplies", "Hide tweet replies", prefs.hide_replies))
(gen_checkbox("hideCommunityNotes", "Hide community notes", prefs.hide_community_notes))
(gen_checkbox("squareAvatars", "Square profile pictures", prefs.square_avatars))
(gen_checkbox("useTwemoji", "Use Twitter emoji (Twemoji)", prefs.use_twemoji))
}
}
// Media section
legend { "Media" }
(gen_checkbox("mp4Playback", "Enable mp4 video playback", prefs.mp4_playback))
(gen_checkbox("muteVideos", "Mute videos by default", prefs.mute_videos))
(gen_checkbox("autoplayGifs", "Autoplay gifs", prefs.autoplay_gifs))
(gen_checkbox("proxyMedia", "Proxy media through instance (recommended for privacy)", prefs.proxy_media))
// Accessibility section
legend { "Accessibility" }
(gen_font_size_select(&prefs.font_size))
div class="pref-section" data-section="media" {
legend { "Media" }
div class="pref-grid" {
(gen_checkbox("mp4Playback", "Enable mp4 video playback", prefs.mp4_playback))
(gen_checkbox("muteVideos", "Mute videos by default", prefs.mute_videos))
(gen_checkbox("autoplayGifs", "Autoplay gifs", prefs.autoplay_gifs))
}
}
// Link replacements section
legend { "Link replacements (blank to disable)" }
(gen_input("replaceTwitter", "Twitter -> teapot", &prefs.replace_twitter, "teapot hostname"))
(gen_input("replaceYouTube", "YouTube -> Piped/Invidious", &prefs.replace_youtube, "Piped hostname"))
(gen_input("replaceReddit", "Reddit -> Teddit/Libreddit", &prefs.replace_reddit, "Teddit hostname"))
div class="pref-section" data-section="links" {
legend { "Link replacements (blank to disable)" }
(gen_input("replaceTwitter", "Twitter -> teapot", &prefs.replace_twitter, "teapot hostname"))
(gen_input("replaceYouTube", "YouTube -> Piped/Invidious", &prefs.replace_youtube, "Piped hostname"))
(gen_input("replaceReddit", "Reddit -> Teddit/Libreddit", &prefs.replace_reddit, "Teddit hostname"))
}
// Translation section
legend { "Translation" }
(gen_input("kagiToken", "Kagi session token", &prefs.kagi_token, "Paste session token here"))
p class="bookmark-note" {
"Uses Kagi Translate instead of Twitter. "
a href="https://kagi.com/settings?p=user_details" target="_blank" { "Get your token here" }
" (Session Link → copy token)"
div class="pref-section" data-section="translation" {
legend { "Translation" }
(gen_input("kagiToken", "Kagi session token", &prefs.kagi_token, "Paste session token here"))
p class="bookmark-note" {
"Uses Kagi Translate instead of Twitter. "
a href="https://kagi.com/settings?p=user_details" target="_blank" { "Get your token here" }
" (Session Link → copy token)"
}
}
// Bookmark section
legend { "Bookmark" }
p class="bookmark-note" {
"Save this URL to restore your preferences (?prefs works on all pages)"
}
pre class="prefs-code" {
(prefs_url)
}
p class="bookmark-note" {
(PreEscaped("You can override preferences with query parameters (e.g. <code>?mp4Playback=on</code>). These overrides aren't saved to cookies, and links won't retain the parameters. Intended for configuring RSS feeds and other cookieless environments. Hover over a preference to see its name."))
div class="pref-section pref-section-bookmark" data-section="bookmark" {
legend { "Bookmark" }
p class="bookmark-note" {
"Save this URL to restore your preferences (?prefs works on all pages)"
}
div class="prefs-code-wrapper" {
pre class="prefs-code" {
code { (prefs_url) }
}
button type="button" class="copy-prefs-btn" data-clipboard=(prefs_url) {
span class="icon-copy" {}
}
}
p class="bookmark-note" {
(PreEscaped("You can override preferences with query parameters (e.g. <code>?mp4Playback=on</code>). These overrides aren't saved to cookies."))
}
}
h4 class="note" { "Preferences are stored client-side using cookies without any personal information." }
button type="submit" class="pref-submit" { "Save preferences" }
}
form method="post" action="/resetprefs" class="pref-reset" {
input type="hidden" name="referer" value=(referer);
button type="submit" { "Reset preferences" }
// Actions row — reset and save side by side
div class="preferences-actions" {
form method="post" action="/resetprefs" class="pref-reset-form" {
input type="hidden" name="referer" value=(referer);
button type="submit" class="pref-reset" { "Reset preferences" }
}
button type="submit" form="saveprefs-form" class="pref-submit" { "Save preferences" }
}
h4 class="note preferences-footer-note" { "Preferences are stored client-side using cookies without any personal information." }
}
}
}
+51 -33
View File
@@ -61,15 +61,18 @@ pub fn render_profile_with_prefs(
html! {
div class="profile-tabs" {
@if !hide_banner {
div class="profile-banner" {
(render_banner(&profile.user.banner, config))
}
}
// Left sidebar with profile card and photo rail
div class=(profile_tab_class) {
(render_user_card(&profile.user, config, prefs))
// Photo rail
div class="profile-header" {
@if !hide_banner {
div class="profile-banner" {
(render_banner(&profile.user.banner, config))
}
}
(render_user_card(&profile.user, config, prefs))
}
// Photo rail positioned on the left side
@if !profile.photo_rail.is_empty() {
(render_photo_rail(&profile.photo_rail, &profile.user, config))
}
@@ -91,10 +94,7 @@ pub fn render_profile_with_prefs(
}
}
} @else {
// Tabs + timeline wrapped in timeline-container
// This is critical for the CSS flexbox layout: profile-tabs has two flex children:
// 1. profile-tab (sidebar)
// 2. timeline-container (main content with tabs + timeline)
// Timeline container - main content area
div class="timeline-container" {
(render_timeline_tabs(timeline_kind, &profile.user.username))
(render_timeline_with_pinned_and_prefs(groups, config, cursor, Some(&base_url), pinned, prefs, newer_url))
@@ -126,19 +126,24 @@ pub fn render_profile_page(
html! {
div class="profile-tabs" {
@if !hide_banner {
div class="profile-banner" {
(render_banner(&user.banner, config))
}
}
// Left sidebar
div class=(profile_tab_class) {
(render_user_card(user, config, Some(prefs)))
div class="profile-header" {
@if !hide_banner {
div class="profile-banner" {
(render_banner(&user.banner, config))
}
}
(render_user_card(user, config, Some(prefs)))
}
// Photo rail on the left sidebar
@if !photo_rail.is_empty() {
(render_photo_rail(photo_rail, user, config))
}
}
// Main content area
div class="timeline-container" {
(render_timeline_tabs(tab, &user.username))
(timeline_content)
@@ -189,18 +194,17 @@ fn render_user_card(user: &User, config: &Config, prefs: Option<&Prefs>) -> Mark
html! {
div class="profile-card" {
div class="profile-card-info" {
a class="profile-card-avatar" href=(orig_avatar_url) target="_blank" {
(gen_img(&avatar_pic, avatar_class, config))
}
a class="profile-card-avatar" href=(orig_avatar_url) target="_blank" {
(gen_img(&avatar_pic, avatar_class, config))
}
div class="profile-card-tabs-name" {
div class="profile-card-identity" {
div {
(link_user(user, "profile-card-fullname"))
" "
(verified_icon(user))
" "
(link_user(user, "profile-card-username"))
}
(link_user(user, "profile-card-username"))
}
div class="profile-card-extra" {
@@ -215,12 +219,17 @@ fn render_user_card(user: &User, config: &Config, prefs: Option<&Prefs>) -> Mark
span { (icon("location", "", "", "", "")) }
" "
@let (place, url) = parse_location(&user.location);
@let display_place = if place.chars().count() > 40 {
format!("{}…", place.chars().take(40).collect::<String>())
} else {
place.clone()
};
@if !url.is_empty() {
a href=(url) { (place) }
a href=(url) title=(place) { (display_place) }
} @else if place.contains("://") {
a href=(place) { (place) }
a href=(place) title=(place) { (display_place) }
} @else {
span { (place) }
span title=(place) { (display_place) }
}
}
}
@@ -287,25 +296,34 @@ fn render_banner(banner: &str, config: &Config) -> Markup {
/// Render photo rail showing recent media.
fn render_photo_rail(photos: &[GalleryPhoto], user: &User, config: &Config) -> Markup {
let count = formatters::format_with_commas(user.media);
let header_text = format!("{count} Photos and videos");
html! {
div class="photo-rail-card" {
div class="photo-rail-header" {
a href=(format!("/{}/media", user.username)) {
(icon("picture", &header_text, "", "", ""))
span class="photo-rail-icon-wrap" {
span class="material-symbols-outlined photo-rail-media-icon" { "photo_library" }
span class="photo-rail-media-badge" { (count) }
}
span class="photo-rail-media-label" { "Media" }
}
}
// Mobile toggle
input id="photo-rail-grid-toggle" type="checkbox";
label for="photo-rail-grid-toggle" class="photo-rail-header-mobile" {
(icon("picture", &header_text, "", "", ""))
div class="photo-rail-header-mobile-inner" {
span class="photo-rail-icon-wrap" {
span class="material-symbols-outlined photo-rail-media-icon" { "photo_library" }
span class="photo-rail-media-badge" { (count) }
}
span { "Media" }
}
(icon("down", "", "", "", ""))
}
div class="photo-rail-grid" {
@for photo in photos.iter().take(16) {
@for photo in photos.iter().take(10) {
@let photo_suffix = if photo.url.contains("format") || photo.url.contains("placeholder") { "" } else { ":thumb" };
a href=(format!("/{}/status/{}#m", user.username, photo.tweet_id)) {
(gen_img(&format!("{}{}", photo.url, photo_suffix), "", config))
+3 -3
View File
@@ -5,6 +5,7 @@ use std::{
use maud::{
Markup,
PreEscaped,
html,
};
@@ -101,9 +102,8 @@ pub fn verified_icon(user: &User) -> Markup {
let title = format!("Verified {lower} account");
html! {
div class=(format!("verified-icon {lower}")) {
(icon("circle", "", &title, "verified-icon-circle", ""))
(icon("ok", "", &title, "verified-icon-check", ""))
div class=(format!("verified-icon {lower}")) title=(title) {
(PreEscaped(r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22" width="18" height="18" aria-hidden="true"><path class="verified-badge-bg" d="M 20.396 11 c -0.018 -0.646 -0.215 -1.275 -0.57 -1.816 c -0.354 -0.54 -0.852 -0.972 -1.438 -1.246 c 0.223 -0.607 0.27 -1.264 0.14 -1.897 c -0.131 -0.634 -0.437 -1.218 -0.882 -1.687 c -0.47 -0.445 -1.053 -0.75 -1.687 -0.882 c -0.633 -0.13 -1.29 -0.083 -1.897 0.14 c -0.273 -0.587 -0.704 -1.086 -1.245 -1.44 S 11.647 1.62 11 1.604 c -0.646 0.017 -1.273 0.213 -1.813 0.568 s -0.969 0.854 -1.24 1.44 c -0.608 -0.223 -1.267 -0.272 -1.902 -0.14 c -0.635 0.13 -1.22 0.436 -1.69 0.882 c -0.445 0.47 -0.749 1.055 -0.878 1.688 c -0.13 0.633 -0.08 1.29 0.144 1.896 c -0.587 0.274 -1.087 0.705 -1.443 1.245 c -0.356 0.54 -0.555 1.17 -0.574 1.817 c 0.02 0.647 0.218 1.276 0.574 1.817 c 0.356 0.54 0.856 0.972 1.443 1.245 c -0.224 0.606 -0.274 1.263 -0.144 1.896 c 0.13 0.634 0.433 1.218 0.877 1.688 c 0.47 0.443 1.054 0.747 1.687 0.878 c 0.633 0.132 1.29 0.084 1.897 -0.136 c 0.274 0.586 0.705 1.084 1.246 1.439 c 0.54 0.354 1.17 0.551 1.816 0.569 c 0.647 -0.016 1.276 -0.213 1.817 -0.567 s 0.972 -0.854 1.245 -1.44 c 0.604 0.239 1.266 0.296 1.903 0.164 c 0.636 -0.132 1.22 -0.447 1.68 -0.907 c 0.46 -0.46 0.776 -1.044 0.908 -1.681 s 0.075 -1.299 -0.165 -1.903 c 0.586 -0.274 1.084 -0.705 1.439 -1.246 c 0.354 -0.54 0.551 -1.17 0.569 -1.816 Z"/><path class="verified-badge-check" d="M 9.662 14.85 l -3.429 -3.428 l 1.293 -1.302 l 2.072 2.072 l 4.4 -4.794 l 1.347 1.246 Z"/></svg>"#))
}
}
}