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

@@ -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
}
/>