From b6006df384659508b89ec633fbb550e7c9839718 Mon Sep 17 00:00:00 2001 From: Brenda Wallace Date: Wed, 28 Oct 2026 07:45:55 +1300 Subject: [PATCH 1/6] feat: Implement draft autosave and preview functionality in PostEditor - Added useDraftAutosave hook to handle automatic saving of post drafts. - Enhanced PostEditor component to include a preview tab that fetches and displays rendered markdown. - Updated Post model to manage draft state and associated published_at timestamp. - Introduced card_photo association to Post for representing a primary image. - Modified post form to support draft saving and publishing actions. - Updated navigation to include links to drafts. - Added tests for draft saving, preview functionality, and card photo validation. - Created migrations for new database fields related to drafts and card photos. --- app/assets/stylesheets/_posts.scss | 120 ++++++++++++++++++ app/controllers/photos_controller.rb | 5 +- app/controllers/posts_controller.rb | 36 +++++- app/helpers/photos_helper.rb | 13 ++ app/javascript/components/CardPhotoDialog.jsx | 90 +++++++++++++ .../components/CardPhotoDialog.test.jsx | 76 +++++++++++ app/javascript/components/PostEditor.jsx | 34 ++++- app/javascript/components/PostEditor.test.jsx | 72 ++++++++++- app/javascript/components/useDraftAutosave.js | 85 +++++++++++++ app/javascript/react_islands.jsx | 3 +- app/models/post.rb | 48 ++++++- app/views/layouts/_nav.haml | 6 +- app/views/posts/_body.html.haml | 3 + app/views/posts/_form.html.haml | 111 +++++++++++----- app/views/posts/_preview.haml | 2 + app/views/posts/_single.html.haml | 2 + app/views/posts/show.html.haml | 21 +++ config/locales/en.yml | 6 + config/routes.rb | 1 + .../20261027000000_add_card_photo_to_posts.rb | 8 ++ ...0261027000100_add_published_at_to_posts.rb | 14 ++ db/schema.rb | 7 +- spec/features/forums_spec.rb | 2 +- spec/features/posts/posting_a_post_spec.rb | 27 +++- spec/models/post_spec.rb | 111 ++++++++++++++++ spec/requests/post_spec.rb | 114 +++++++++++++++++ spec/views/posts/show.html.haml_spec.rb | 52 ++++++++ 27 files changed, 1018 insertions(+), 51 deletions(-) create mode 100644 app/javascript/components/CardPhotoDialog.jsx create mode 100644 app/javascript/components/CardPhotoDialog.test.jsx create mode 100644 app/javascript/components/useDraftAutosave.js create mode 100644 app/views/posts/_body.html.haml create mode 100644 db/migrate/20261027000000_add_card_photo_to_posts.rb create mode 100644 db/migrate/20261027000100_add_published_at_to_posts.rb diff --git a/app/assets/stylesheets/_posts.scss b/app/assets/stylesheets/_posts.scss index f533ecb01..5eb986d48 100644 --- a/app/assets/stylesheets/_posts.scss +++ b/app/assets/stylesheets/_posts.scss @@ -67,3 +67,123 @@ font-family: monospace; } } + +// The photo that leads a post's page. +.post-card-photo { + img { + display: block; + width: 100%; + max-height: 260px; + object-fit: cover; + } +} + +// "More from this author" in the post sidebar. +.more-from-author { + .post-compact { + text-decoration: none; + } + + .post-compact-photo { + width: 56px; + height: 56px; + flex: none; + object-fit: cover; + border-radius: 2px; + } +} + +// The choices in the card photo dialog. +.card-photo-choices { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + gap: 16px; + padding: 16px; + background: #f4f2ef; + border: 1px solid rgba(65, 63, 59, .15); + border-radius: 4px; +} + +.card-photo-choice { + position: relative; + cursor: pointer; + + input { + position: absolute; + opacity: 0; + inset: 0; + width: 100%; + height: 100%; + margin: 0; + cursor: pointer; + } + + .card-photo-polaroid { + display: block; + padding: 6px 6px 0; + background: #fff; + font-size: 12px; + box-shadow: 0 1px 3px rgba(65, 63, 59, .25); + outline: 3px solid transparent; + outline-offset: 2px; + + img { + display: block; + width: 100%; + height: 90px; + object-fit: cover; + } + + span { + display: block; + padding: 6px 2px 8px; + } + } + + input:checked + .card-photo-polaroid { + outline-color: #57803c; + } + + input:focus-visible + .card-photo-polaroid { + outline-color: #2f4365; + } +} + +.card-photo-preview { + display: flex; + gap: 16px; + align-items: flex-start; + + img { + width: 200px; + height: 130px; + flex: none; + object-fit: cover; + border-radius: 2px; + } +} + +.post-card-photo-change { + display: flex; + justify-content: flex-end; + padding: 8px 16px 0; +} + +.post-editor { + .post-editor-preview { + min-height: 320px; + padding: 16px 20px; + } +} + +// Saving and publishing, always in reach at the foot of the editor page. +.post-save-bar { + position: sticky; + bottom: 0; + z-index: 10; + margin-top: 24px; + padding: 12px 16px; + background: #fff; + border-top: 1px solid rgba(65, 63, 59, .35); + box-shadow: 0 -2px 6px rgba(0, 0, 0, .08); +} diff --git a/app/controllers/photos_controller.rb b/app/controllers/photos_controller.rb index 8f9d3eb9a..228e60045 100644 --- a/app/controllers/photos_controller.rb +++ b/app/controllers/photos_controller.rb @@ -123,7 +123,10 @@ class PhotosController < ApplicationController item_class = params[:type].capitalize raise "Photos not supported" unless Photo::PHOTO_CAPABLE.include? item_class - item_class.constantize.find(params[:id]) + klass = item_class.constantize + # A draft post is still its author's to add photos to. + klass = klass.with_drafts if klass.respond_to?(:with_drafts) + klass.find(params[:id]) end # diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb index 85c947b9f..702d2fd40 100644 --- a/app/controllers/posts_controller.rb +++ b/app/controllers/posts_controller.rb @@ -2,7 +2,8 @@ class PostsController < ApplicationController before_action :authenticate_member!, except: %i(index show) - load_and_authorize_resource + before_action :find_post, only: %i(show edit update destroy) + load_and_authorize_resource except: :preview responders :flash respond_to :html, :json respond_to :rss, only: %i(index show) @@ -14,7 +15,6 @@ class PostsController < ApplicationController end def show - @post = Post.includes(:author).find(params[:id]) respond_with(@post) end @@ -30,15 +30,24 @@ class PostsController < ApplicationController def edit; end + # The body as it will look when posted, for the editor's Preview tab. + def preview + authorize! :create, Post + render partial: 'posts/body', formats: [:html], locals: { body: params[:body].to_s } + end + def create params[:post][:author_id] = current_member.id @post = Post.new(post_params) - flash[:notice] = t('posts.created') if @post.save + flash[:notice] = t(@post.draft? ? 'posts.draft_saved' : 'posts.created') if @post.save respond_with(@post) end def update - flash[:notice] = t('posts.updated') if @post.update(post_params) + was_draft = @post.draft? + if @post.update(post_params) + flash[:notice] = t(was_draft && @post.published? ? 'posts.created' : 'posts.updated') + end respond_with(@post) end @@ -49,13 +58,28 @@ class PostsController < ApplicationController private + # A draft is only its author's: to anyone else it does not exist. + def find_post + @post = Post.with_drafts.includes(:author).find(params[:id]) + raise ActiveRecord::RecordNotFound if @post.draft? && @post.author != current_member + end + def post_params - params.require(:post).permit(:body, :subject, :author_id, :forum_id) + params.require(:post).permit(:body, :subject, :author_id, :forum_id, :card_photo_id, :draft) + end + + # All of their own posts with drafts among them, or only the drafts. + def own_posts + return @author.posts unless @author == current_member + + mine = Post.with_drafts.where(author: @author) + params[:drafts].present? ? mine.where(published_at: nil) : mine end def posts if @author - @author.posts + # Members see their own drafts on their own page. + own_posts else Post end.order(created_at: :desc) diff --git a/app/helpers/photos_helper.rb b/app/helpers/photos_helper.rb index 9b24f1716..64d81dba2 100644 --- a/app/helpers/photos_helper.rb +++ b/app/helpers/photos_helper.rb @@ -57,6 +57,19 @@ module PhotosHelper post.default_photo&.thumbnail_url || post.crops.first&.default_photo&.thumbnail_url || placeholder_image end + # What the CardPhotoDialog island needs: where to save the choice, the post's + # photos, and enough of the post to draw how its card will look. + def card_photo_dialog_props(post) + { + url: post_path(post), + subject: post.subject, + excerpt: truncate(strip_tags(markdownify(post.body)), length: 90, separator: ' '), + byline: "by #{post.author} · #{l(post.created_at.to_date, format: :short)}", + selectedId: post.card_photo_id, + photos: post.photos.map { |p| { id: p.id, title: p.title, thumbnail_url: p.thumbnail_url, fullsize_url: p.fullsize_url } } + } + end + private def photo_or_placeholder(item) diff --git a/app/javascript/components/CardPhotoDialog.jsx b/app/javascript/components/CardPhotoDialog.jsx new file mode 100644 index 000000000..d6cc9620a --- /dev/null +++ b/app/javascript/components/CardPhotoDialog.jsx @@ -0,0 +1,90 @@ +import React, {useState} from 'react'; + +import {patchJson} from '../api'; +import Modal from './Modal'; + +// A button on a post's page that opens a dialog for choosing the card photo: +// the photo that stands for the post on the posts list, an author's page and +// shared links. The photos are the post's own. Saving reloads, because the +// page is server rendered. +export default function CardPhotoDialog({url, photos, selectedId = null, subject, excerpt = '', byline = ''}) { + const [open, setOpen] = useState(false); + const [chosenId, setChosenId] = useState(selectedId || (photos[0] && photos[0].id)); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + const chosen = photos.find((p) => p.id === chosenId); + + async function save() { + setSaving(true); + setError(null); + const {ok} = await patchJson(url, {post: {card_photo_id: chosenId}}); + if (ok) { + setTimeout(() => window.location.reload(), 0); + return; + } + setSaving(false); + setError('The photo could not be saved. Try again.'); + } + + return ( + <> + + {open && ( + setOpen(false)}> +
+

