max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_01MptwXZ8k65v19rFmdGAyki
13 files changed,
+970 insertions,
-340 deletions
| @@ -399,8 +399,12 @@ | |||
| 399 | 399 | per width tier; new pages should add their body class here rather than | |
| 400 | 400 | defining a new .foo-page .container rule. */ | |
| 401 | 401 | .delete-account-page .container, | |
| 402 | - | .receipt-page .container, | |
| 403 | - | .user-page .container { max-width: 600px; margin: 0 auto; } | |
| 402 | + | .receipt-page .container { max-width: 600px; margin: 0 auto; } | |
| 403 | + | ||
| 404 | + | /* The profile is described, so it has no container: the measure lands on the | |
| 405 | + | element the renderer draws the page into. The width is this page's own and | |
| 406 | + | the rest of the block was the design system's. */ | |
| 407 | + | .user-page main { max-width: 600px; } | |
| 404 | 408 | ||
| 405 | 409 | .import-page .container, | |
| 406 | 410 | .export-page .container { max-width: 800px; margin: 0 auto; } | |
| @@ -3601,63 +3605,6 @@ | |||
| 3601 | 3605 | /* (Stripe Connect disclaimer page rules defined in the canonical | |
| 3602 | 3606 | STRIPE DISCLAIMER PAGE section near end of file.) */ | |
| 3603 | 3607 | ||
| 3604 | - | /* User profile page (templates/pages/user.html). */ | |
| 3605 | - | .user-page .profile-header { text-align: center; margin-bottom: var(--gap-page); } | |
| 3606 | - | ||
| 3607 | - | .user-page .profile-avatar { | |
| 3608 | - | width: 120px; | |
| 3609 | - | height: 120px; | |
| 3610 | - | background: var(--surface-overlay); | |
| 3611 | - | margin: 0 auto var(--gap-section); | |
| 3612 | - | display: flex; | |
| 3613 | - | align-items: center; | |
| 3614 | - | justify-content: center; | |
| 3615 | - | font-size: var(--text-hero); | |
| 3616 | - | opacity: 0.5; | |
| 3617 | - | } | |
| 3618 | - | ||
| 3619 | - | .user-page .profile-name { font-size: var(--text-title); margin-bottom: var(--gap-peer); } | |
| 3620 | - | ||
| 3621 | - | .user-page .profile-username { | |
| 3622 | - | font-size: var(--text-note); | |
| 3623 | - | opacity: 0.7; | |
| 3624 | - | margin-bottom: var(--gap-section); | |
| 3625 | - | } | |
| 3626 | - | ||
| 3627 | - | .user-page .profile-bio { | |
| 3628 | - | font-size: var(--text-body); | |
| 3629 | - | max-width: 500px; | |
| 3630 | - | margin: 0 auto; | |
| 3631 | - | text-align: center; | |
| 3632 | - | } | |
| 3633 | - | ||
| 3634 | - | .user-page .links-section { margin-bottom: var(--gap-page); } | |
| 3635 | - | .user-page .link-item { text-align: center; } | |
| 3636 | - | ||
| 3637 | - | .user-page .link-title { | |
| 3638 | - | font-size: var(--text-lead); | |
| 3639 | - | margin-bottom: var(--gap-bound); | |
| 3640 | - | font-family: var(--font-display); | |
| 3641 | - | font-weight: bold; | |
| 3642 | - | } | |
| 3643 | - | ||
| 3644 | - | .user-page .link-description { font-size: var(--text-note); opacity: 0.7; } | |
| 3645 | - | ||
| 3646 | - | .user-page .project-title { | |
| 3647 | - | font-size: var(--text-lead); | |
| 3648 | - | margin-bottom: var(--gap-peer); | |
| 3649 | - | font-family: var(--font-display); | |
| 3650 | - | font-weight: bold; | |
| 3651 | - | } | |
| 3652 | - | ||
| 3653 | - | .user-page .project-meta { | |
| 3654 | - | font-size: var(--text-note); | |
| 3655 | - | opacity: 0.7; | |
| 3656 | - | margin-bottom: var(--gap-peer); | |
| 3657 | - | } | |
| 3658 | - | ||
| 3659 | - | .user-page .project-description { font-size: var(--text-note); opacity: 0.8; } | |
| 3660 | - | ||
| 3661 | 3608 | /* The creators page moved to the description layer. Its headings, its ordered | |
| 3662 | 3609 | list, its stat box, its four-column table and its call-to-action box are the | |
| 3663 | 3610 | design system's now: the count is a `Figure`, the tiers are a `Node::Table` | |
| @@ -4550,24 +4497,6 @@ | |||
| 4550 | 4497 | color: var(--content-muted); | |
| 4551 | 4498 | } | |
| 4552 | 4499 | ||
| 4553 | - | .profile-footer { | |
| 4554 | - | margin-top: var(--gap-page); | |
| 4555 | - | padding-top: var(--gap-pane); | |
| 4556 | - | border-top: 1px solid var(--border); | |
| 4557 | - | text-align: center; | |
| 4558 | - | opacity: 0.6; | |
| 4559 | - | font-size: var(--text-note); | |
| 4560 | - | } | |
| 4561 | - | ||
| 4562 | - | .profile-footer a { | |
| 4563 | - | color: var(--content); | |
| 4564 | - | text-decoration: none; | |
| 4565 | - | } | |
| 4566 | - | ||
| 4567 | - | .profile-footer a:hover { | |
| 4568 | - | text-decoration: underline; | |
| 4569 | - | } | |
| 4570 | - | ||
| 4571 | 4500 | /* Site-wide header. | |
| 4572 | 4501 | The class names are quasi-webview's, not this stylesheet's: the header is a | |
| 4573 | 4502 | `quasi_router::Chrome::band` and `crate::shell::site_header` builds the | |
| @@ -9642,56 +9571,6 @@ | |||
| 9642 | 9571 | } | |
| 9643 | 9572 | ||
| 9644 | 9573 | ||
| 9645 | - | /* =========================================== | |
| 9646 | - | USER PROFILE PAGE (replaces inline styles in pages/user.html) | |
| 9647 | - | =========================================== */ | |
| 9648 | - | ||
| 9649 | - | .user-page .creator-paused-notice { | |
| 9650 | - | margin-bottom: var(--gap-pane); | |
| 9651 | - | padding: var(--gap-section); | |
| 9652 | - | background: var(--surface-sunken); | |
| 9653 | - | border: 1px solid var(--border); | |
| 9654 | - | text-align: center; | |
| 9655 | - | font-size: var(--text-body); | |
| 9656 | - | } | |
| 9657 | - | ||
| 9658 | - | .user-page .profile-action-row { | |
| 9659 | - | margin-top: var(--gap-section); | |
| 9660 | - | } | |
| 9661 | - | ||
| 9662 | - | .user-page .profile-edit-btn { | |
| 9663 | - | display: inline-block; | |
| 9664 | - | padding: var(--gap-peer) var(--gap-section); | |
| 9665 | - | text-decoration: none; | |
| 9666 | - | font-size: var(--text-note); | |
| 9667 | - | } | |
| 9668 | - | ||
| 9669 | - | .user-page .follow-btn.is-selected { | |
| 9670 | - | opacity: 0.7; | |
| 9671 | - | } | |
| 9672 | - | ||
| 9673 | - | .user-page .follower-count { | |
| 9674 | - | margin-top: var(--gap-section); | |
| 9675 | - | font-size: var(--text-note); | |
| 9676 | - | opacity: 0.7; | |
| 9677 | - | } | |
| 9678 | - | ||
| 9679 | - | .user-page .profile-share-row { | |
| 9680 | - | margin-top: var(--gap-section); | |
| 9681 | - | display: flex; | |
| 9682 | - | gap: var(--gap-section); | |
| 9683 | - | justify-content: center; | |
| 9684 | - | font-size: var(--text-note); | |
| 9685 | - | } | |
| 9686 | - | ||
| 9687 | - | .user-page .profile-share-link { | |
| 9688 | - | opacity: 0.7; | |
| 9689 | - | } | |
| 9690 | - | ||
| 9691 | - | .user-page .collections-section { | |
| 9692 | - | margin-bottom: var(--gap-page); | |
| 9693 | - | } | |
| 9694 | - | ||
| 9695 | 9574 | /* =========================================== | |
| 9696 | 9575 | SUSPENSION BANNER (replaces inline styles in partials/suspension_banner.html) | |
| 9697 | 9576 | =========================================== */ |
| @@ -52,6 +52,7 @@ | |||
| 52 | 52 | pub mod export_portal; | |
| 53 | 53 | pub mod fan_plus; | |
| 54 | 54 | pub mod feeds; | |
| 55 | + | pub mod follow; | |
| 55 | 56 | pub mod forum_memberships; | |
| 56 | 57 | pub mod git_explore; | |
| 57 | 58 | pub mod git_repos; | |
| @@ -82,8 +83,10 @@ | |||
| 82 | 83 | pub mod shortcuts; | |
| 83 | 84 | pub mod ssh_keys; | |
| 84 | 85 | pub mod team; | |
| 86 | + | pub mod tip; | |
| 85 | 87 | pub mod upload_field; | |
| 86 | 88 | pub mod use_cases; | |
| 89 | + | pub mod user; | |
| 87 | 90 | pub mod user_analytics; | |
| 88 | 91 | pub mod user_projects; | |
| 89 | 92 | pub mod user_support; |
| @@ -145,7 +145,6 @@ | |||
| 145 | 145 | LibraryTemplate, | |
| 146 | 146 | CartTemplate, | |
| 147 | 147 | OAuthAuthorizeTemplate, | |
| 148 | - | UserTemplate, | |
| 149 | 148 | ProjectTemplate, | |
| 150 | 149 | ProjectPaywallTemplate, | |
| 151 | 150 | ItemTemplate, | |
| @@ -245,7 +244,6 @@ | |||
| 245 | 244 | LibraryPurchasesTabTemplate, | |
| 246 | 245 | LibraryCollectionsTabTemplate, | |
| 247 | 246 | // Follow button | |
| 248 | - | FollowButtonTemplate, | |
| 249 | 247 | TagFollowToggleTemplate, | |
| 250 | 248 | // Tag suggestions | |
| 251 | 249 | TagSuggestionsTemplate, |
| @@ -732,18 +732,6 @@ | |||
| 732 | 732 | pub license_keys: Vec<crate::types::LicenseKeyRow>, | |
| 733 | 733 | } | |
| 734 | 734 | ||
| 735 | - | // Follow Button Partial | |
| 736 | - | ||
| 737 | - | /// HTMX follow/unfollow toggle button. | |
| 738 | - | #[derive(Template)] | |
| 739 | - | #[template(path = "partials/follow_button.html")] | |
| 740 | - | pub struct FollowButtonTemplate { | |
| 741 | - | pub target_type: String, | |
| 742 | - | pub target_id: String, | |
| 743 | - | pub is_following: bool, | |
| 744 | - | pub follower_count: i64, | |
| 745 | - | } | |
| 746 | - | ||
| 747 | 735 | // Tag Follow Toggle (compact, for discover sidebar) | |
| 748 | 736 | ||
| 749 | 737 | /// Compact follow/unfollow toggle for tags in the discover sidebar. |
| @@ -2,7 +2,7 @@ | |||
| 2 | 2 | ||
| 3 | 3 | use axum::{ | |
| 4 | 4 | extract::{Path, State}, | |
| 5 | - | response::{IntoResponse, Response}, | |
| 5 | + | response::{Html, IntoResponse, Response}, | |
| 6 | 6 | }; | |
| 7 | 7 | use uuid::Uuid; | |
| 8 | 8 | ||
| @@ -15,7 +15,7 @@ | |||
| 15 | 15 | auth::AuthUser, | |
| 16 | 16 | db::{self, FollowTargetType, UserId}, | |
| 17 | 17 | error::{AppError, Result}, | |
| 18 | - | templates::{FollowButtonTemplate, TagFollowToggleTemplate}, | |
| 18 | + | templates::TagFollowToggleTemplate, | |
| 19 | 19 | }; | |
| 20 | 20 | ||
| 21 | 21 | /// POST /api/follow/{target_type}/{target_id}: follow a user or project. | |
| @@ -139,12 +139,12 @@ | |||
| 139 | 139 | ||
| 140 | 140 | let count = db::follows::get_follower_count(&db, target_type, target_id).await?; | |
| 141 | 141 | ||
| 142 | - | Ok(FollowButtonTemplate { | |
| 143 | - | target_type: target_type.to_string(), | |
| 144 | - | target_id: target_id.to_string(), | |
| 145 | - | is_following: true, | |
| 146 | - | follower_count: count, | |
| 147 | - | } | |
| 142 | + | Ok(Html(crate::quasi::follow::answered( | |
| 143 | + | &target_type.to_string(), | |
| 144 | + | &target_id.to_string(), | |
| 145 | + | true, | |
| 146 | + | count, | |
| 147 | + | )) | |
| 148 | 148 | .into_response()) | |
| 149 | 149 | } | |
| 150 | 150 | ||
| @@ -186,12 +186,12 @@ | |||
| 186 | 186 | ||
| 187 | 187 | let count = db::follows::get_follower_count(&db, target_type, target_id).await?; | |
| 188 | 188 | ||
| 189 | - | Ok(FollowButtonTemplate { | |
| 190 | - | target_type: target_type.to_string(), | |
| 191 | - | target_id: target_id.to_string(), | |
| 192 | - | is_following: false, | |
| 193 | - | follower_count: count, | |
| 194 | - | } | |
| 189 | + | Ok(Html(crate::quasi::follow::answered( | |
| 190 | + | &target_type.to_string(), | |
| 191 | + | &target_id.to_string(), | |
| 192 | + | false, | |
| 193 | + | count, | |
| 194 | + | )) | |
| 195 | 195 | .into_response()) | |
| 196 | 196 | } | |
| 197 | 197 |
| @@ -15,8 +15,8 @@ | |||
| 15 | 15 | ||
| 16 | 16 | use crate::auth::SessionUser; | |
| 17 | 17 | use crate::types::{ | |
| 18 | - | Chapter, Collection, CustomLink, DiscoverItem, DiscoverProject, Item, ItemContent, ItemSection, | |
| 19 | - | Project, SidebarView, SubscriptionTier, TagBreadcrumb, TagTreeNode, User, Version, | |
| 18 | + | Chapter, DiscoverItem, DiscoverProject, Item, ItemContent, ItemSection, Project, SidebarView, | |
| 19 | + | SubscriptionTier, TagBreadcrumb, TagTreeNode, Version, | |
| 20 | 20 | }; | |
| 21 | 21 | ||
| 22 | 22 | use super::CsrfTokenOption; | |
| @@ -254,40 +254,6 @@ | |||
| 254 | 254 | pub error_message: Option<String>, | |
| 255 | 255 | } | |
| 256 | 256 | ||
| 257 | - | /// Public user profile page. | |
| 258 | - | #[derive(Template)] | |
| 259 | - | #[template(path = "pages/user.html")] | |
| 260 | - | #[allow(dead_code)] // Fields used by Askama template | |
| 261 | - | pub struct UserTemplate { | |
| 262 | - | pub csrf_token: CsrfTokenOption, | |
| 263 | - | pub session_user: Option<SessionUser>, | |
| 264 | - | pub user: User, | |
| 265 | - | pub custom_links: Vec<CustomLink>, | |
| 266 | - | pub projects: Vec<Project>, | |
| 267 | - | pub public_collections: Vec<Collection>, | |
| 268 | - | /// User ID for the follow button target. | |
| 269 | - | pub user_id: String, | |
| 270 | - | /// Whether the current viewer is looking at their own profile. | |
| 271 | - | pub is_own_profile: bool, | |
| 272 | - | /// Whether the current viewer is following this user. | |
| 273 | - | pub is_following: bool, | |
| 274 | - | /// Total follower count for this user. | |
| 275 | - | pub follower_count: i64, | |
| 276 | - | /// Base URL for OG meta tags. | |
| 277 | - | pub host_url: Arc<str>, | |
| 278 | - | /// Whether this creator has voluntarily paused their account. | |
| 279 | - | pub creator_paused: bool, | |
| 280 | - | /// Whether this creator accepts tips. | |
| 281 | - | pub tips_enabled: bool, | |
| 282 | - | /// Creator's user ID for tip checkout (string for template use). | |
| 283 | - | pub creator_id: String, | |
| 284 | - | /// Project ID for tip attribution (None on user profile pages). | |
| 285 | - | pub tip_project_id: Option<String>, | |
| 286 | - | /// Pre-rendered primitive-layer CSS for this creator's chosen theme, | |
| 287 | - | /// injected into the page `<head>` (Tier 0). See `crate::theming`. | |
| 288 | - | pub theme_css: &'static str, | |
| 289 | - | } | |
| 290 | - | ||
| 291 | 257 | /// Public project page with item listing. | |
| 292 | 258 | #[derive(Template)] | |
| 293 | 259 | #[template(path = "pages/project.html")] |