max / makenotwork
14 files changed,
+263 insertions,
-4 deletions
| @@ -0,0 +1,14 @@ | |||
| 1 | + | -- Operator-controlled flag marking a changelog post as landing-page-worthy. | |
| 2 | + | -- | |
| 3 | + | -- The landing page surfaces a single "Last shipped" velocity line drawn from | |
| 4 | + | -- the most recent *published* blog post that (a) has this flag set and (b) | |
| 5 | + | -- belongs to the project whose slug is CHANGELOG_PROJECT_SLUG. Not every | |
| 6 | + | -- changelog entry warrants landing-page weight, so the operator opts a post in | |
| 7 | + | -- explicitly via the "Show on landing" checkbox in the blog editor. | |
| 8 | + | -- | |
| 9 | + | -- The column lives on every blog_posts row (any project's editor can set it), | |
| 10 | + | -- but the landing reader filters by the changelog project slug, so the flag is | |
| 11 | + | -- inert on non-changelog projects. Defaults FALSE: existing posts stay off the | |
| 12 | + | -- landing page until deliberately flagged. | |
| 13 | + | ALTER TABLE blog_posts | |
| 14 | + | ADD COLUMN IF NOT EXISTS show_on_landing BOOLEAN NOT NULL DEFAULT FALSE; |
| @@ -21,13 +21,14 @@ pub async fn create_blog_post( | |||
| 21 | 21 | body_html: &str, | |
| 22 | 22 | publish: bool, | |
| 23 | 23 | web_only: bool, | |
| 24 | + | show_on_landing: bool, | |
| 24 | 25 | ) -> Result<DbBlogPost> { | |
| 25 | 26 | let published_at = if publish { Some(Utc::now()) } else { None }; | |
| 26 | 27 | ||
| 27 | 28 | let post = sqlx::query_as::<_, DbBlogPost>( | |
| 28 | 29 | r#" | |
| 29 | - | INSERT INTO blog_posts (project_id, author_id, title, slug, body_markdown, body_html, published_at, web_only) | |
| 30 | - | VALUES ($1, $2, $3, $4, $5, $6, $7, $8) | |
| 30 | + | INSERT INTO blog_posts (project_id, author_id, title, slug, body_markdown, body_html, published_at, web_only, show_on_landing) | |
| 31 | + | VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) | |
| 31 | 32 | RETURNING * | |
| 32 | 33 | "#, | |
| 33 | 34 | ) | |
| @@ -39,6 +40,7 @@ pub async fn create_blog_post( | |||
| 39 | 40 | .bind(body_html) | |
| 40 | 41 | .bind(published_at) | |
| 41 | 42 | .bind(web_only) | |
| 43 | + | .bind(show_on_landing) | |
| 42 | 44 | .fetch_one(pool) | |
| 43 | 45 | .await?; | |
| 44 | 46 | ||
| @@ -126,6 +128,39 @@ pub async fn get_published_blog_posts_by_project( | |||
| 126 | 128 | Ok(posts) | |
| 127 | 129 | } | |
| 128 | 130 | ||
| 131 | + | /// Fetch the single landing-page "Last shipped" post: the most recent | |
| 132 | + | /// published, landing-flagged post belonging to a public project with the | |
| 133 | + | /// given slug (CHANGELOG_PROJECT_SLUG). Returns `None` when nothing qualifies, | |
| 134 | + | /// which the landing route uses to suppress the velocity line entirely. | |
| 135 | + | /// | |
| 136 | + | /// `show_on_landing` is set on rows across every project, but the slug join | |
| 137 | + | /// confines the landing reader to the changelog project, so the flag is inert | |
| 138 | + | /// elsewhere. | |
| 139 | + | #[tracing::instrument(skip_all)] | |
| 140 | + | pub async fn get_landing_changelog_post( | |
| 141 | + | pool: &PgPool, | |
| 142 | + | changelog_slug: &str, | |
| 143 | + | ) -> Result<Option<DbBlogPost>> { | |
| 144 | + | let post = sqlx::query_as::<_, DbBlogPost>( | |
| 145 | + | r#" | |
| 146 | + | SELECT bp.* | |
| 147 | + | FROM blog_posts bp | |
| 148 | + | JOIN projects p ON p.id = bp.project_id | |
| 149 | + | WHERE p.slug = $1 | |
| 150 | + | AND p.is_public = true | |
| 151 | + | AND bp.show_on_landing = true | |
| 152 | + | AND bp.published_at IS NOT NULL | |
| 153 | + | ORDER BY bp.published_at DESC | |
| 154 | + | LIMIT 1 | |
| 155 | + | "#, | |
| 156 | + | ) | |
| 157 | + | .bind(changelog_slug) | |
| 158 | + | .fetch_optional(pool) | |
| 159 | + | .await?; | |
| 160 | + | ||
| 161 | + | Ok(post) | |
| 162 | + | } | |
| 163 | + | ||
| 129 | 164 | /// Update a blog post's fields. | |
| 130 | 165 | /// | |
| 131 | 166 | /// `publish_at` uses a double-Option: `None` = no change, `Some(None)` = clear schedule, | |
| @@ -145,6 +180,7 @@ pub async fn update_blog_post( | |||
| 145 | 180 | publish: bool, | |
| 146 | 181 | publish_at: Option<Option<chrono::DateTime<chrono::Utc>>>, | |
| 147 | 182 | web_only: Option<bool>, | |
| 183 | + | show_on_landing: Option<bool>, | |
| 148 | 184 | ) -> Result<DbBlogPost> { | |
| 149 | 185 | let update_publish_at = publish_at.is_some(); | |
| 150 | 186 | let publish_at_value = publish_at.flatten(); | |
| @@ -169,6 +205,7 @@ pub async fn update_blog_post( | |||
| 169 | 205 | END, | |
| 170 | 206 | publish_at = CASE WHEN $7 THEN $8 ELSE publish_at END, | |
| 171 | 207 | web_only = COALESCE($9, web_only), | |
| 208 | + | show_on_landing = COALESCE($10, show_on_landing), | |
| 172 | 209 | updated_at = NOW() | |
| 173 | 210 | WHERE id = $1 | |
| 174 | 211 | RETURNING * | |
| @@ -183,6 +220,7 @@ pub async fn update_blog_post( | |||
| 183 | 220 | .bind(update_publish_at) | |
| 184 | 221 | .bind(publish_at_value) | |
| 185 | 222 | .bind(web_only) | |
| 223 | + | .bind(show_on_landing) | |
| 186 | 224 | .fetch_one(pool) | |
| 187 | 225 | .await?; | |
| 188 | 226 |
| @@ -36,6 +36,9 @@ pub struct DbBlogPost { | |||
| 36 | 36 | pub mt_thread_id: Option<MtThreadId>, | |
| 37 | 37 | /// Whether this post should only be published on the web (skip email announcements). | |
| 38 | 38 | pub web_only: bool, | |
| 39 | + | /// Operator-only flag marking this post as the landing-page "Last shipped" | |
| 40 | + | /// line. Inert unless the post lives on the CHANGELOG_PROJECT_SLUG project. | |
| 41 | + | pub show_on_landing: bool, | |
| 39 | 42 | /// When a release announcement was sent (prevents re-announcement on unpublish/republish). | |
| 40 | 43 | pub release_announced_at: Option<DateTime<Utc>>, | |
| 41 | 44 | } |
| @@ -32,6 +32,9 @@ pub struct CreateBlogPostRequest { | |||
| 32 | 32 | pub is_published: Option<bool>, | |
| 33 | 33 | /// Whether to skip email announcements when publishing. | |
| 34 | 34 | pub web_only: Option<bool>, | |
| 35 | + | /// Operator-only: surface this post as the landing "Last shipped" line. | |
| 36 | + | /// Only meaningful on the changelog project; inert elsewhere. | |
| 37 | + | pub show_on_landing: Option<bool>, | |
| 35 | 38 | } | |
| 36 | 39 | ||
| 37 | 40 | /// JSON input for updating a blog post. | |
| @@ -45,6 +48,9 @@ pub struct UpdateBlogPostRequest { | |||
| 45 | 48 | pub publish_at: Option<String>, | |
| 46 | 49 | /// Whether to skip email announcements when publishing. | |
| 47 | 50 | pub web_only: Option<bool>, | |
| 51 | + | /// Operator-only: surface this post as the landing "Last shipped" line. | |
| 52 | + | /// `None` leaves the existing flag unchanged (e.g. autosave). | |
| 53 | + | pub show_on_landing: Option<bool>, | |
| 48 | 54 | } | |
| 49 | 55 | ||
| 50 | 56 | /// JSON response representing a blog post. | |
| @@ -57,6 +63,7 @@ pub struct BlogPostResponse { | |||
| 57 | 63 | pub is_published: bool, | |
| 58 | 64 | pub published_at: Option<String>, | |
| 59 | 65 | pub web_only: bool, | |
| 66 | + | pub show_on_landing: bool, | |
| 60 | 67 | pub created_at: String, | |
| 61 | 68 | pub updated_at: String, | |
| 62 | 69 | } | |
| @@ -71,6 +78,7 @@ pub struct BlogPostEditResponse { | |||
| 71 | 78 | pub is_published: bool, | |
| 72 | 79 | pub publish_at: Option<String>, | |
| 73 | 80 | pub web_only: bool, | |
| 81 | + | pub show_on_landing: bool, | |
| 74 | 82 | } | |
| 75 | 83 | ||
| 76 | 84 | fn blog_post_edit_response(post: &db::DbBlogPost) -> BlogPostEditResponse { | |
| @@ -82,6 +90,7 @@ fn blog_post_edit_response(post: &db::DbBlogPost) -> BlogPostEditResponse { | |||
| 82 | 90 | is_published: post.published_at.is_some(), | |
| 83 | 91 | publish_at: post.publish_at.map(|d| d.to_rfc3339()), | |
| 84 | 92 | web_only: post.web_only, | |
| 93 | + | show_on_landing: post.show_on_landing, | |
| 85 | 94 | } | |
| 86 | 95 | } | |
| 87 | 96 | ||
| @@ -94,6 +103,7 @@ fn blog_post_response(post: &db::DbBlogPost) -> BlogPostResponse { | |||
| 94 | 103 | is_published: post.published_at.is_some(), | |
| 95 | 104 | published_at: post.published_at.map(|d| d.to_rfc3339()), | |
| 96 | 105 | web_only: post.web_only, | |
| 106 | + | show_on_landing: post.show_on_landing, | |
| 97 | 107 | created_at: post.created_at.to_rfc3339(), | |
| 98 | 108 | updated_at: post.updated_at.to_rfc3339(), | |
| 99 | 109 | } | |
| @@ -153,13 +163,14 @@ pub(super) async fn create_blog_post( | |||
| 153 | 163 | // Retry with suffixes if a concurrent request creates the same slug | |
| 154 | 164 | // between our existence check and insert (TOCTOU race). | |
| 155 | 165 | let web_only = req.web_only.unwrap_or(false); | |
| 166 | + | let show_on_landing = req.show_on_landing.unwrap_or(false); | |
| 156 | 167 | ||
| 157 | 168 | let base_slug = slug.clone(); | |
| 158 | 169 | let mut suffix = 1u32; | |
| 159 | 170 | let post = loop { | |
| 160 | 171 | match db::blog_posts::create_blog_post( | |
| 161 | 172 | &state.db, project_id, user.id, &req.title, &slug, | |
| 162 | - | body_markdown, &body_html, is_published, web_only, | |
| 173 | + | body_markdown, &body_html, is_published, web_only, show_on_landing, | |
| 163 | 174 | ).await { | |
| 164 | 175 | Ok(post) => break post, | |
| 165 | 176 | Err(e) => { | |
| @@ -242,6 +253,7 @@ pub(super) async fn update_blog_post( | |||
| 242 | 253 | is_published, | |
| 243 | 254 | publish_at, | |
| 244 | 255 | req.web_only, | |
| 256 | + | req.show_on_landing, | |
| 245 | 257 | ) | |
| 246 | 258 | .await?; | |
| 247 | 259 |
| @@ -150,6 +150,7 @@ pub(super) async fn create_blog_post( | |||
| 150 | 150 | &body_html, | |
| 151 | 151 | publish, | |
| 152 | 152 | false, | |
| 153 | + | false, | |
| 153 | 154 | ) | |
| 154 | 155 | .await?; | |
| 155 | 156 | ||
| @@ -171,6 +172,7 @@ pub(super) async fn create_blog_post( | |||
| 171 | 172 | false, // not published yet — scheduler handles it | |
| 172 | 173 | Some(Some(dt_utc)), | |
| 173 | 174 | None, | |
| 175 | + | None, | |
| 174 | 176 | ) | |
| 175 | 177 | .await? | |
| 176 | 178 | } else { |
| @@ -152,6 +152,10 @@ pub(super) async fn blog_editor( | |||
| 152 | 152 | ||
| 153 | 153 | let csrf_token = get_csrf_token(&session).await; | |
| 154 | 154 | ||
| 155 | + | // The landing "Show on landing" control only applies to the platform | |
| 156 | + | // changelog project; surfacing it elsewhere would expose an inert flag. | |
| 157 | + | let is_changelog_project = db_project.slug.as_str() == crate::constants::CHANGELOG_PROJECT_SLUG; | |
| 158 | + | ||
| 155 | 159 | // If editing an existing post, load it | |
| 156 | 160 | if let Some(post_id_str) = &query.post { | |
| 157 | 161 | let post_id: BlogPostId = post_id_str.parse().map_err(|_| AppError::NotFound)?; | |
| @@ -174,6 +178,8 @@ pub(super) async fn blog_editor( | |||
| 174 | 178 | post_slug: post.slug.to_string(), | |
| 175 | 179 | post_body: post.body_markdown, | |
| 176 | 180 | post_is_published: post.published_at.is_some(), | |
| 181 | + | is_changelog_project, | |
| 182 | + | post_show_on_landing: post.show_on_landing, | |
| 177 | 183 | }); | |
| 178 | 184 | } | |
| 179 | 185 | ||
| @@ -189,5 +195,7 @@ pub(super) async fn blog_editor( | |||
| 189 | 195 | post_slug: String::new(), | |
| 190 | 196 | post_body: String::new(), | |
| 191 | 197 | post_is_published: false, | |
| 198 | + | is_changelog_project, | |
| 199 | + | post_show_on_landing: false, | |
| 192 | 200 | }) | |
| 193 | 201 | } |
| @@ -45,6 +45,23 @@ pub(super) async fn index( | |||
| 45 | 45 | let total_creators = db::waitlist::count_active_creators(&state.db).await? as u32; | |
| 46 | 46 | let total_items = db::items::count_public_listed(&state.db).await?; | |
| 47 | 47 | ||
| 48 | + | // "Last shipped" velocity line: most recent published, landing- | |
| 49 | + | // flagged post on the changelog project. Read once per render; the | |
| 50 | + | // line is suppressed entirely when nothing qualifies (no | |
| 51 | + | // placeholder), matching the runway disclosure's no-fabrication rule. | |
| 52 | + | let last_shipped = db::blog_posts::get_landing_changelog_post( | |
| 53 | + | &state.db, | |
| 54 | + | constants::CHANGELOG_PROJECT_SLUG, | |
| 55 | + | ) | |
| 56 | + | .await? | |
| 57 | + | .and_then(|post| { | |
| 58 | + | post.published_at.map(|published_at| LandingVelocity { | |
| 59 | + | title: post.title, | |
| 60 | + | date: published_at.format("%b %d, %Y").to_string(), | |
| 61 | + | href: format!("/changelog/{}", post.slug), | |
| 62 | + | }) | |
| 63 | + | }); | |
| 64 | + | ||
| 48 | 65 | // Surface remaining founder slots only when close enough to feel | |
| 49 | 66 | // scarce. 200 is "last chunk" — enough warning to convert, not so | |
| 50 | 67 | // early that the number stays prominent for months. | |
| @@ -88,6 +105,7 @@ pub(super) async fn index( | |||
| 88 | 105 | founder_slots_remaining, | |
| 89 | 106 | tier_prices: state.tier_prices.clone(), | |
| 90 | 107 | landing_carousel, | |
| 108 | + | last_shipped, | |
| 91 | 109 | }.into_response()) | |
| 92 | 110 | } | |
| 93 | 111 | } |
| @@ -277,6 +277,12 @@ pub struct BlogEditorTemplate { | |||
| 277 | 277 | pub post_slug: String, | |
| 278 | 278 | pub post_body: String, | |
| 279 | 279 | pub post_is_published: bool, | |
| 280 | + | /// Whether this editor is for the platform changelog project. Gates the | |
| 281 | + | /// owner-only "Show on landing" control so it never appears on a regular | |
| 282 | + | /// creator's blog, where the flag would be inert. | |
| 283 | + | pub is_changelog_project: bool, | |
| 284 | + | /// Current value of the post's landing flag (false for new posts). | |
| 285 | + | pub post_show_on_landing: bool, | |
| 280 | 286 | } | |
| 281 | 287 | ||
| 282 | 288 | /// HTMX partial: onboarding checklist (returned by the restore handler). |
| @@ -59,6 +59,20 @@ pub struct IndexTemplate { | |||
| 59 | 59 | /// placeholders; swap the images (and tighten the alt text) once real | |
| 60 | 60 | /// captures exist. Empty hides the section. | |
| 61 | 61 | pub landing_carousel: Vec<super::CarouselFrame>, | |
| 62 | + | /// The "Last shipped" velocity line, drawn from the most recent published, | |
| 63 | + | /// landing-flagged changelog post. `None` suppresses the line entirely | |
| 64 | + | /// (no placeholder) — same honesty rule the runway disclosure uses. | |
| 65 | + | pub last_shipped: Option<LandingVelocity>, | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | /// One-line "Last shipped" velocity signal for the landing page. | |
| 69 | + | pub struct LandingVelocity { | |
| 70 | + | /// Post title. | |
| 71 | + | pub title: String, | |
| 72 | + | /// Publication date, preformatted (e.g. "Jun 07, 2026"). | |
| 73 | + | pub date: String, | |
| 74 | + | /// Link target: `/changelog/{slug}`. | |
| 75 | + | pub href: String, | |
| 62 | 76 | } | |
| 63 | 77 | ||
| 64 | 78 | /// User's library shell with inline purchases tab (other tabs loaded via HTMX). |
| @@ -23,6 +23,9 @@ | |||
| 23 | 23 | }; | |
| 24 | 24 | } | |
| 25 | 25 | ||
| 26 | + | // Present only on the changelog project editor; null elsewhere. | |
| 27 | + | var landingToggle = document.getElementById('post-show-on-landing'); | |
| 28 | + | ||
| 26 | 29 | function goBack() { | |
| 27 | 30 | window.location.href = '/dashboard/project/' + projectSlug; | |
| 28 | 31 | } | |
| @@ -35,6 +38,7 @@ | |||
| 35 | 38 | } | |
| 36 | 39 | var payload = { title: f.title, body_markdown: f.body, is_published: publish }; | |
| 37 | 40 | if (f.slug) payload.slug = f.slug; | |
| 41 | + | if (landingToggle) payload.show_on_landing = landingToggle.checked; | |
| 38 | 42 | ||
| 39 | 43 | fetch('/api/projects/' + projectId + '/blog', { | |
| 40 | 44 | method: 'POST', | |
| @@ -62,10 +66,12 @@ | |||
| 62 | 66 | postStatus.innerHTML = '<span style="color: var(--danger);">Slug is required</span>'; | |
| 63 | 67 | return; | |
| 64 | 68 | } | |
| 69 | + | var updatePayload = { title: f.title, slug: f.slug, body_markdown: f.body, is_published: publish }; | |
| 70 | + | if (landingToggle) updatePayload.show_on_landing = landingToggle.checked; | |
| 65 | 71 | fetch('/api/blog/' + postId, { | |
| 66 | 72 | method: 'PUT', | |
| 67 | 73 | headers: { 'Content-Type': 'application/json', ...csrfHeaders() }, | |
| 68 | - | body: JSON.stringify({ title: f.title, slug: f.slug, body_markdown: f.body, is_published: publish }) | |
| 74 | + | body: JSON.stringify(updatePayload) | |
| 69 | 75 | }) | |
| 70 | 76 | .then(function(res) { | |
| 71 | 77 | if (!res.ok) return apiErrorMessage(res, 'Failed to update post').then(function(m) { throw new Error(m); }); |
| @@ -5832,6 +5832,13 @@ textarea:focus-visible { | |||
| 5832 | 5832 | margin: 1.25rem 0 0; | |
| 5833 | 5833 | } | |
| 5834 | 5834 | ||
| 5835 | + | .landing-mobile-note { | |
| 5836 | + | text-align: center; | |
| 5837 | + | margin: 0.4rem 0 0; | |
| 5838 | + | font-size: 0.8rem; | |
| 5839 | + | color: var(--text-muted); | |
| 5840 | + | } | |
| 5841 | + | ||
| 5835 | 5842 | .founder-tagline { | |
| 5836 | 5843 | font-family: var(--font-body); | |
| 5837 | 5844 | font-size: 0.95rem; | |
| @@ -5845,6 +5852,24 @@ textarea:focus-visible { | |||
| 5845 | 5852 | gap: 0.5rem; | |
| 5846 | 5853 | } | |
| 5847 | 5854 | ||
| 5855 | + | .landing-velocity { | |
| 5856 | + | font-family: var(--font-mono); | |
| 5857 | + | font-size: 0.8rem; | |
| 5858 | + | text-align: center; | |
| 5859 | + | margin: 1rem auto 0; | |
| 5860 | + | color: var(--text-muted); | |
| 5861 | + | } | |
| 5862 | + | ||
| 5863 | + | .landing-velocity a { | |
| 5864 | + | color: var(--text); | |
| 5865 | + | text-decoration: none; | |
| 5866 | + | border-bottom: 1px solid var(--border); | |
| 5867 | + | } | |
| 5868 | + | ||
| 5869 | + | .landing-velocity a:hover { | |
| 5870 | + | border-bottom-color: var(--text); | |
| 5871 | + | } | |
| 5872 | + | ||
| 5848 | 5873 | .founder-tagline-detail { | |
| 5849 | 5874 | display: block; | |
| 5850 | 5875 | } |
| @@ -39,6 +39,15 @@ | |||
| 39 | 39 | <textarea id="post-body" rows="20" class="blog-editor-input input--sm w-full" placeholder="Write your post in Markdown...">{{ post_body }}</textarea> | |
| 40 | 40 | <button type="button" class="btn-secondary blog-editor-media-btn" onclick="mediaPickerOpen('post-body')">Insert Image</button> | |
| 41 | 41 | </div> | |
| 42 | + | {% if is_changelog_project %} | |
| 43 | + | <div class="form-group blog-editor-landing-toggle"> | |
| 44 | + | <label for="post-show-on-landing"> | |
| 45 | + | <input type="checkbox" id="post-show-on-landing"{% if post_show_on_landing %} checked{% endif %}> | |
| 46 | + | Show on landing | |
| 47 | + | </label> | |
| 48 | + | <p class="hint">Surfaces this post as the "Last shipped" line on the landing page. The most recent published post with this set wins; uncheck to retire it.</p> | |
| 49 | + | </div> | |
| 50 | + | {% endif %} | |
| 42 | 51 | <div class="blog-editor-actions"> | |
| 43 | 52 | <button class="btn-primary" id="save-draft-btn">{% if editing %}Save as Draft{% else %}Save Draft{% endif %}</button> | |
| 44 | 53 | <button class="btn-secondary" id="publish-btn">{% if editing && post_is_published %}Update{% else %}Publish{% endif %}</button> |
| @@ -29,6 +29,10 @@ | |||
| 29 | 29 | </p> | |
| 30 | 30 | {% endif %} | |
| 31 | 31 | ||
| 32 | + | {% if let Some(shipped) = last_shipped %} | |
| 33 | + | <p class="landing-velocity">Last shipped: <a href="{{ shipped.href }}">{{ shipped.title }}</a> · {{ shipped.date }}</p> | |
| 34 | + | {% endif %} | |
| 35 | + | ||
| 32 | 36 | <ul class="do-rail" aria-label="What we offer"> | |
| 33 | 37 | <li class="do-card"> | |
| 34 | 38 | <h3 class="do-card-title">A complete storefront</h3> | |
| @@ -137,6 +141,7 @@ | |||
| 137 | 141 | <p class="landing-browse-cta"> | |
| 138 | 142 | <a class="fork-secondary-link" href="/discover">Browse creators →</a> | |
| 139 | 143 | </p> | |
| 144 | + | <p class="landing-mobile-note">Works in any browser, on any phone. No app to install.</p> | |
| 140 | 145 | ||
| 141 | 146 | {% if total_creators > 0 || total_items > 0 %} | |
| 142 | 147 | <p class="landing-stats-line"> |
| @@ -293,3 +293,102 @@ async fn blog_post_list_respects_visibility() { | |||
| 293 | 293 | assert_eq!(resp.status, 200, "Published post should be accessible at {}", blog_url); | |
| 294 | 294 | assert!(resp.text.contains("Public Post"), "Page should contain the post title"); | |
| 295 | 295 | } | |
| 296 | + | ||
| 297 | + | /// The landing "Last shipped" velocity line: suppressed with no eligible post, | |
| 298 | + | /// surfaced for the most recent flagged changelog post, and never fed by | |
| 299 | + | /// landing-flagged posts on non-changelog projects. | |
| 300 | + | #[tokio::test] | |
| 301 | + | async fn landing_velocity_line() { | |
| 302 | + | let mut h = TestHarness::new().await; | |
| 303 | + | ||
| 304 | + | // State 1: no eligible post on a fresh platform — line is suppressed. | |
| 305 | + | let resp = h.client.get("/").await; | |
| 306 | + | assert_eq!(resp.status, 200, "Landing should render: {}", resp.text); | |
| 307 | + | assert!( | |
| 308 | + | !resp.text.contains("Last shipped:"), | |
| 309 | + | "Velocity line must be absent with zero eligible posts" | |
| 310 | + | ); | |
| 311 | + | ||
| 312 | + | // Creator owns both a non-changelog project and the changelog project. | |
| 313 | + | let user_id = h.signup("shipper", "shipper@example.com", "password123").await; | |
| 314 | + | h.grant_creator(user_id).await; | |
| 315 | + | h.client.post_form("/logout", "").await; | |
| 316 | + | h.login("shipper", "password123").await; | |
| 317 | + | ||
| 318 | + | // A non-changelog project with a landing-flagged, published post. | |
| 319 | + | let resp = h | |
| 320 | + | .client | |
| 321 | + | .post_form("/api/projects", "slug=updates&title=Updates") | |
| 322 | + | .await; | |
| 323 | + | let updates: Value = resp.json(); | |
| 324 | + | let updates_id = updates["id"].as_str().unwrap(); | |
| 325 | + | h.client | |
| 326 | + | .put_json(&format!("/api/projects/{}", updates_id), r#"{"is_public": true}"#) | |
| 327 | + | .await; | |
| 328 | + | let resp = h | |
| 329 | + | .client | |
| 330 | + | .post_json( | |
| 331 | + | &format!("/api/projects/{}/blog", updates_id), | |
| 332 | + | r#"{"title": "Off-topic note", "body_markdown": "Body", "is_published": true, "show_on_landing": true}"#, | |
| 333 | + | ) | |
| 334 | + | .await; | |
| 335 | + | assert!(resp.status.is_success(), "Create flagged non-changelog post failed: {}", resp.text); | |
| 336 | + | ||
| 337 | + | // State 2: a flagged post on a non-changelog project is ignored by the | |
| 338 | + | // landing reader (which filters by the changelog slug). | |
| 339 | + | h.client.post_form("/logout", "").await; | |
| 340 | + | let resp = h.client.get("/").await; | |
| 341 | + | assert!( | |
| 342 | + | !resp.text.contains("Last shipped:"), | |
| 343 | + | "Landing-flagged post on a non-changelog project must not surface" | |
| 344 | + | ); | |
| 345 | + | assert!( | |
| 346 | + | !resp.text.contains("Off-topic note"), | |
| 347 | + | "Non-changelog post title must not appear on the landing page" | |
| 348 | + | ); | |
| 349 | + | ||
| 350 | + | // The changelog project with a landing-flagged, published post. | |
| 351 | + | h.login("shipper", "password123").await; | |
| 352 | + | let resp = h | |
| 353 | + | .client | |
| 354 | + | .post_form("/api/projects", "slug=changelog&title=Changelog") | |
| 355 | + | .await; | |
| 356 | + | let changelog: Value = resp.json(); | |
| 357 | + | let changelog_id = changelog["id"].as_str().unwrap(); | |
| 358 | + | h.client | |
| 359 | + | .put_json(&format!("/api/projects/{}", changelog_id), r#"{"is_public": true}"#) | |
| 360 | + | .await; | |
| 361 | + | let resp = h | |
| 362 | + | .client | |
| 363 | + | .post_json( | |
| 364 | + | &format!("/api/projects/{}/blog", changelog_id), | |
| 365 | + | r#"{"title": "Shipped gallery widget", "body_markdown": "Body", "is_published": true, "show_on_landing": true}"#, | |
| 366 | + | ) | |
| 367 | + | .await; | |
| 368 | + | assert!(resp.status.is_success(), "Create flagged changelog post failed: {}", resp.text); | |
| 369 | + | let post: Value = resp.json(); | |
| 370 | + | let post_slug = post["slug"].as_str().unwrap(); | |
| 371 | + | assert_eq!( | |
| 372 | + | post["show_on_landing"].as_bool(), | |
| 373 | + | Some(true), | |
| 374 | + | "Create response should echo the landing flag" | |
| 375 | + | ); | |
| 376 | + | ||
| 377 | + | // State 3: the changelog post surfaces as the velocity line. | |
| 378 | + | h.client.post_form("/logout", "").await; | |
| 379 | + | let resp = h.client.get("/").await; | |
| 380 | + | assert!( | |
| 381 | + | resp.text.contains("Last shipped:"), | |
| 382 | + | "Velocity line should appear once a changelog post is flagged: {}", | |
| 383 | + | resp.text | |
| 384 | + | ); | |
| 385 | + | assert!( | |
| 386 | + | resp.text.contains("Shipped gallery widget"), | |
| 387 | + | "Velocity line should carry the post title" | |
| 388 | + | ); | |
| 389 | + | assert!( | |
| 390 | + | resp.text.contains(&format!("/changelog/{}", post_slug)), | |
| 391 | + | "Velocity line should link to /changelog/{}", | |
| 392 | + | post_slug | |
| 393 | + | ); | |
| 394 | + | } |