add friends list and chuni rival selection

This commit is contained in:
sk1982 2024-03-31 23:00:26 -04:00
parent 769a484fc1
commit baf1898d23
6 changed files with 206 additions and 11 deletions

View File

@ -1,9 +1,9 @@
'use server'; 'use server';
import { db } from '@/db'; import { GeneratedDB, db } from '@/db';
import { getUser, requireUser } from './auth'; import { getUser, requireUser } from './auth';
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import { CompiledQuery, sql } from 'kysely'; import { CompiledQuery, Transaction, sql } from 'kysely';
import { syncUserFriends, withChuniRivalCount } from '@/data/friend'; import { syncUserFriends, withChuniRivalCount } from '@/data/friend';
import { SqlBool } from 'kysely'; import { SqlBool } from 'kysely';
import { Exact } from 'type-fest'; import { Exact } from 'type-fest';
@ -131,3 +131,58 @@ export const rejectFriendRequest = async (id: string) => {
.where('uuid', '=', id) .where('uuid', '=', id)
.executeTakeFirst(); .executeTakeFirst();
}; };
const setRivalStatus = (user1: number, user2: number, chuniRival: number, trx: Transaction<GeneratedDB>) => {
return trx.updateTable('actaeon_user_friends')
.where(({ and, eb, or }) => or([
and([
eb('user1', '=', user1),
eb('user2', '=', user2)
]),
and([
eb('user2', '=', user1),
eb('user1', '=', user2)
]),
]))
.set({ chuniRival })
.execute();
}
export const addFriendAsRival = async (friend: number) => {
const user = await requireUser();
const rivalCount = await withChuniRivalCount()
.selectFrom('chuni_rival_count')
.where(({ or, eb }) => or([
eb('user', '=', user.id),
eb('user', '=', friend)
]))
.select(['user', 'rivalCount'])
.execute();
const userRivalCount = Number(rivalCount.find(r => r.user === user.id)?.rivalCount ?? 0);
const friendRivalCount = Number(rivalCount.find(r => r.user === friend)?.rivalCount ?? 0);
if (userRivalCount >= 4)
return { error: true, message: 'You already have 4 rivals. You must remove a rival before adding a new one.' };
if (friendRivalCount >= 4)
return { error: true, message: 'This user already has 4 rivals. They must remove a rival before you can add them as a rival.' };
await db.transaction().execute(async trx => {
await setRivalStatus(user.id, friend, 1, trx);
await syncUserFriends(user.id, trx);
await syncUserFriends(friend, trx);
});
};
export const removeFriendAsRival = async (friend: number) => {
const user = await requireUser();
await db.transaction().execute(async trx => {
await setRivalStatus(user.id, friend, 0, trx);
await syncUserFriends(user.id, trx);
await syncUserFriends(friend, trx);
});
};

View File

@ -0,0 +1,66 @@
import { Avatar, Button, Dropdown, DropdownTrigger, DropdownMenu, DropdownItem, Divider, Tooltip } from '@nextui-org/react';
import { Friend } from '@/data/friend';
import Link from 'next/link';
import { ChuniPenguinIcon } from '@/components/chuni/chuni-penguin-icon';
import { EllipsisVerticalIcon } from '@heroicons/react/24/outline';
import { useConfirmModal } from '@/components/confirm-modal';
import { useState } from 'react';
import { addFriendAsRival, removeFriendAsRival, unfriend } from '@/actions/friend';
import { useErrorModal } from '@/components/error-modal';
type FriendRowProps = {
friend: Friend,
onUnfriend: () => void
};
export const FriendRow = ({ friend: initialFriend, onUnfriend }: FriendRowProps) => {
const confirm = useConfirmModal();
const [friend, setFriend] = useState(initialFriend);
const setError = useErrorModal();
return (<section>
<section className="px-3 sm:bg-content1 sm:rounded-lg py-2.5 flex items-center">
<Link href={`/user/${friend.uuid}`} className="flex items-center">
<Avatar
name={friend.username?.[0]?.toUpperCase() ?? undefined}
className={`w-10 h-10 mr-2 text-2xl [font-feature-settings:"fwid"]`} />
<span className="font-semibold transition hover:text-secondary">{friend.username}</span>
</Link>
{!!friend.chuniRival && <Tooltip content="Chunithm Rival">
<div>
<ChuniPenguinIcon className="ml-2 h-9" />
</div>
</Tooltip>}
<Dropdown>
<DropdownTrigger>
<Button isIconOnly variant="light" className="ml-auto" radius="full">
<EllipsisVerticalIcon className="h-3/4" />
</Button>
</DropdownTrigger>
<DropdownMenu>
<DropdownItem onPress={() => {
if (friend.chuniRival)
removeFriendAsRival(friend.id).then(() => setFriend({ ...friend, chuniRival: 0 }));
else
addFriendAsRival(friend.id).then(res => {
if (res?.error)
return setError(res.message);
setFriend({ ...friend, chuniRival: 1 })
});
}}>
{friend.chuniRival ? 'Remove as rival' : 'Add as rival'}
</DropdownItem>
<DropdownItem color="danger" variant="flat" className="text-danger" onPress={() =>
confirm('Are you sure you want to unfriend this user? This will also remove them as a rival.', () => {
unfriend(friend.id)
.then(onUnfriend)
})}>
Unfriend
</DropdownItem>
</DropdownMenu>
</Dropdown>
</section>
<Divider className="sm:hidden mt-1.5" />
</section>);
};

