feat: member portal Sprint 11 — Memories / Gallery
- GalleryAlbum + GalleryItem models + migration (publish gate, cover image, ordered items) - GalleryModule: admin CRUD for albums + items - Member endpoints: GET /my/memories (published albums w/ cover + count), GET /my/memories/:id (album + photos) - /my/memories page: album grid with cover thumbnails - /my/memories/[id] page: photo grid with captions - Memories nav item; register GalleryModule Completes Phase 2 (Intelligence) of the member portal — Sprints 9-11. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
import { jsonResponse, memberApiFetch } from '../../../../_lib/api';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
): Promise<Response> {
|
||||
const { id } = await params;
|
||||
const res = await memberApiFetch(`/my/memories/${id}`);
|
||||
const body = await res.json();
|
||||
return jsonResponse(body, res.status);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { jsonResponse, memberApiFetch } from '../../../_lib/api';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(): Promise<Response> {
|
||||
const res = await memberApiFetch('/my/memories');
|
||||
const body = await res.json();
|
||||
return jsonResponse(body, res.status);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ const NAV = [
|
||||
{ href: '/my/seva', label: 'Seva & Points' },
|
||||
{ href: '/my/circles', label: 'Circles' },
|
||||
{ href: '/my/directory', label: 'Directory' },
|
||||
{ href: '/my/memories', label: 'Memories' },
|
||||
{ href: '/my/groups', label: 'My Groups' },
|
||||
{ href: '/my/settings', label: 'Settings' },
|
||||
];
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { getMemberToken, getApiBaseUrl } from '../../../_lib/api';
|
||||
import Link from 'next/link';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
interface AlbumDetail {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
items: Array<{ id: string; mediaUrl: string; caption: string | null }>;
|
||||
}
|
||||
|
||||
async function fetchAlbum(token: string, id: string): Promise<AlbumDetail | null> {
|
||||
const res = await fetch(`${getApiBaseUrl()}/my/memories/${id}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
cache: 'no-store',
|
||||
}).catch(() => null);
|
||||
if (!res || !res.ok) return null;
|
||||
return (await res.json()) as AlbumDetail;
|
||||
}
|
||||
|
||||
export default async function AlbumPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const token = await getMemberToken();
|
||||
if (!token) return null;
|
||||
|
||||
const { id } = await params;
|
||||
const album = await fetchAlbum(token, id);
|
||||
if (!album) notFound();
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-gray-900">{album.title}</h1>
|
||||
{album.description && <p className="text-sm text-gray-500 mt-0.5">{album.description}</p>}
|
||||
</div>
|
||||
<Link href="/my/memories" className="text-sm text-indigo-600 hover:underline shrink-0">← Albums</Link>
|
||||
</div>
|
||||
|
||||
{album.items.length === 0 ? (
|
||||
<p className="text-center py-16 text-gray-400 text-sm">This album has no photos yet.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
{album.items.map((item) => (
|
||||
<figure key={item.id} className="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<div className="aspect-square bg-gray-100 overflow-hidden">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={item.mediaUrl} alt={item.caption ?? ''} className="w-full h-full object-cover" />
|
||||
</div>
|
||||
{item.caption && (
|
||||
<figcaption className="text-xs text-gray-500 px-2 py-1.5 truncate">{item.caption}</figcaption>
|
||||
)}
|
||||
</figure>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { getMemberToken, getApiBaseUrl } from '../../_lib/api';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface Album {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
coverUrl: string | null;
|
||||
itemCount: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
async function fetchAlbums(token: string): Promise<Album[]> {
|
||||
const res = await fetch(`${getApiBaseUrl()}/my/memories`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
cache: 'no-store',
|
||||
}).catch(() => null);
|
||||
if (!res || !res.ok) return [];
|
||||
return (await res.json()) as Album[];
|
||||
}
|
||||
|
||||
export default async function MemoriesPage() {
|
||||
const token = await getMemberToken();
|
||||
if (!token) return null;
|
||||
|
||||
const albums = await fetchAlbums(token);
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold text-gray-900">Memories</h1>
|
||||
<Link href="/my" className="text-sm text-indigo-600 hover:underline">← Dashboard</Link>
|
||||
</div>
|
||||
|
||||
{albums.length === 0 ? (
|
||||
<div className="text-center py-16 text-gray-400">
|
||||
<p className="text-sm">No photo albums yet.</p>
|
||||
<p className="text-xs mt-1">Your chapter admin will share event photos and memories here.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{albums.map((a) => (
|
||||
<Link
|
||||
key={a.id}
|
||||
href={`/my/memories/${a.id}`}
|
||||
className="group bg-white rounded-xl border border-gray-200 overflow-hidden hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="aspect-[4/3] bg-gray-100 overflow-hidden">
|
||||
{a.coverUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={a.coverUrl}
|
||||
alt={a.title}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-3xl text-gray-300">🖼️</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<p className="text-sm font-semibold text-gray-900 truncate">{a.title}</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">{a.itemCount} photo{a.itemCount !== 1 ? 's' : ''}</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user