max / makenotwork
27 files changed,
+691 insertions,
-109 deletions
| @@ -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>© 2026 Make Creative</p> | |
| 30 | + | <p>© 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> · | |
| 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 %} |