From 689aaed16d4d0e695f9e5fb19c22a9b9ee7da82c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lipt=C3=A1k=20P=C3=A9ter?= Date: Sun, 12 Jul 2026 22:50:32 +0200 Subject: [PATCH] feat: implement redesigned events page with event cards and gallery support --- .../rendezvenyek/components/EventCard.tsx | 120 ++++++++++++ .../rendezvenyek/components/EventGallery.tsx | 83 +++++++++ .../[lang]/kozelet/rendezvenyek/loading.tsx | 33 ++++ .../[lang]/kozelet/rendezvenyek/page.tsx | 171 ++++++------------ src/collections/EhkEvents.ts | 20 +- src/dictionaries/en/news.json | 4 + src/dictionaries/hu/news.json | 4 + ...701_000000_ehk_events_cover_and_gallery.ts | 29 +++ src/migrations/index.ts | 6 + src/payload-types.ts | 20 +- 10 files changed, 362 insertions(+), 128 deletions(-) create mode 100644 src/app/(app)/[lang]/kozelet/rendezvenyek/components/EventCard.tsx create mode 100644 src/app/(app)/[lang]/kozelet/rendezvenyek/components/EventGallery.tsx create mode 100644 src/app/(app)/[lang]/kozelet/rendezvenyek/loading.tsx create mode 100644 src/migrations/20260701_000000_ehk_events_cover_and_gallery.ts diff --git a/src/app/(app)/[lang]/kozelet/rendezvenyek/components/EventCard.tsx b/src/app/(app)/[lang]/kozelet/rendezvenyek/components/EventCard.tsx new file mode 100644 index 0000000..d4b3d62 --- /dev/null +++ b/src/app/(app)/[lang]/kozelet/rendezvenyek/components/EventCard.tsx @@ -0,0 +1,120 @@ +import Image from "next/image"; +import { Media } from "@/payload-types"; +import { Locale } from "@/i18n-config"; +import type { SerializedEditorState } from "@payloadcms/richtext-lexical/lexical"; +import { RichText } from "@payloadcms/richtext-lexical/react"; +import { getSocialIcon, getSocialName, getSocialPriority } from "@/lib/social-utils"; +import { EventGallery } from "./EventGallery"; + +interface EventCardLabels { + leiras: string; + gallery: string; + links: string; + prev_image: string; + next_image: string; +} + +interface EventLink { + label: string; + url: string; +} + +interface EventCardProps { + title: string; + coverImage: Media | null; + description: SerializedEditorState | null | undefined; + galleryImages: Media[]; + links: EventLink[]; + lang: Locale; + labels: EventCardLabels; +} + +function isValidUrl(url: string | null | undefined): boolean { + if (!url) return false; + try { + const parsed = new URL(url); + return parsed.protocol === "http:" || parsed.protocol === "https:"; + } catch { + return false; + } +} + +export function EventCard({ + title, + coverImage, + description, + galleryImages, + links, + lang, + labels, +}: EventCardProps) { + const validLinks = links + .filter((link) => isValidUrl(link.url)) + .sort((a, b) => getSocialPriority(a.label) - getSocialPriority(b.label)); + + return ( +
+ {/* Title bar */} +
+

{title}

+
+ + {/* Cover image */} +
+ {coverImage?.url ? ( + {coverImage.alt + ) : null} +
+ + {/* Body */} +
+ {/* LEÍRÁS */} + {description ? ( +
+

{labels.leiras}

+
+ +
+
+ ) : null} + + {/* GALÉRIA */} + {galleryImages.length > 0 ? ( + + ) : null} + + {/* LINKEK */} + {validLinks.length > 0 ? ( +
+

{labels.links}

+ +
+ ) : null} +
+
+ ); +} diff --git a/src/app/(app)/[lang]/kozelet/rendezvenyek/components/EventGallery.tsx b/src/app/(app)/[lang]/kozelet/rendezvenyek/components/EventGallery.tsx new file mode 100644 index 0000000..33803ff --- /dev/null +++ b/src/app/(app)/[lang]/kozelet/rendezvenyek/components/EventGallery.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { useState } from "react"; +import Image from "next/image"; +import { ChevronLeft, ChevronRight } from "lucide-react"; +import { Media } from "@/payload-types"; +import { cn } from "@/lib/utils"; + +interface EventGalleryProps { + images: Media[]; + label: string; + prevLabel: string; + nextLabel: string; +} + +export function EventGallery({ images, label, prevLabel, nextLabel }: EventGalleryProps) { + const [startIndex, setStartIndex] = useState(0); + const VISIBLE_DESKTOP = 3; + + const canPrev = startIndex > 0; + const canNext = startIndex + VISIBLE_DESKTOP < images.length; + + const visibleImages = images.slice(startIndex, startIndex + VISIBLE_DESKTOP); + const emptySlots = Math.max(0, VISIBLE_DESKTOP - visibleImages.length); + + return ( +
+

{label}

+
+ + +
+ {visibleImages.map((media, idx) => ( +
+ {media.url && ( + {media.alt + )} +
+ ))} + {Array.from({ length: emptySlots }).map((_, idx) => ( +
+ ))} +
+ + +
+
+ ); +} diff --git a/src/app/(app)/[lang]/kozelet/rendezvenyek/loading.tsx b/src/app/(app)/[lang]/kozelet/rendezvenyek/loading.tsx new file mode 100644 index 0000000..2ed4264 --- /dev/null +++ b/src/app/(app)/[lang]/kozelet/rendezvenyek/loading.tsx @@ -0,0 +1,33 @@ +export default function Loading() { + return ( +
+
+ {/* PageHeader skeleton */} +
+
+
+
+
+
+
+
+
+
+ + {/* Event card skeletons */} +
+ {Array.from({ length: 2 }).map((_, i) => ( +
+
+
+
+
+
+
+
+ ))} +
+
+
+ ); +} diff --git a/src/app/(app)/[lang]/kozelet/rendezvenyek/page.tsx b/src/app/(app)/[lang]/kozelet/rendezvenyek/page.tsx index 02fdf49..a7d6f27 100644 --- a/src/app/(app)/[lang]/kozelet/rendezvenyek/page.tsx +++ b/src/app/(app)/[lang]/kozelet/rendezvenyek/page.tsx @@ -1,27 +1,14 @@ export const dynamic = "force-dynamic"; import { PageHeader } from "@/components/common/PageHeader"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Separator } from "@/components/ui/separator"; -import { Button } from "@/components/ui/button"; +import { EmptyState } from "@/components/common/EmptyState"; import { getDictionary } from "@/get-dictionary"; import { Locale } from "@/i18n-config"; import { getEhkEvents } from "@/lib/payload-cms"; -import { Media, EhkEvent } from "@/payload-types"; +import { EhkEvent, Media } from "@/payload-types"; import type { SerializedEditorState } from "@payloadcms/richtext-lexical/lexical"; -import { RichText } from "@payloadcms/richtext-lexical/react"; -import { getSocialIcon, getSocialPriority, getSocialName } from "@/lib/social-utils"; -import Image from "next/image"; - -function isValidUrl(url: string | null | undefined): boolean { - if (!url) return false; - try { - const parsed = new URL(url); - return parsed.protocol === "http:" || parsed.protocol === "https:"; - } catch { - return false; - } -} +import { PartyPopper } from "lucide-react"; +import { EventCard } from "./components/EventCard"; export default async function RendezvenyekPage({ params, @@ -29,128 +16,74 @@ export default async function RendezvenyekPage({ params: Promise<{ lang: Locale }>; }) { const { lang } = await params; - + const [dictionary, events] = await Promise.all([ - getDictionary(lang, 'news'), + getDictionary(lang, "news"), getEhkEvents(), ]); - const title = dictionary.rendezvenyek?.title || "Rendezvények"; + const d = dictionary.rendezvenyek; + + const labels = { + leiras: d.description_label, + gallery: d.gallery, + links: d.links_label, + prev_image: d.prev_image, + next_image: d.next_image, + }; if (events.length === 0) { return ( -
+
- -
- {dictionary.rendezvenyek?.no_results || "Jelenleg nincsenek elérhető rendezvények."} -
+ +
); } return ( -
+
- + - {dictionary.rendezvenyek?.description && ( - - -

- {dictionary.rendezvenyek.description} -

-
-
- )} - -
+
{events.map((event: EhkEvent) => { - const descriptionData = event.description?.[`text_${lang}`]; - const images = event.images || []; + const coverImage = + typeof event.coverImage === "object" && event.coverImage !== null + ? (event.coverImage as Media) + : null; - return ( - - -
- - {event.title} - -
-
- - {descriptionData ? ( -
- -
- ) : null} + const description = event.description?.[`text_${lang}`] as + | SerializedEditorState + | null + | undefined; - {images.length > 0 && ( -
- {images.map((imgObj, imgIdx) => { - const coverImage = typeof imgObj.image === "object" && imgObj.image !== null ? (imgObj.image as Media) : null; - if (!coverImage?.url) return null; - - return ( -
- {images.length === 1 ? ( - // eslint-disable-next-line @next/next/no-img-element - {coverImage.alt - ) : ( - {coverImage.alt - )} -
- ); - })} -
- )} + const galleryImages = (event.gallery ?? []) + .map((item) => + typeof item.image === "object" && item.image !== null + ? (item.image as Media) + : null + ) + .filter((img): img is Media => img !== null && !!img.url); - {event.links && event.links.length > 0 && ( -
- -

- {lang === 'hu' ? 'További információk:' : 'More information:'} -

-
- {[...event.links] - .filter((link) => isValidUrl(link.url)) - .sort((a, b) => getSocialPriority(a.label) - getSocialPriority(b.label)) - .map((link, lIdx) => ( - - ))} -
-
- )} -
-
+ const links = (event.links ?? []).map((link) => ({ + label: link.label, + url: link.url, + })); + + return ( + ); })}
diff --git a/src/collections/EhkEvents.ts b/src/collections/EhkEvents.ts index 942abcb..d5be7f8 100644 --- a/src/collections/EhkEvents.ts +++ b/src/collections/EhkEvents.ts @@ -51,11 +51,23 @@ export const EhkEvents: CollectionConfig = { ], }, { - name: 'images', - label: 'Képek', + name: 'coverImage', + label: 'Borítókép', + type: 'upload', + relationTo: 'media', + required: false, + admin: { + description: 'A rendezvény kártyáján megjelenő nagy borítókép', + }, + }, + { + name: 'gallery', + label: 'Galéria', type: 'array', - required: true, - minRows: 1, + required: false, + admin: { + description: 'Képek a galéria karuszelhez', + }, fields: [ { name: 'image', diff --git a/src/dictionaries/en/news.json b/src/dictionaries/en/news.json index d5c1233..3386c85 100644 --- a/src/dictionaries/en/news.json +++ b/src/dictionaries/en/news.json @@ -29,7 +29,11 @@ "time": "TIME", "location": "LOCATION", "link": "LINK", + "links_label": "LINKS", "description_label": "DESCRIPTION", + "gallery": "GALLERY", + "prev_image": "Previous image", + "next_image": "Next image", "share": "Share", "no_events": "No events scheduled for this week." }, diff --git a/src/dictionaries/hu/news.json b/src/dictionaries/hu/news.json index e81a208..f57917f 100644 --- a/src/dictionaries/hu/news.json +++ b/src/dictionaries/hu/news.json @@ -29,7 +29,11 @@ "time": "IDŐPONT", "location": "HELYSZÍN", "link": "LINK", + "links_label": "LINKEK", "description_label": "LEÍRÁS", + "gallery": "GALÉRIA", + "prev_image": "Előző kép", + "next_image": "Következő kép", "share": "Megosztás", "no_events": "Nincsenek események ezen a héten." }, diff --git a/src/migrations/20260701_000000_ehk_events_cover_and_gallery.ts b/src/migrations/20260701_000000_ehk_events_cover_and_gallery.ts new file mode 100644 index 0000000..2358ab3 --- /dev/null +++ b/src/migrations/20260701_000000_ehk_events_cover_and_gallery.ts @@ -0,0 +1,29 @@ +import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres' + +export async function up({ db, payload, req }: MigrateUpArgs): Promise { + await db.execute(sql` + ALTER TABLE "ehk_events_images" RENAME TO "ehk_events_gallery"; + ALTER TABLE "ehk_events_gallery" RENAME CONSTRAINT "ehk_events_images_pkey" TO "ehk_events_gallery_pkey"; + ALTER TABLE "ehk_events_gallery" RENAME CONSTRAINT "ehk_events_images_image_id_media_id_fk" TO "ehk_events_gallery_image_id_media_id_fk"; + ALTER TABLE "ehk_events_gallery" RENAME CONSTRAINT "ehk_events_images_parent_id_fk" TO "ehk_events_gallery_parent_id_fk"; + ALTER INDEX "ehk_events_images_order_idx" RENAME TO "ehk_events_gallery_order_idx"; + ALTER INDEX "ehk_events_images_parent_id_idx" RENAME TO "ehk_events_gallery_parent_id_idx"; + ALTER INDEX "ehk_events_images_image_idx" RENAME TO "ehk_events_gallery_image_idx"; + ALTER TABLE "ehk_events" ADD COLUMN "cover_image_id" integer; + ALTER TABLE "ehk_events" ADD CONSTRAINT "ehk_events_cover_image_id_media_id_fk" FOREIGN KEY ("cover_image_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action; + CREATE INDEX "ehk_events_cover_image_idx" ON "ehk_events" USING btree ("cover_image_id");`) +} + +export async function down({ db, payload, req }: MigrateDownArgs): Promise { + await db.execute(sql` + ALTER TABLE "ehk_events" DROP CONSTRAINT "ehk_events_cover_image_id_media_id_fk"; + DROP INDEX "ehk_events_cover_image_idx"; + ALTER TABLE "ehk_events" DROP COLUMN "cover_image_id"; + ALTER INDEX "ehk_events_gallery_order_idx" RENAME TO "ehk_events_images_order_idx"; + ALTER INDEX "ehk_events_gallery_parent_id_idx" RENAME TO "ehk_events_images_parent_id_idx"; + ALTER INDEX "ehk_events_gallery_image_idx" RENAME TO "ehk_events_images_image_idx"; + ALTER TABLE "ehk_events_gallery" RENAME CONSTRAINT "ehk_events_gallery_image_id_media_id_fk" TO "ehk_events_images_image_id_media_id_fk"; + ALTER TABLE "ehk_events_gallery" RENAME CONSTRAINT "ehk_events_gallery_parent_id_fk" TO "ehk_events_images_parent_id_fk"; + ALTER TABLE "ehk_events_gallery" RENAME CONSTRAINT "ehk_events_gallery_pkey" TO "ehk_events_images_pkey"; + ALTER TABLE "ehk_events_gallery" RENAME TO "ehk_events_images";`) +} diff --git a/src/migrations/index.ts b/src/migrations/index.ts index 567a50b..b99ebb3 100644 --- a/src/migrations/index.ts +++ b/src/migrations/index.ts @@ -26,6 +26,7 @@ import * as migration_20260619_141957_add_permissions_submission_and_external_li import * as migration_20260619_173736_academic_scholarship_faq from './20260619_173736_academic_scholarship_faq'; import * as migration_20260620_123301 from './20260620_123301'; import * as migration_20260620_151905_ehk_scholarships from './20260620_151905_ehk_scholarships'; +import * as migration_20260701_000000_ehk_events_cover_and_gallery from './20260701_000000_ehk_events_cover_and_gallery'; export const migrations = [ { @@ -168,4 +169,9 @@ export const migrations = [ down: migration_20260620_151905_ehk_scholarships.down, name: '20260620_151905_ehk_scholarships', }, + { + up: migration_20260701_000000_ehk_events_cover_and_gallery.up, + down: migration_20260701_000000_ehk_events_cover_and_gallery.down, + name: '20260701_000000_ehk_events_cover_and_gallery', + }, ]; diff --git a/src/payload-types.ts b/src/payload-types.ts index 533e368..fae6ea9 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -613,10 +613,19 @@ export interface EhkEvent { [k: string]: unknown; }; }; - images: { - image: number | Media; - id?: string | null; - }[]; + /** + * A rendezvény kártyáján megjelenő nagy borítókép + */ + coverImage?: (number | null) | Media; + /** + * Képek a galéria karuszelhez + */ + gallery?: + | { + image: number | Media; + id?: string | null; + }[] + | null; order?: number | null; /** * Eseményhez tartozó weboldal, Facebook, vagy más hivatkozások. @@ -1255,7 +1264,8 @@ export interface EhkEventsSelect { text_hu?: T; text_en?: T; }; - images?: + coverImage?: T; + gallery?: | T | { image?: T;