Skip to content
Closed
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
142 changes: 142 additions & 0 deletions src/app/(frontend)/_components/PortalListControls.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import Link from 'next/link'
import React from 'react'

import { Input } from '@/components/ui/input'

type SearchParams = Record<string, string | number | null | undefined>
export type ListSearchParams = {
page?: string | string[]
q?: string | string[]
}

export const PortalSearchForm: React.FC<{
action: string
label: string
placeholder: string
query: string
}> = ({ action, label, placeholder, query }) => (
<form action={action} className="mt-10 flex flex-col gap-3 portal-card sm:flex-row">
<label className="flex-1">
<span className="sr-only">{label}</span>
<Input defaultValue={query} name="q" placeholder={placeholder} type="search" />
</label>
<button className="portal-admin-link justify-center" type="submit">
Search
</button>
{query ? (
<Link className="portal-admin-link justify-center" href={action}>
Clear
</Link>
) : null}
</form>
)

export const PortalPagination: React.FC<{
basePath: string
page?: number | null
query?: string
totalPages?: number | null
}> = ({ basePath, page = 1, query = '', totalPages = 1 }) => {
if (!page || !totalPages || totalPages <= 1) return null

const hasPrevious = page > 1
const hasNext = page < totalPages
const pages = getVisiblePages(page, totalPages)

return (
<nav aria-label="Pagination" className="mt-10 flex flex-wrap items-center gap-2">
<PaginationLink
disabled={!hasPrevious}
href={getListHref(basePath, { page: page - 1, q: query })}
>
Previous
</PaginationLink>
{pages.map((item, index) =>
item === 'ellipsis' ? (
<span className="px-3 py-2 text-sm text-muted-foreground" key={`ellipsis-${index}`}>
...
</span>
) : (
<PaginationLink
active={item === page}
href={getListHref(basePath, { page: item, q: query })}
key={item}
>
{item}
</PaginationLink>
),
)}
<PaginationLink
disabled={!hasNext}
href={getListHref(basePath, { page: page + 1, q: query })}
>
Next
</PaginationLink>
</nav>
)
}

export const getListHref = (basePath: string, params: SearchParams): string => {
const searchParams = new URLSearchParams()

for (const [key, value] of Object.entries(params)) {
if (value === null || typeof value === 'undefined' || value === '') continue
if (key === 'page' && Number(value) <= 1) continue
searchParams.set(key, String(value))
}

const queryString = searchParams.toString()

return queryString ? `${basePath}?${queryString}` : basePath
}

export const getListQueryValue = (value?: string | string[]): string => {
const normalized = Array.isArray(value) ? value[0] : value

return (normalized || '').trim().slice(0, 80)
}

export const getListPageValue = (value?: string | string[]): number => {
const normalized = Number(Array.isArray(value) ? value[0] : value)

return Number.isInteger(normalized) && normalized > 0 ? normalized : 1
}

const PaginationLink: React.FC<{
active?: boolean
children: React.ReactNode
disabled?: boolean
href: string
}> = ({ active = false, children, disabled = false, href }) => {
const className = [
'border px-4 py-2 font-mono text-xs font-bold uppercase tracking-[0.08em] transition-colors',
active
? 'border-primary bg-primary text-primary-foreground'
: 'border-border text-foreground hover:border-primary hover:text-primary',
disabled ? 'pointer-events-none opacity-40' : '',
]
.filter(Boolean)
.join(' ')

return (
<Link aria-current={active ? 'page' : undefined} className={className} href={href}>
{children}
</Link>
)
Comment on lines +121 to +125
}

const getVisiblePages = (page: number, totalPages: number): (number | 'ellipsis')[] => {
if (totalPages <= 7) return Array.from({ length: totalPages }, (_, index) => index + 1)

const pages = new Set([1, totalPages, page - 1, page, page + 1])
const sortedPages = [...pages]
.filter((value) => value >= 1 && value <= totalPages)
.sort((a, b) => a - b)

return sortedPages.flatMap((value, index) => {
const previous = sortedPages[index - 1]
if (!previous || value - previous === 1) return [value]

return ['ellipsis' as const, value]
})
}
21 changes: 16 additions & 5 deletions src/app/(frontend)/api/users/verify-email/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { getPayload } from 'payload'

import type { User } from '@/payload-types'
import { getServerSideURL } from '@/utilities/getURL'
import { renderTransactionalEmail } from '@/utilities/transactionalEmail'

type UserRole = Exclude<NonNullable<User['roles']>[number], undefined>

