Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified backend/app/__pycache__/__init__.cpython-312.pyc
Binary file not shown.
Binary file not shown.
Binary file added backend/app/__pycache__/main.cpython-312.pyc
Binary file not shown.
Binary file modified backend/app/core/__pycache__/__init__.cpython-312.pyc
Binary file not shown.
Binary file modified backend/app/core/__pycache__/config.cpython-312.pyc
Binary file not shown.
Binary file modified backend/app/core/__pycache__/logging.cpython-312.pyc
Binary file not shown.
Binary file modified backend/app/core/__pycache__/security.cpython-312.pyc
Binary file not shown.
Binary file modified backend/app/database/__pycache__/__init__.cpython-312.pyc
Binary file not shown.
Binary file modified backend/app/database/__pycache__/base.cpython-312.pyc
Binary file not shown.
Binary file modified backend/app/database/__pycache__/database.cpython-312.pyc
Binary file not shown.
Binary file modified backend/app/database/__pycache__/session.cpython-312.pyc
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file modified backend/app/models/__pycache__/__init__.cpython-312.pyc
Binary file not shown.
Binary file modified backend/app/models/__pycache__/activity.cpython-312.pyc
Binary file not shown.
Binary file modified backend/app/models/__pycache__/application.cpython-312.pyc
Binary file not shown.
Binary file modified backend/app/models/__pycache__/audit_log.cpython-312.pyc
Binary file not shown.
Binary file modified backend/app/models/__pycache__/bookmark.cpython-312.pyc
Binary file not shown.
Binary file modified backend/app/models/__pycache__/builder_flare.cpython-312.pyc
Binary file not shown.
Binary file modified backend/app/models/__pycache__/conversation.cpython-312.pyc
Binary file not shown.
Binary file not shown.
Binary file modified backend/app/models/__pycache__/follower.cpython-312.pyc
Binary file not shown.
Binary file modified backend/app/models/__pycache__/message.cpython-312.pyc
Binary file not shown.
Binary file modified backend/app/models/__pycache__/notification.cpython-312.pyc
Binary file not shown.
Binary file modified backend/app/models/__pycache__/organization.cpython-312.pyc
Binary file not shown.
Binary file modified backend/app/models/__pycache__/project.cpython-312.pyc
Binary file not shown.
Binary file modified backend/app/models/__pycache__/project_member.cpython-312.pyc
Binary file not shown.
Binary file modified backend/app/models/__pycache__/project_skill.cpython-312.pyc
Binary file not shown.
Binary file modified backend/app/models/__pycache__/refresh_token.cpython-312.pyc
Binary file not shown.
Binary file modified backend/app/models/__pycache__/repository.cpython-312.pyc
Binary file not shown.
Binary file modified backend/app/models/__pycache__/skill.cpython-312.pyc
Binary file not shown.
Binary file modified backend/app/models/__pycache__/user.cpython-312.pyc
Binary file not shown.
Binary file modified backend/app/models/__pycache__/user_skill.cpython-312.pyc
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
19 changes: 18 additions & 1 deletion backend/app/routers/followers.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
from app.models.user import User
from app.schemas.follower import FollowerResponse
from app.services.follower_service import FollowerService
from app.models.notification import NotificationType
from app.services.notification_service import NotificationService

