diff --git a/.beans/sendou.ink-iulp--friendrepository-implementation.md b/.beans/sendou.ink-iulp--friendrepository-implementation.md index e03a36a65..03937c437 100644 --- a/.beans/sendou.ink-iulp--friendrepository-implementation.md +++ b/.beans/sendou.ink-iulp--friendrepository-implementation.md @@ -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 diff --git a/.beans/sendou.ink-ivaf--friends-section-in-sidebar-with-quick-invite.md b/.beans/sendou.ink-ivaf--friends-section-in-sidebar-with-quick-invite.md new file mode 100644 index 000000000..ff477e832 --- /dev/null +++ b/.beans/sendou.ink-ivaf--friends-section-in-sidebar-with-quick-invite.md @@ -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 diff --git a/.beans/sendou.ink-nucy--consistent-avatar-border-radius-in-sidebar.md b/.beans/sendou.ink-nucy--consistent-avatar-border-radius-in-sidebar.md new file mode 100644 index 000000000..842c674af --- /dev/null +++ b/.beans/sendou.ink-nucy--consistent-avatar-border-radius-in-sidebar.md @@ -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.) diff --git a/.beans/sendou.ink-qu4s--show-team-and-association-members-in-friends-secti.md b/.beans/sendou.ink-qu4s--show-team-and-association-members-in-friends-secti.md new file mode 100644 index 000000000..ae8eec2b3 --- /dev/null +++ b/.beans/sendou.ink-qu4s--show-team-and-association-members-in-friends-secti.md @@ -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 \ No newline at end of file diff --git a/.beans/sendou.ink-xsyy--sidebar-friends-section-with-real-data.md b/.beans/sendou.ink-xsyy--sidebar-friends-section-with-real-data.md index b7ef2336b..52ed9bb1f 100644 --- a/.beans/sendou.ink-xsyy--sidebar-friends-section-with-real-data.md +++ b/.beans/sendou.ink-xsyy--sidebar-friends-section-with-real-data.md @@ -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) \ No newline at end of file diff --git a/app/components/Avatar.tsx b/app/components/Avatar.tsx index 3d015e0c3..ecea33ad7 100644 --- a/app/components/Avatar.tsx +++ b/app/components/Avatar.tsx @@ -8,6 +8,7 @@ const dimensions = { xxxs: 16, xxxsm: 20, xxs: 24, + xxsm: 32, xs: 36, sm: 44, xsm: 62, diff --git a/app/components/MobileNav.tsx b/app/components/MobileNav.tsx index 7891fb7c6..4b8b51502 100644 --- a/app/components/MobileNav.tsx +++ b/app/components/MobileNav.tsx @@ -357,8 +357,11 @@ function FriendsPanel({ {friends.map((friend) => ( diff --git a/app/components/SideNav.tsx b/app/components/SideNav.tsx index 08d2e5b23..824d4cfe0 100644 --- a/app/components/SideNav.tsx +++ b/app/components/SideNav.tsx @@ -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) => void; isActive?: boolean; imageUrl?: string; + user?: Pick; subtitle?: string; badge?: string; badgeVariant?: "default" | "warning"; @@ -97,7 +101,9 @@ export function SideNavLink({ onClick={onClick} aria-current={isActive ? "page" : undefined} > - {imageUrl ? ( + {user ? ( + + ) : imageUrl ? ( ) : null}
diff --git a/app/components/layout/index.tsx b/app/components/layout/index.tsx index e4ffa9d93..a31a6e061 100644 --- a/app/components/layout/index.tsx +++ b/app/components/layout/index.tsx @@ -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) => ( diff --git a/app/features/friends/FriendRepository.server.ts b/app/features/friends/FriendRepository.server.ts new file mode 100644 index 000000000..4fb8ccebd --- /dev/null +++ b/app/features/friends/FriendRepository.server.ts @@ -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; +} diff --git a/app/features/sidebar/routes/sidebar.ts b/app/features/sidebar/routes/sidebar.ts index a3f7df210..8f0fe6e25 100644 --- a/app/features/sidebar/routes/sidebar.ts +++ b/app/features/sidebar/routes/sidebar.ts @@ -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, 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 +>[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() { diff --git a/e2e/seeds/db-seed-DEFAULT.sqlite3 b/e2e/seeds/db-seed-DEFAULT.sqlite3 index b5d0dd146..eb131720a 100644 Binary files a/e2e/seeds/db-seed-DEFAULT.sqlite3 and b/e2e/seeds/db-seed-DEFAULT.sqlite3 differ diff --git a/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 b/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 index 9fcf07d25..4ae0ab89d 100644 Binary files a/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 and b/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 differ diff --git a/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 b/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 index 76c8d6d54..a5e392bf9 100644 Binary files a/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 and b/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 differ diff --git a/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 b/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 index c955d7c97..3355cb56a 100644 Binary files a/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 and b/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 differ diff --git a/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 b/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 index 321f1dfe5..97e195f7d 100644 Binary files a/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 and b/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 differ diff --git a/e2e/seeds/db-seed-REG_OPEN.sqlite3 b/e2e/seeds/db-seed-REG_OPEN.sqlite3 index bbb9dd39a..9b1103c55 100644 Binary files a/e2e/seeds/db-seed-REG_OPEN.sqlite3 and b/e2e/seeds/db-seed-REG_OPEN.sqlite3 differ diff --git a/e2e/seeds/db-seed-SMALL_SOS.sqlite3 b/e2e/seeds/db-seed-SMALL_SOS.sqlite3 index 28db2aa02..502f8a6c5 100644 Binary files a/e2e/seeds/db-seed-SMALL_SOS.sqlite3 and b/e2e/seeds/db-seed-SMALL_SOS.sqlite3 differ