Real friends data initial

This commit is contained in:
Kalle
2026-01-16 21:30:03 +02:00
parent 9dd14fe1b7
commit fcd5d9bbba
18 changed files with 264 additions and 58 deletions

View File

@@ -1,11 +1,11 @@
---
# sendou.ink-iulp
title: FriendRepository implementation
status: todo
status: in-progress
type: task
priority: normal
created_at: 2026-01-13T09:32:05Z
updated_at: 2026-01-13T09:32:10Z
updated_at: 2026-01-16T07:59:43Z
parent: sendou.ink-255r
---
@@ -37,8 +37,8 @@ Create repository for friend-related database operations.
## Checklist
- [ ] Create FriendRepository.server.ts
- [x] Create FriendRepository.server.ts
- [x] Implement `findByUserIdWithActivity(userId)` for sidebar
- [ ] Implement friendship CRUD
- [ ] Implement friend request operations
- [ ] Implement connections aggregation query
- [ ] Unit tests for repository functions

View File

@@ -0,0 +1,42 @@
---
# sendou.ink-ivaf
title: Friends section in sidebar with quick invite
status: todo
type: feature
created_at: 2026-01-16T10:17:54Z
updated_at: 2026-01-16T10:17:54Z
parent: sendou.ink-6eko
---
Integrate friends into the sidebar, enabling quick invites and friend status viewing.
**Related:** sendou.ink-255r (Friends feature epic)
## Features
### Quick Invite
- Users can send a "quick invite" to friends who have a partial SendouQ group that is currently looking for members
- The invite should be fast/easy to send directly from the sidebar
### Expandable Friend Details
When a user expands a friend entry in the sidebar, display:
- User avatar
- User name
- Weapons (their current weapon pool or equipped weapons)
- Current group info (if they're in a SendouQ group)
- Any custom status text if available
For each of the friends groups' members
### Pending Requests Indicator
- Show pending requests somewhere visible in the UI
## Checklist
- [ ] Design UI for friends list in sidebar
- [ ] Add expand/collapse functionality for friend entries
- [ ] Display friend details (avatar, name, weapons, status text)
- [ ] Show current SendouQ group info for friends who are looking
- [ ] Implement quick invite button for friends with partial groups
- [ ] Add pending friend requests indicator
- [ ] Handle loading and empty states

View File

@@ -0,0 +1,39 @@
---
# sendou.ink-nucy
title: Consistent avatar border radius in sidebar
status: draft
type: feature
created_at: 2026-01-16T09:23:10Z
updated_at: 2026-01-16T09:23:10Z
---
Currently sidebar avatars have inconsistent border radius styles:
- Some are fully rounded (circle)
- Some are slightly rounded
- Some have no background/different treatment
## Idea
Add a theme option for avatar border radius that applies globally to all avatars:
- **round** - fully circular (border-radius: 50%)
- **rounded** - slightly rounded corners
- **square** - no border radius
This would provide visual consistency and give users control over their preferred avatar style.
## Alternative
Choose one and apply everywhere.
## Areas to check
- Tournament/event avatars in sidebar
- Friend list avatars
- User avatars throughout the app
- Team avatars
## Checklist
- [ ] Audit current avatar border radius usage across codebase
- [ ] Design CSS variable approach for avatar border radius
- [ ] Add theme option to settings
- [ ] Apply consistent border radius to all Avatar components
- [ ] Test across different contexts (sidebar, profiles, etc.)

View File

@@ -0,0 +1,24 @@
---
# sendou.ink-qu4s
title: Show team and association members in friends section
status: todo
type: feature
created_at: 2026-01-16T10:20:57Z
updated_at: 2026-01-16T10:20:57Z
parent: sendou.ink-255r
---
Extend the friends section in the sidebar to also display:
- Team members (from the user's team)
- Association members (from the user's association)
## Sorting priority
Friends should appear first in the list, followed by team members, then association members.
## Checklist
- [ ] Query team members for the current user
- [ ] Query association members for the current user
- [ ] Combine friends, team members, and association members into a single list
- [ ] Implement sorting logic: friends first, then team, then association
- [ ] Handle duplicate entries (e.g., a friend who is also a team member should only appear once, as a friend)
- [ ] Update sidebar UI to display the combined list

View File

@@ -1,10 +1,11 @@
---
# sendou.ink-xsyy
title: Sidebar friends section with real data
status: todo
status: in-progress
type: task
priority: normal
created_at: 2026-01-13T09:32:29Z
updated_at: 2026-01-13T09:32:29Z
updated_at: 2026-01-16T08:04:13Z
parent: sendou.ink-255r
---
@@ -42,9 +43,9 @@ No grouping by relationship type in sidebar (mixed list with priority ordering).
## Checklist
- [ ] Update sidebar loader to fetch real connections
- [ ] Implement priority sorting logic
- [ ] Quota system for activity types
- [ ] Update SideNavFriendItem component with real data
- [x] Update sidebar loader to fetch real connections
- [x] Implement basic priority sorting (SendouQ first, then tournament subs)
- [x] Update SideNavFriendItem component with real data (URLs, avatars)
- [ ] Full priority sorting logic with quota system
- [ ] "See all" link to /friends page
- [ ] Handle empty state (no friends yet)

View File

@@ -8,6 +8,7 @@ const dimensions = {
xxxs: 16,
xxxsm: 20,
xxs: 24,
xxsm: 32,
xs: 36,
sm: 44,
xsm: 62,

View File

@@ -357,8 +357,11 @@ function FriendsPanel({
{friends.map((friend) => (
<SideNavLink
key={friend.id}
to=""
imageUrl={friend.avatarUrl}
to={friend.url}
user={{
discordId: friend.discordId,
discordAvatar: friend.discordAvatar,
}}
subtitle={friend.subtitle}
badge={friend.badge}
>

View File

@@ -9,6 +9,8 @@ import {
} from "react-aria-components";
import { Link } from "react-router";
import { SendouButton } from "~/components/elements/Button";
import type { Tables } from "~/db/tables";
import { Avatar } from "./Avatar";
import { Image } from "./Image";
import styles from "./SideNav.module.css";
@@ -77,6 +79,7 @@ export function SideNavLink({
onClick,
isActive,
imageUrl,
user,
subtitle,
badge,
badgeVariant,
@@ -86,6 +89,7 @@ export function SideNavLink({
onClick?: (event: React.MouseEvent<HTMLAnchorElement>) => void;
isActive?: boolean;
imageUrl?: string;
user?: Pick<Tables["User"], "discordId" | "discordAvatar">;
subtitle?: string;
badge?: string;
badgeVariant?: "default" | "warning";
@@ -97,7 +101,9 @@ export function SideNavLink({
onClick={onClick}
aria-current={isActive ? "page" : undefined}
>
{imageUrl ? (
{user ? (
<Avatar user={user} size="xxsm" />
) : imageUrl ? (
<img src={imageUrl} alt="" className={styles.sideNavLinkImage} />
) : null}
<div className={styles.sideNavLinkContent}>

View File

@@ -66,7 +66,6 @@ function useTimeFormat() {
}
return date.toLocaleDateString(i18n.language, {
weekday: "short",
month: "short",
day: "numeric",
hour: "numeric",
@@ -232,8 +231,11 @@ export function Layout({
{friends.map((friend) => (
<SideNavLink
key={friend.id}
to=""
imageUrl={friend.avatarUrl}
to={friend.url}
user={{
discordId: friend.discordId,
discordAvatar: friend.discordAvatar,
}}
subtitle={friend.subtitle}
badge={friend.badge}
>

View File

@@ -0,0 +1,51 @@
import { db } from "~/db/sql";
export async function findByUserIdWithActivity(userId: number) {
const rows = await db
.selectFrom("Friendship")
.innerJoin("User", (join) =>
join.on((eb) =>
eb.or([
eb.and([
eb("Friendship.userOneId", "=", userId),
eb("User.id", "=", eb.ref("Friendship.userTwoId")),
]),
eb.and([
eb("Friendship.userTwoId", "=", userId),
eb("User.id", "=", eb.ref("Friendship.userOneId")),
]),
]),
),
)
.leftJoin("TournamentSub", "TournamentSub.userId", "User.id")
.leftJoin(
"CalendarEvent",
"CalendarEvent.tournamentId",
"TournamentSub.tournamentId",
)
.leftJoin(
"CalendarEventDate",
"CalendarEventDate.eventId",
"CalendarEvent.id",
)
.select([
"User.id",
"User.username",
"User.discordId",
"User.discordAvatar",
"User.customUrl",
"CalendarEvent.name as tournamentName",
"TournamentSub.tournamentId",
"CalendarEventDate.startTime as tournamentStartTime",
])
.where((eb) =>
eb.or([
eb("Friendship.userOneId", "=", userId),
eb("Friendship.userTwoId", "=", userId),
]),
)
.orderBy("Friendship.createdAt", "desc")
.execute();
return rows;
}

View File

@@ -1,14 +1,17 @@
import { href, type LoaderFunctionArgs } from "react-router";
import { getUser } from "~/features/auth/core/user.server";
import * as FriendRepository from "~/features/friends/FriendRepository.server";
import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server";
import * as ScrimPostRepository from "~/features/scrims/ScrimPostRepository.server";
import { SendouQ } from "~/features/sendouq/core/SendouQ.server";
import { FULL_GROUP_SIZE } from "~/features/sendouq/q-constants";
import { RunningTournaments } from "~/features/tournament-bracket/core/RunningTournaments.server";
import {
navIconUrl,
sendouQMatchPage,
tournamentBracketsPage,
tournamentMatchPage,
userPage,
} from "~/utils/urls";
export type SidebarEvent = {
@@ -36,15 +39,16 @@ export const loader = async (_args: LoaderFunctionArgs) => {
roundName?: string;
logoUrl: string | null;
} | null,
friends: getMockFriends(),
friends: [] as ReturnType<typeof resolveFriends>,
streams: getMockStreams(),
};
}
const [tournamentsData, scrimsData, ownGroup] = await Promise.all([
const ownGroup = SendouQ.findOwnGroup(user.id);
const [tournamentsData, scrimsData, friendsWithActivity] = await Promise.all([
ShowcaseTournaments.frontPageTournamentsByUserId(user.id),
ScrimPostRepository.findUserScrims(user.id),
Promise.resolve(SendouQ.findOwnGroup(user.id)),
FriendRepository.findByUserIdWithActivity(user.id),
]);
const tournamentMatchStatus = resolveTournamentMatchStatus(user.id);
@@ -85,13 +89,15 @@ export const loader = async (_args: LoaderFunctionArgs) => {
.sort((a, b) => a.startTime - b.startTime)
.slice(0, MAX_EVENTS_VISIBLE);
const friends = resolveFriends(friendsWithActivity);
return {
events,
matchStatus: ownGroup?.matchId
? { matchId: ownGroup.matchId, url: sendouQMatchPage(ownGroup.matchId) }
: null,
tournamentMatchStatus,
friends: getMockFriends(),
friends,
streams: getMockStreams(),
};
};
@@ -144,44 +150,75 @@ function resolveTournamentMatchStatus(userId: number) {
return null;
}
function getMockFriends() {
return [
{
id: 1,
name: "Splat_Master",
avatarUrl: "https://i.pravatar.cc/150?u=friend1",
subtitle: "SendouQ",
badge: "2/4",
},
{
id: 2,
name: "InklingPro",
avatarUrl: "https://i.pravatar.cc/150?u=friend2",
subtitle: "Lobby",
badge: "2/8",
},
{
id: 3,
name: "OctoKing",
avatarUrl: "https://i.pravatar.cc/150?u=friend3",
subtitle: "In The Zone 22",
badge: "3/4",
},
{
id: 4,
name: "TurfWarrior",
avatarUrl: "https://i.pravatar.cc/150?u=friend4",
subtitle: "SendouQ",
badge: "1/4",
},
{
id: 5,
name: "RankedGrinder",
avatarUrl: "https://i.pravatar.cc/150?u=friend5",
subtitle: "Lobby",
badge: "5/8",
},
];
type FriendWithActivity = Awaited<
ReturnType<typeof FriendRepository.findByUserIdWithActivity>
>[number];
const MAX_FRIENDS_VISIBLE = 4;
const SENDOUQ_QUOTA = 2;
const TOURNAMENT_SUB_QUOTA = 2;
function resolveFriends(friendsWithActivity: FriendWithActivity[]) {
type SidebarFriend = {
id: number;
name: string;
discordId: string;
discordAvatar: string | null;
url: string;
subtitle: string;
badge: string;
};
const sendouqFriends: SidebarFriend[] = [];
const tournamentSubFriends: SidebarFriend[] = [];
for (const friend of friendsWithActivity) {
const ownGroup = SendouQ.findOwnGroup(friend.id);
const url = userPage({
discordId: friend.discordId,
customUrl: friend.customUrl,
});
if (ownGroup && ownGroup.members.length < FULL_GROUP_SIZE) {
sendouqFriends.push({
id: friend.id,
name: friend.username,
discordId: friend.discordId,
discordAvatar: friend.discordAvatar,
url,
subtitle: "SendouQ",
badge: `${ownGroup.members.length}/${FULL_GROUP_SIZE}`,
});
} else if (friend.tournamentName) {
// this is temporary, will be replaced with "SQified tournament team creator"
tournamentSubFriends.push({
id: friend.id,
name: friend.username,
discordId: friend.discordId,
discordAvatar: friend.discordAvatar,
url,
subtitle: friend.tournamentName,
badge: `1/${FULL_GROUP_SIZE}`,
});
}
}
const result: SidebarFriend[] = [];
const sendouqToShow = sendouqFriends.slice(0, SENDOUQ_QUOTA);
const tournamentToShow = tournamentSubFriends.slice(0, TOURNAMENT_SUB_QUOTA);
result.push(...sendouqToShow, ...tournamentToShow);
const remaining = MAX_FRIENDS_VISIBLE - result.length;
if (remaining > 0) {
const extraSendouq = sendouqFriends.slice(SENDOUQ_QUOTA);
const extraTournament = tournamentSubFriends.slice(TOURNAMENT_SUB_QUOTA);
result.push(...[...extraSendouq, ...extraTournament].slice(0, remaining));
}
return result;
}
function getMockStreams() {

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.