router = APIRouter(
prefix="/followers",
Expand Down Expand Up @@ -46,12 +48,27 @@ def follow_user(
detail="Already following this user",
)

return FollowerService.follow_user(
follow = FollowerService.follow_user(
db,
current_user.id,
user_id,
)

try:
NotificationService.enqueue(
db,
recipient_id=user_id,
sender_id=current_user.id,
type=NotificationType.FOLLOW,
title="New follower",
message=f"{current_user.username} started following you.",
action_url=f"/users/{current_user.id}",
)
except Exception:
db.rollback()

return follow


@router.delete(
"/{user_id}",
Expand Down
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
58 changes: 58 additions & 0 deletions backend/app/schemas/follower.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
from __future__ import annotations

import uuid
from datetime import datetime

from pydantic import BaseModel, ConfigDict


# ==========================================================
# Follower Response
# ==========================================================


class FollowerResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)

id: uuid.UUID
follower_id: uuid.UUID
following_id: uuid.UUID
created_at: datetime


# ==========================================================
# Follow Status
# ==========================================================


class FollowStatusResponse(BaseModel):
is_following: bool
follower_count: int
following_count: int


# ==========================================================
# Follow Action Response
# ==========================================================


class FollowActionResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)

id: uuid.UUID
follower_id: uuid.UUID
following_id: uuid.UUID
created_at: datetime
follower_count: int
following_count: int


# ==========================================================
# Unfollow Response
# ==========================================================


class UnfollowResponse(BaseModel):
message: str
follower_count: int
following_count: int
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
12 changes: 0 additions & 12 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

86 changes: 86 additions & 0 deletions frontend/src/components/shared/FollowButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { useFollowStatus, useFollow, useUnfollow } from "@/hooks/useFollow";
import { Loader2, UserPlus, UserCheck, UserMinus } from "lucide-react";
import { cn } from "@/lib/utils";
import type { UUID } from "@/lib/api";

export function FollowButton({
userId,
className,
}: {
userId: UUID;
className?: string;
}) {
const { data: status, isLoading: statusLoading } = useFollowStatus(userId);
const followMutation = useFollow(userId);
const unfollowMutation = useUnfollow(userId);

const [hovered, setHovered] = useState(false);

const isFollowing = status?.is_following ?? false;
const isBusy = followMutation.isPending || unfollowMutation.isPending;

function handleClick() {
if (isBusy) return;
if (isFollowing) {
unfollowMutation.mutate();
} else {
followMutation.mutate();
}
}

if (statusLoading) {
return (
<Button variant="outline" size="sm" disabled className={cn("min-w-[100px]", className)}>
<Loader2 size={14} className="animate-spin" />
</Button>
);
}

if (isFollowing) {
return (
<Button
variant={hovered ? "destructive" : "outline"}
size="sm"
onClick={handleClick}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
disabled={isBusy}
className={cn("min-w-[100px] transition-all", className)}
>
{isBusy ? (
<Loader2 size={14} className="animate-spin" />
) : hovered ? (
<>
<UserMinus size={14} />
Unfollow
</>
) : (
<>
<UserCheck size={14} />
Following
</>
)}
</Button>
);
}

return (
<Button
size="sm"
onClick={handleClick}
disabled={isBusy}
className={cn("min-w-[100px]", className)}
>
{isBusy ? (
<Loader2 size={14} className="animate-spin" />
) : (
<>
<UserPlus size={14} />
Follow
</>
)}
</Button>
);
}
1 change: 1 addition & 0 deletions frontend/src/features/dashboard/sections.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
Users2,
FileText,
BarChart3,
Trophy,
} from "lucide-react";
import { Link } from "@tanstack/react-router";
import { cn } from "@/lib/utils";
Expand Down
112 changes: 112 additions & 0 deletions frontend/src/hooks/useFollow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
followUser,
unfollowUser,
getFollowStatus,
toastError,
type UUID,
type FollowStatusResponse,
} from "@/lib/api";

export function useFollowStatus(userId: UUID | undefined) {
return useQuery({
queryKey: ["follow-status", userId],
queryFn: () => getFollowStatus(userId!),
enabled: !!userId,
});
}

export function useFollow(userId: UUID) {
const queryClient = useQueryClient();
const queryKey = ["follow-status", userId];

return useMutation({
mutationFn: () => followUser(userId),

onMutate: async () => {
await queryClient.cancelQueries({ queryKey });

const previous = queryClient.getQueryData<FollowStatusResponse>(queryKey);

queryClient.setQueryData<FollowStatusResponse>(queryKey, (old) =>
old
? {
...old,
is_following: true,
follower_count: old.follower_count + 1,
}
: { is_following: true, follower_count: 1, following_count: 0 },
);

return { previous };
},

onError: (err, _vars, context) => {
if (context?.previous) {
queryClient.setQueryData(queryKey, context.previous);
}
toastError(err, "Failed to follow");
},

onSuccess: (data) => {
queryClient.setQueryData<FollowStatusResponse>(queryKey, {
is_following: true,
follower_count: data.follower_count,
following_count: data.following_count,
});
toast.success("Followed successfully");
},

onSettled: () => {
queryClient.invalidateQueries({ queryKey });
},
});
}

