image update

This commit is contained in:
2026-09-25 14:41:56 +05:30
parent 3773b3c1ed
commit 558bd0e629
7 changed files with 156 additions and 46 deletions
+11 -1
View File
@@ -339,7 +339,17 @@ function EventCard({
</label> </label>
<label className="block text-xs font-medium text-rotary-navy"> <label className="block text-xs font-medium text-rotary-navy">
Date 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> </label>
</div> </div>
<label className="block text-xs font-medium text-rotary-navy"> <label className="block text-xs font-medium text-rotary-navy">
+8 -9
View File
@@ -1,10 +1,12 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import ViewableImage from "@/components/ViewableImage";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import DetailShell, { Chip } from "@/components/DetailShell"; import DetailShell, { Chip } from "@/components/DetailShell";
import RichText from "@/components/RichText"; import RichText from "@/components/RichText";
import { getContent } from "@/lib/content"; import { getContent } from "@/lib/content";
import { formatEventDate } from "@/lib/meetings";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -36,21 +38,18 @@ export default async function EventPage({ params }: Props) {
title={event.title} title={event.title}
backHref="/#events" backHref="/#events"
backLabel="All 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]"> <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 && ( {event.image && (
<div className="relative aspect-[16/9] w-full bg-rotary-navy/[0.04]"> <ViewableImage
<Image
src={event.image} src={event.image}
alt={event.title} alt={event.title}
fill caption={event.title}
priority priority
sizes="(min-width: 896px) 896px, 100vw" sizes="(min-width: 1024px) 1024px, 100vw"
unoptimized={event.image.startsWith("/uploads/")} frameClassName="aspect-[16/9] w-full bg-rotary-navy/[0.04]"
className="object-contain"
/> />
</div>
)} )}
<div className="p-6 sm:p-10"> <div className="p-6 sm:p-10">
{event.text && ( {event.text && (
@@ -87,7 +86,7 @@ export default async function EventPage({ params }: Props) {
)} )}
</div> </div>
<div className="p-4"> <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> <p className="mt-0.5 font-semibold text-rotary-navy group-hover:text-rotary-blue">{e.title}</p>
</div> </div>
</Link> </Link>
+4 -13
View File
@@ -1,5 +1,5 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import Image from "next/image"; import ViewableImage from "@/components/ViewableImage";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import DetailShell, { Chip } from "@/components/DetailShell"; import DetailShell, { Chip } from "@/components/DetailShell";
import RichText from "@/components/RichText"; import RichText from "@/components/RichText";
@@ -101,23 +101,14 @@ export default async function MeetingPage({ params }: Props) {
</article> </article>
{meeting.poster && ( {meeting.poster && (
<a <ViewableImage
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} src={meeting.poster}
alt={meeting.topic || "Meeting poster"} alt={meeting.topic || "Meeting poster"}
fill caption={meeting.topic}
priority priority
sizes="(min-width: 1024px) 320px, 100vw" sizes="(min-width: 1024px) 320px, 100vw"
unoptimized={meeting.poster.startsWith("/uploads/")} 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"
className="object-contain"
/> />
</a>
)} )}
</div> </div>
</DetailShell> </DetailShell>
+4 -6
View File
@@ -1,5 +1,6 @@
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import ViewableImage from "./ViewableImage";
import Reveal from "./Reveal"; import Reveal from "./Reveal";
import type { MeetingItem } from "@/lib/content"; import type { MeetingItem } from "@/lib/content";
import { formatMeetingDate } from "@/lib/meetings"; 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" */} {/* Right: next meeting poster, next meeting card, or "no upcoming events" */}
<Reveal delay={120}> <Reveal delay={120}>
{poster ? ( {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]"> <ViewableImage
<Image
src={poster} src={poster}
unoptimized={poster.startsWith("/uploads/")}
alt={next?.topic || "Upcoming meeting poster"} alt={next?.topic || "Upcoming meeting poster"}
fill caption={next?.topic}
sizes="(min-width: 1024px) 560px, 100vw" sizes="(min-width: 1024px) 560px, 100vw"
className="object-contain" 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]"
/> />
</div>
) : next ? ( ) : 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]"> <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"> <p className="text-xs font-semibold uppercase tracking-[0.28em] text-rotary-goldlight">
+6 -2
View File
@@ -3,6 +3,7 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import Image from "next/image"; import Image from "next/image";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { createPortal } from "react-dom";
import Reveal from "./Reveal"; import Reveal from "./Reveal";
import EmptyNote from "./EmptyNote"; import EmptyNote from "./EmptyNote";
import type { GalleryPhoto } from "@/lib/content"; import type { GalleryPhoto } from "@/lib/content";
@@ -241,8 +242,11 @@ export default function Gallery({ photos }: { photos: GalleryPhoto[] }) {
</div> </div>
)} )}
{open !== null && photos[open] && ( {open !== null &&
<Lightbox photos={photos} index={open} onClose={() => setOpen(null)} onGo={setOpen} /> photos[open] &&
createPortal(
<Lightbox photos={photos} index={open} onClose={() => setOpen(null)} onGo={setOpen} />,
document.body,
)} )}
</section> </section>
); );
+103
View File
@@ -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
View File
@@ -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. */ /** A card in the website's "Past Events" list: a manual event or a completed meeting. */
export type PastEventCard = { export type PastEventCard = {
id: string; id: string;
@@ -55,7 +60,7 @@ export function buildPastEvents(
id: e.id, id: e.id,
href: `/events/${e.id}`, href: `/events/${e.id}`,
image: e.image, image: e.image,
date: e.date, date: formatEventDate(e.date),
title: e.title, title: e.title,
text: e.text, text: e.text,
kind: "event" as const, kind: "event" as const,