structural refactor and codebase improvements

This commit is contained in:
2026-08-09 14:58:14 +02:00
parent 22b1dd3f56
commit c9715baab8
247 changed files with 11515 additions and 3063 deletions

View File

@@ -1,5 +1,6 @@
import type { LucideIcon } from "lucide-react"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Link } from "@tanstack/react-router"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Card, CardContent } from "@/components/ui/card"
import { Skeleton } from "@/components/ui/skeleton"
import { EmptyState } from "@/components/empty-state"
@@ -28,9 +29,9 @@ export function ActorList({ data, isPending, emptyIcon, emptyTitle, emptyDescrip
}
function actorHandle(actor: RemoteActorDto): string {
if (actor.handle.startsWith("@")) return actor.handle
try {
const host = new URL(actor.url).host
return `@${actor.handle}@${host}`
return `@${actor.handle}@${new URL(actor.url).host}`
} catch {
return `@${actor.handle}`
}
@@ -39,16 +40,31 @@ function actorHandle(actor: RemoteActorDto): string {
function ActorCard({ actor, action }: { actor: RemoteActorDto; action?: React.ReactNode }) {
const initial = (actor.display_name || actor.handle)[0]?.toUpperCase() ?? "?"
const identity = (
<div className="flex min-w-0 flex-1 items-center gap-3">
<Avatar>
{actor.avatar_url && <AvatarImage src={actor.avatar_url} />}
<AvatarFallback>{initial}</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold">{actor.display_name || actor.handle}</p>
<p className="truncate text-xs text-muted-foreground">{actorHandle(actor)}</p>
</div>
</div>
)
return (
<Card size="sm">
<CardContent className="flex items-center gap-3">
<Avatar>
<AvatarFallback>{initial}</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold">{actor.display_name || actor.handle}</p>
<p className="truncate text-xs text-muted-foreground">{actorHandle(actor)}</p>
</div>
{actor.user_id ? (
<Link to="/users/$id" params={{ id: actor.user_id }} className="min-w-0 flex-1">
{identity}
</Link>
) : (
<a href={actor.url} target="_blank" rel="noopener noreferrer" className="min-w-0 flex-1">
{identity}
</a>
)}
{action}
</CardContent>
</Card>

View File

@@ -3,7 +3,13 @@ import { useTranslation } from "react-i18next"
import { Home, Search, BookOpen, User } from "lucide-react"
import { cn } from "@/lib/utils"
export function BottomTabBar({ onLogTap }: { onLogTap: () => void }) {
export function BottomTabBar({
onLogTap,
pendingCount = 0,
}: {
onLogTap: () => void
pendingCount?: number
}) {
const { t } = useTranslation()
const matchRoute = useMatchRoute()
@@ -55,7 +61,12 @@ export function BottomTabBar({ onLogTap }: { onLogTap: () => void }) {
active ? "text-foreground" : "text-muted-foreground",
)}
>
<tab.icon className="size-5" strokeWidth={active ? 2.5 : 2} />
<div className="relative">
<tab.icon className="size-5" strokeWidth={active ? 2.5 : 2} />
{tab.to === "/profile" && pendingCount > 0 && (
<span className="absolute -right-1 -top-0.5 size-2 rounded-full bg-primary" />
)}
</div>
<span className="text-[10px] font-medium">{tab.label}</span>
</Link>
)

View File

@@ -41,7 +41,6 @@ export function ProfileView({
userId,
search,
onSearchChange,
isFederated,
bio,
handle,
}: ProfileViewProps) {
@@ -65,7 +64,7 @@ export function ProfileView({
</Avatar>
<div className="min-w-0 flex-1">
<p className="font-semibold">{data.username}</p>
{isFederated && handle && (
{handle && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Globe className="size-3" />
<span>{handle}</span>

View File

@@ -6,9 +6,20 @@ export const remoteActorDtoSchema = z.object({
handle: z.string(),
display_name: z.string().optional(),
url: z.string(),
user_id: z.string().nullish(),
avatar_url: z.string().nullish(),
})
export type RemoteActorDto = z.infer<typeof remoteActorDtoSchema>
export const followStateSchema = z.enum(["none", "pending", "accepted", "rejected"])
export type FollowState = z.infer<typeof followStateSchema>
export const followRelationSchema = z.object({
following: followStateSchema,
followed_by: followStateSchema,
})
export type FollowRelation = z.infer<typeof followRelationSchema>
export const actorListResponseSchema = z.object({
actors: z.array(remoteActorDtoSchema),
})
@@ -45,6 +56,11 @@ export const blockedActorResponseSchema = z.object({
})
export type BlockedActorResponse = z.infer<typeof blockedActorResponseSchema>
export const pendingCountResponseSchema = z.object({
count: z.number(),
})
export type PendingCountResponse = z.infer<typeof pendingCountResponseSchema>
function getFollowing() {
return get<ActorListResponse>("/social/following")
}
@@ -65,6 +81,18 @@ function getPendingFollowers() {
return get<ActorListResponse>("/social/followers/pending")
}
function getPendingFollowerCount() {
return get<PendingCountResponse>("/social/followers/pending/count")
}
function getPendingFollowing() {
return get<ActorListResponse>("/social/following/pending")
}
function getRelationship(actorUrl: string) {
return get<FollowRelation>(`/social/relationship?actor_url=${encodeURIComponent(actorUrl)}`)
}
function follow(data: FollowRequest) {
return post("/social/follow", data)
}
@@ -113,6 +141,9 @@ export const socialKeys = {
following: ["following"] as const,
followers: ["followers"] as const,
pending: ["followers-pending"] as const,
pendingCount: ["followers-pending-count"] as const,
pendingFollowing: ["following-pending"] as const,
relationship: (url: string) => ["relationship", url] as const,
userFollowing: (id: string) => ["following", id] as const,
userFollowers: (id: string) => ["followers", id] as const,
blockedDomains: ["blocked-domains"] as const,
@@ -156,12 +187,35 @@ export function usePendingFollowers() {
})
}
export function usePendingFollowerCount() {
return useQuery({
queryKey: socialKeys.pendingCount,
queryFn: getPendingFollowerCount,
})
}
export function usePendingFollowing() {
return useQuery({
queryKey: socialKeys.pendingFollowing,
queryFn: getPendingFollowing,
})
}
export function useRelationship(actorUrl?: string) {
return useQuery({
queryKey: socialKeys.relationship(actorUrl ?? ""),
queryFn: () => getRelationship(actorUrl!),
enabled: !!actorUrl,
})
}
export function useFollow() {
const qc = useQueryClient()
return useMutation({
mutationFn: (data: FollowRequest) => follow(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: socialKeys.following })
qc.invalidateQueries({ queryKey: ["relationship"] })
},
})
}
@@ -172,6 +226,18 @@ export function useUnfollow() {
mutationFn: (data: ActorUrlRequest) => unfollow(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: socialKeys.following })
qc.invalidateQueries({ queryKey: ["relationship"] })
},
})
}
export function useCancelFollow() {
const qc = useQueryClient()
return useMutation({
mutationFn: (data: ActorUrlRequest) => unfollow(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: socialKeys.pendingFollowing })
qc.invalidateQueries({ queryKey: ["relationship"] })
},
})
}
@@ -182,7 +248,9 @@ export function useAcceptFollower() {
mutationFn: (data: ActorUrlRequest) => acceptFollower(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: socialKeys.pending })
qc.invalidateQueries({ queryKey: socialKeys.pendingCount })
qc.invalidateQueries({ queryKey: socialKeys.followers })
qc.invalidateQueries({ queryKey: ["relationship"] })
},
})
}
@@ -193,6 +261,8 @@ export function useRejectFollower() {
mutationFn: (data: ActorUrlRequest) => rejectFollower(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: socialKeys.pending })
qc.invalidateQueries({ queryKey: socialKeys.pendingCount })
qc.invalidateQueries({ queryKey: ["relationship"] })
},
})
}
@@ -203,6 +273,7 @@ export function useRemoveFollower() {
mutationFn: (data: ActorUrlRequest) => removeFollower(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: socialKeys.followers })
qc.invalidateQueries({ queryKey: ["relationship"] })
},
})
}