export function useUnfollow(userId: UUID) {
const queryClient = useQueryClient();
const queryKey = ["follow-status", userId];

return useMutation({
mutationFn: () => unfollowUser(userId),

onMutate: async () => {
await queryClient.cancelQueries({ queryKey });

const previous = queryClient.getQueryData<FollowStatusResponse>(queryKey);

queryClient.setQueryData<FollowStatusResponse>(queryKey, (old) =>
old
? {
...old,
is_following: false,
follower_count: Math.max(0, old.follower_count - 1),
}
: { is_following: false, follower_count: 0, following_count: 0 },
);

return { previous };
},

onError: (err, _vars, context) => {
if (context?.previous) {
queryClient.setQueryData(queryKey, context.previous);
}
toastError(err, "Failed to unfollow");
},

onSuccess: (data) => {
queryClient.setQueryData<FollowStatusResponse>(queryKey, {
is_following: false,
follower_count: data.follower_count,
following_count: data.following_count,
});
toast.success("Unfollowed successfully");
},

onSettled: () => {
queryClient.invalidateQueries({ queryKey });
},
});
}
20 changes: 6 additions & 14 deletions frontend/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,7 @@ import { toast } from "sonner";
type JsonPrimitive = string | number | boolean | null;
type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };

export type ApplicationStatus =
| "pending"
| "reviewing"
| "accepted"
| "rejected"
| "withdrawn";
export type ApplicationStatus = "pending" | "reviewing" | "accepted" | "rejected" | "withdrawn";

export type UUID = string;

Expand Down Expand Up @@ -63,13 +58,11 @@ function assertJson<T>(data: unknown): T {
return data as T;
}

async function requestJson<TResponse, TBody extends JsonValue | undefined = undefined>(
input: {
url: string;
method: "GET" | "POST" | "PATCH" | "PUT" | "DELETE";
body?: TBody;
},
): Promise<TResponse> {
async function requestJson<TResponse, TBody extends JsonValue | undefined = undefined>(input: {
url: string;
method: "GET" | "POST" | "PATCH" | "PUT" | "DELETE";
body?: TBody;
}): Promise<TResponse> {
const { baseUrl } = getApiConfig();

const res = await fetch(`${baseUrl}${input.url}`, {
Expand Down Expand Up @@ -167,4 +160,3 @@ export function toastError(err: unknown, fallback = "Something went wrong") {
const message = err instanceof Error ? err.message : fallback;
toast.error(message);
}

7 changes: 3 additions & 4 deletions frontend/src/routes/_app.builders.$builderId.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import { createFileRoute, Link, notFound } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import { buildersService } from "@/services";
import { Card, TagChip, Avatar } from "@/components/shared/primitives";
import { ArrowLeft, MessageSquare, UserPlus } from "lucide-react";
import { FollowButton } from "@/components/shared/FollowButton";
import { ArrowLeft, MessageSquare } from "lucide-react";
import { BackButton } from "@/components/shared/BackButton";

export const Route = createFileRoute("/_app/builders/$builderId")({
Expand Down Expand Up @@ -35,9 +36,7 @@ function BuilderProfile() {
</div>
</div>
<div className="flex gap-2">
<button className="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-2 text-[13px] font-semibold text-primary-foreground hover:opacity-90">
<UserPlus size={14} /> Connect
</button>
<FollowButton userId={b.id} />
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-2 text-[13px] font-medium text-foreground hover:bg-muted">
<MessageSquare size={14} /> Message
</button>
Expand Down
Loading
Loading