Skip to content
Merged
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
6 changes: 4 additions & 2 deletions app/components/search-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,19 @@ export function SearchBar({
status,
autoFocus = false,
autoSubmit = false,
action,
}: {
status: 'idle' | 'pending' | 'success' | 'error'
autoFocus?: boolean
autoSubmit?: boolean
action?: string
}) {
const id = useId()
const [searchParams] = useSearchParams()
const submit = useSubmit()
const isSubmitting = useIsPending({
formMethod: 'GET',
formAction: '/users',
formAction: action,
})

const handleFormChange = useDebounce((form: HTMLFormElement) => {
Expand All @@ -30,7 +32,7 @@ export function SearchBar({
return (
<Form
method="GET"
action="/users"
action={action}
className="flex flex-wrap items-center justify-center gap-2"
onChange={(e) => autoSubmit && handleFormChange(e.currentTarget)}
>
Expand Down
225 changes: 184 additions & 41 deletions app/routes/_app+/recipients+/$recipientId.past.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,48 +4,79 @@ import {
json,
type LoaderFunctionArgs,
} from '@remix-run/node'
import { useLoaderData } from '@remix-run/react'
import { Link, useLoaderData, useSearchParams } from '@remix-run/react'
import { GeneralErrorBoundary } from '#app/components/error-boundary.tsx'
import { SearchBar } from '#app/components/search-bar.tsx'
import { Button } from '#app/components/ui/button.tsx'
import { Icon } from '#app/components/ui/icon.tsx'
import { requireUserId } from '#app/utils/auth.server.ts'
import { prisma } from '#app/utils/db.server.ts'
import { cn, useDelayedIsPending } from '#app/utils/misc.tsx'

const MESSAGES_PER_PAGE = 100

export async function loader({ params, request }: LoaderFunctionArgs) {
const userId = await requireUserId(request)
const url = new URL(request.url)
const searchQuery = url.searchParams.get('search') ?? ''
const page = Math.max(1, parseInt(url.searchParams.get('page') ?? '1', 10) || 1)

const recipient = await prisma.recipient.findUnique({
where: { id: params.recipientId, userId },
select: {
name: true,
phoneNumber: true,
messages: {
select: { id: true, content: true, sentAt: true, order: true },
orderBy: { order: 'asc' },
where: { sentAt: { not: null } },
},
},
})

invariantResponse(recipient, 'Not found', { status: 404 })

const { messages, ...recipientProps } = recipient
// Build the where clause for messages
const messageWhere = {
recipientId: params.recipientId,
sentAt: { not: null },
...(searchQuery
? { content: { contains: searchQuery } }
: {}),
}

// Get total count for pagination
const totalMessages = await prisma.message.count({
where: messageWhere,
})

const totalPages = Math.max(1, Math.ceil(totalMessages / MESSAGES_PER_PAGE))
const currentPage = Math.min(page, totalPages)

// Get paginated messages
const messages = await prisma.message.findMany({
where: messageWhere,
select: { id: true, content: true, sentAt: true },
orderBy: { sentAt: 'desc' },
skip: (currentPage - 1) * MESSAGES_PER_PAGE,
take: MESSAGES_PER_PAGE,
})

return json({
recipient: recipientProps,
messageCountDisplay: messages.length.toLocaleString(),
pastMessages: messages
.filter((m) => m.sentAt)
.sort((m1, m2) => m2.sentAt!.getTime() - m1.sentAt!.getTime())
.map((m) => ({
id: m.id,
sentAtDisplay: m.sentAt!.toLocaleDateString('en-US', {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
}),
content: m.content,
})),
recipient,
searchQuery,
pagination: {
currentPage,
totalPages,
totalMessages,
},
pastMessages: messages.map((m) => ({
id: m.id,
sentAtDisplay: m.sentAt!.toLocaleDateString('en-US', {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
}),
content: m.content,
})),
})
}

Expand All @@ -57,29 +88,141 @@ export const meta: MetaFunction<typeof loader> = ({ data }) => {
]
}

export default function RecipientRoute() {
const data = useLoaderData<typeof loader>()
function Pagination({
currentPage,
totalPages,
totalMessages,
searchQuery,
}: {
currentPage: number
totalPages: number
totalMessages: number
searchQuery: string
}) {
const [searchParams] = useSearchParams()

const buildPageUrl = (page: number) => {
const params = new URLSearchParams(searchParams)
if (page === 1) {
params.delete('page')
} else {
params.set('page', page.toString())
}
const queryString = params.toString()
return queryString ? `?${queryString}` : '.'
}

const hasPrevPage = currentPage > 1
const hasNextPage = currentPage < totalPages

return (
<div>
<p className="mb-8">
You have sent <strong>{data.messageCountDisplay}</strong>{' '}
{data.pastMessages.length === 1 ? 'message' : 'messages'} to{' '}
{data.recipient.name}.
<div className="flex flex-col items-center gap-4 sm:flex-row sm:justify-between">
<p className="text-sm text-muted-foreground">
{totalMessages === 0
? 'No messages found'
: `Showing ${((currentPage - 1) * MESSAGES_PER_PAGE) + 1}-${Math.min(currentPage * MESSAGES_PER_PAGE, totalMessages)} of ${totalMessages.toLocaleString()} message${totalMessages === 1 ? '' : 's'}`}
{searchQuery ? (
<>
{' '}
matching "<strong>{searchQuery}</strong>"
</>
) : null}
</p>
<ul className="flex flex-col gap-2">
{data.pastMessages.map((m) => (
<li
key={m.id}
className="flex flex-col justify-start gap-2 align-top lg:flex-row"
{totalPages > 1 ? (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
asChild={hasPrevPage}
disabled={!hasPrevPage}
>
{hasPrevPage ? (
<Link to={buildPageUrl(currentPage - 1)} preventScrollReset>
<Icon name="arrow-left" size="sm" />
Previous
</Link>
) : (
<span>
<Icon name="arrow-left" size="sm" />
Previous
</span>
)}
</Button>
<span className="px-2 text-sm">
Page {currentPage} of {totalPages}
</span>
<Button
variant="outline"
size="sm"
asChild={hasNextPage}
disabled={!hasNextPage}
>
<span className="min-w-36 text-muted-secondary-foreground">
{m.sentAtDisplay}
</span>
<span>{m.content}</span>
{hasNextPage ? (
<Link to={buildPageUrl(currentPage + 1)} preventScrollReset>
Next
<Icon name="arrow-right" size="sm" />
</Link>
) : (
<span>
Next
<Icon name="arrow-right" size="sm" />
</span>
)}
</Button>
</div>
) : null}
</div>
)
}

export default function RecipientRoute() {
const data = useLoaderData<typeof loader>()
const isPending = useDelayedIsPending({
formMethod: 'GET',
})

return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-4">
<SearchBar status="idle" autoSubmit />
<Pagination
currentPage={data.pagination.currentPage}
totalPages={data.pagination.totalPages}
totalMessages={data.pagination.totalMessages}
searchQuery={data.searchQuery}
/>
</div>

<ul className={cn('flex flex-col gap-2', { 'opacity-50': isPending })}>
{data.pastMessages.length === 0 ? (
<li className="py-8 text-center text-muted-foreground">
{data.searchQuery
? 'No messages match your search.'
: 'No past messages yet.'}
</li>
))}
) : (
data.pastMessages.map((m) => (
<li
key={m.id}
className="flex flex-col justify-start gap-2 align-top lg:flex-row"
>
<span className="min-w-36 text-muted-secondary-foreground">
{m.sentAtDisplay}
</span>
<span>{m.content}</span>
</li>
))
)}
</ul>

{data.pastMessages.length > 0 && data.pagination.totalPages > 1 ? (
<Pagination
currentPage={data.pagination.currentPage}
totalPages={data.pagination.totalPages}
totalMessages={data.pagination.totalMessages}
searchQuery={data.searchQuery}
/>
) : null}
</div>
)
}
Expand Down
Loading