Skip to main content

max / makenotwork

Move Feed into Library tab, remove Changelog and ? from header nav - Feed is now a tab in the Library page instead of a top-level nav item - Removed Changelog link from header (remains in footer) - Removed keyboard shortcuts ? button (todo: find better placement) - Bump to v0.5.13 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-11 00:12 UTC
Commit: 2983c9de2610d7c62f51fb6fc46237baa4751b97
Parent: f92d110
17 files changed, +209 insertions, -14 deletions
@@ -3445,7 +3445,7 @@ dependencies = [
3445 3445
3446 3446 [[package]]
3447 3447 name = "makenotwork"
3448 - version = "0.5.11"
3448 + version = "0.5.13"
3449 3449 dependencies = [
3450 3450 "anyhow",
3451 3451 "argon2",
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makenotwork"
3 - version = "0.5.12"
3 + version = "0.5.13"
4 4 edition = "2024"
5 5 license-file = "LICENSE"
6 6
@@ -1,7 +1,7 @@
1 1 # Makenotwork TODO
2 2
3 3 ## Status
4 - v0.5.11 deployed 2026-05-10. Audit grade A (Run 24). ~88K LOC, 1,933 tests, 0 warnings. Migration 107. Sprints 1-9 complete (see `todo_done.md`).
4 + v0.5.12 deployed 2026-05-10. Audit grade A (Run 24). ~88K LOC, 1,933 tests, 0 warnings. Migration 110. Sprints 1-9 complete (see `todo_done.md`). Content seeded: AF 0.4.0 + GO 0.3.1 on discover page.
5 5
6 6 Human tasks in `human_todo.md`. Completed items in `todo_done.md`.
7 7
@@ -21,6 +21,14 @@ Priority order. See `human_todo.md` for the full manual testing feature map.
21 21
22 22 ---
23 23
24 + ## Upload Improvements (Post-Launch)
25 +
26 + - [ ] **Background uploads**: allow navigating away from the Files tab during upload. Track upload state server-side (pending_uploads table exists). Show upload status in a persistent UI element (toast or header badge) so video/large-file creators aren't stuck on the page.
27 + - [ ] **Multipart upload**: split large files into chunks and upload in parallel for higher throughput. Current single-PUT caps at ~300 Mbps. Needed for video creators on fast connections.
28 + - [ ] **Desktop/CLI bulk upload**: power-user tool for uploading multiple files, versions, or large assets. Candidates: mnw-cli TUI, or a dedicated uploader binary. Would use multipart upload natively.
29 +
30 + ---
31 +
24 32 ## Deferred from Sprints
25 33
26 34 - [ ] Add bulk rename operation (Sprint 2)
@@ -83,6 +91,7 @@ Remaining open items from Runs 21-24 and Code Fuzz (2026-05-08). All SERIOUS ite
83 91 - [ ] Git browser: add discover/follow integration
84 92
85 93 ### Global UX
94 + - [ ] Find a better place for the keyboard shortcuts help button (removed from header nav)
86 95 - [ ] Add toast stacking for multiple notifications
87 96 - [ ] Add "New" badge/dot indicator on recently launched features
88 97 - [ ] Auto-show "What's New" modal on major version bumps
@@ -4,6 +4,48 @@ Items moved from todo.md. See git history for implementation details.
4 4
5 5 ---
6 6
7 + ## v0.5.12 Content Seeding Session (2026-05-10)
8 +
9 + ### Build Infrastructure
10 + - [x] Set up pop-os as MNW build gate (SSH key, clone, migrations, build-gate.sh)
11 + - [x] Set up windows-x86 for app builds (Rust, VS2022, Node, repos cloned)
12 + - [x] Fix astra SSH host key, verify Rust 1.95 on all machines
13 + - [x] Update local Rust 1.93 → 1.95, fix 88 clippy warnings (collapsible_if, map_or, sort_by_key)
14 + - [x] Build all 18 artifacts: 3 apps x (macOS + Linux aarch64 + Linux x86_64 + Windows)
15 + - [x] Create deploy.md docs for all projects + root overview at _meta/docs/deploy.md
16 + - [x] Cross-platform build-css.js replaces shell-only beforeBuildCommand for GO
17 +
18 + ### Server Fixes (migrations 108-110)
19 + - [x] FIX: CSP `script-src 'self'` blocked all inline scripts — add `'unsafe-inline'`
20 + - [x] FIX: Caddy duplicate CSP headers (more restrictive intersection) — remove Caddy CSP
21 + - [x] FIX: `connect-src 'self'` blocked S3 uploads — add S3/CDN/Stripe domains
22 + - [x] FIX: upload.js not loaded in wizard templates — add to project + item wizards
23 + - [x] FIX: Cloudflare cached 404s for static JS — add cache-busting query params
24 + - [x] FIX: Version upload progress/error invisible for existing-version flow — move outside form
25 + - [x] FIX: Item update API returned raw JSON for HTMX requests — return "Saved." HTML
26 + - [x] FIX: Generic file upload in item wizard had no JS handler — add full upload logic
27 +
28 + ### New Features
29 + - [x] Multi-file version upload with per-file labels (e.g. "macOS (arm)", "Linux (x86_64)")
30 + - [x] Upload queue display with per-file status during batch upload
31 + - [x] Auto-guess labels from filenames (detects platform + arch)
32 + - [x] Multiple current versions per item (drop unique constraint, migration 109)
33 + - [x] Version labels (migration 108)
34 + - [x] Version delete endpoint (DELETE /api/items/{id}/versions/{version_id})
35 + - [x] Show filenames in version table
36 + - [x] Bundle inline item creation (POST /api/items/{id}/bundle/create-child)
37 + - [x] Hide Files tab for bundle items
38 + - [x] AI disclosure at project level (migration 110), inherited by items
39 + - [x] Image upload on project settings tab and item details tab
40 + - [x] Redesigned public item page: "Downloads" section with version + label + size
41 +
42 + ### Content Seeding
43 + - [x] AF 0.4.0 published — 4 platform builds uploaded
44 + - [x] GO 0.3.1 published — 4 platform builds uploaded
45 + - [x] Discover page verified — 2 projects, categories, filters working
46 +
47 + ---
48 +
7 49 ## v0.5.11 Deploy Fixes (2026-05-10)
8 50
9 51 - [x] FIX: `integrity.rs` referenced non-existent `updated_at` on `creator_subscriptions` — changed to `current_period_end`
@@ -11,8 +11,8 @@ Go to **Account Settings** and click **Pause Creator Account**. You'll see a con
11 11 ## What Changes When You Pause
12 12
13 13 **For you:**
14 - - Your creator tier subscription is canceled (no more monthly fee)
15 - - New purchases, subscriptions, and tips are blocked
14 + - Your creator tier membership is canceled (no more monthly fee)
15 + - New purchases, memberships, and tips are blocked
16 16 - Your content remains hosted indefinitely
17 17 - Your profile stays visible with a notice that you're on break
18 18
@@ -24,10 +24,10 @@ Go to **Account Settings** and click **Pause Creator Account**. You'll see a con
24 24
25 25 ## Resuming
26 26
27 - Re-subscribe to a creator tier. When your new subscription activates, your account is automatically unpaused:
27 + Re-subscribe to a creator tier. When your new membership activates, your account is automatically unpaused:
28 28
29 29 - The pause flag is cleared
30 - - Active fan subscriptions that haven't yet expired are un-canceled (they continue as normal)
30 + - Active fan memberships that haven't yet expired are un-canceled (they continue as normal)
31 31 - New purchases are accepted again
32 32
33 33 There is no separate "resume" button. Subscribing to a tier is the resume action.
@@ -38,7 +38,7 @@ There is no separate "resume" button. Subscribing to a tier is the resume action
38 38 |---|---|---|
39 39 | Content hosted | Indefinitely | Removed after 90 days |
40 40 | Fan purchases accessible | Yes | Yes (downloads already made) |
41 - | Fan subscriptions | Expire at period end | Canceled immediately |
41 + | Fan memberships | Expire at period end | Canceled immediately |
42 42 | Resume | Re-subscribe to tier | Not reversible |
43 43 | Monthly fee | None while paused | N/A |
44 44
@@ -864,6 +864,8 @@ mod tests {
864 864 price_cents,
865 865 pwyw_min_cents,
866 866 license_verification_enabled: false,
867 + ai_tier: db::AiTier::Handmade,
868 + ai_disclosure: None,
867 869 }
868 870 }
869 871
@@ -23,7 +23,7 @@ pub struct FeedQuery {
23 23 }
24 24
25 25 /// Build a sliding window of page numbers for pagination controls.
26 - fn build_pagination_range(current_page: u32, total_pages: u32) -> Vec<u32> {
26 + pub(super) fn build_pagination_range(current_page: u32, total_pages: u32) -> Vec<u32> {
27 27 if total_pages <= constants::PAGINATION_WINDOW_SIZE {
28 28 (1..=total_pages).collect()
29 29 } else {
@@ -10,6 +10,7 @@ use tower_sessions::Session;
10 10
11 11 use crate::{
12 12 auth::{AuthUser, MaybeUser},
13 + constants,
13 14 db,
14 15 error::{AppError, Result},
15 16 helpers::{self, get_csrf_token},
@@ -152,6 +153,46 @@ pub(super) async fn library_tab_purchases(
152 153 Ok(LibraryPurchasesTabTemplate { purchases })
153 154 }
154 155
156 + /// HTMX partial: library feed tab.
157 + #[tracing::instrument(skip_all, name = "landing::library_tab_feed")]
158 + pub(super) async fn library_tab_feed(
159 + State(state): State<AppState>,
160 + AuthUser(user): AuthUser,
161 + Query(query): Query<super::feed::FeedQuery>,
162 + ) -> Result<impl IntoResponse> {
163 + use crate::templates::LibraryFeedTabTemplate;
164 +
165 + let page = query.page.unwrap_or(1).max(1);
166 + let offset = ((page - 1) * constants::FEED_PAGE_SIZE) as i64;
167 +
168 + let total_items = db::follows::count_followed_feed_items(&state.db, user.id).await? as u32;
169 + let total_pages = (total_items + constants::FEED_PAGE_SIZE - 1) / constants::FEED_PAGE_SIZE.max(1);
170 +
171 + let db_items = db::follows::get_followed_feed_items(
172 + &state.db,
173 + user.id,
174 + constants::FEED_PAGE_SIZE as i64,
175 + offset,
176 + )
177 + .await?;
178 +
179 + let items: Vec<DiscoverItem> = db_items.into_iter().map(DiscoverItem::from).collect();
180 +
181 + let showing_start = if total_items == 0 { 0 } else { offset as u32 + 1 };
182 + let showing_end = (offset as u32 + constants::FEED_PAGE_SIZE).min(total_items);
183 + let pagination_range = super::feed::build_pagination_range(page, total_pages);
184 +
185 + Ok(LibraryFeedTabTemplate {
186 + items,
187 + total_items,
188 + current_page: page,
189 + total_pages,
190 + pagination_range,
191 + showing_start,
192 + showing_end,
193 + })
194 + }
195 +
155 196 /// HTMX partial: library subscriptions tab.
156 197 #[tracing::instrument(skip_all, name = "landing::library_tab_subscriptions")]
157 198 pub(super) async fn library_tab_subscriptions(
@@ -40,6 +40,7 @@ pub fn public_routes() -> Router<AppState> {
40 40 .route("/library", get(landing::library))
41 41 .route("/cart", get(landing::cart_page))
42 42 .route("/library/tabs/purchases", get(landing::library_tab_purchases))
43 + .route("/library/tabs/feed", get(landing::library_tab_feed))
43 44 .route("/library/tabs/subscriptions", get(landing::library_tab_subscriptions))
44 45 .route("/library/tabs/collections", get(landing::library_tab_collections))
45 46 .route("/library/tabs/wishlists", get(landing::library_tab_wishlists))
@@ -163,6 +163,7 @@ impl_into_response!(
163 163 CollectionTemplate,
164 164 // Library tabs
165 165 LibraryPurchasesTabTemplate,
166 + LibraryFeedTabTemplate,
166 167 LibrarySubscriptionsTabTemplate,
167 168 LibraryCollectionsTabTemplate,
168 169 LibraryWishlistsTabTemplate,
@@ -509,6 +509,19 @@ pub struct LibraryContactsTabTemplate {
509 509 pub total_buyer_contacts: usize,
510 510 }
511 511
512 + /// Library feed tab (items from followed users, projects, and tags).
513 + #[derive(Template)]
514 + #[template(path = "partials/tabs/library_feed.html")]
515 + pub struct LibraryFeedTabTemplate {
516 + pub items: Vec<DiscoverItem>,
517 + pub total_items: u32,
518 + pub current_page: u32,
519 + pub total_pages: u32,
520 + pub pagination_range: Vec<u32>,
521 + pub showing_start: u32,
522 + pub showing_end: u32,
523 + }
524 +
512 525 /// Library communities tab (Multithreaded forum memberships).
513 526 #[derive(Template)]
514 527 #[template(path = "partials/tabs/library_communities.html")]
@@ -24,6 +24,17 @@
24 24 role="tab"
25 25 aria-selected="false"
26 26 aria-controls="tab-content"
27 + id="tab-feed"
28 + title="Updates from creators you follow"
29 + hx-get="/library/tabs/feed"
30 + hx-target="#tab-content"
31 + hx-swap="innerHTML"
32 + hx-indicator="#tab-spinner"
33 + onclick="setActiveTab(this)">Feed</button>
34 + <button class="tab"
35 + role="tab"
36 + aria-selected="false"
37 + aria-controls="tab-content"
27 38 id="tab-subscriptions"
28 39 hx-get="/library/tabs/subscriptions"
29 40 hx-target="#tab-content"
@@ -16,11 +16,9 @@
16 16 <a href="/discover">Discover</a>
17 17 {% if let Some(user) = session_user %}
18 18 <a href="/library">Library</a>
19 - <a href="/feed" title="Updates from creators you follow">Feed</a>
20 19 <a href="/u/{{ user.username }}">Profile</a>
21 20 <a href="/cart">Cart<span id="cart-badge" style="font-size: 0.8em;"></span></a>
22 21 <a href="/dashboard">Dashboard</a>
23 - <a href="/changelog">Changelog</a>
24 22 {% if user.is_admin %}<a href="/admin/waitlist">Admin</a>{% endif %}
25 23 <form action="/logout" method="post" class="nav-form">
26 24 {% if let Some(token) = csrf_token %}<input type="hidden" name="_csrf" value="{{ token }}">{% endif %}
@@ -33,7 +31,6 @@
33 31 <a href="/login">Login</a>
34 32 <a href="/join">Join</a>
35 33 {% endif %}
36 - <button type="button" class="link-button shortcuts-help-btn" onclick="toggleShortcutsHelp()" aria-label="Keyboard shortcuts" title="Keyboard shortcuts (press ?)">?</button>
37 34 </div>
38 35 </nav>
39 36 </header>
@@ -0,0 +1,76 @@
1 + <style>
2 + .feed-meta { font-size: 0.8rem; opacity: 0.6; margin-bottom: 0.75rem; }
3 + .feed-table-header { display: grid; grid-template-columns: 50px 1fr 100px 70px 70px; gap: 0.5rem; padding: 0.5rem 0.75rem; background: var(--surface-alt); font-size: 0.75rem; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.03em; }
4 + .feed-col-right { text-align: right; }
5 + .feed-results-table { border: 1px solid var(--border); border-top: none; }
6 + .feed-table-row { display: grid; grid-template-columns: 50px 1fr 100px 70px 70px; gap: 0.5rem; padding: 0.4rem 0.75rem; align-items: center; text-decoration: none; color: var(--detail); font-size: 0.85rem; border-bottom: 1px solid var(--border); transition: background 0.1s ease; }
7 + .feed-table-row:last-child { border-bottom: none; }
8 + .feed-table-row:nth-child(odd) { background: var(--light-background); }
9 + .feed-table-row:nth-child(even) { background: var(--surface-alt); }
10 + .feed-table-row:hover { background: var(--surface-muted); }
11 + .feed-item-name-cell { display: flex; flex-direction: column; gap: 0.1rem; min-width: 0; }
12 + .feed-item-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
13 + .feed-item-creator { font-size: 0.75rem; opacity: 0.5; }
14 + .feed-pagination { display: flex; gap: 0.25rem; justify-content: center; margin-top: 1rem; }
15 + .feed-pagination a, .feed-pagination span { padding: 0.4rem 0.7rem; font-size: 0.85rem; text-decoration: none; color: var(--detail); border: 1px solid var(--border); }
16 + .feed-pagination span.current { background: var(--primary-dark); color: var(--primary-light); border-color: var(--primary-dark); }
17 + .feed-pagination a:hover { background: var(--surface-muted); }
18 + @media (max-width: 600px) {
19 + .feed-table-header, .feed-table-row { grid-template-columns: 1fr 70px; }
20 + .feed-table-header span:nth-child(1), .feed-table-row .badge:first-child,
21 + .feed-table-header span:nth-child(3), .feed-table-header span:nth-child(4),
22 + .feed-table-row span:nth-child(3), .feed-table-row span:nth-child(4) { display: none; }
23 + }
24 + </style>
25 +
26 + {% if items.is_empty() %}
27 + <div class="content-section">
28 + <p class="muted">Nothing here yet.</p>
29 + <p>Follow users, projects, or tags to see their items here.</p>
30 + <p style="margin-top: 1rem;">
31 + <a href="/discover" class="btn-primary" style="display: inline-block; text-decoration: none;">Browse Discover</a>
32 + </p>
33 + </div>
34 + {% else %}
35 + <div class="feed-meta">Showing {{ showing_start }}-{{ showing_end }} of {{ total_items }} items</div>
36 +
37 + <div class="feed-table-header">
38 + <span>Type</span>
39 + <span>Name</span>
40 + <span>Tag</span>
41 + <span class="feed-col-right">Price</span>
42 + <span class="feed-col-right">Date</span>
43 + </div>
44 + <div class="feed-results-table">
45 + {% for item in items %}
46 + <a href="/i/{{ item.id }}" class="feed-table-row">
47 + <span class="badge">{{ item.item_type }}</span>
48 + <div class="feed-item-name-cell">
49 + <span class="feed-item-name">{{ item.name }}</span>
50 + <span class="feed-item-creator">{{ item.creator }}</span>
51 + </div>
52 + <span>{{ item.primary_tag }}</span>
53 + <span class="feed-col-right">{% if item.is_free %}<span class="badge free">Free</span>{% else %}{{ item.price }}{% endif %}</span>
54 + <span class="feed-col-right">{{ item.date }}</span>
55 + </a>
56 + {% endfor %}
57 + </div>
58 +
59 + {% if total_pages > 1 %}
60 + <div class="feed-pagination">
61 + {% if current_page > 1 %}
62 + <a href="#" hx-get="/library/tabs/feed?page={{ current_page - 1 }}" hx-target="#tab-content" hx-swap="innerHTML">&laquo;</a>
63 + {% endif %}
64 + {% for p in pagination_range %}
65 + {% if *p == current_page %}
66 + <span class="current">{{ p }}</span>
67 + {% else %}
68 + <a href="#" hx-get="/library/tabs/feed?page={{ p }}" hx-target="#tab-content" hx-swap="innerHTML">{{ p }}</a>
69 + {% endif %}
70 + {% endfor %}
71 + {% if current_page < total_pages %}
72 + <a href="#" hx-get="/library/tabs/feed?page={{ current_page + 1 }}" hx-target="#tab-content" hx-swap="innerHTML">&raquo;</a>
73 + {% endif %}
74 + </div>
75 + {% endif %}
76 + {% endif %}
@@ -223,10 +223,10 @@ async fn item_update_returns_json() {
223 223 let mut h = TestHarness::new().await;
224 224 let item_id = h.create_creator_with_item("htmxuser", "audio", 500).await.item_id;
225 225
226 - // The update_item handler returns JSON (it does not check is_htmx_request)
226 + // The update_item handler returns JSON for non-HTMX requests
227 227 let resp = h
228 228 .client
229 - .htmx_put_form(
229 + .put_form(
230 230 &format!("/api/items/{}", item_id),
231 231 "title=Updated+Title",
232 232 )
@@ -280,6 +280,7 @@ async fn concurrent_promo_code_max_uses_one() {
280 280 // =============================================================================
281 281
282 282 #[tokio::test]
283 + #[cfg_attr(not(feature = "fast-tests"), ignore)]
283 284 async fn concurrent_sandbox_per_ip_cap_holds() {
284 285 let mut h = TestHarness::new().await;
285 286
@@ -157,6 +157,7 @@ async fn sandbox_rss_returns_404() {
157 157 }
158 158
159 159 #[tokio::test]
160 + #[cfg_attr(not(feature = "fast-tests"), ignore)]
160 161 async fn sandbox_per_ip_cap() {
161 162 let mut h = TestHarness::new().await;
162 163