Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 87 additions & 2 deletions app/admin/RichEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,86 @@ const thumbUrl = (url: string) => (url.startsWith('//') ? `https:${url}` : url);

const AssetUrls = createContext<Record<string, string>>({});

type CaptionState = 'loading' | 'idle' | 'saving' | 'saved' | 'error';

const PhotoCaption = ({ assetId }: { assetId: string }) => {
const [name, setName] = useState('');
const [caption, setCaption] = useState('');
const [state, setState] = useState<CaptionState>('loading');
const saved = useRef({ name: '', caption: '' });

useEffect(() => {
if (!assetId) return;
let cancelled = false;
fetch(`/api/admin/asset-caption?id=${encodeURIComponent(assetId)}`)
.then((res) => (res.ok ? res.json() : Promise.reject()))
.then((data: { name: string; caption: string }) => {
if (cancelled) return;
saved.current = data;
setName(data.name);
setCaption(data.caption);
setState('idle');
})
.catch(() => !cancelled && setState('idle'));
return () => {
cancelled = true;
};
}, [assetId]);

const save = async () => {
const next = { name: name.trim(), caption: caption.trim() };
if (next.name === saved.current.name && next.caption === saved.current.caption) return;
setState('saving');
try {
const res = await fetch('/api/admin/asset-caption', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: assetId, ...next }),
});
if (!res.ok) throw new Error();
saved.current = next;
setState('saved');
} catch {
setState('error');
}
};

const field =
'w-full rounded-md border border-gray-200 bg-white px-2.5 py-1.5 text-sm outline-none focus:border-black disabled:bg-gray-50';

return (
<span className="block space-y-1.5 border-t border-gray-100 bg-white p-2.5" contentEditable={false}>
<input
className={field}
placeholder="Name (for a person's photo)"
value={name}
maxLength={120}
disabled={state === 'loading'}
onChange={(event) => setName(event.target.value)}
onBlur={save}
onKeyDown={(event) => event.stopPropagation()}
/>
<input
className={field}
placeholder="Role or caption, e.g. President"
value={caption}
maxLength={300}
disabled={state === 'loading'}
onChange={(event) => setCaption(event.target.value)}
onBlur={save}
onKeyDown={(event) => event.stopPropagation()}
/>
<span className="block text-xs text-gray-500">
{state === 'saving' && 'Saving...'}
{state === 'saved' && 'Saved.'}
{state === 'error' && <span className="text-red-700">Could not save. Try again.</span>}
{(state === 'idle' || state === 'loading') &&
'Photos placed one after another show as a grid. Add a name and role to show them as a team.'}
</span>
</span>
);
};