+ This photo stands for your post wherever it appears as a card: the posts list, your page and links shared + elsewhere. +

+ {photos.length === 0 ? ( +

Add a photo to this post first, then you can choose it here.

+ ) : ( +
+ Photos in this post +
+ {photos.map((photo) => ( + + ))} +
+
+ )} + {chosen && ( + <> +

How it will look

+
+ Preview of the card photo +
+ {subject} +

{excerpt}

+ {byline} +
+
+ + )} + {error &&
{error}
} +
+
+ + +
+
+ )} + + ); +} diff --git a/app/javascript/components/CardPhotoDialog.test.jsx b/app/javascript/components/CardPhotoDialog.test.jsx new file mode 100644 index 000000000..c9925297c --- /dev/null +++ b/app/javascript/components/CardPhotoDialog.test.jsx @@ -0,0 +1,76 @@ +import {render, screen, waitFor} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import {afterEach, describe, expect, it, vi} from 'vitest'; + +import * as api from '../api'; +import CardPhotoDialog from './CardPhotoDialog'; + +const photos = [ + {id: 1, title: 'Fermenting jar', thumbnail_url: '/jar-thumb.jpg', fullsize_url: '/jar.jpg'}, + {id: 2, title: 'Drying on a plate', thumbnail_url: '/plate-thumb.jpg', fullsize_url: '/plate.jpg'}, +]; + +const props = {url: '/posts/7', photos, subject: 'Saving tomato seed', excerpt: 'The tomatoes', byline: 'by brenda'}; + +async function openDialog(extra = {}) { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole('button', {name: 'Change'})); + return user; +} + +describe('CardPhotoDialog', () => { + afterEach(() => vi.restoreAllMocks()); + + it('is closed until Change is clicked', () => { + render(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('lists the photos in the post', async () => { + await openDialog(); + expect(screen.getAllByRole('radio')).toHaveLength(2); + }); + + it('selects the current card photo', async () => { + await openDialog({selectedId: 2}); + expect(screen.getByRole('radio', {name: /Drying on a plate/})).toBeChecked(); + }); + + it('selects the first photo when none is chosen', async () => { + await openDialog(); + expect(screen.getByRole('radio', {name: /Fermenting jar/})).toBeChecked(); + }); + + it('previews the chosen photo', async () => { + const user = await openDialog(); + await user.click(screen.getByRole('radio', {name: /Drying on a plate/})); + expect(screen.getByAltText('Preview of the card photo')).toHaveAttribute('src', '/plate.jpg'); + }); + + it('saves the chosen photo on the post', async () => { + const patch = vi.spyOn(api, 'patchJson').mockResolvedValue({ok: false, status: 422, data: null}); + const user = await openDialog(); + await user.click(screen.getByRole('radio', {name: /Drying on a plate/})); + await user.click(screen.getByRole('button', {name: 'Use this photo'})); + await waitFor(() => expect(patch).toHaveBeenCalledWith('/posts/7', {post: {card_photo_id: 2}})); + }); + + it('shows an error when saving fails', async () => { + vi.spyOn(api, 'patchJson').mockResolvedValue({ok: false, status: 422, data: null}); + const user = await openDialog(); + await user.click(screen.getByRole('button', {name: 'Use this photo'})); + expect(await screen.findByRole('alert')).toHaveTextContent('could not be saved'); + }); + + it('asks for a photo first when the post has none', async () => { + await openDialog({photos: []}); + expect(screen.getByText(/Add a photo to this post first/)).toBeInTheDocument(); + }); + + it('closes on Cancel', async () => { + const user = await openDialog(); + await user.click(screen.getByRole('button', {name: 'Cancel'})); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); +}); diff --git a/app/javascript/components/PostEditor.jsx b/app/javascript/components/PostEditor.jsx index b627ff5cd..479736c07 100644 --- a/app/javascript/components/PostEditor.jsx +++ b/app/javascript/components/PostEditor.jsx @@ -3,6 +3,9 @@ import {EditorContent, useEditor, useEditorState} from '@tiptap/react'; import StarterKit from '@tiptap/starter-kit'; import {Markdown} from '@tiptap/markdown'; +import {csrfToken} from '../api'; +import useDraftAutosave from './useDraftAutosave'; + const BLOCKS = [ {value: 'paragraph', label: 'Paragraph'}, {value: 'h2', label: 'Heading'}, @@ -90,9 +93,28 @@ function Toolbar({editor}) { // format without knowing it. The Markdown tab shows and edits the raw text. // The markdown goes in a hidden field called `name`, so the surrounding form // submits it as it would a textarea. -export default function PostEditor({name, value = '', id = 'post_body'}) { +// +// With `autosave` (see useDraftAutosave) the post is saved as a draft as it is +// written. With `previewUrl` a Preview tab shows the body as the site will +// render it. +export default function PostEditor({name, value = '', id = 'post_body', autosave = null, previewUrl = null}) { const [markdown, setMarkdown] = useState(value); const [tab, setTab] = useState('write'); + const [previewHtml, setPreviewHtml] = useState(''); + const status = useDraftAutosave(markdown, autosave); + + const tabs = [['write', 'Write'], previewUrl && ['preview', 'Preview'], ['markdown', 'Markdown']].filter(Boolean); + + async function loadPreview() { + setPreviewHtml(''); + const response = await fetch(previewUrl, { + method: 'POST', + credentials: 'same-origin', + headers: {'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken()}, + body: JSON.stringify({body: markdown}), + }); + setPreviewHtml(response.ok ? await response.text() : '

The preview could not be loaded.

'); + } const editor = useEditor({ extensions: [StarterKit, Markdown], @@ -106,6 +128,7 @@ export default function PostEditor({name, value = '', id = 'post_body'}) { if (next === 'write' && editor) { editor.commands.setContent(markdown, {contentType: 'markdown'}); } + if (next === 'preview') loadPreview(); setTab(next); }; @@ -113,7 +136,7 @@ export default function PostEditor({name, value = '', id = 'post_body'}) {
- {[['write', 'Write'], ['markdown', 'Markdown']].map(([key, label]) => ( + {tabs.map(([key, label]) => (
{tab === 'write' && editor && ( @@ -133,6 +159,10 @@ export default function PostEditor({name, value = '', id = 'post_body'}) { )} + {tab === 'preview' && ( + // The server renders (and sanitises) the markdown, as it does for a posted post. +
+ )} {tab === 'markdown' && (