View File

@ -0,0 +1,30 @@
'use client';
import { Friend } from '@/data/friend';
import { Divider } from '@nextui-org/react';
import { useState } from 'react';
import { FriendRow } from './friend-row';
export type FriendsProps = {
friends: Friend[],
};
export const Friends = ({ friends: initialFriends }: FriendsProps) => {
const [friends, setFriends] = useState(initialFriends);
return (<main className="w-full mx-auto max-w-7xl flex flex-col">
<header className="font-semibold flex items-center text-2xl p-4">
Friends
</header>
<Divider className="mb-1.5 sm:mb-2" />
{!friends.length && <span className="text-gray-500 italic ml-4 mt-4">You don&apos;t have any friends</span>}
<section className="flex flex-col sm:grid gap-1.5 grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{friends.map(f => (<FriendRow friend={f} key={f.id} onUnfriend={() => {
setFriends(friends => friends.filter(friend => friend.uuid !== f.uuid))
}} />))}
</section>
</main>);
};

View File

@ -0,0 +1,10 @@
import { requireUser } from '@/actions/auth';
import { getFriends } from '@/data/friend';
import { Friends } from './friends';
export default async function FriendsPage() {
const user = await requireUser();
const friends = await getFriends(user.id);
return (<Friends friends={friends} />);
};

View File

