image update
This commit is contained in:
@@ -339,7 +339,17 @@ function EventCard({
|
||||
</label>
|
||||
<label className="block text-xs font-medium text-rotary-navy">
|
||||
Date
|
||||
<input value={draft.date} placeholder="e.g. 12 Aug 2026" onChange={(e) => setDraft({ date: e.target.value })} className={`${input} mt-1`} />
|
||||
<input
|
||||
type="date"
|
||||
value={/^\d{4}-\d{2}-\d{2}$/.test(draft.date) ? draft.date : ""}
|
||||
onChange={(e) => setDraft({ date: e.target.value })}
|
||||
className={`${input} mt-1`}
|
||||
/>
|
||||
{draft.date && !/^\d{4}-\d{2}-\d{2}$/.test(draft.date) && (
|
||||
<span className="mt-1 block text-[11px] font-normal text-rotary-amber">
|
||||
Currently “{draft.date}” — pick a date to replace it
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
<label className="block text-xs font-medium text-rotary-navy">
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { Metadata } from "next";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import ViewableImage from "@/components/ViewableImage";
|
||||
import { notFound } from "next/navigation";
|
||||
import DetailShell, { Chip } from "@/components/DetailShell";
|
||||
import RichText from "@/components/RichText";
|
||||
import { getContent } from "@/lib/content";
|
||||
import { formatEventDate } from "@/lib/meetings";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -36,21 +38,18 @@ export default async function EventPage({ params }: Props) {
|
||||
title={event.title}
|
||||
backHref="/#events"
|
||||
backLabel="All events"
|
||||
badge={event.date ? <Chip tone="gold">{event.date}</Chip> : undefined}
|
||||
badge={event.date ? <Chip tone="gold">{formatEventDate(event.date)}</Chip> : undefined}
|
||||
>
|
||||
<article className="overflow-hidden rounded-3xl bg-white shadow-[0_20px_50px_-24px_rgba(3,20,58,0.35)] ring-1 ring-rotary-navy/[0.06]">
|
||||
{event.image && (
|
||||
<div className="relative aspect-[16/9] w-full bg-rotary-navy/[0.04]">
|
||||
<Image
|
||||
src={event.image}
|
||||
alt={event.title}
|
||||
fill
|
||||
priority
|
||||
sizes="(min-width: 896px) 896px, 100vw"
|
||||
unoptimized={event.image.startsWith("/uploads/")}
|
||||
className="object-contain"
|
||||
/>
|
||||
</div>
|
||||
<ViewableImage
|
||||
src={event.image}
|
||||
alt={event.title}
|
||||
caption={event.title}
|
||||
priority
|
||||
sizes="(min-width: 1024px) 1024px, 100vw"
|
||||
frameClassName="aspect-[16/9] w-full bg-rotary-navy/[0.04]"
|
||||
/>
|
||||
)}
|
||||
<div className="p-6 sm:p-10">
|
||||
{event.text && (
|
||||
@@ -87,7 +86,7 @@ export default async function EventPage({ params }: Props) {
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4">
|
||||
{e.date && <p className="text-[11px] font-semibold uppercase tracking-wider text-rotary-azure">{e.date}</p>}
|
||||
{e.date && <p className="text-[11px] font-semibold uppercase tracking-wider text-rotary-azure">{formatEventDate(e.date)}</p>}
|
||||
<p className="mt-0.5 font-semibold text-rotary-navy group-hover:text-rotary-blue">{e.title}</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Metadata } from "next";
|
||||
import Image from "next/image";
|
||||
import ViewableImage from "@/components/ViewableImage";
|
||||
import { notFound } from "next/navigation";
|
||||
import DetailShell, { Chip } from "@/components/DetailShell";
|
||||
import RichText from "@/components/RichText";
|
||||
@@ -101,23 +101,14 @@ export default async function MeetingPage({ params }: Props) {
|
||||
</article>
|
||||
|
||||
{meeting.poster && (
|
||||
<a
|
||||
href={meeting.poster}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="relative block aspect-[4/5] overflow-hidden rounded-3xl bg-white shadow-[0_20px_50px_-24px_rgba(3,20,58,0.35)] ring-4 ring-white"
|
||||
title="Open poster"
|
||||
>
|
||||
<Image
|
||||
src={meeting.poster}
|
||||
alt={meeting.topic || "Meeting poster"}
|
||||
fill
|
||||
priority
|
||||
sizes="(min-width: 1024px) 320px, 100vw"
|
||||
unoptimized={meeting.poster.startsWith("/uploads/")}
|
||||
className="object-contain"
|
||||
/>
|
||||
</a>
|
||||
<ViewableImage
|
||||
src={meeting.poster}
|
||||
alt={meeting.topic || "Meeting poster"}
|
||||
caption={meeting.topic}
|
||||
priority
|
||||
sizes="(min-width: 1024px) 320px, 100vw"
|
||||
frameClassName="aspect-[4/5] w-full rounded-3xl bg-white shadow-[0_20px_50px_-24px_rgba(3,20,58,0.35)] ring-4 ring-white"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</DetailShell>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import ViewableImage from "./ViewableImage";
|
||||
import Reveal from "./Reveal";
|
||||
import type { MeetingItem } from "@/lib/content";
|
||||
import { formatMeetingDate } from "@/lib/meetings";
|
||||
@@ -114,16 +115,13 @@ export default function EventDetails({ next }: { next?: MeetingItem }) {
|
||||
{/* Right: next meeting poster, next meeting card, or "no upcoming events" */}
|
||||
<Reveal delay={120}>
|
||||
{poster ? (
|
||||
<div className="relative h-80 w-full overflow-hidden rounded-[1.5rem] bg-white shadow-[0_20px_50px_-20px_rgba(3,20,58,0.35)] ring-4 ring-white sm:h-full sm:min-h-[22rem]">
|
||||
<Image
|
||||
src={poster}
|
||||
unoptimized={poster.startsWith("/uploads/")}
|
||||
alt={next?.topic || "Upcoming meeting poster"}
|
||||
fill
|
||||
sizes="(min-width: 1024px) 560px, 100vw"
|
||||
className="object-contain"
|
||||
/>
|
||||
</div>
|
||||
<ViewableImage
|
||||
src={poster}
|
||||
alt={next?.topic || "Upcoming meeting poster"}
|
||||
caption={next?.topic}
|
||||
sizes="(min-width: 1024px) 560px, 100vw"
|
||||
frameClassName="h-80 w-full rounded-[1.5rem] bg-white shadow-[0_20px_50px_-20px_rgba(3,20,58,0.35)] ring-4 ring-white sm:h-full sm:min-h-[22rem]"
|
||||
/>
|
||||
) : next ? (
|
||||
<div className="flex h-80 w-full flex-col items-center justify-center rounded-[1.5rem] bg-gradient-to-br from-rotary-navy to-rotary-blue px-6 text-center text-white shadow-[0_20px_50px_-20px_rgba(3,20,58,0.35)] sm:h-full sm:min-h-[22rem]">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-rotary-goldlight">
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import Image from "next/image";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import Reveal from "./Reveal";
|
||||
import EmptyNote from "./EmptyNote";
|
||||
import type { GalleryPhoto } from "@/lib/content";
|
||||
@@ -241,9 +242,12 @@ export default function Gallery({ photos }: { photos: GalleryPhoto[] }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{open !== null && photos[open] && (
|
||||
<Lightbox photos={photos} index={open} onClose={() => setOpen(null)} onGo={setOpen} />
|
||||
)}
|
||||
{open !== null &&
|
||||
photos[open] &&
|
||||
createPortal(
|
||||
<Lightbox photos={photos} index={open} onClose={() => setOpen(null)} onGo={setOpen} />,
|
||||
document.body,
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import Image from "next/image";
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
/**
|
||||
* An image inside a frame; clicking it opens a full-screen viewer.
|
||||
* Used for meeting posters and event photos.
|
||||
*/
|
||||
export default function ViewableImage({
|
||||
src,
|
||||
alt,
|
||||
frameClassName,
|
||||
sizes,
|
||||
priority,
|
||||
caption,
|
||||
}: {
|
||||
src: string;
|
||||
alt: string;
|
||||
frameClassName: string;
|
||||
sizes: string;
|
||||
priority?: boolean;
|
||||
caption?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
|
||||
window.addEventListener("keydown", onKey);
|
||||
const overflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKey);
|
||||
document.body.style.overflow = overflow;
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label={`View full size: ${alt}`}
|
||||
className={`group relative block cursor-zoom-in overflow-hidden text-left ${frameClassName}`}
|
||||
>
|
||||
<Image
|
||||
src={src}
|
||||
alt={alt}
|
||||
fill
|
||||
priority={priority}
|
||||
sizes={sizes}
|
||||
unoptimized={src.startsWith("/uploads/")}
|
||||
className="object-contain transition-transform duration-500 group-hover:scale-[1.02]"
|
||||
/>
|
||||
<span className="pointer-events-none absolute bottom-3 right-3 flex items-center gap-1.5 rounded-full bg-rotary-navy/80 px-3 py-1.5 text-xs font-medium text-white opacity-90 backdrop-blur transition-opacity group-hover:opacity-100">
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<path d="M21 21l-4.3-4.3M11 8v6M8 11h6" strokeLinecap="round" />
|
||||
</svg>
|
||||
View
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{open &&
|
||||
createPortal(
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={alt}
|
||||
onClick={() => setOpen(false)}
|
||||
className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-rotary-navy/95 p-4 backdrop-blur-sm animate-fade-up"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
onClick={() => setOpen(false)}
|
||||
className="absolute right-4 top-4 flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white hover:bg-white/20"
|
||||
>
|
||||
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M6 6l12 12M18 6L6 18" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="max-h-[85vh] w-auto max-w-full rounded-xl object-contain shadow-2xl"
|
||||
/>
|
||||
<div className="mt-4 flex items-center gap-4 text-sm text-white/70" onClick={(e) => e.stopPropagation()}>
|
||||
{caption && <span className="font-medium text-white">{caption}</span>}
|
||||
<a href={src} target="_blank" rel="noopener noreferrer" className="underline-offset-4 hover:text-white hover:underline">
|
||||
Open original ↗
|
||||
</a>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+6
-1
@@ -23,6 +23,11 @@ export function formatMeetingDate(date: string) {
|
||||
});
|
||||
}
|
||||
|
||||
/** Event dates are saved as YYYY-MM-DD (date picker); older free-text dates are shown as typed. */
|
||||
export function formatEventDate(date: string) {
|
||||
return /^\d{4}-\d{2}-\d{2}$/.test(date) ? formatMeetingDate(date) : date;
|
||||
}
|
||||
|
||||
/** A card in the website's "Past Events" list: a manual event or a completed meeting. */
|
||||
export type PastEventCard = {
|
||||
id: string;
|
||||
@@ -55,7 +60,7 @@ export function buildPastEvents(
|
||||
id: e.id,
|
||||
href: `/events/${e.id}`,
|
||||
image: e.image,
|
||||
date: e.date,
|
||||
date: formatEventDate(e.date),
|
||||
title: e.title,
|
||||
text: e.text,
|
||||
kind: "event" as const,
|
||||
|
||||
Reference in New Issue
Block a user