const AssetImageView = ({ node, deleteNode, selected }: NodeViewProps) => {
const urls = useContext(AssetUrls);
const id = node.attrs.assetId as string;
Expand All @@ -40,7 +120,7 @@ const AssetImageView = ({ node, deleteNode, selected }: NodeViewProps) => {
return (
<NodeViewWrapper
className={`relative my-4 inline-block w-full max-w-sm overflow-hidden rounded-xl border-2 ${
selected ? 'border-[#FFBE00]' : 'border-transparent'
selected ? 'border-[#FFBE00]' : 'border-gray-200'
}`}
data-drag-handle
>
Expand All @@ -61,6 +141,8 @@ const AssetImageView = ({ node, deleteNode, selected }: NodeViewProps) => {
)}
</span>

<PhotoCaption assetId={id} />

<button
type="button"
onClick={() => deleteNode()}
Expand All @@ -86,7 +168,10 @@ const AssetImage = Node.create({
'div',
{ 'data-asset-id': HTMLAttributes.assetId as string },
],
addNodeView: () => ReactNodeViewRenderer(AssetImageView),
addNodeView: () =>
ReactNodeViewRenderer(AssetImageView, {
stopEvent: ({ event }) => event.target instanceof HTMLInputElement,
}),
});

interface RichEditorProps {
Expand Down
66 changes: 66 additions & 0 deletions app/api/admin/asset-caption/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { NextRequest, NextResponse } from "next/server";
import { isSignedIn } from "@/lib/admin/session";
import { isWriteConfigured } from "@/lib/admin/cma";
import { readAssetCaption, saveAssetCaption } from "@/lib/admin/assets";
import { refreshSite } from "@/lib/admin/refresh";
import { LogError } from "@/lib/logger";

const ASSET_ID = /^[A-Za-z0-9._-]{1,64}$/;
const MAX_NAME = 120;
const MAX_CAPTION = 300;

async function guard() {
if (!(await isSignedIn())) {
return NextResponse.json({ message: "Please sign in again." }, { status: 401 });
}
if (!isWriteConfigured()) {
return NextResponse.json({ message: "Not set up yet." }, { status: 503 });
}
return null;
}

export async function GET(request: NextRequest) {
const denied = await guard();
if (denied) return denied;

const id = request.nextUrl.searchParams.get("id") ?? "";
if (!ASSET_ID.test(id)) {
return NextResponse.json({ message: "Something went wrong. Try again." }, { status: 400 });
}

try {
const caption = await readAssetCaption(id);
if (!caption) return NextResponse.json({ message: "That photo was not found." }, { status: 404 });
return NextResponse.json(caption);
} catch (error) {
LogError("[/api/admin/asset-caption GET]", id, error);
return NextResponse.json({ message: "That photo could not be read." }, { status: 502 });
}
}

export async function POST(request: NextRequest) {
const denied = await guard();
if (denied) return denied;

let body: { id?: unknown; name?: unknown; caption?: unknown };
try {
body = await request.json();
} catch {
return NextResponse.json({ message: "Something went wrong. Try again." }, { status: 400 });
}

const name = typeof body.name === "string" ? body.name.trim() : "";
const caption = typeof body.caption === "string" ? body.caption.trim() : "";
if (typeof body.id !== "string" || !ASSET_ID.test(body.id) || name.length > MAX_NAME || caption.length > MAX_CAPTION) {
return NextResponse.json({ message: "Something went wrong. Try again." }, { status: 400 });
}

try {
await saveAssetCaption(body.id, { name, caption });
refreshSite();
return NextResponse.json({ ok: true });
} catch (error) {
LogError("[/api/admin/asset-caption POST]", body.id, error);
return NextResponse.json({ message: "That caption could not be saved." }, { status: 502 });
}
}
1 change: 1 addition & 0 deletions app/clubs/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ query ClubById($id: String!) {
description
width
height
fileName
}
}
}
Expand Down
22 changes: 16 additions & 6 deletions components/clubs/ClubDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,25 +41,32 @@
findAsset: (id: string) => EmbeddedAsset | undefined;
}

const personName = (asset: EmbeddedAsset) => {
const title = asset.title?.trim() ?? '';
const fileStem = asset.fileName?.replace(/\.[^.]+$/, '') ?? '';
return title && title !== fileStem ? title : '';
};

const isPortrait = (asset: EmbeddedAsset) => Boolean(asset.width && asset.height && asset.height > asset.width);