Expand Down Expand Up @@ -102,11 +103,21 @@ export async function POST(request: Request) {

try {
await payload.sendEmail({
html: `
<p>Verify your RaidGuild Portal account email.</p>
<p><a href="${verificationURL}">Verify this email address</a></p>
<p>This link expires in 30 minutes. If you did not request this, you can ignore this email.</p>
`,
html: renderTransactionalEmail({
action: {
href: verificationURL,
label: 'Verify email',
},
footer:
'This link expires in 30 minutes. If you did not request it, you can ignore this email.',
intro:
'Confirm this address so your Portal account can use contributor actions, check-ins, and notification delivery.',
preheader: 'Verify your RaidGuild Portal account email.',
sections: [
'Email verification helps keep member and contributor activity tied to a trusted account.',
],
title: 'Verify your Portal email',
}),
subject: 'Verify your RaidGuild Portal email',
to: user.email,
})
Expand Down
134 changes: 117 additions & 17 deletions src/app/(frontend)/events/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,24 @@ import Link from 'next/link'
import React from 'react'

import configPromise from '@payload-config'
import { getPayload } from 'payload'
import { getPayload, type Where } from 'payload'

import { canContributeContent } from '@/access/roles'
import { PageRange } from '@/components/PageRange'
import type { Event, Profile, Project, Thread } from '@/payload-types'
import { createGoogleCalendarURL } from '@/utilities/calendarLinks'
import { getCurrentUser } from '@/utilities/getCurrentUser'
import { toSafeURL } from '@/utilities/safeURL'
import {
getListPageValue,
getListQueryValue,
PortalPagination,
PortalSearchForm,
type ListSearchParams,
} from '../_components/PortalListControls'

export const dynamic = 'force-dynamic'
const EVENTS_PER_PAGE = 24

const formatDateTime = (date?: string | null) => {
if (!date) return null
Expand Down Expand Up @@ -59,15 +68,21 @@ const sourceStatusLabels: Record<NonNullable<Event['sourceStatus']>, string> = {
summarized: 'Summarized',
}

export default async function EventsPage() {
const user = await getCurrentUser()
const events = await getEvents(user)
type Args = {
searchParams?: Promise<ListSearchParams>
}

export default async function EventsPage({ searchParams: searchParamsPromise }: Args) {
const [user, searchParams] = await Promise.all([getCurrentUser(), searchParamsPromise])
const query = getListQueryValue(searchParams?.q)
const page = getListPageValue(searchParams?.page)
const events = await getEvents(user, { page, query })
const now = Date.now()
const live = events.filter((event) => isLiveEvent(event, now))
const upcoming = events.filter(
const live = events.docs.filter((event) => isLiveEvent(event, now))
const upcoming = events.docs.filter(
(event) => !isLiveEvent(event, now) && new Date(event.startsAt).getTime() >= now,
Comment on lines +79 to 83
)
const past = events.filter(
const past = events.docs.filter(
(event) => !isLiveEvent(event, now) && new Date(event.startsAt).getTime() < now,
)
upcoming.sort((a, b) => new Date(b.startsAt).getTime() - new Date(a.startsAt).getTime())
Expand All @@ -92,6 +107,22 @@ export default async function EventsPage() {
) : null}
</section>

<PortalSearchForm
action="/events"
label="Search sessions"
placeholder="Search by session title, summary, location, type, or series"
query={query}
/>

<div className="mt-8">
<PageRange
collectionLabels={{ plural: 'Sessions', singular: 'Session' }}
currentPage={events.page}
limit={EVENTS_PER_PAGE}
totalDocs={events.totalDocs}
/>
</div>

{live.length ? (
<section className="mt-10 border border-primary/40 bg-primary/10 p-5">
<p className="portal-kicker">Live Now</p>
Expand All @@ -116,7 +147,11 @@ export default async function EventsPage() {
<SessionRow canManageSessions={canManageSessions} event={event} key={event.id} />
))
) : (
<p className="text-sm text-muted-foreground">No upcoming sessions are published yet.</p>
<p className="text-sm text-muted-foreground">
{query
? 'No upcoming sessions match that search on this page.'
: 'No upcoming sessions are published yet.'}
</p>
)}
</div>
</section>
Expand All @@ -136,6 +171,13 @@ export default async function EventsPage() {
</div>
</section>
) : null}

<PortalPagination
basePath="/events"
page={events.page}
query={query}
totalPages={events.totalPages}
/>
</main>
)
}
Expand Down Expand Up @@ -314,22 +356,80 @@ const isLiveEvent = (event: Event, now: number): boolean => {
return startsAt <= now && endsAt > now
}

const getEvents = async (user: Awaited<ReturnType<typeof getCurrentUser>>) => {
const getEvents = async (
user: Awaited<ReturnType<typeof getCurrentUser>>,
{
page,
query,
}: {
page: number
query: string
},
) => {
const payload = await getPayload({ config: configPromise })
const where: Where = {
and: [
{
_status: {
equals: 'published',
},
},
],
}

if (query) {
where.and?.push({
or: [
{
title: {
like: query,
},
},
{
summary: {
like: query,
},
},
{
locationLabel: {
like: query,
},
},
{
sessionType: {
like: query,
},
},
{
seriesTitle: {
like: query,
},
},
{
'relatedProjects.title': {
like: query,
},
},
{
'relatedThreads.title': {
like: query,
},
},
],
})
}

const result = await payload.find({
collection: 'events',
depth: 2,
draft: false,
limit: 100,
limit: EVENTS_PER_PAGE,
overrideAccess: false,
sort: 'startsAt',
page,
sort: '-startsAt',
user: user || undefined,
where: {
_status: {
equals: 'published',
},
},
where,
})

return result.docs
return result
}
Loading