diff --git a/app/assets/stylesheets/_posts.scss b/app/assets/stylesheets/_posts.scss
index f533ecb01..b3b745314 100644
--- a/app/assets/stylesheets/_posts.scss
+++ b/app/assets/stylesheets/_posts.scss
@@ -67,3 +67,300 @@
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: #212121;
+ }
+
+ .card-photo-star {
+ color: #57803c;
+ }
+
+ .card-photo-tick {
+ display: none;
+ position: absolute;
+ top: 14px;
+ left: 14px;
+ padding: 2px 8px;
+ border-radius: 999px;
+ background: #212121;
+ color: #fff;
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: .04em;
+ }
+
+ input:checked ~ .card-photo-tick {
+ display: inline-flex;
+ }
+
+ 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);
+}
+
+// The editor page: the form, its sidebar and the save bar share one width.
+.post-edit-page {
+ max-width: 1140px;
+ margin: 0 auto;
+}
+
+// Links to crops, members and plantings in the editor, and the list for choosing them.
+.post-editor-content .link-chip {
+ padding: 0 8px;
+ border: 1px solid rgba(65, 63, 59, .15);
+ border-radius: 999px;
+ background: #e8f5e9;
+ color: #413f3b;
+ font-weight: 600;
+ cursor: default;
+
+ &.ProseMirror-selectednode {
+ outline: 2px solid #2f4365;
+ }
+}
+
+.link-suggestions {
+ z-index: 1080;
+ width: 340px;
+ max-height: 340px;
+ overflow-y: auto;
+ background: #fff;
+ border: 1px solid rgba(65, 63, 59, .15);
+ border-radius: 4px;
+
+ .link-suggestions-group {
+ padding: 6px 14px;
+ background: #f4f2ef;
+ border-bottom: 1px solid rgba(65, 63, 59, .15);
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: .06em;
+ text-transform: uppercase;
+ color: #413f3b;
+ }
+
+ .link-suggestions-item {
+ padding: 8px 14px;
+ cursor: pointer;
+
+ &.active,
+ &:hover {
+ background: #f4f2ef;
+ }
+ }
+
+ .link-suggestions-hint {
+ padding: 8px 14px;
+ border-top: 1px solid rgba(65, 63, 59, .15);
+ font-size: 12px;
+ }
+}
+
+// Photos placed in a post's text, on the post page.
+.post-content .post-figure {
+ margin: 22px 0;
+ padding: 8px 8px 0;
+ background: #fff;
+ box-shadow: 0 1px 3px rgba(65, 63, 59, .25);
+ max-width: 720px;
+
+ img {
+ display: block;
+ width: 100%;
+ }
+
+ .post-figure-caption {
+ margin: 0;
+ padding: 8px 4px 10px;
+ font-size: 13px;
+ color: #413f3b;
+ }
+
+ &.post-figure-half {
+ max-width: 350px;
+ }
+
+ // Two pair photos in a row sit side by side.
+ &.post-figure-pair {
+ display: inline-block;
+ vertical-align: top;
+ width: calc(50% - 10px);
+ max-width: 350px;
+ margin-right: 16px;
+ }
+}
+
+// A photo in the editor: the polaroid, with its controls beside it.
+.photo-figure {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 20px;
+ align-items: flex-start;
+ margin: 20px 0 8px;
+
+ &.photo-figure-selected .photo-figure-picture {
+ outline: 2px solid #2f4365;
+ outline-offset: 4px;
+ }
+
+ .photo-figure-picture {
+ flex: 1 1 320px;
+ max-width: 460px;
+ }
+
+ .photo-figure-half,
+ .photo-figure-pair {
+ max-width: 230px;
+ }
+
+ .photo-figure-polaroid {
+ padding: 8px 8px 0;
+ background: #fff;
+ box-shadow: 0 1px 3px rgba(65, 63, 59, .25);
+
+ img {
+ display: block;
+ width: 100%;
+ }
+ }
+
+ .photo-figure-missing {
+ padding: 40px 0;
+ text-align: center;
+ background: #f4f2ef;
+ }
+
+ .photo-figure-caption {
+ padding: 8px 4px 10px;
+ font-size: 13px;
+ color: #413f3b;
+ }
+
+ .photo-figure-size {
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ align-items: center;
+ gap: 4px;
+ }
+
+ .photo-figure-controls {
+ flex: 1 1 220px;
+ padding: 12px 14px;
+ background: #f4f2ef;
+ border: 1px solid rgba(65, 63, 59, .15);
+ border-radius: 2px;
+ }
+}
diff --git a/app/controllers/photos_controller.rb b/app/controllers/photos_controller.rb
index 2da4aaa7a..6d9c724a4 100644
--- a/app/controllers/photos_controller.rb
+++ b/app/controllers/photos_controller.rb
@@ -101,7 +101,9 @@ class PhotosController < ApplicationController
def render_added_json
garden = @item.is_a?(Garden) ? @item : (@item.garden if @item.respond_to?(:garden))
card = GardenCardSerializer.collection([garden], ability: current_ability, show_owner: false).first if garden
- render json: { photo: { id: @photo.id, url: photo_path(@photo) }, garden: card }, status: :created
+ photo = { id: @photo.id, url: photo_path(@photo), title: @photo.title,
+ thumbnail_url: @photo.thumbnail_url, fullsize_url: @photo.fullsize_url }
+ render json: { photo:, garden: card }, status: :created
end
def photo_params
@@ -125,7 +127,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..96a26cf00 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: %i(preview link_suggestions)
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,32 @@ class PostsController < ApplicationController
def edit; end
+ # What the editor's "link to" list offers besides crops (which it gets from
+ # /crops/search.json): members, and the plantings of the member writing.
+ def link_suggestions
+ authorize! :create, Post
+ term = params[:term].to_s.strip
+ render json: { members: member_suggestions(term), plantings: planting_suggestions(term) }
+ 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 +66,47 @@ class PostsController < ApplicationController
private
+ SUGGESTIONS = 5
+
+ def member_suggestions(term)
+ return [] if term.blank?
+
+ Member.kept.confirmed.where('login_name ILIKE ?', "#{Member.sanitize_sql_like(term)}%")
+ .order(:login_name).limit(SUGGESTIONS).map { |member| { name: member.login_name } }
+ end
+
+ def planting_suggestions(term)
+ return [] if term.blank?
+
+ like = "#{Planting.sanitize_sql_like(term)}%"
+ current_member.plantings.joins(:crop, :garden).includes(:crop, :garden)
+ .where('crops.name ILIKE :like OR gardens.name ILIKE :like', like:)
+ .order(created_at: :desc).limit(SUGGESTIONS)
+ .map { |planting| { slug: planting.slug, name: planting.to_s, crop: planting.crop.name } }
+ end
+
+ # 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/application_helper.rb b/app/helpers/application_helper.rb
index 14a909efd..529578c2c 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -92,10 +92,16 @@ module ApplicationHelper
# A helper to replace the complex template compilation mess
# of HAML, Tilt, and dynamic compilation with interpolated ruby.
- def markdownify(text)
+ #
+ # Photos placed in a post are only drawn with `figures: true`, which is for
+ # the post itself, and must be given text that has already had its tags
+ # stripped, as the figures are HTML.
+ def markdownify(text, figures: false)
translator = Haml::Filters::GrowstuffMarkdown.new
text = text.to_s.dup
+ translator.expand_photos!(text, figures:)
translator.expand_crops!(text)
+ translator.expand_plantings!(text)
translator.expand_members!(text)
text
end
diff --git a/app/helpers/photos_helper.rb b/app/helpers/photos_helper.rb
index 9b24f1716..cf45415b0 100644
--- a/app/helpers/photos_helper.rb
+++ b/app/helpers/photos_helper.rb
@@ -57,6 +57,26 @@ 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,
+ addPhotoUrl: new_photo_path_for(post),
+ addPhotoLabel: photo_label(post),
+ photos: post.photos.map { |p| { id: p.id, title: p.title, thumbnail_url: p.thumbnail_url, fullsize_url: p.fullsize_url } }
+ }
+ end
+
+ # A post's photos, as the editor's islands draw them.
+ def post_photo_props(post)
+ 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..4951e374b
--- /dev/null
+++ b/app/javascript/components/CardPhotoDialog.jsx
@@ -0,0 +1,109 @@
+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 = '', addPhotoUrl = null, addPhotoLabel = '',
+}) {
+ 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)}>
+
+
+ Photos come from your Flickr account. This photo stands for your post wherever it appears as a card: the
+ posts list, your page and links shared elsewhere.
+
+ {photos.length === 0 ? (
+
Pick a photo from Flickr for this post first, then you can choose it here.
↑↓ to choose · Enter to link · Esc to keep it as text
+
+ );
+}
diff --git a/app/javascript/components/LinkSuggestionList.test.jsx b/app/javascript/components/LinkSuggestionList.test.jsx
new file mode 100644
index 000000000..c921544c2
--- /dev/null
+++ b/app/javascript/components/LinkSuggestionList.test.jsx
@@ -0,0 +1,42 @@
+import {render, screen} from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import {describe, expect, it, vi} from 'vitest';
+
+import LinkSuggestionList from './LinkSuggestionList';
+
+const items = [
+ {group: 'Crops', kind: 'crop', label: 'Pumpkin'},
+ {group: 'Crops', kind: 'crop', label: 'Pumpkin, Crown Prince'},
+ {group: 'Members', kind: 'member', label: 'pumpkinfan'},
+ {group: 'Yours', kind: 'planting', label: 'Pumpkin in Patch', slug: 'a-patch-pumpkin', detail: 'Planting'},
+];
+const rect = {left: 10, bottom: 20};
+
+describe('LinkSuggestionList', () => {
+ it('lists every match', () => {
+ render( {}} />);
+ expect(screen.getAllByRole('option')).toHaveLength(4);
+ });
+
+ it('says what each group is', () => {
+ render( {}} />);
+ expect(screen.getAllByText(/^(Crops|Members|Yours)$/)).toHaveLength(3);
+ });
+
+ it('marks the highlighted match', () => {
+ render( {}} />);
+ expect(screen.getByRole('option', {name: /pumpkinfan/})).toHaveAttribute('aria-selected', 'true');
+ });
+
+ it('chooses a match with the mouse', async () => {
+ const onChoose = vi.fn();
+ render();
+ await userEvent.click(screen.getByRole('option', {name: /Pumpkin in Patch/}));
+ expect(onChoose).toHaveBeenCalledWith(items[3]);
+ });
+
+ it('draws nothing without matches', () => {
+ const {container} = render( {}} />);
+ expect(container).toBeEmptyDOMElement();
+ });
+});
diff --git a/app/javascript/components/PostEditor.jsx b/app/javascript/components/PostEditor.jsx
index b627ff5cd..84d31b6f5 100644
--- a/app/javascript/components/PostEditor.jsx
+++ b/app/javascript/components/PostEditor.jsx
@@ -1,8 +1,15 @@
-import React, {useState} from 'react';
+import React, {useMemo, useRef, useState} from 'react';
import {EditorContent, useEditor, useEditorState} from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import {Markdown} from '@tiptap/markdown';
+import {csrfToken, patchJson} from '../api';
+import AddPhotoModal from './AddPhotoModal';
+import LinkSuggestionList from './LinkSuggestionList';
+import {createLinkChip, createLinkSearch} from './linkChip';
+import photoFigure from './photoFigure';
+import useDraftAutosave from './useDraftAutosave';
+
const BLOCKS = [
{value: 'paragraph', label: 'Paragraph'},
{value: 'h2', label: 'Heading'},
@@ -24,7 +31,7 @@ function ToolButton({label, active, onClick, children, ...rest}) {
);
}
-function Toolbar({editor}) {
+function Toolbar({editor, onAddPhoto}) {
const state = useEditorState({
editor,
selector: ({editor: e}) => ({
@@ -82,30 +89,152 @@ function Toolbar({editor}) {
>
❝ Quote
+ {onAddPhoto && (
+ + Flickr photo
+ )}
+ {/* Typing @ opens the list of things to link to, so this just types it. */}
+ editor.chain().focus().insertContent('@').run()}>
+ @ Link to…
+
);
}
+const search = createLinkSearch();
+
+function suggestionState(props) {
+ return {items: props.items, selected: 0, rect: props.clientRect && props.clientRect(), command: props.command};
+}
+
// A rich text editor for a post body that stores markdown, so people can
// 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.
+//
+// With `canAddPhoto`, the toolbar picks a Flickr photo for the post and places
+// it in the text. `photos` are the post's photos so far ({id, title,
+// thumbnail_url, fullsize_url}), `postPath` is where an existing post lives
+// (a draft not yet saved has none: it is saved first), and `cardPhotoId` is the
+// photo that stands for the post.
+export default function PostEditor({
+ name, value = '', id = 'post_body', autosave = null, previewUrl = null,
+ canAddPhoto = false, photos = [], postPath = null, cardPhotoId = null,
+}) {
const [markdown, setMarkdown] = useState(value);
const [tab, setTab] = useState('write');
+ const [previewHtml, setPreviewHtml] = useState('');
+ const {status, saveNow} = useDraftAutosave(markdown, autosave);
+ const [notice, setNotice] = useState('');
+ const [pickerUrl, setPickerUrl] = useState(null); // the add photo dialog's address, while it is open
+
+ // The photos and card photo the figures in the text look up as they draw.
+ const photoList = useRef(photos);
+ const cardId = useRef(cardPhotoId);
+ const editorRef = useRef(null);
+
+ // Where the post lives, saving the draft first if it is a new one. Null when
+ // there is nothing to save yet (it needs a title and some text).
+ async function ensurePostPath() {
+ return autosave ? saveNow() : postPath;
+ }
+
+ async function makeCardPhoto(photoId) {
+ const path = await ensurePostPath();
+ if (!path) return false;
+ const {ok} = await patchJson(path, {post: {card_photo_id: photoId}});
+ if (ok) cardId.current = photoId;
+ return ok;
+ }
+
+ async function openPhotoPicker() {
+ setNotice('');
+ const path = await ensurePostPath();
+ if (!path) {
+ setNotice('The draft could not be saved, so a photo cannot be added yet. Try again.');
+ return;
+ }
+ setPickerUrl(`/photos/new?type=post&id=${path.split('/').pop()}`);
+ }
+
+ function photoAdded({photo}) {
+ setPickerUrl(null);
+ photoList.current = [...photoList.current, photo];
+ // After the current line, or after the photo that is selected: inserting
+ // over a selected photo would replace it, and only one could ever be added.
+ const editor = editorRef.current;
+ const {selection} = editor.state;
+ const position = selection.node ? selection.to : selection.from;
+ editor
+ .chain()
+ .focus()
+ .insertContentAt(position, {type: 'photoFigure', attrs: {photoId: photo.id, caption: photo.title, size: 'wide'}})
+ .run();
+ }
+
+ const figure = useMemo(() => photoFigure.configure({
+ getPhoto: (photoId) => photoList.current.find((p) => p.id === photoId),
+ getCardPhotoId: () => cardId.current,
+ setCardPhoto: (photoId) => makeCardPhoto(photoId),
+ }), []); // eslint-disable-line react-hooks/exhaustive-deps
+
+ 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() : '