@ -4,7 +4,7 @@ import { Avatar, Badge, Button, Divider, Dropdown, DropdownItem, DropdownMenu, D
import { Bars3Icon, ChevronLeftIcon, XMarkIcon } from '@heroicons/react/20/solid'; import { Bars3Icon, ChevronLeftIcon, XMarkIcon } from '@heroicons/react/20/solid';
import { Fragment, useEffect, useMemo, useState } from 'react'; import { Fragment, useEffect, useMemo, useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { ThemeSwitcherSwitch } from '@/components/theme-switcher'; import { ThemeSwitcherDropdown, ThemeSwitcherSwitch } from '@/components/theme-switcher';
import { AdjustmentsHorizontalIcon } from '@heroicons/react/24/solid'; import { AdjustmentsHorizontalIcon } from '@heroicons/react/24/solid';
import { login, logout } from '@/actions/auth'; import { login, logout } from '@/actions/auth';
import { usePathname, useRouter } from 'next/navigation'; import { usePathname, useRouter } from 'next/navigation';
@ -12,7 +12,7 @@ import { MAIN_ROUTES, ROUTES, Subroute, UserOnly, filterRoute } from '@/routes';
import { useUser } from '@/helpers/use-user'; import { useUser } from '@/helpers/use-user';
import { useBreakpoint } from '@/helpers/use-breakpoint'; import { useBreakpoint } from '@/helpers/use-breakpoint';
import { useCookies } from 'next-client-cookies'; import { useCookies } from 'next-client-cookies';
import { BellIcon, ChevronDownIcon } from '@heroicons/react/24/outline'; import { ChevronRightIcon, BellIcon, ChevronDownIcon } from '@heroicons/react/24/outline';
import { useTheme } from 'next-themes'; import { useTheme } from 'next-themes';
import { BellAlertIcon } from '@heroicons/react/24/solid'; import { BellAlertIcon } from '@heroicons/react/24/solid';
import { FriendRequest, acceptFriendRequest, getFriendRequests, rejectFriendRequest } from '@/actions/friend'; import { FriendRequest, acceptFriendRequest, getFriendRequests, rejectFriendRequest } from '@/actions/friend';
@ -248,7 +248,23 @@ export const HeaderSidebar = ({ children }: HeaderSidebarProps) => {
</Button> </Button>
</header> </header>
<Divider className="my-4" /> <Divider className="mt-4" />
<Link href="/settings" className="font-semibold text-2xl p-3 pl-4 flex items-center" onClick={() => setNotificationsOpen(false)}>
Settings
<ChevronRightIcon className="ml-auto h-6" />
</Link>
<Divider />
<Link href="/friends" className="font-semibold text-2xl p-3 pl-4 flex items-center" onClick={() => setNotificationsOpen(false)}>
Friends
<ChevronRightIcon className="ml-auto h-6" />
</Link>
<Divider className="mb-4" />
<section className="px-2 flex flex-col flex-auto overflow-y-auto mb-3"> <section className="px-2 flex flex-col flex-auto overflow-y-auto mb-3">
<header className="font-semibold text-2xl px-2 mb-3">Notifications</header> <header className="font-semibold text-2xl px-2 mb-3">Notifications</header>
@ -320,12 +336,17 @@ export const HeaderSidebar = ({ children }: HeaderSidebarProps) => {
<DropdownItem showDivider className="p-0"> <DropdownItem showDivider className="p-0">
<Link className="text-lg font-semibold block w-full h-full px-2 py-1.5" href={`/user/${user.uuid}`}>{user.username}</Link> <Link className="text-lg font-semibold block w-full h-full px-2 py-1.5" href={`/user/${user.uuid}`}>{user.username}</Link>
</DropdownItem> </DropdownItem>
<DropdownItem className="p-0 mt-1"> <DropdownItem className="p-0 mt-0.5">
<Link href="/settings" className="w-full h-full block px-2 py-1.5"> <Link href="/settings" className="w-full h-full block px-2 py-1.5">
Settings Settings
</Link> </Link>
</DropdownItem> </DropdownItem>
<DropdownItem className="p-0 mt-1" showDivider closeOnSelect={false}> <DropdownItem className="p-0 mt-0.5">
<Link href="/friends" className="w-full h-full block px-2 py-1.5">
Friends
</Link>
</DropdownItem>
<DropdownItem className="p-0 mt-0.5" showDivider closeOnSelect={false}>
<div className="w-full flex pl-2 h-8 items-center" onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}> <div className="w-full flex pl-2 h-8 items-center" onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
Dark Theme Dark Theme
@ -340,10 +361,12 @@ export const HeaderSidebar = ({ children }: HeaderSidebarProps) => {
</DropdownMenu> </DropdownMenu>
</Dropdown> </Dropdown>
</> : </> :
<>
<ThemeSwitcherDropdown />
<Button size="sm" className="ml-2" color="primary" onClick={() => login()}> <Button size="sm" className="ml-2" color="primary" onClick={() => login()}>
Login Login
</Button> </Button>
</>
} }
</div> </div>
</Navbar> </Navbar>

View File

@ -81,3 +81,14 @@ export const syncUserFriends = async (user: number, builder: Kysely<GeneratedDB>
}) })
.executeTakeFirst(); .executeTakeFirst();
}; };
export const getFriends = async (user: number) => {
return db.selectFrom('actaeon_user_friends as friend')
.where('friend.user1', '=', user)
.innerJoin('aime_user as u', 'u.id', 'friend.user2')
.innerJoin('actaeon_user_ext as ext', 'ext.userId', 'u.id')
.select(['friend.user2 as id', 'friend.chuniRival', 'u.username', 'ext.uuid'])
.execute();
};
export type Friend = Awaited<ReturnType<typeof getFriends>>[number];