structural refactor and codebase improvements
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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 ?? []} />
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user