const AssetGrid = ({ ids, findAsset }: AssetGridProps) => {
const items = ids.map(findAsset).filter((asset): asset is EmbeddedAsset => Boolean(asset?.url));
if (items.length === 0) return null;

const single = items.length === 1;
const captioned = items.every((asset) => asset.description);
const people = captioned && (!single || isPortrait(items[0]));
const named = items.every((asset) => personName(asset) || asset.description);
const headshots = items.every(isPortrait);
const people = single ? named && headshots : named || headshots;

if (single && !people) {
const image = contentfulImage(items[0].url, { intrinsicWidth: items[0].width, intrinsicHeight: items[0].height });
return (
<figure className="my-8 w-full max-w-2xl mx-auto">
<img

Check warning on line 65 in components/clubs/ClubDetail.tsx

View workflow job for this annotation

GitHub Actions / build-app

Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element
src={image.src}
srcSet={image.srcSet || undefined}
sizes="(max-width: 768px) 100vw, 672px"
alt={items[0].description || items[0].title || ''}
alt={items[0].description || personName(items[0])}
width={image.width}
height={image.height}
loading="lazy"
Expand All @@ -81,6 +88,7 @@
return (
<div className={`grid ${columns} gap-4 sm:gap-5 my-8`}>
{items.map((asset) => {
const name = personName(asset);
const image = contentfulImage(asset.url, {
widths: [320, 480, 640, 960],
intrinsicWidth: asset.width,
Expand All @@ -90,20 +98,22 @@
return (
<figure key={asset.sys.id} className="group">
<div className="overflow-hidden rounded-2xl bg-gray-100 shadow-sm">
<img

Check warning on line 101 in components/clubs/ClubDetail.tsx

View workflow job for this annotation

GitHub Actions / build-app

Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element
src={image.src}
srcSet={image.srcSet || undefined}
sizes={people ? '(max-width: 640px) 50vw, 240px' : '(max-width: 640px) 100vw, 360px'}
alt={asset.title || ''}
alt={name || asset.description || ''}
loading="lazy"
decoding="async"
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-[1.03]"
style={{ aspectRatio: String(aspect) }}
/>
</div>
{people && (
{people && (name || asset.description) && (
<figcaption className="mt-3 space-y-0.5">
<p className="font-header font-bold text-gray-900 text-sm sm:text-base leading-snug">{asset.title}</p>
{name && (
<p className="font-header font-bold text-gray-900 text-sm sm:text-base leading-snug">{name}</p>
)}
{asset.description && (
<p className="text-xs sm:text-sm text-gray-500 leading-snug">{asset.description}</p>
)}
Expand Down
35 changes: 35 additions & 0 deletions lib/admin/assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,3 +102,38 @@ export async function assertPublishedAsset(id: string) {
if (!asset.sys.publishedVersion) throw new Error(`Image ${id} is not published`);
if (!asset.fields?.file?.[LOCALE]?.url) throw new Error(`Image ${id} has no file`);
}

export interface AssetCaption {
name: string;
caption: string;
}

const stemOf = (fileName: unknown) =>
typeof fileName === "string" ? fileName.replace(/\.[^.]+$/, "") : "";

export async function readAssetCaption(id: string): Promise<AssetCaption | null> {
const asset = await cma(`/assets/${id}`);
if (!asset) return null;

const title = asset.fields?.title?.[LOCALE] ?? "";
const fileStem = stemOf(asset.fields?.file?.[LOCALE]?.fileName);
return {
name: title === fileStem ? "" : title,
caption: asset.fields?.description?.[LOCALE] ?? "",
};
}

export async function saveAssetCaption(id: string, { name, caption }: AssetCaption) {
const asset = await cma(`/assets/${id}`);
if (!asset) throw new Error(`Image ${id} does not exist`);

const fileStem = stemOf(asset.fields?.file?.[LOCALE]?.fileName);
const fields = {
...asset.fields,
title: { [LOCALE]: name || fileStem || "image" },
description: { [LOCALE]: caption },
};

const saved = await cma(`/assets/${id}`, { method: "PUT", body: { fields }, version: asset.sys.version });
await cma(`/assets/${id}/published`, { method: "PUT", version: saved.sys.version });
}
1 change: 1 addition & 0 deletions lib/data/club.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export interface ClubItems {
url: string;
title: string;
description: string;
fileName?: string;
width: number;
height: number;
}[];
Expand Down
1 change: 1 addition & 0 deletions lib/richTextOptions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export interface EmbeddedAsset {
url: string;
title?: string | null;
description?: string | null;
fileName?: string | null;
width?: number | null;
height?: number | null;
}
Expand Down
Loading