max / makenotwork
- Co-Authored-By
- Claude Opus 4.8 (1M context) <noreply@anthropic.com>
14 files changed,
+263 insertions,
-4 deletions
| @@ -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 @@ | |||
| 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 @@ | |||
| 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 | } |
| @@ -21,13 +21,14 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 |
| @@ -277,6 +277,12 @@ | |||
| 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). |