mirror of
https://github.com/Growstuff/growstuff.git
synced 2026-09-29 09:25:03 -04:00
* Restore the Ruby 4.0.6 upgrade PR #4831 committed a stale Gemfile.lock over the newer one, taking .ruby-version back to 3.2.3 and rolling back active_median, dalli, parallel, rbs and selenium-webdriver. Dockerfile and .devcontainer/Dockerfile still build on Ruby 4, so the Gemfile's version check fails against the image. Restores both files to what they were before that merge. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Upgrade Font Awesome from 5.15.1 to 6.7.2 Font names have moved on twice since this was pinned: the codebase still carried Font Awesome 4 names that render as an empty box, and FA6 icons such as fa-ellipsis-vertical were unavailable. The 6.x gem no longer ships _font-awesome-sprockets.scss, but its @font-face rules still call fa-font-path(), so application.scss defines that against Sprockets' font-path helper. Its fonts live under assets/fonts/font-awesome, which is already in the precompile list. Icon names: - The bare "fa" prefix is not a style in 6. Every "fa fa-<name>" becomes "fas fa-<name>" (38 call sites, all solid; the one brands icon already used fab). - fa-sun-o was a Font Awesome 4 name and had been rendering as a box; now fa-sun. - fa-pencil was likewise broken under 5, where it was fa-pencil-alt. It exists again in 6, so the existing name now works. - activity_icon asked for 'fa-truck-pickup' through a helper that already adds the prefix, so it emitted "fa-fa-truck-pickup". Verified that the solid, regular and brands fonts all resolve through the asset pipeline. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * couple more places to upgrade font awesome --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
257 lines
12 KiB
React
257 lines
12 KiB
React
import React, {useCallback, useEffect, useRef, useState} from 'react';
|
|
|
|
import {getJson, postJson} from '../api';
|
|
import Modal from './Modal';
|
|
import Steps from './Steps';
|
|
|
|
// Flickr's own sign-in can't be shown inside the dialog, so it opens in a small
|
|
// window, and lands on a page that closes itself (see AuthenticationsController#connected).
|
|
const CONNECT_URL = `/members/auth/flickr?origin=${encodeURIComponent('/authentications/connected')}`;
|
|
const POLL_MS = 2000;
|
|
|
|
function errorMessages(status, data) {
|
|
if (status === 422 && data && data.errors) {
|
|
const messages = Object.entries(data.errors).flatMap(([field, list]) => list.map((m) => `${field.replace(/_/g, ' ').replace(/^./, (c) => c.toUpperCase())} ${m}`));
|
|
if (messages.length > 0) return messages;
|
|
}
|
|
if (status === 401) return ['Please sign in again to add this photo.'];
|
|
return ['Something went wrong adding that photo. Please try again.'];
|
|
}
|
|
|
|
// Adding a photo to a planting or garden, as a dialog over the garden list. The
|
|
// photos are the member's own, from Flickr, so if Flickr isn't connected (or the
|
|
// connection has expired) the first step connects it, here, in a pop-up; the dialog
|
|
// notices when that has happened. Then choose a photo (by album or tag, a page at a
|
|
// time), confirm it, and it is added and the garden's card refreshed, so the page
|
|
// is never left. `newUrl` is the menu item's own link, /photos/new?type=..&id=...
|
|
export default function AddPhotoModal({label, newUrl, iconUrl, onClose, onAdded}) {
|
|
const target = new URL(newUrl, window.location.origin).searchParams;
|
|
const type = target.get('type');
|
|
const id = target.get('id');
|
|
|
|
const [data, setData] = useState(null); // {state: connect | reconnect | ready, photos, sets, page, total_pages, ...}
|
|
const [filters, setFilters] = useState({set: '', tag: '', page: 1});
|
|
const [tagInput, setTagInput] = useState('');
|
|
const [loading, setLoading] = useState(true);
|
|
const [loadError, setLoadError] = useState(false);
|
|
const [connectFirst, setConnectFirst] = useState(false); // opened without Flickr connected
|
|
const [waiting, setWaiting] = useState(false); // the connect window is open
|
|
const [blocked, setBlocked] = useState(false); // ...but the browser would not open it
|
|
const [chosen, setChosen] = useState(null);
|
|
const [saving, setSaving] = useState(false);
|
|
const [errors, setErrors] = useState([]);
|
|
const popup = useRef(null);
|
|
|
|
const load = useCallback(async (next, signal) => {
|
|
const query = new URLSearchParams({type, id});
|
|
if (next.set) query.set('set', next.set);
|
|
if (next.tag) query.set('tag', next.tag);
|
|
if (next.page > 1) query.set('page', next.page);
|
|
const {ok, data: result} = await getJson(`/photos/new.json?${query}`, {signal});
|
|
if (!ok) throw new Error('load failed');
|
|
return result;
|
|
}, [type, id]);
|
|
|
|
useEffect(() => {
|
|
const controller = new AbortController();
|
|
setLoading(true);
|
|
(async () => {
|
|
try {
|
|
const result = await load(filters, controller.signal);
|
|
setData(result);
|
|
setLoadError(false);
|
|
if (result.state !== 'ready') setConnectFirst(true);
|
|
} catch (error) {
|
|
if (error.name !== 'AbortError') setLoadError(true);
|
|
}
|
|
if (!controller.signal.aborted) setLoading(false);
|
|
})();
|
|
return () => controller.abort();
|
|
}, [filters, load]);
|
|
|
|
// While the connect window is open, ask the server whether it worked.
|
|
useEffect(() => {
|
|
if (!waiting) return undefined;
|
|
const timer = setInterval(async () => {
|
|
try {
|
|
const result = await load(filters);
|
|
if (result.state === 'ready') {
|
|
setData(result);
|
|
setWaiting(false);
|
|
if (popup.current && !popup.current.closed) popup.current.close();
|
|
}
|
|
} catch (error) {
|
|
// Try again at the next tick.
|
|
}
|
|
}, POLL_MS);
|
|
return () => clearInterval(timer);
|
|
}, [waiting, filters, load]);
|
|
|
|
function connect() {
|
|
popup.current = window.open(CONNECT_URL, 'flickr-connect', 'width=640,height=760');
|
|
setBlocked(!popup.current);
|
|
setWaiting(true);
|
|
}
|
|
|
|
function search(event) {
|
|
event.preventDefault();
|
|
setFilters({set: '', tag: tagInput.trim(), page: 1});
|
|
}
|
|
|
|
function chooseAlbum(event) {
|
|
setTagInput('');
|
|
setFilters({set: event.target.value, tag: '', page: 1});
|
|
}
|
|
|
|
async function add() {
|
|
setSaving(true);
|
|
setErrors([]);
|
|
try {
|
|
const {ok, status, data: result} = await postJson(`/photos.json?type=${encodeURIComponent(type)}&id=${encodeURIComponent(id)}`, {
|
|
photo: {source_id: chosen.id, source: 'flickr'},
|
|
});
|
|
if (ok) {
|
|
onAdded(result); // closes this dialog
|
|
return;
|
|
}
|
|
setErrors(errorMessages(status, result));
|
|
} catch (error) {
|
|
setErrors(['Couldn\'t reach the server. Please try again.']);
|
|
}
|
|
setSaving(false);
|
|
}
|
|
|
|
const ready = data && data.state === 'ready';
|
|
const steps = connectFirst ? ['Connect Flickr', 'Choose a photo', 'Confirm'] : ['Choose a photo', 'Confirm'];
|
|
const firstChoose = connectFirst ? 2 : 1;
|
|
const current = !ready ? 1 : chosen ? firstChoose + 1 : firstChoose;
|
|
|
|
const title = (
|
|
<>
|
|
{iconUrl && <img src={iconUrl} alt="" className="modal-title-icon" />}
|
|
Add a photo to {label}
|
|
</>
|
|
);
|
|
|
|
return (
|
|
<Modal title={title} titleId="add-photo-title" onClose={onClose}>
|
|
{loadError && (
|
|
<div className="modal-body">
|
|
<div className="alert alert-danger mb-0" role="alert">Couldn’t load your photos. Please close this and try again.</div>
|
|
</div>
|
|
)}
|
|
{!loadError && !data && (
|
|
<div className="modal-body" role="status"><i className="fas fa-spinner fa-spin" aria-hidden="true" /> Loading…</div>
|
|
)}
|
|
{data && (
|
|
<>
|
|
<div className="modal-body plant-dialog-body">
|
|
<Steps steps={steps} current={current} />
|
|
|
|
{!ready && (
|
|
<div className="crop-picker">
|
|
<h3 className="crop-picker-label">{data.state === 'reconnect' ? 'Reconnect Flickr' : 'Connect your Flickr account'}</h3>
|
|
<p>
|
|
Photos on Growstuff are shared from Flickr, so you choose them from your own photos there.{' '}
|
|
{data.state === 'reconnect' ? 'Your connection has expired, or been taken back on Flickr.' : 'Connect your account and you can choose one here.'}{' '}
|
|
Growstuff only reads your photos.
|
|
</p>
|
|
<button type="button" className="btn btn-success btn-plant" onClick={connect} disabled={waiting} autoFocus>
|
|
<i className="fab fa-flickr" aria-hidden="true" /> {data.state === 'reconnect' ? 'Reconnect Flickr' : 'Connect Flickr'}
|
|
</button>
|
|
{waiting && (
|
|
<div className="photo-picker-waiting" role="status">
|
|
<i className="fas fa-spinner fa-spin" aria-hidden="true" />{' '}
|
|
{blocked ? 'Your browser blocked the window.' : 'Waiting for you to allow access on Flickr…'}{' '}
|
|
{blocked && <a href={CONNECT_URL} target="_blank" rel="noopener noreferrer">Connect in a new tab</a>}
|
|
{!blocked && <span className="photo-picker-hint">This picks up on its own once you have.</span>}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{ready && !chosen && (
|
|
<div className="photo-picker">
|
|
<p className="photo-picker-hint">
|
|
Connected to Flickr as <a href={data.profile_url} target="_blank" rel="noopener noreferrer">{data.name}</a>.
|
|
{' '}Choose a photo.
|
|
</p>
|
|
<form className="photo-picker-filters" onSubmit={search}>
|
|
{data.sets.length > 0 && (
|
|
<>
|
|
<label className="visually-hidden" htmlFor="add-photo-album">Album</label>
|
|
<select id="add-photo-album" className="form-select" value={filters.set} onChange={chooseAlbum}>
|
|
<option value="">All your recent photos</option>
|
|
{data.sets.map((set) => <option key={set.id} value={set.id}>{set.title}</option>)}
|
|
</select>
|
|
</>
|
|
)}
|
|
<label className="visually-hidden" htmlFor="add-photo-tag">Search by tag</label>
|
|
<input id="add-photo-tag" type="search" className="form-control" placeholder="or search by tag" value={tagInput} onChange={(event) => setTagInput(event.target.value)} />
|
|
<button type="submit" className="btn btn-sm btn-outline-secondary">Search</button>
|
|
</form>
|
|
|
|
<div className="photo-picker-grid" aria-busy={loading}>
|
|
{data.photos.map((photo) => (
|
|
<button key={photo.id} type="button" className="photo-picker-photo" title={photo.title} onClick={() => setChosen(photo)}>
|
|
<img src={photo.thumb_url} alt={photo.title || 'Untitled photo'} loading="lazy" />
|
|
</button>
|
|
))}
|
|
</div>
|
|
{data.photos.length === 0 && (
|
|
<p className="photo-picker-hint">No photos found{filters.tag ? ` tagged “${filters.tag}”` : ''}.</p>
|
|
)}
|
|
|
|
{data.total_pages > 1 && (
|
|
<div className="photo-picker-pager">
|
|
<button type="button" className="btn btn-sm btn-outline-secondary" disabled={loading || data.page <= 1} onClick={() => setFilters({...filters, page: data.page - 1})}>Previous</button>
|
|
<span>Page {data.page} of {data.total_pages}</span>
|
|
<button type="button" className="btn btn-sm btn-outline-secondary" disabled={loading || data.page >= data.total_pages} onClick={() => setFilters({...filters, page: data.page + 1})}>Next</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{ready && chosen && (
|
|
<>
|
|
{errors.length > 0 && (
|
|
<div className="alert alert-danger" role="alert">
|
|
<i className="fas fa-exclamation-triangle" aria-hidden="true" />{' '}
|
|
<strong>That didn’t work.</strong>
|
|
<ul className="mb-0">{errors.map((message) => <li key={message}>{message}</li>)}</ul>
|
|
<span>Your choice is kept, so you can press Add photo to try again.</span>
|
|
</div>
|
|
)}
|
|
<div className="crop-confirm">
|
|
<p className="crop-confirm-label">Ready to add this photo?</p>
|
|
<dl className="crop-confirm-details photo-picker-confirm">
|
|
<dt>To</dt>
|
|
<dd>{label}</dd>
|
|
<dt>Photo</dt>
|
|
<dd>
|
|
<img src={chosen.preview_url} alt={chosen.title || 'The photo you chose'} className="photo-picker-preview" />
|
|
<span className="photo-picker-title">{chosen.title}</span>
|
|
<button type="button" className="btn btn-sm btn-outline-secondary ms-3" onClick={() => setChosen(null)} disabled={saving}>
|
|
<i className="fas fa-edit" aria-hidden="true" /> Change<span className="visually-hidden"> photo</span>
|
|
</button>
|
|
</dd>
|
|
</dl>
|
|
<p className="photo-picker-hint mb-0">The photo stays on Flickr. Growstuff shows it and links back to it.</p>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
{ready && chosen && (
|
|
<div className="modal-footer">
|
|
<button type="button" className="btn btn-link" onClick={() => setChosen(null)} disabled={saving}>Back</button>
|
|
<button type="button" className="btn btn-success btn-plant" onClick={add} disabled={saving} aria-busy={saving} autoFocus>
|
|
{saving ? <><i className="fas fa-spinner fa-spin" aria-hidden="true" /> Adding…</> : 'Add photo'}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</Modal>
|
|
);
|
|
}
|