import { data } from "react-router"; import { Link } from "react-router"; import { useTranslation } from "react-i18next"; import type { Route } from "./+types/explore"; import { getSessionUser } from "~/lib/auth.server"; import { EXPLORE_DEFAULT_PAGE_SIZE, countFollowersBatch, getFollowStateBatch, listActiveRecently, listDirectory, } from "~/lib/explore.server"; import { FollowButton } from "~/components/FollowButton"; const BIO_TRUNCATE = 120; function truncateBio(bio: string | null): string | null { if (!bio) return null; const trimmed = bio.trim(); if (trimmed.length === 0) return null; if (trimmed.length <= BIO_TRUNCATE) return trimmed; return trimmed.slice(0, BIO_TRUNCATE).trimEnd() + "…"; } export async function loader({ request }: Route.LoaderArgs) { const viewer = await getSessionUser(request); const url = new URL(request.url); const page = Number(url.searchParams.get("page") ?? "1"); const perPage = Number(url.searchParams.get("perPage") ?? String(EXPLORE_DEFAULT_PAGE_SIZE)); const [activeRecently, directory] = await Promise.all([ listActiveRecently(), listDirectory({ page, perPage }), ]); // Per-row data: follower count (for everyone) + follow state (for // signed-in viewers only). Both are batched so the page issues at // most two extra queries regardless of page size. const allRows = [...activeRecently, ...directory.rows]; const allIds = allRows.map((r) => r.id); const followerCounts = await countFollowersBatch(allIds); const followStates = viewer ? await getFollowStateBatch(viewer.id, allRows.map((r) => ({ id: r.id, username: r.username }))) : new Map(); const isSelf = (rowId: string) => viewer?.id === rowId; const decorate = (row: typeof allRows[number]) => ({ id: row.id, username: row.username, displayName: row.displayName, bio: truncateBio(row.bio), followerCount: followerCounts.get(row.id) ?? 0, followState: followStates.get(row.id) ?? null, isSelf: isSelf(row.id), }); // Resolved page size (after loader-side clamping inside listDirectory) // for the pagination math here. We can compute totalPages without // re-querying since `directory.totalCount` is authoritative. const resolvedPerPage = Math.max(1, Math.min(100, Math.floor(Number.isFinite(perPage) ? perPage : EXPLORE_DEFAULT_PAGE_SIZE))); const resolvedPage = Math.max(1, Math.floor(Number.isFinite(page) ? page : 1)); const totalPages = Math.max(1, Math.ceil(directory.totalCount / resolvedPerPage)); return data({ isSignedIn: !!viewer, activeRecently: activeRecently.map(decorate), directory: directory.rows.map(decorate), page: resolvedPage, perPage: resolvedPerPage, totalPages, totalCount: directory.totalCount, }); } export function meta(_args: Route.MetaArgs) { return [{ title: "Explore — trails.cool" }]; } interface DecoratedRow { id: string; username: string; displayName: string | null; bio: string | null; followerCount: number; followState: { following: boolean; pending: boolean } | null; isSelf: boolean; } function DirectoryRow({ row, isSignedIn }: { row: DecoratedRow; isSignedIn: boolean }) { const { t } = useTranslation("journal"); return (
  • {row.displayName ?? row.username}

    @{row.username} · {t("social.followers.count", { count: row.followerCount })}

    {row.bio &&

    {row.bio}

    }
    {isSignedIn && !row.isSelf && ( )}
  • ); } export default function Explore({ loaderData }: Route.ComponentProps) { const { isSignedIn, activeRecently, directory, page, totalPages, totalCount } = loaderData; const { t } = useTranslation("journal"); return (

    {t("explore.heading")}

    {activeRecently.length > 0 && (

    {t("explore.activeRecently.heading")}

    )}

    {t("explore.directory.heading")}

    {totalCount === 0 ? (

    {t("explore.empty")}

    ) : ( <> )}
    ); }