// Server-only loader for the user profile page. Splitting this out keeps // the route component file free of direct DB/auth/follow imports — see // `home.server.ts` for the pattern. import { data } from "react-router"; import { eq } from "drizzle-orm"; import { getDb } from "~/lib/db"; import { users } from "@trails-cool/db/schema/journal"; import { getSessionUser } from "~/lib/auth/session.server"; import { listPublicRoutesForOwner } from "~/lib/routes.server"; import { listPublicActivitiesForOwner } from "~/lib/activities.server"; import { loadPersona } from "~/lib/demo-bot.server"; import { countFollowers, countFollowing, getFollowState } from "~/lib/follow.server"; export async function loadUserProfile(request: Request, username: string) { const db = getDb(); const [user] = await db.select().from(users).where(eq(users.username, username)); if (!user) { throw data({ error: "User not found" }, { status: 404 }); } const currentUser = await getSessionUser(request); const isOwn = currentUser?.id === user.id; // Follow state: null when anonymous or owner; { following, pending } // otherwise. const followState = !isOwn && currentUser ? await getFollowState(currentUser.id, user.username) : null; // Locked-account model: a private profile renders a stub for // non-followers (anonymous OR signed-in but not an accepted follower). // Owners always see their own profile in full. const canSeeContent = isOwn || user.profileVisibility === "public" || (followState !== null && followState.following === true); // For private-stub viewers we still want counts (cheap) but skip the // expensive content fetches. const [followers, following] = await Promise.all([ countFollowers(user.id), countFollowing(user.id), ]); const url = new URL(request.url); const sortParam = url.searchParams.get("sort"); const activitySort = sortParam === "addedAt" ? "addedAt" : "startedAt"; const [publicRoutes, publicActivities] = canSeeContent ? await Promise.all([ listPublicRoutesForOwner(user.id), listPublicActivitiesForOwner(user.id, activitySort), ]) : [[], []]; // Demo-account badge: true when this profile matches the instance's // configured demo persona username. Computed server-side so we don't // ship the persona config through client HTML. const isDemoUser = user.username === loadPersona().username; return { user: { username: user.username, displayName: user.displayName, bio: user.bio, domain: user.domain, createdAt: user.createdAt.toISOString(), }, routes: publicRoutes.map((r) => ({ id: r.id, name: r.name, description: r.description, distance: r.distance, elevationGain: r.elevationGain, updatedAt: r.updatedAt.toISOString(), })), activities: publicActivities.map((a) => ({ id: a.id, name: a.name, description: a.description, distance: a.distance, duration: a.duration, startedAt: a.startedAt?.toISOString() ?? null, createdAt: a.createdAt.toISOString(), })), activitySort, isOwn, isDemoUser, followers, following, followState, isLoggedIn: currentUser !== null, profileVisibility: user.profileVisibility, canSeeContent, }; }