View File

@@ -2,6 +2,7 @@
"common": {
"cancel": "Cancel",
"confirm": "Confirm",
"requested": "Requested",
"delete": "Delete",
"edit": "Edit",
"save": "Save",
@@ -143,11 +144,16 @@
"following": "Following",
"followers": "Followers",
"pending": "Pending",
"requested": "Requested",
"notFollowing": "Not following anyone",
"notFollowingDesc": "Follow users to see their reviews in your feed",
"noFollowers": "No followers yet",
"noFollowersOther": "No followers",
"noPending": "No pending requests",
"noRequested": "No requests sent",
"noRequestedDesc": "Follow requests you send will appear here until accepted",
"cancelRequest": "Cancel request",
"cancelRequestConfirm": "Cancel this follow request?",
"followSent": "Follow request sent to {{handle}}",
"followError": "Could not follow that user",
"handlePlaceholder": "@user@instance.example"

View File

@@ -11,6 +11,7 @@ import { Toaster } from "@/components/ui/sonner"
import { BottomTabBar } from "@/components/bottom-tab-bar"
import { ReviewSheet } from "@/components/review-sheet"
import { getAuth } from "@/lib/auth"
import { usePendingFollowerCount } from "@/features/social"
export const Route = createFileRoute("/_app")({
beforeLoad: () => {
@@ -39,6 +40,8 @@ function AppLayout() {
const [logOpen, setLogOpen] = useState(false)
const matches = useMatches()
const routeKey = matches.at(-1)?.id ?? ""
const { data: pending } = usePendingFollowerCount()
const pendingCount = pending?.count ?? 0
return (
<div className="mx-auto min-h-svh max-w-lg">
@@ -47,7 +50,7 @@ function AppLayout() {
<Outlet />
</div>
</main>
<BottomTabBar onLogTap={() => setLogOpen(true)} />
<BottomTabBar onLogTap={() => setLogOpen(true)} pendingCount={pendingCount} />
<ReviewSheet mode="log" open={logOpen} onOpenChange={setLogOpen} />
<Toaster position="top-center" />
</div>

View File

@@ -46,6 +46,8 @@ function ProfilePage() {
userId={auth.user_id}
search={search}
onSearchChange={setSearch}
bio={data.bio}
handle={data.handle}
actions={
<>
<GoalSection goals={data.goals ?? []} />

View File

@@ -1,26 +1,31 @@
import { createFileRoute, Link } from "@tanstack/react-router"
import { useState } from "react"
import { useTranslation } from "react-i18next"
import { ArrowLeft, UserCheck, UserMinus, UserPlus, UserX, Users } from "lucide-react"
import { ArrowLeft, Clock, UserCheck, UserMinus, UserPlus, UserX, Users, X } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { toast } from "sonner"
import { useAuth } from "@/components/auth-provider"
import { ActorList } from "@/components/actor-list"
import { ConfirmDialog } from "@/components/confirm-dialog"
import {
useFollow,
useFollowing,
useFollowers,
usePendingFollowers,
usePendingFollowing,
useUnfollow,
useCancelFollow,
useAcceptFollower,
useRejectFollower,
useRemoveFollower,
useUserFollowing,
useUserFollowers,
} from "@/features/social"
import type { RemoteActorDto } from "@/features/social"
import { useDocumentTitle } from "@/hooks/use-document-title"
type SearchParams = { user?: string }
@@ -57,14 +62,31 @@ function SocialPage() {
function OwnSocialTabs() {
const { t } = useTranslation()
const { data: pendingFollowers } = usePendingFollowers()
const { data: pendingFollowing } = usePendingFollowing()
const pendingCount = pendingFollowers?.actors.length ?? 0
const requestedCount = pendingFollowing?.actors.length ?? 0
return (
<Tabs defaultValue="following">
<TabsList className="w-full">
<TabsTrigger value="following">{t("social.following")}</TabsTrigger>
<TabsTrigger value="requested">
{t("social.requested")}
{requestedCount > 0 && (
<Badge variant="secondary" className="ml-1.5 text-[10px]">{requestedCount}</Badge>
)}
</TabsTrigger>
<TabsTrigger value="followers">{t("social.followers")}</TabsTrigger>
<TabsTrigger value="pending">{t("social.pending")}</TabsTrigger>
<TabsTrigger value="pending">
{t("social.pending")}
{pendingCount > 0 && (
<Badge variant="secondary" className="ml-1.5 text-[10px]">{pendingCount}</Badge>
)}
</TabsTrigger>
</TabsList>
<TabsContent value="following"><OwnFollowingTab /></TabsContent>
<TabsContent value="requested"><RequestedTab /></TabsContent>
<TabsContent value="followers"><OwnFollowersTab /></TabsContent>
<TabsContent value="pending"><PendingTab /></TabsContent>
</Tabs>
@@ -165,6 +187,49 @@ function PendingTab() {
)
}
function RequestedTab() {
const { t } = useTranslation()
const { data, isPending } = usePendingFollowing()
const cancelMutation = useCancelFollow()
const [target, setTarget] = useState<RemoteActorDto | null>(null)
return (
<>
<ActorList
data={data}
isPending={isPending}
emptyIcon={Clock}
emptyTitle={t("social.noRequested")}
emptyDescription={t("social.noRequestedDesc")}
renderAction={(actor) => (
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-[10px]">{t("common.requested")}</Badge>
<Button
variant="outline"
size="sm"
onClick={() => setTarget(actor)}
disabled={cancelMutation.isPending}
>
<X className="mr-1 size-3.5" />
{t("social.cancelRequest")}
</Button>
</div>
)}
/>
<ConfirmDialog
open={!!target}
onOpenChange={(open) => !open && setTarget(null)}
title={t("social.cancelRequestConfirm")}
confirmLabel={t("social.cancelRequest")}
onConfirm={() => {
if (target) cancelMutation.mutate({ actor_url: target.url })
setTarget(null)
}}
/>
</>
)
}
function UserFollowingTab({ userId }: { userId: string }) {
const { t } = useTranslation()
const { data, isPending } = useUserFollowing(userId)

View File

@@ -1,15 +1,23 @@
import { createFileRoute } from "@tanstack/react-router"
import { useState } from "react"
import { useTranslation } from "react-i18next"
import { ExternalLink, UserCheck, UserPlus } from "lucide-react"
import { Clock, ExternalLink, UserCheck, UserPlus } from "lucide-react"
import { BackButton } from "@/components/back-button"
import { Button } from "@/components/ui/button"
import { ConfirmDialog } from "@/components/confirm-dialog"
import { ProfileView, ProfileSkeleton } from "@/components/profile-view"
import { GoalCard } from "@/components/goal-card"
import { useAuth } from "@/components/auth-provider"
import { useUserProfile } from "@/features/users"
import { useFollow, useUnfollow, useFollowing } from "@/features/social"
import { useFollow, useUnfollow, useCancelFollow, useRelationship } from "@/features/social"
import { useDocumentTitle } from "@/hooks/use-document-title"
import type { FollowState } from "@/features/social"
type FollowButtonContent = {
icon: React.ReactNode
label: string
variant: "default" | "outline"
}
export const Route = createFileRoute("/_app/users/$id")({
component: UserProfilePage,
@@ -20,17 +28,77 @@ function UserProfilePage() {
const { id } = Route.useParams()
const { auth } = useAuth()
const { data, isPending } = useUserProfile(id, { view: "trends" })
const { data: followingData } = useFollowing()
const { data: relation } = useRelationship(data?.actor_url ?? undefined)
const followMutation = useFollow()
const unfollowMutation = useUnfollow()
const cancelFollowMutation = useCancelFollow()
const [search, setSearch] = useState("")
const [confirmOpen, setConfirmOpen] = useState(false)
useDocumentTitle(data?.username)
if (isPending) return <ProfileSkeleton />
if (!data) return null
const isSelf = auth?.user_id === id
const isFollowing = followingData?.actors.some((a) => a.handle === data.username) ?? false
const followState: FollowState = relation?.following ?? "none"
// The follow endpoint requires the full "user@domain" handle — a bare
// username 500s server-side (CompositeSocialAdapter.resolve_target_identity
// only recognizes the local instance when the handle carries "@<host>").
// Arrow consts, not function declarations: hoisted declarations would not see
// the `if (!data) return null` narrowing above.
const sendFollowRequest = () => {
followMutation.mutate({ handle: data.handle ?? data.username })
}
const handleFollowButtonClick = () => {
switch (followState) {
case "pending":
setConfirmOpen(true)
return
case "accepted":
unfollowMutation.mutate({ actor_url: data.actor_url ?? "" })
return
case "rejected":
// A rejected request leaves nothing pending to cancel — let the user ask again.
sendFollowRequest()
return
case "none":
sendFollowRequest()
return
}
}
function followButtonContent(): FollowButtonContent {
switch (followState) {
case "pending":
return {
icon: <Clock className="mr-1 size-3.5" />,
label: t("social.requested", { defaultValue: "Requested" }),
variant: "outline",
}
case "accepted":
return {
icon: <UserCheck className="mr-1 size-3.5" />,
label: t("common.following"),
variant: "outline",
}
case "rejected":
return {
icon: <UserPlus className="mr-1 size-3.5" />,
label: t("common.follow"),
variant: "default",
}
case "none":
return {
icon: <UserPlus className="mr-1 size-3.5" />,
label: t("common.follow"),
variant: "default",
}
}
}
const { icon: followIcon, label: followLabel, variant: followVariant } = followButtonContent()
return (
<div className="p-4">
@@ -55,34 +123,36 @@ function UserProfilePage() {
) : undefined
}
headerRight={
!isSelf && !data.is_federated ? (
isFollowing ? (
!isSelf ? (
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
onClick={() => unfollowMutation.mutate({ actor_url: followingData?.actors.find((a) => a.handle === data.username)?.url ?? "" })}
disabled={unfollowMutation.isPending}
variant={followVariant}
onClick={handleFollowButtonClick}
disabled={followMutation.isPending || unfollowMutation.isPending}
>
<UserCheck className="mr-1 size-3.5" />
{t("common.following")}
{followIcon}
{followLabel}
</Button>
) : (
<Button
size="sm"
onClick={() => followMutation.mutate({ handle: data.username })}
disabled={followMutation.isPending}
>
<UserPlus className="mr-1 size-3.5" />
{t("common.follow")}
</Button>
)
) : data.is_federated && data.actor_url ? (
<a href={data.actor_url} target="_blank" rel="noopener noreferrer">
<Button size="sm" variant="outline">
<ExternalLink className="mr-1 size-3.5" />
{t("common.viewOnRemote", { defaultValue: "Remote" })}
</Button>
</a>
{data.is_federated && data.actor_url && (
<a href={data.actor_url} target="_blank" rel="noopener noreferrer">
<Button size="sm" variant="outline">
<ExternalLink className="mr-1 size-3.5" />
{t("common.viewOnRemote", { defaultValue: "Remote" })}
</Button>
</a>
)}
<ConfirmDialog
open={confirmOpen}
onOpenChange={setConfirmOpen}
title={t("social.cancelFollowRequest", { defaultValue: "Cancel follow request?" })}
confirmLabel={t("social.cancelRequest", { defaultValue: "Cancel request" })}
onConfirm={() => {
cancelFollowMutation.mutate({ actor_url: data.actor_url ?? "" })
setConfirmOpen(false)
}}
/>
</div>
) : undefined
}
/>