Skip to main content

max / makenotwork

Frontend audit: og:image fallbacks, email signups, discover UX, SEO - og:image 3-way fallback chain (item cover → project cover → logo) on all content pages - canonical URLs on item, project, user, blog post, landing pages - twitter:card switches to summary_large_image when image available - Email signup capture for non-joiners (migration 050, landing form, admin view) - Discover default view changed to grid, empty state messages added - Error page button classes fixed, static index.html → redirect - Use Cases added to logged-out nav, Fan+ added to footer - Admin signups page at /admin/signups Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-31 16:30 UTC
Commit: 0ec4ab3cf76961e294c4d94cb375f02680aefa59
Parent: 749c48c
27 files changed, +691 insertions, -109 deletions
M docs/todo.md +79 -28
@@ -1,11 +1,13 @@
1 1 # Makenotwork TODO
2 2
3 3 ## Status
4 - Live at makenot.work (Hetzner CCX13). v0.3.17. Stripe live mode. Postmark live. 1,174 tests (584 unit + 545 integration + 17 admin + 28 health). Audit grade: A (Run 12). Git browser + email-first issue tracker + SSH clone live. SyncKit OTA (S6) + CI/Build (S7) deployed. Tracing-only observability. DocEngine extracted (2026-03-21). Creation wizards (Phase 25) + Join wizard (Phase 26) complete. TagTree integration (migration 038). Platform integration I1+I2+I3+I4+I5 complete. Phase 11C tier enforcement complete (migration 042). Phase 14C custom domains complete (migration 043). Email-first issue tracker complete (migration 045). Developer docs + hosted rustdoc (2026-03-22). Phase 27 item dashboard tabs complete (2026-03-29). Security hardening (2026-03-29): DOM XSS fix, tier ownership verification, N+1 batch queries, dashboard rate limiting, presign quota check, license key cap, past-date validation. Code quality pass (2026-03-29): discover query extraction, promo code validation dedup, CreatorTier enum on model. Internal git authorize endpoint for mnw-cli proxy (2026-03-29). Dashboard simplification phases 28-32 complete (2026-03-29). All pre-beta coding complete. Zero test failures.
4 + Done: All pre-beta phases + frontend audit. Active: None. Next: Post-beta features below.
5 +
6 + Live at makenot.work. v0.3.17. Audit grade A. Stripe + Postmark live. All platform integrations (I1-I5) deployed.
5 7
6 8 **Scope:** Sections tagged `(pre-beta)` ship before initial beta. Untagged sections are post-beta.
7 9
8 - Completed phases (10, 10D, 11, 11C, 13, 13B, 13C, 14C, 15, 25, 26, 27, 28, 29, 30, 31, 32, S4, S6, S7, frontend audit, Run 5 fixes, concurrency, type safety, environment audit, documentation, developer docs + rustdoc, bug hunt fixes, test suite fixes, email-first issue tracker, I5 git patch inbound, security hardening, code quality pass, git authorize endpoint) archived in `docs/archive/mnw_todo_done.md`.
10 + Completed phases archived in `docs/archive/mnw_todo_done.md`.
9 11
10 12 ---
11 13
@@ -33,52 +35,56 @@ Completed phases (10, 10D, 11, 11C, 13, 13B, 13C, 14C, 15, 25, 26, 27, 28, 29, 3
33 35 - [ ] End-to-end test: build signed GO release, upload artifact, verify auto-update check returns 200
34 36
35 37 ### Content Seeding
36 - Copy and configuration pre-generated in `docs/mnw/content_seed.md`.
38 + Copy and configuration pre-generated in `docs/content_seed.md`.
37 39
38 40 #### Creator Setup
39 - - [ ] Set creator tier to Small Files ($20/mo)
40 - - [ ] Complete Stripe Connect onboarding (live mode)
41 - - [ ] Set display name and bio
41 + - [x] Set display name and bio
42 + - [ ] Confirm creator tier is Small Files ($20/mo)
43 + - [ ] Confirm Stripe Connect onboarding complete (live mode)
42 44
43 45 #### Project: GoingsOn (free download + $3/mo sync subscription)
44 - - [ ] Create project (slug: goingson, type: software)
45 - - [ ] Write description, set cover image
46 - - [ ] Create content item: "GoingsOn for macOS" (type: digital, price: free)
47 - - [ ] Upload signed+notarized DMG
48 - - [ ] Create subscription tier: "Cloud Sync" ($3/mo)
49 - - [ ] Add tags: productivity, tasks, email, calendar, macos, desktop, rust
46 + - [x] Create project (slug: goingson)
47 + - [x] Write description
48 + - [x] Create content item: free macOS download
49 + - [x] Set cover image
50 + - [x] Upload signed+notarized DMG
51 + - [ ] Create subscription tier: "Cloud Sync" ($3/mo) — not yet created
52 + - [ ] Add tags (planned: productivity, tasks, email, calendar, macos, desktop, rust)
50 53 - [ ] Write launch blog post
51 54 - [ ] Register OTA slug `goingson`
52 55
53 - #### Project: audiofiles (one-time purchase $29)
54 - - [ ] Create project (slug: audiofiles, type: software)
55 - - [ ] Write description, set cover image
56 - - [ ] Create content item (type: plugin, price: $29)
57 - - [ ] Upload signed+notarized plugin bundle
56 + #### Project: audiofiles (one-time purchase)
57 + - [x] Create project (slug: audiofiles)
58 + - [x] Write description
59 + - [x] Create content item: Desktop App Bundle
60 + - [x] Set cover image
61 + - [x] Upload signed+notarized plugin bundle
58 62 - [ ] Enable license keys (test activation flow)
59 - - [ ] Add tags: audio, samples, plugin, clap, vst3, music-production, macos, daw
63 + - [ ] Add tags (planned: audio, samples, plugin, clap, vst3, music-production, macos, daw)
60 64 - [ ] Write launch blog post
61 65 - [ ] Create a test discount code (e.g. LAUNCH50, 50% off)
62 66 - [ ] Register OTA slug `audiofiles`
63 67
64 - #### Project: Balanced Breakfast (PWYW, $0 floor)
65 - - [ ] Create project (slug: balanced-breakfast, type: software)
66 - - [ ] Write description, set cover image
67 - - [ ] Create content item (type: digital, PWYW enabled, min $0)
68 - - [ ] Upload signed+notarized DMG
69 - - [ ] Add tags: rss, feeds, reader, macos, desktop, rust
68 + #### Project: Balanced Breakfast
69 + - [x] Create project (slug: balanced-breakfast)
70 + - [x] Write description
71 + - [x] Create content item
72 + - [x] Set cover image
73 + - [x] Upload signed+notarized DMG
74 + - [ ] Add tags (planned: rss, feeds, reader, macos, desktop, rust)
70 75 - [ ] Write launch blog post
71 76 - [ ] Register OTA slug `balanced-breakfast`
72 77
73 - #### Project: Makenot.work (platform changelog blog)
74 - - [ ] Create project (slug: makenotwork, type: blog)
78 + #### Project: Changelog (platform development log)
79 + - [x] `/changelog` and `/changelog/{post_slug}` route aliases (`routes/pages/blog.rs`, `constants.rs`)
80 + - [ ] Create project (slug: changelog, type: blog)
75 81 - [ ] Write description
76 82 - [ ] Write "What this is" blog post
77 83
78 84 #### Cross-Project
79 85 - [ ] Create "All Apps" collection (GO + AF + BB)
80 - - [ ] Add custom links (source code, contact)
81 - - [ ] Verify all 4 projects appear on `/discover`
86 + - [ ] Add custom links (source code link, support@makenot.work — currently profile has Twitter/Mastodon/htpy.app)
87 + - [ ] Verify all 4 projects appear on `/discover` (currently 3 — changelog blog missing)
82 88 - [ ] Test free download flow (GO), PWYW flow (BB), purchase flow (AF), subscription flow (GO)
83 89 - [ ] Test discount code on AF purchase
84 90 - [ ] Test license key delivery after AF purchase
@@ -135,6 +141,51 @@ User details tab has 13 sections all visible at once (347 lines). Collapse secon
135 141
136 142 ---
137 143
144 + ## Frontend Audit
145 +
146 + Findings from investor/business and marketing review of all customer-facing templates.
147 +
148 + ### P0 — Launch blockers
149 +
150 + - [x] Add `<meta name="description">` to `base.html` with block override pattern
151 + - [x] Add OG tags to landing page (`pages/index.html` — og:title, og:description, og:type, og:url, twitter:card)
152 + - [x] Fix dead password reset link on login page (`href="#reset"` → `/forgot-password`)
153 +
154 + ### P1 — Trust and conversion
155 +
156 + - [x] Replace "Private Alpha" badge and "Join the Alpha" CTA with "Early Access" / "Join Early Access"
157 + - [x] Add social proof to landing page (active creator count + published items count, server-side queries)
158 + - [x] Remove or visually separate "Streaming (coming soon)" tier (`.planned` class, dashed border, "Planned" label)
159 + - [x] Add Terms of Service and Privacy Policy links to site footer in `base.html`
160 + - [x] Share buttons / copy-link — already exists on item, project, and blog post pages
161 +
162 + ### P2 — Conversion and engagement
163 +
164 + - [x] Move "How it works" section higher on landing page (now first section, above tier cards)
165 + - [x] Link `/use-cases` from site header nav (added to logged-out nav) and landing secondary CTA
166 + - [x] Add email capture for non-joiners (notify-me form, migration 050, `POST /api/email-signup`)
167 + - [x] Make Discover grid view the default (changed localStorage default from `list` to `grid`)
168 + - [x] Surface RSS as a headline feature on landing page (added to "Host anything" feature card)
169 + - [x] Make Fan+ page discoverable (added to site footer)
170 +
171 + ### P3 — SEO and polish
172 +
173 + - [x] Add `<link rel="canonical">` to content pages (item, project, blog post, user profile, landing)
174 + - [x] JSON-LD structured data already present on item, project, blog post, and user profile pages
175 + - [x] Fix static `templates/index.html` (replaced dead links with meta-refresh redirect to `/`)
176 + - [x] Standardize error page button classes (updated to `.primary`/`.secondary`)
177 + - [x] Resolve "Make Creative" vs "Makenotwork" in footer copyright
178 +
179 + ### Follow-up
180 +
181 + - [x] og:image fallback chain on all pages (item cover → project cover → logo.png)
182 + - [x] Admin email signups page (`/admin/signups`, migration 050)
183 + - [x] Discover empty-state messages (list + grid views)
184 + - [ ] Add a visual to landing page (HTML/CSS ready, needs `static/images/landing-screenshot.png`)
185 + - [ ] Create og:image social card (1200x630, for landing page and fallback — distinct from logo.png)
186 +
187 + ---
188 +
138 189 ## Post-Beta
139 190
140 191 ### Phase 11B: Promotions
@@ -0,0 +1,9 @@
1 + -- Email signups from landing page (notify-me for non-joiners)
2 + CREATE TABLE email_signups (
3 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
4 + email TEXT NOT NULL UNIQUE,
5 + source VARCHAR(50) NOT NULL DEFAULT 'landing',
6 + created_at TIMESTAMPTZ NOT NULL DEFAULT now()
7 + );
8 +
9 + CREATE INDEX idx_email_signups_created_at ON email_signups (created_at DESC);
@@ -0,0 +1,55 @@
1 + use sqlx::PgPool;
2 + use uuid::Uuid;
3 +
4 + use crate::error::Result;
5 +
6 + /// Insert a new email signup, ignoring duplicates.
7 + /// Returns the signup ID (new or existing).
8 + pub async fn insert_email_signup(pool: &PgPool, email: &str, source: &str) -> Result<Uuid> {
9 + let id = sqlx::query_scalar!(
10 + r#"
11 + INSERT INTO email_signups (email, source)
12 + VALUES ($1, $2)
13 + ON CONFLICT (email) DO UPDATE SET email = EXCLUDED.email
14 + RETURNING id
15 + "#,
16 + email,
17 + source,
18 + )
19 + .fetch_one(pool)
20 + .await?;
21 + Ok(id)
22 + }
23 +
24 + /// Row returned by the admin email signups query.
25 + pub struct DbEmailSignup {
26 + pub id: Uuid,
27 + pub email: String,
28 + pub source: String,
29 + pub created_at: chrono::DateTime<chrono::Utc>,
30 + }
31 +
32 + /// Get all email signups ordered by newest first.
33 + pub async fn get_all_email_signups(pool: &PgPool) -> Result<Vec<DbEmailSignup>> {
34 + let rows = sqlx::query_as!(
35 + DbEmailSignup,
36 + r#"
37 + SELECT id, email, source, created_at
38 + FROM email_signups
39 + ORDER BY created_at DESC
40 + LIMIT 500
41 + "#,
42 + )
43 + .fetch_all(pool)
44 + .await?;
45 + Ok(rows)
46 + }
47 +
48 + /// Count total email signups.
49 + pub async fn count_email_signups(pool: &PgPool) -> Result<i64> {
50 + let count = sqlx::query_scalar!("SELECT COUNT(*) FROM email_signups")
51 + .fetch_one(pool)
52 + .await?
53 + .unwrap_or(0);
54 + Ok(count)
55 + }
@@ -51,6 +51,7 @@ pub(crate) mod mailing_lists;
51 51 pub mod custom_domains;
52 52 pub mod patches;
53 53 pub mod bundles;
54 + pub(crate) mod email_signups;
54 55
55 56 pub use id_types::*;
56 57 pub use validated_types::*;
@@ -2,6 +2,7 @@
2 2
3 3 mod labels;
4 4 mod moderation;
5 + mod signups;
5 6 mod uploads;
6 7 mod users;
7 8 mod waitlist;
@@ -52,6 +53,8 @@ pub fn admin_routes() -> Router<AppState> {
52 53 .route("/api/admin/labels", post(labels::admin_create_label))
53 54 .route("/api/admin/labels/{id}", put(labels::admin_update_label).delete(labels::admin_delete_label))
54 55 .route("/api/admin/projects/{project_id}/remove-label/{label_id}", post(labels::admin_remove_project_label))
56 + // Email signups
57 + .route("/admin/signups", get(signups::admin_signups))
55 58 // Reports
56 59 .route("/admin/reports", get(moderation::admin_reports))
57 60 .route("/admin/reports/entries", get(moderation::admin_report_entries))
@@ -0,0 +1,40 @@
1 + use axum::{extract::State, response::IntoResponse};
2 + use tower_sessions::Session;
3 +
4 + use crate::{
5 + auth::AdminUser,
6 + db,
7 + error::Result,
8 + helpers::get_csrf_token,
9 + templates::AdminSignupsTemplate,
10 + types::AdminSignupRow,
11 + AppState,
12 + };
13 +
14 + /// Admin page listing email signups from the landing page.
15 + #[tracing::instrument(skip_all, name = "admin::admin_signups")]
16 + pub(super) async fn admin_signups(
17 + State(state): State<AppState>,
18 + session: Session,
19 + AdminUser(admin): AdminUser,
20 + ) -> Result<impl IntoResponse> {
21 + let db_signups = db::email_signups::get_all_email_signups(&state.db).await?;
22 + let total = db::email_signups::count_email_signups(&state.db).await?;
23 +
24 + let signups: Vec<AdminSignupRow> = db_signups
25 + .into_iter()
26 + .map(|s| AdminSignupRow {
27 + email: s.email,
28 + source: s.source,
29 + created_at: s.created_at.format("%b %d, %Y %H:%M").to_string(),
30 + })
31 + .collect();
32 +
33 + Ok(AdminSignupsTemplate {
34 + csrf_token: get_csrf_token(&session).await,
35 + session_user: Some(admin),
36 + signups,
37 + total,
38 + admin_active_page: "signups",
39 + })
40 + }
@@ -49,7 +49,7 @@ use axum::{
49 49 use serde_json::json;
50 50 use tower_governor::GovernorLayer;
51 51
52 - use serde::Serialize;
52 + use serde::{Deserialize, Serialize};
53 53
54 54 use crate::{
55 55 constants,
@@ -152,6 +152,32 @@ async fn public_projects(
152 152 Ok(Json(json!({ "data": data })))
153 153 }
154 154
155 + // ── Email signup (public, no auth) ──
156 +
157 + #[derive(Deserialize)]
158 + struct EmailSignupForm {
159 + email: String,
160 + }
161 +
162 + #[tracing::instrument(skip_all, name = "api::email_signup")]
163 + async fn email_signup(
164 + State(state): State<AppState>,
165 + Json(form): Json<EmailSignupForm>,
166 + ) -> Result<impl IntoResponse> {
167 + let email = form.email.trim().to_lowercase();
168 + // Basic format check: must have @ with a . after it
169 + if let Some(at_pos) = email.find('@') {
170 + if !email[at_pos + 1..].contains('.') {
171 + return Err(AppError::Validation("Please enter a valid email address".into()));
172 + }
173 + } else {
174 + return Err(AppError::Validation("Please enter a valid email address".into()));
175 + }
176 +
177 + db::email_signups::insert_email_signup(&state.db, &email, "landing").await?;
178 + Ok(Json(json!({"success": true})))
179 + }
180 +
155 181 /// Register all JSON API routes for projects, items, links, tags, and account management.
156 182 ///
157 183 /// Routes are split into three tiers with different rate limits:
@@ -292,6 +318,8 @@ pub fn api_routes() -> Router<AppState> {
292 318 .route("/api/domains/{id}", delete(domains::remove_domain))
293 319 // Invite codes
294 320 .route("/api/invites/create", post(users::create_invite))
321 + // Email signup (public, landing page notify-me)
322 + .route("/api/email-signup", post(email_signup))
295 323 .route_layer(GovernorLayer {
296 324 config: write_rate_limit,
297 325 });
@@ -345,7 +373,8 @@ pub fn api_routes() -> Router<AppState> {
345 373 .route("/api/collections/for-item/{item_id}", get(collections::collections_for_item))
346 374 // Custom domains (read)
347 375 .route("/api/domains", get(domains::get_domain))
348 - .route("/api/domains/caddy-ask", get(domains::caddy_ask));
376 + .route("/api/domains/caddy-ask", get(domains::caddy_ask))
377 + .route("/api/restart-status", get(internal::restart_status));
349 378
350 379 let validate_rate_limit = crate::helpers::rate_limiter_per_sec(constants::VALIDATE_RATE_LIMIT_PER_SEC, constants::VALIDATE_RATE_LIMIT_BURST);
351 380
@@ -390,7 +419,8 @@ pub fn api_routes() -> Router<AppState> {
390 419 // Settings
391 420 .route("/api/internal/creator/ssh-keys", get(internal::list_ssh_keys))
392 421 // Git authorization
393 - .route("/api/internal/git/authorize", post(internal::git_authorize));
422 + .route("/api/internal/git/authorize", post(internal::git_authorize))
423 + .route("/api/internal/restart-warning", post(internal::set_restart_warning));
394 424
395 425 write_routes
396 426 .merge(export_routes)
@@ -10,6 +10,7 @@ use tower_sessions::Session;
10 10
11 11 use crate::{
12 12 auth::MaybeUser,
13 + constants,
13 14 db::{self, Slug},
14 15 error::{AppError, Result},
15 16 helpers::{fetch_discussion_info, get_csrf_token, get_initials},
@@ -23,6 +24,8 @@ pub fn blog_routes() -> Router<AppState> {
23 24 Router::new()
24 25 .route("/p/{slug}/blog", get(project_blog_page))
25 26 .route("/p/{slug}/blog/{post_slug}", get(blog_post_page))
27 + .route("/changelog", get(changelog_index))
28 + .route("/changelog/{post_slug}", get(changelog_post))
26 29 }
27 30
28 31 /// Public blog index page for a project.
@@ -116,6 +119,109 @@ async fn blog_post_page(
116 119 project_slug: project_slug_str,
117 120 post_slug: post_slug.to_string(),
118 121 host_url: state.config.host_url.clone(),
122 + project_cover_image_url: db_project.cover_image_url,
123 + discussion_url,
124 + discussion_count,
125 + })
126 + }
127 +
128 + // -- /changelog alias routes --
129 +
130 + /// Platform changelog index (alias for the "changelog" project blog).
131 + #[tracing::instrument(skip_all, name = "blog_pages::changelog_index")]
132 + async fn changelog_index(
133 + State(state): State<AppState>,
134 + session: Session,
135 + MaybeUser(maybe_user): MaybeUser,
136 + ) -> Result<impl IntoResponse> {
137 + let csrf_token = get_csrf_token(&session).await;
138 +
139 + let slug = Slug::from_trusted(constants::CHANGELOG_PROJECT_SLUG.to_owned());
140 + let db_project = db::projects::get_public_project_by_slug(&state.db, &slug)
141 + .await?
142 + .ok_or(AppError::NotFound)?;
143 +
144 + let db_user = db::users::get_user_by_id(&state.db, db_project.user_id)
145 + .await?
146 + .ok_or(AppError::NotFound)?;
147 +
148 + let db_posts =
149 + db::blog_posts::get_published_blog_posts_by_project(&state.db, db_project.id).await?;
150 +
151 + let project = Project::from_db(&db_project, 0);
152 + let posts: Vec<BlogPostSummary> = db_posts.iter().map(BlogPostSummary::from).collect();
153 +
154 + Ok(ProjectBlogTemplate {
155 + csrf_token,
156 + session_user: maybe_user,
157 + project,
158 + creator_username: db_user.username.to_string(),
159 + project_slug: db_project.slug.to_string(),
160 + posts,
161 + })
162 + }
163 +
164 + /// Platform changelog post (alias for a "changelog" project blog post).
165 + #[tracing::instrument(skip_all, name = "blog_pages::changelog_post")]
166 + async fn changelog_post(
167 + State(state): State<AppState>,
168 + session: Session,
169 + MaybeUser(maybe_user): MaybeUser,
170 + Path(post_slug): Path<String>,
171 + ) -> Result<impl IntoResponse> {
172 + let csrf_token = get_csrf_token(&session).await;
173 +
174 + let slug = Slug::from_trusted(constants::CHANGELOG_PROJECT_SLUG.to_owned());
175 + let post_slug = Slug::from_trusted(post_slug);
176 + let db_project = db::projects::get_public_project_by_slug(&state.db, &slug)
177 + .await?
178 + .ok_or(AppError::NotFound)?;
179 +
180 + let db_user = db::users::get_user_by_id(&state.db, db_project.user_id)
181 + .await?
182 + .ok_or(AppError::NotFound)?;
183 +
184 + let db_post = db::blog_posts::get_blog_post_by_slug(&state.db, db_project.id, &post_slug)
185 + .await?
186 + .ok_or(AppError::NotFound)?;
187 +
188 + let is_owner = maybe_user
189 + .as_ref()
190 + .map(|u| u.id == db_project.user_id)
191 + .unwrap_or(false);
192 + if db_post.published_at.is_none() && !is_owner {
193 + return Err(AppError::NotFound);
194 + }
195 +
196 + let avatar_initials =
197 + get_initials(db_user.display_name.as_deref().unwrap_or(&db_user.username));
198 +
199 + let title_json = json_escape(&db_post.title);
200 + let project_title_json = json_escape(&db_project.title);
201 +
202 + let project_slug_str = db_project.slug.to_string();
203 + let (discussion_url, discussion_count) =
204 + fetch_discussion_info(&state, db_post.mt_thread_id, &project_slug_str, "blog").await;
205 +
206 + Ok(BlogPostTemplate {
207 + csrf_token,
208 + session_user: maybe_user,
209 + title: db_post.title,
210 + title_json,
211 + body_html: db_post.body_html,
212 + published_at: db_post
213 + .published_at
214 + .map(|d| d.format("%b %d, %Y").to_string())
215 + .unwrap_or_default(),
216 + creator_username: db_user.username.to_string(),
217 + creator_display_name: db_user.display_name.clone(),
218 + creator_avatar_initials: avatar_initials,
219 + project_title: db_project.title,
220 + project_title_json,
221 + project_slug: project_slug_str,
222 + post_slug: post_slug.to_string(),
223 + host_url: state.config.host_url.clone(),
224 + project_cover_image_url: db_project.cover_image_url,
119 225 discussion_url,
120 226 discussion_count,
121 227 })
@@ -446,6 +446,7 @@ pub(crate) async fn render_item_page(
446 446 project_slug: project_slug_str,
447 447 versions,
448 448 host_url: state.config.host_url.clone(),
449 + project_cover_image_url: db_project.cover_image_url.clone(),
449 450 discussion_url,
450 451 discussion_count,
451 452 bundle_items: bundle_item_views,
@@ -140,6 +140,17 @@ pub struct AdminReportsTemplate {
140 140 pub admin_active_page: &'static str,
141 141 }
142 142
143 + /// Admin email signups page.
144 + #[derive(Template)]
145 + #[template(path = "dashboards/admin-signups.html")]
146 + pub struct AdminSignupsTemplate {
147 + pub csrf_token: CsrfTokenOption,
148 + pub session_user: Option<SessionUser>,
149 + pub signups: Vec<AdminSignupRow>,
150 + pub total: i64,
151 + pub admin_active_page: &'static str,
152 + }
153 +
143 154 // ============================================================================
144 155 // Export & Account Management
145 156 // ============================================================================
@@ -100,6 +100,7 @@ impl_into_response!(
100 100 AdminAppealsTemplate,
101 101 AdminLabelsTemplate,
102 102 AdminReportsTemplate,
103 + AdminSignupsTemplate,
103 104 // Export & account management
104 105 ExportPortalTemplate,
105 106 DeleteAccountTemplate,
@@ -27,6 +27,9 @@ pub struct PolicyTemplate {
27 27 #[template(path = "pages/index.html")]
28 28 pub struct IndexTemplate {
29 29 pub csrf_token: CsrfTokenOption,
30 + pub host_url: String,
31 + pub total_creators: u32,
32 + pub total_items: u32,
30 33 }
31 34
32 35 /// User's library shell (tabs loaded via HTMX).
@@ -244,6 +247,8 @@ pub struct ItemTemplate {
244 247 pub discussion_url: Option<String>,
245 248 /// Number of posts in the linked discussion thread.
246 249 pub discussion_count: Option<i64>,
250 + /// Project cover image URL (fallback for og:image when item has no cover).
251 + pub project_cover_image_url: Option<String>,
247 252 /// Child items for bundle-type items (empty for non-bundles).
248 253 pub bundle_items: Vec<Item>,
249 254 /// Bundles containing this item (for unlisted items, to show "Available in" links).
@@ -461,6 +466,8 @@ pub struct BlogPostTemplate {
461 466 pub post_slug: String,
462 467 /// Base URL for OG meta tags.
463 468 pub host_url: String,
469 + /// Project cover image URL (fallback for og:image when blog post has no specific image).
470 + pub project_cover_image_url: Option<String>,
464 471 /// URL to the MT discussion thread (None if no linked thread or MT unavailable).
465 472 pub discussion_url: Option<String>,
466 473 /// Number of posts in the linked discussion thread.
@@ -440,6 +440,15 @@ pub struct AdminWaitlistRow {
440 440 pub invited_by_username: Option<String>,
441 441 }
442 442
443 + /// Admin view of an email signup (landing page notify-me)
444 + #[derive(Clone)]
445 + #[allow(dead_code)] // Fields used by Askama templates
446 + pub struct AdminSignupRow {
447 + pub email: String,
448 + pub source: String,
449 + pub created_at: String,
450 + }
451 +
443 452 /// Wave statistics for public transparency page
444 453 #[derive(Clone)]
445 454 #[allow(dead_code)] // Fields used by Askama templates
@@ -1614,6 +1614,35 @@ footer {
1614 1614 }
1615 1615
1616 1616 /* ===========================================
1617 + RESTART WARNING BANNER
1618 + =========================================== */
1619 +
1620 + #restart-banner {
1621 + position: fixed;
1622 + top: 0;
1623 + left: 0;
1624 + right: 0;
1625 + z-index: 10000;
1626 + background: #fff3cd;
1627 + border-bottom: 2px solid #ffc107;
1628 + color: var(--detail);
1629 + font-family: var(--font-mono);
1630 + font-size: 0.9rem;
1631 + text-align: center;
1632 + padding: 0.6rem 1rem;
1633 + animation: banner-slide-down 0.3s ease-out;
1634 + }
1635 +
1636 + @keyframes banner-slide-down {
1637 + from {
1638 + transform: translateY(-100%);
1639 + }
1640 + to {
1641 + transform: translateY(0);
1642 + }
1643 + }
1644 +
1645 + /* ===========================================
1617 1646 SAVE STATUS INDICATORS
1618 1647 =========================================== */
1619 1648
@@ -2131,6 +2160,23 @@ textarea:focus-visible {
2131 2160 color: var(--primary-light);
2132 2161 }
2133 2162
2163 + .results-empty {
2164 + text-align: center;
2165 + padding: 3rem 1rem;
2166 + opacity: 0.7;
2167 + }
2168 +
2169 + .results-empty p {
2170 + margin: 0;
2171 + }
2172 +
2173 + .results-empty-hint {
2174 + font-family: var(--font-mono);
2175 + font-size: 0.8rem;
2176 + margin-top: 0.5rem !important;
2177 + opacity: 0.6;
2178 + }
2179 +
2134 2180 /* Results container layout switching */
2135 2181 .results-container.results-list .results-grid {
2136 2182 display: none;
@@ -2320,6 +2366,82 @@ textarea:focus-visible {
2320 2366 margin-bottom: 0.5rem;
2321 2367 }
2322 2368
2369 + .landing-stats {
2370 + display: flex;
2371 + gap: 1.5rem;
2372 + justify-content: center;
2373 + margin-bottom: 1rem;
2374 + }
2375 +
2376 + .landing-stat {
2377 + font-family: var(--font-mono);
2378 + font-size: 0.85rem;
2379 + opacity: 0.6;
2380 + }
2381 +
2382 + .landing-screenshot {
2383 + margin: 1.5rem auto;
2384 + max-width: 700px;
2385 + width: 100%;
2386 + }
2387 +
2388 + .landing-screenshot img {
2389 + width: 100%;
2390 + border-radius: 8px;
2391 + border: 1px solid var(--border);
2392 + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
2393 + }
2394 +
2395 + .notify-form {
2396 + display: flex;
2397 + gap: 0.5rem;
2398 + max-width: 400px;
2399 + margin: 0 auto;
2400 + }
2401 +
2402 + .notify-input {
2403 + flex: 1;
2404 + padding: 0.5rem 0.75rem;
2405 + border: 1px solid var(--border);
2406 + border-radius: 4px;
2407 + font-family: var(--font-body);
2408 + font-size: 0.9rem;
2409 + background: var(--background);
2410 + color: var(--text);
2411 + }
2412 +
2413 + .notify-btn {
2414 + padding: 0.5rem 1rem;
2415 + border: 1px solid var(--border);
2416 + border-radius: 4px;
2417 + font-family: var(--font-mono);
2418 + font-size: 0.8rem;
2419 + background: var(--text);
2420 + color: var(--background);
2421 + cursor: pointer;
2422 + white-space: nowrap;
2423 + }
2424 +
2425 + .notify-btn:hover {
2426 + opacity: 0.85;
2427 + }
2428 +
2429 + .notify-status {
2430 + font-family: var(--font-mono);
2431 + font-size: 0.8rem;
2432 + text-align: center;
2433 + margin-top: 0.5rem;
2434 + min-height: 1.2em;
2435 + }
2436 +
2437 + .notify-status.success {
2438 + color: var(--text);
2439 + }
2440 +
2441 + .notify-status.error {
2442 + color: #c0392b;
2443 + }
2444 +
2323 2445 .section-label {
2324 2446 font-family: var(--font-mono);
2325 2447 font-size: 0.85rem;
@@ -2364,6 +2486,21 @@ textarea:focus-visible {
2364 2486 opacity: 0.7;
2365 2487 }
2366 2488
2489 + .tier-card.planned {
2490 + opacity: 0.5;
2491 + border-style: dashed;
2492 + position: relative;
2493 + }
2494 +
2495 + .tier-planned-label {
2496 + font-family: var(--font-mono);
2497 + font-size: 0.7rem;
2498 + text-transform: uppercase;
2499 + letter-spacing: 0.05em;
2500 + margin-top: 0.5rem;
2501 + opacity: 0.7;
2502 + }
2503 +
2367 2504 .landing-cta {
2368 2505 display: flex;
2369 2506 align-items: center;
@@ -5,6 +5,7 @@
5 5 <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 6 {% if let Some(token) = csrf_token %}<meta name="csrf-token" content="{{ token }}">{% endif %}
7 7 <title>{% block title %}Makenotwork{% endblock %}</title>
8 + <meta name="description" content="{% block meta_description %}Fair distribution for creatives of all kinds. 0% platform fee — sell music, software, writing, and more for a flat monthly rate.{% endblock %}">
8 9 {% include "_head_assets.html" %}
9 10 {% block head %}{% endblock %}
10 11 </head>
@@ -20,9 +21,13 @@
20 21 <a href="/use-cases">Use Cases</a>
21 22 <a href="/creators">Creators</a>
22 23 <a href="/docs">Docs</a>
24 + <a href="/fan-plus">Fan+</a>
25 + <a href="/docs/faq">FAQ</a>
23 26 <a href="/policy">Policy</a>
27 + <a href="/docs/terms-of-service">Terms</a>
28 + <a href="/docs/privacy-policy">Privacy</a>
24 29 </div>
25 - <p>&copy; 2026 Make Creative</p>
30 + <p>&copy; 2026 Makenotwork</p>
26 31 </footer>
27 32
28 33 <!-- Toast notification container -->
@@ -0,0 +1,40 @@
1 + {% extends "base.html" %}
2 +
3 + {% block title %}Admin: Signups - Makenot.work{% endblock %}
4 + {% block body_attrs %} class="padded-page"{% endblock %}
5 +
6 + {% block content %}
7 + {% include "partials/site_header.html" %}
8 +
9 + <div class="container">
10 + {% include "partials/admin_nav.html" %}
11 +
12 + <h1>Email Signups</h1>
13 + <p style="font-family: var(--font-mono); font-size: 0.85rem; opacity: 0.6; margin-bottom: 1.5rem;">{{ total }} total signups from landing page</p>
14 +
15 + {% if signups.is_empty() %}
16 + <p>No email signups yet.</p>
17 + {% else %}
18 + <div class="table-scroll">
19 + <table class="admin-table">
20 + <thead>
21 + <tr>
22 + <th>Email</th>
23 + <th>Source</th>
24 + <th>Date</th>
25 + </tr>
26 + </thead>
27 + <tbody>
28 + {% for s in signups %}
29 + <tr>
30 + <td>{{ s.email }}</td>
31 + <td>{{ s.source }}</td>
32 + <td>{{ s.created_at }}</td>
33 + </tr>
34 + {% endfor %}
35 + </tbody>
36 + </table>
37 + </div>
38 + {% endif %}
39 + </div>
40 + {% endblock %}
@@ -1,27 +1,12 @@
1 1 <!doctype html>
2 2 <html lang="en">
3 3 <head>
4 - <link rel="stylesheet" href="style.css" />
5 - <link rel="icon" href="images/favicon.ico" type="image/x-icon" />
6 4 <meta charset="UTF-8" />
7 5 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
8 6 <title>Makenotwork</title>
7 + <meta http-equiv="refresh" content="0;url=/" />
9 8 </head>
10 - <body class="centered-page">
11 - <div class="container">
12 - <h1>Makenot<span class="dot">.</span>work</h1>
13 - <p class="tagline">
14 - Fair and transparent distribution for creators<span class="dot">.</span>
15 - </p>
16 - </div>
17 -
18 - <div class="button-grid">
19 - <a class="big-button" href="pages/join.html">Join</a>
20 - <a class="big-button" href="#">Docs</a>
21 - <a class="big-button" href="pages/discover.html">Discover</a>
22 - <a class="big-button" href="pages/project.html">Demo</a>
23 - </div>
24 -
25 - <footer>© 2026 Make Creative</footer>
9 + <body>
10 + <p>Redirecting to <a href="/">makenot.work</a>...</p>
26 11 </body>
27 12 </html>
@@ -7,10 +7,19 @@
7 7 <meta property="og:description" content="{{ creator_display_name.as_deref().unwrap_or(&creator_username) }} on Makenot.work">
8 8 <meta property="og:type" content="article">
9 9 <meta property="og:url" content="{{ host_url }}/p/{{ project_slug }}/blog/{{ post_slug }}">
10 + <link rel="canonical" href="{{ host_url }}/p/{{ project_slug }}/blog/{{ post_slug }}">
10 11 <meta property="og:site_name" content="Makenot.work">
11 - <meta name="twitter:card" content="summary">
12 12 <meta name="twitter:title" content="{{ title }} - {{ project_title }}">
13 13 <meta name="twitter:description" content="{{ creator_display_name.as_deref().unwrap_or(&creator_username) }} on Makenot.work">
14 + {% if let Some(img) = project_cover_image_url %}
15 + <meta name="twitter:card" content="summary_large_image">
16 + <meta property="og:image" content="{{ img }}">
17 + <meta name="twitter:image" content="{{ img }}">
18 + {% else %}
19 + <meta name="twitter:card" content="summary">
20 + <meta property="og:image" content="{{ host_url }}/static/images/logo.png">
21 + <meta name="twitter:image" content="{{ host_url }}/static/images/logo.png">
22 + {% endif %}
14 23 <script type="application/ld+json">
15 24 {
16 25 "@context": "https://schema.org",
@@ -229,6 +238,6 @@
229 238
230 239 <footer class="text-reader-footer">
231 240 <a href="javascript:void(0)" onclick="navigator.clipboard.writeText(window.location.origin + '/p/{{ project_slug }}/blog/{{ post_slug }}').then(() => { this.textContent = 'Copied!'; setTimeout(() => this.textContent = 'Copy link', 1500) })">Copy link</a> &middot;
232 - <a href="/">Makenot<span class="dot">.</span>work</a> | Fair and transparent distribution for creators
241 + <a href="/">Makenot<span class="dot">.</span>work</a> | Fair distribution for creatives of all kinds
233 242 </footer>
234 243 {% endblock %}
@@ -148,8 +148,8 @@
148 148 hx-push-url="true"
149 149 hx-vals='{"mode": "projects"}'>Projects</button>
150 150 <div class="view-controls">
151 - <button type="button" class="view-btn active" data-view="list" title="List view">|||</button>
152 - <button type="button" class="view-btn" data-view="grid" title="Grid view">:::</button>
151 + <button type="button" class="view-btn" data-view="list" title="List view">|||</button>
152 + <button type="button" class="view-btn active" data-view="grid" title="Grid view">:::</button>
153 153 </div>
154 154 </div>
155 155
@@ -264,7 +264,7 @@
264 264
265 265 // Load saved preference on page load
266 266 document.addEventListener('DOMContentLoaded', function() {
267 - var saved = safeStorageGet('discoverViewPref') || 'list';
267 + var saved = safeStorageGet('discoverViewPref') || 'grid';
268 268 applyView(saved);
269 269 });
270 270
@@ -280,7 +280,7 @@
280 280 // Re-apply view preference after HTMX swaps new content
281 281 document.body.addEventListener('htmx:afterSwap', function(evt) {
282 282 if (evt.detail.target.id === 'results-container') {
283 - var saved = safeStorageGet('discoverViewPref') || 'list';
283 + var saved = safeStorageGet('discoverViewPref') || 'grid';
284 284 applyView(saved);
285 285 }
286 286 });
@@ -9,8 +9,8 @@
9 9 <h1 class="error-title">{{ status_text }}</h1>
10 10 <p class="error-message">{{ message }}</p>
11 11 <div class="error-actions">
12 - <a href="/" class="btn btn-primary">Go Home</a>
13 - <a href="javascript:history.back()" class="btn btn-secondary">Go Back</a>
12 + <a href="/" class="primary">Go Home</a>
13 + <a href="javascript:history.back()" class="secondary">Go Back</a>
14 14 </div>
15 15 </div>
16 16 </div>
@@ -56,31 +56,12 @@
56 56 justify-content: center;
57 57 }
58 58
59 - .btn {
60 - padding: 0.75rem 1.5rem;
61 - border-radius: 6px;
62 - font-weight: 500;
59 + .error-actions a.primary {
63 60 text-decoration: none;
64 - transition: all 0.2s;
65 61 }
66 62
67 - .btn-primary {
68 - background: var(--detail);
69 - color: var(--background);
70 - }
71 -
72 - .btn-primary:hover {
73 - opacity: 0.85;
74 - }
75 -
76 - .btn-secondary {
77 - background: var(--light-background);
78 - color: var(--detail);
79 - border: 1px solid var(--border);
80 - }
81 -
82 - .btn-secondary:hover {
83 - background: var(--surface-muted);
63 + .error-actions a.secondary {
64 + text-decoration: none;
84 65 }
85 66 </style>
86 67 {% endblock %}