add friending and initial rival support

This commit is contained in:
sk1982 2024-03-31 04:59:04 -04:00
parent 91e8b2a357
commit 1a39d5bb3a
21 changed files with 711 additions and 66 deletions

View File

@ -0,0 +1,53 @@
'use strict';
var dbm;
var type;
var seed;
var fs = require('fs');
var path = require('path');
var Promise;
/**
* We receive the dbmigrate dependency from dbmigrate initially.
* This enables us to not have to rely on NODE_PATH.
*/
exports.setup = function(options, seedLink) {
dbm = options.dbmigrate;
type = dbm.dataType;
seed = seedLink;
Promise = options.Promise;
};
exports.up = function(db) {
var filePath = path.join(__dirname, 'sqls', '20240331021022-create-friend-requests-up.sql');
return new Promise( function( resolve, reject ) {
fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){
if (err) return reject(err);
console.log('received data: ' + data);
resolve(data);
});
})
.then(function(data) {
return db.runSql(data);
});
};
exports.down = function(db) {
var filePath = path.join(__dirname, 'sqls', '20240331021022-create-friend-requests-down.sql');
return new Promise( function( resolve, reject ) {
fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){
if (err) return reject(err);
console.log('received data: ' + data);
resolve(data);
});
})
.then(function(data) {
return db.runSql(data);
});
};
exports._meta = {
"version": 1
};

View File

@ -0,0 +1,53 @@
'use strict';
var dbm;
var type;
var seed;
var fs = require('fs');
var path = require('path');
var Promise;
/**
* We receive the dbmigrate dependency from dbmigrate initially.
* This enables us to not have to rely on NODE_PATH.
*/
exports.setup = function(options, seedLink) {
dbm = options.dbmigrate;
type = dbm.dataType;
seed = seedLink;
Promise = options.Promise;
};
exports.up = function(db) {
var filePath = path.join(__dirname, 'sqls', '20240331061344-add-rival-to-user-friends-up.sql');
return new Promise( function( resolve, reject ) {
fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){
if (err) return reject(err);
console.log('received data: ' + data);
resolve(data);
});
})
.then(function(data) {
return db.runSql(data);
});
};
exports.down = function(db) {
var filePath = path.join(__dirname, 'sqls', '20240331061344-add-rival-to-user-friends-down.sql');
return new Promise( function( resolve, reject ) {
fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){
if (err) return reject(err);
console.log('received data: ' + data);
resolve(data);
});
})
.then(function(data) {
return db.runSql(data);
});
};
exports._meta = {
"version": 1
};

View File

@ -0,0 +1 @@
DROP TABLE actaeon_friend_requests;

View File

@ -0,0 +1,12 @@
CREATE TABLE actaeon_friend_requests (
uuid CHAR(36) PRIMARY KEY,
user INT NOT NULL,
friend INT NOT NULL,
createdDate TIMESTAMP NOT NULL,
FOREIGN KEY (user) REFERENCES aime_user(id)
ON DELETE CASCADE ON UPDATE CASCADE,
FOREIGN KEY (friend) REFERENCES aime_user(id)
ON DELETE CASCADE ON UPDATE CASCADE,
UNIQUE KEY (user, friend)
);

View File

@ -0,0 +1,2 @@
ALTER TABLE actaeon_user_friends
DROP COLUMN chuniRival;

View File

@ -0,0 +1,2 @@
ALTER TABLE actaeon_user_friends
ADD COLUMN chuniRival TINYINT(1);

View File

@ -11,6 +11,8 @@ import { requirePermission } from '@/helpers/permissions';
import { UserPayload } from '@/types/user';
import { sql } from 'kysely';
import { EMAIL_REGEX } from '@/helpers/validators';
import { createActaeonTeamsFromExistingTeams } from '@/data/team';
import { createActaeonFriendsFromExistingFriends } from '@/data/friend';
export const getUser = async () => {
const session = await auth();
@ -138,5 +140,10 @@ export const register = async (formData: FormData) => {
})
.executeTakeFirst();
await Promise.all([
createActaeonTeamsFromExistingTeams().catch(console.error),
createActaeonFriendsFromExistingFriends().catch(console.error)
]);
return { error: false };
};

133
src/actions/friend.ts Normal file
View File

@ -0,0 +1,133 @@
'use server';
import { db } from '@/db';
import { getUser, requireUser } from './auth';
import { notFound } from 'next/navigation';
import { CompiledQuery, sql } from 'kysely';
import { syncUserFriends, withChuniRivalCount } from '@/data/friend';
import { SqlBool } from 'kysely';
import { Exact } from 'type-fest';
export const getFriendRequests = async () => {
const user = await getUser();
if (!user) return [];
return db.selectFrom('actaeon_friend_requests as req')
.where('user', '=', user.id)
.innerJoin('aime_user as u', 'u.id', 'req.friend')
.innerJoin('actaeon_user_ext as ext', 'ext.userId', 'u.id')
.select([
'req.friend', 'req.createdDate', 'req.uuid as reqUuid',
'u.username',
'ext.uuid as userUuid'
])
.orderBy('req.createdDate desc')
.execute();
};
export type FriendRequest = Awaited<ReturnType<typeof getFriendRequests>>[number];
export const sendFriendRequest = async (toUser: number) => {
const requestingUser = await requireUser();
if (requestingUser.id === toUser) return;
const existing = await db.selectFrom('actaeon_friend_requests')
.where('user', '=', toUser)
.where('friend', '=', requestingUser.id)
.select('uuid')
.executeTakeFirst();
if (existing) return;
await db.insertInto('actaeon_friend_requests')
.values({
user: toUser,
friend: requestingUser.id,
createdDate: new Date(),
uuid: sql`uuid_v4()`
})
.executeTakeFirst();
}
export const unfriend = async (friend: number) => {
const user = await requireUser();
await db.transaction().execute(async trx => {
await trx.deleteFrom('actaeon_user_friends')
.where(({ or, eb, and }) => or([
and([
eb('user1', '=', friend),
eb('user2', '=', user.id)
]),
and([
eb('user2', '=', friend),
eb('user1', '=', user.id)
])
]))
.executeTakeFirst();
await syncUserFriends(user.id, trx);
await syncUserFriends(friend, trx);
});
};
export const acceptFriendRequest = async (id: string) => {
const user = await requireUser();
const request = await db.selectFrom('actaeon_friend_requests')
.where('user', '=', user.id)
.where('uuid', '=', id)
.select(['friend'])
.executeTakeFirst();
if (!request) return notFound();
await db.transaction().execute(async trx => {
const COLUMNS = ['user1', 'user2', 'chuniRival'] as const;
const insertSql = trx.insertInto('actaeon_user_friends')
.columns(COLUMNS).compile();
const selectSql = withChuniRivalCount(trx)
.with('insert_users', db => db.selectNoFrom(({ lit }) => [lit(user.id).as('u1'), lit(request.friend).as('u2')])
.union(db.selectNoFrom(({ lit }) => [lit(request.friend).as('u1'), lit(user.id).as('u2')])))
.selectFrom('insert_users')
.select(({ eb, fn, lit, selectFrom }) => [
'insert_users.u1 as user1', 'insert_users.u2 as user2',
eb(fn<number>('coalesce', [selectFrom('chuni_max_rival_count')
.whereRef('chuni_max_rival_count.user1', '=', 'insert_users.u1')
.whereRef('chuni_max_rival_count.user2', '=', 'insert_users.u2')
.select('maxRivalCount'), lit(0)
]), '<', lit(4))
.as('chuniRival')
] as const)
.compile();
// mariadb needs insert into before cte's but kysely puts it after :(
// if any of these have a type error, then the insert and select statements are not compatible
type SelectVals = (typeof selectSql) extends CompiledQuery<infer R> ? { [K in keyof R]: R[K] extends SqlBool ? number : R[K] } : never;
// verify same number of selections in select as insert
const _: Exact<{ [K in (typeof COLUMNS)[number]]: SelectVals[K] }, SelectVals> = {} as SelectVals;
// verify data types are insertable
if (false) db.insertInto('actaeon_user_friends').values({} as SelectVals);
await sql.raw(`${insertSql.sql}\n${selectSql.sql}`).execute(trx);
await trx.deleteFrom('actaeon_friend_requests')
.where('uuid', '=', id)
.executeTakeFirst();
await syncUserFriends(user.id, trx);
await syncUserFriends(request.friend, trx);
});
};
export const rejectFriendRequest = async (id: string) => {
const user = await requireUser();
await db.deleteFrom('actaeon_friend_requests')
.where('user', '=', user.id)
.where('uuid', '=', id)
.executeTakeFirst();
};

View File

@ -68,6 +68,11 @@ export const deleteUser = async (user: number): Promise<ActionResult> => {
.where('id', '=', user)
.executeTakeFirst();
await db.deleteFrom('chuni_item_favorite')
.where('favKind', '=', 2)
.where('favId', '=', user)
.executeTakeFirst();
return {};
};

View File

@ -1,10 +1,10 @@
'use client';
import { Button, Divider, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, Navbar } from '@nextui-org/react';
import { Avatar, Badge, Button, Divider, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, Navbar, Popover, PopoverContent, PopoverTrigger, Tooltip } from '@nextui-org/react';
import { Bars3Icon, ChevronLeftIcon, XMarkIcon } from '@heroicons/react/20/solid';
import { Fragment, useState } from 'react';
import { Fragment, useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import { ThemeSwitcherDropdown, ThemeSwitcherSwitch } from '@/components/theme-switcher';
import { ThemeSwitcherSwitch } from '@/components/theme-switcher';
import { AdjustmentsHorizontalIcon } from '@heroicons/react/24/solid';
import { login, logout } from '@/actions/auth';
import { usePathname, useRouter } from 'next/navigation';
@ -12,25 +12,71 @@ import { MAIN_ROUTES, ROUTES, Subroute, UserOnly, filterRoute } from '@/routes';
import { useUser } from '@/helpers/use-user';
import { useBreakpoint } from '@/helpers/use-breakpoint';
import { useCookies } from 'next-client-cookies';
import { ChevronDownIcon } from '@heroicons/react/24/outline';
import { BellIcon, ChevronDownIcon } from '@heroicons/react/24/outline';
import { useTheme } from 'next-themes';
import { BellAlertIcon } from '@heroicons/react/24/solid';
import { FriendRequest, acceptFriendRequest, getFriendRequests, rejectFriendRequest } from '@/actions/friend';
import { useWindowListener } from '@/helpers/use-window-listener';
import { useErrorModal } from '@/components/error-modal';
export type HeaderSidebarProps = {
children?: React.ReactNode,
};
const NOTIFICATION_DATETIME = {
month: 'numeric',
day: 'numeric',
year: '2-digit',
hour: 'numeric',
minute: '2-digit'
} as const;
export const HeaderSidebar = ({ children }: HeaderSidebarProps) => {
const user = useUser();
const pathname = usePathname();
const [isMenuOpen, setMenuOpen] = useState(false);
const [isMenuOpen, _setMenuOpen] = useState(false);
const breakpoint = useBreakpoint();
const cookies = useCookies();
const router = useRouter();
const [friendRequests, setFriendRequests] = useState<FriendRequest[]>([]);
const [isNotificationsOpen, _setNotificationsOpen] = useState(false);
const [userDropdownOpen, setUserDropdownOpen] = useState(false);
const path = pathname === '/' ? (user?.homepage ?? '/dashboard') : pathname;
const from = cookies.get('actaeon-navigated-from');
const filter = filterRoute.bind(null, user);
const routeGroup = ROUTES.find(route => route.title === from || path?.startsWith(route.url))!;
const { setTheme, theme } = useTheme();
const setError = useErrorModal();
useEffect(() => {
if (user)
getFriendRequests().then(setFriendRequests);
}, [pathname, user]);
const setNotificationsOpen = (open: boolean) => {
_setNotificationsOpen(open);
if (open)
router.push('#notifications', { scroll: false });
};
const setMenuOpen = (open: boolean) => {
_setMenuOpen(open);
if (open)
router.push('#sidebar', { scroll: false });
};
useWindowListener('hashchange', () => {
setMenuOpen(window.location.hash === '#sidebar');
setNotificationsOpen(window.location.hash === '#notifications');
});
useEffect(() => {
setMenuOpen(window.location.hash === '#sidebar');
setNotificationsOpen(window.location.hash === '#notifications');
}, []);
const renderHeaderLink = (route: Subroute) => {
const linkStyle = path?.startsWith(route.url) ?
@ -48,14 +94,14 @@ export const HeaderSidebar = ({ children }: HeaderSidebarProps) => {
</DropdownTrigger>
<DropdownMenu>
{route.routes.filter(filter)
.map(route => (<DropdownItem key={route.url} className="[&:hover_*]:text-secondary"
.map(route => (<DropdownItem key={route.url} className="[&:hover_*]:text-secondary p-0"
onPress={() => {
router.push(route.url);
cookies.set('actaeon-navigated-from', routeGroup.title);
}}
onMouseEnter={() => router.prefetch(route.url)}>
<span className={`transition text-medium ${path?.startsWith(route.url) ? 'font-semibold text-primary' : ''}`}>{route.name}</span>
</DropdownItem>))}
router.push(route.url);
cookies.set('actaeon-navigated-from', routeGroup.title);
}}>
<Link href={route.url}
className={`w-full h-10 pl-2 items-center flex transition text-medium ${path?.startsWith(route.url) ? 'font-semibold text-primary' : ''}`}>{route.name}</Link>
</DropdownItem>))}
</DropdownMenu>
</Dropdown>);
@ -67,20 +113,63 @@ export const HeaderSidebar = ({ children }: HeaderSidebarProps) => {
className={`mr-4 transition ${linkStyle}`}>
{route.name}
</Link>);
}
};
const notifications = useMemo(() => {
if (!friendRequests.length) return null;
return friendRequests.map(req => {
const removeRequest = () => {
setFriendRequests(r => r.filter(r => r.reqUuid !== req.reqUuid));
}
return (<section key={req.reqUuid} className="flex flex-col h-32 w-full bg-content1 px-4 py-2.5 rounded-lg border-gray-500/25 border mb-2 mr-1">
<time dateTime={req.createdDate.toISOString()} className="text-xs ">
{req.createdDate.toLocaleDateString(undefined, NOTIFICATION_DATETIME)}
</time>
<div>
New friend request from <Link href={`/user/${req.userUuid}`} className="font-semibold underline transition hover:text-secondary" onClick={() => {
setNotificationsOpen(false);
}}>{req.username}</Link>
</div>
<div className="grid grid-cols-2 gap-2 mt-auto">
<Button onPress={() => {
rejectFriendRequest(req.reqUuid)
.then(removeRequest)
.catch(e => setError('Failed to reject friend request'));
}}>Ignore</Button>
<Button color="primary" onPress={() => {
acceptFriendRequest(req.reqUuid)
.then(removeRequest)
.catch(() => setError('Failed to accept friend request'));
}}>Accept</Button>
</div>
</section>);
});
}, [friendRequests]);
return (<>
{/* begin sidebar */}
<div className={`fixed inset-0 w-full h-full z-[49] ${isMenuOpen ? '' : 'pointer-events-none'}`}>
<div className={`transition bg-black z-[49] absolute inset-0 w-full h-full ${isMenuOpen ? 'bg-opacity-25' : 'bg-opacity-0 pointer-events-none'}`} onClick={() => setMenuOpen(false)} />
<div className={`transition bg-black z-[49] absolute inset-0 w-full h-full ${isMenuOpen ? 'bg-opacity-25' : 'bg-opacity-0 pointer-events-none'}`} onClick={() => {
setMenuOpen(false);
router.back();
}} />
<div className={`dark flex flex-col text-white absolute p-6 top-0 h-full max-w-full w-96 bg-gray-950 z-[49] transition-all ${isMenuOpen ? 'left-0 shadow-2xl' : '-left-full'}`}>
<div className="flex">
<Button className="text-2xl mb-6 font-bold cursor-pointer flex items-center ps-1.5 pe-2" variant="light"
onClick={() => setMenuOpen(false)}>
onClick={() => {
setMenuOpen(false);
router.back();
}}>
<ChevronLeftIcon className="h-6 mt-0.5" />
<span>{ routeGroup.title }</span>
</Button>
<Button className="ml-auto" isIconOnly color="danger" onClick={() => setMenuOpen(false)}>
<Button className="ml-auto" isIconOnly color="danger" onClick={() => {
setMenuOpen(false);
router.back();
}}>
<XMarkIcon className="w-5" />
</Button>
</div>
@ -136,10 +225,50 @@ export const HeaderSidebar = ({ children }: HeaderSidebarProps) => {
</div>
{/* end sidebar */}
{/* being mobile notifications */}
<div className={`fixed inset-0 w-full h-full max-h-full z-[49] ${isNotificationsOpen ? '' : 'pointer-events-none'}`}>
<div className={`flex flex-col dark text-white absolute pt-6 pb-3 top-0 h-full max-w-full w-full max-h-full bg-gray-950 z-[49] transition-all ${isNotificationsOpen ? 'left-0 shadow-2xl' : 'left-full'}`}>
<header className="font-semibold text-2xl flex items-center pr-4 pl-2 w-full">
<Link href={`/user/${user?.uuid}`} className="flex items-center gap-3" onClick={() => setNotificationsOpen(false)}>
<Avatar name={user?.username?.[0]?.toUpperCase() ?? undefined} className={`w-12 h-12 ml-2.5 cursor-pointer text-2xl [font-feature-settings:"fwid"]`} />
{user?.username}
</Link>
<Link href={`/user/${user?.uuid}`} className="ml-auto flex" onClick={() => setNotificationsOpen(false)}>
<Button className="min-w-0">
Profile
</Button>
</Link>
<Button isIconOnly className="ml-2" color="danger" onPress={() => {
setNotificationsOpen(false);
router.back();
}}>
<XMarkIcon className="h-1/2" />
</Button>
</header>
<Divider className="my-4" />
<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>
{!friendRequests.length && <span className="ml-3 italic text-gray-500">No notifications</span>}
<div className="overflow-y-auto flex-auto overflow-x-hidden">
{notifications}
</div>
</section>
<Button className="mt-auto mx-3 flex-shrink-0" color="danger" onPress={() => logout({ redirectTo: '/' })}>
Logout
</Button>
</div>
</div>
{/* end mobile notifications */}
{/* begin top navbar */}
<div className="flex flex-col flex-grow">
<Navbar className="w-full fixed" classNames={{ wrapper: 'max-w-full p-0' }} shouldHideOnScroll={breakpoint === undefined} height="5.5rem">
<div className="flex p-6 items-center flex-shrink-0 w-full z-[48]">
<div className="flex h-header px-6 items-center flex-shrink-0 w-full z-[48]">
<Button className="text-2xl font-bold cursor-pointer flex items-center m-0 ps-1.5 pe-2 mr-6" variant="light"
onClick={() => setMenuOpen(true)}>
<Bars3Icon className="h-6 mt-0.5" />
@ -148,24 +277,78 @@ export const HeaderSidebar = ({ children }: HeaderSidebarProps) => {
<div className="mr-auto mt-1 hidden md:flex text-lg">
{routeGroup.routes?.filter(filter).map(renderHeaderLink)}
</div>
<div className="mx-auto"></div>
{routeGroup !== MAIN_ROUTES && <div className="mr-4 mt-1 hidden [@media(min-width:1175px)]:flex text-lg">
{MAIN_ROUTES.routes.filter(filter).map(renderHeaderLink)}
</div>}
<div className="hidden md:flex">
<Link href="/settings">
{user && <Button isIconOnly variant="bordered" size="sm" className="mr-2">
<AdjustmentsHorizontalIcon className="w-6" />
</Button>}
</Link>
<ThemeSwitcherDropdown />
<Button size="sm" className="ml-2" color="primary" onClick={() => user ? logout({ redirectTo: '/' }) : login()}>
{user ? 'Logout' : 'Login'}
</Button>
</div>
</div>}
{user ? <>
{friendRequests.length ? <Popover>
<PopoverTrigger>
<div className="hidden sm:block">
<Badge content={friendRequests.length.toString()} color="danger" shape="circle">
<div className="flex items-center justify-center border-medium w-unit-8 h-unit-8 border-default box-border rounded-small cursor-pointer">
<BellAlertIcon className="h-5" />
</div>
</Badge>
</div>
</PopoverTrigger>
<PopoverContent className="max-h-96 overflow-y-auto w-96 flex flex-col overflow-x-hidden pt-2.5 items-center justify-center">
{ notifications }
</PopoverContent>
</Popover> : <Tooltip content={<span className="text-gray-500 italic">No notifications</span>}>
<Button isIconOnly size="sm" variant="bordered" className="hidden sm:flex" disableRipple disableAnimation>
<BellIcon className="h-5" />
</Button>
</Tooltip>}
<Dropdown isOpen={userDropdownOpen && !isNotificationsOpen} onOpenChange={o => {
if (!o || breakpoint)
setUserDropdownOpen(o);
else
setNotificationsOpen(true);
}}>
<DropdownTrigger>
<div className="flex items-center">
<Badge content={friendRequests.length.toString()} color="danger" shape="circle" isInvisible={!!breakpoint || !friendRequests.length} size="lg">
<Avatar name={user.username?.[0]?.toUpperCase() ?? undefined} className={`w-12 h-12 ml-2.5 cursor-pointer text-2xl [font-feature-settings:"fwid"]`} />
</Badge>
</div>
</DropdownTrigger>
<DropdownMenu>
<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>
</DropdownItem>
<DropdownItem className="p-0 mt-1">
<Link href="/settings" className="w-full h-full block px-2 py-1.5">
Settings
</Link>
</DropdownItem>
<DropdownItem className="p-0 mt-1" showDivider closeOnSelect={false}>
<div className="w-full flex pl-2 h-8 items-center" onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
Dark Theme
<ThemeSwitcherSwitch className="ml-auto" size="sm" />
</div>
</DropdownItem>
<DropdownItem className="p-0" color="danger" variant="flat">
<div className="w-full h-full block px-2 py-1.5 text-danger" onClick={() => logout({ redirectTo: '/' })}>
Logout
</div>
</DropdownItem>
</DropdownMenu>
</Dropdown>
</> :
<Button size="sm" className="ml-2" color="primary" onClick={() => login()}>
Login
</Button>
}
</div>
</Navbar>
<div className="sm:px-5 flex-grow pt-fixed flex flex-col">
<div className={`sm:px-5 flex-grow pt-fixed flex flex-col ${isNotificationsOpen ? 'overflow-hidden' : ''}`}>
{children}
</div>
</div>

View File

@ -16,23 +16,32 @@ export default async function UserProfilePage({ params }: { params: { userId: st
'u.id',
'ext.uuid',
'u.permissions',
'created_date',
'last_login_date',
userIsVisible('u.id').as('visible')
])
.executeTakeFirst();
if (!user)
return notFound();
const isFriend = !!(await db.selectFrom('actaeon_user_friends')
.where('user1', '=', user.id)
.where('user2', '=', viewingUser?.id!)
.select('user1')
.executeTakeFirst());
const [friend, pendingFriend] = await Promise.all([
db.selectFrom('actaeon_user_friends')
.where('user1', '=', user.id)
.where('user2', '=', viewingUser?.id!)
.select('chuniRival')
.executeTakeFirst(),
db.selectFrom('actaeon_friend_requests')
.where('user', '=', user.id)
.where('friend', '=', viewingUser?.id!)
.select('user')
.executeTakeFirst()
]);
if (!user.visible)
return (<UserProfile isFriend={isFriend} user={user as UserProfile<false>}/>);
return (<UserProfile friend={friend} pendingFriend={!!pendingFriend} user={user as UserProfile<false>}/>);
const chuniProfile = await getChuniUserData(user);
return (<UserProfile isFriend={isFriend} user={user as UserProfile<true>} chuniProfile={chuniProfile} />);
return (<UserProfile friend={friend} pendingFriend={!!pendingFriend} user={user as UserProfile<true>} chuniProfile={chuniProfile} />);
}

View File

@ -6,30 +6,51 @@ import { ChuniNameplate } from '@/components/chuni/nameplate';
import { hasPermission } from '@/helpers/permissions';
import { useUser } from '@/helpers/use-user';
import { USER_PERMISSION_NAMES, UserPermissions } from '@/types/permissions';
import { UserPayload } from '@/types/user';
import { DBUserPayload, UserPayload } from '@/types/user';
import { ArrowUpRightIcon } from '@heroicons/react/16/solid';
import { UserIcon, UserMinusIcon, UserPlusIcon } from '@heroicons/react/24/outline';
import { Button, Divider, Tooltip } from '@nextui-org/react';
import { Button, Divider, Tooltip, user } from '@nextui-org/react';
import Link from 'next/link';
import { PermissionIcon } from '@/components/permission-icon';
import { sendFriendRequest, unfriend } from '@/actions/friend';
import { useState } from 'react';
import { useConfirmModal } from '@/components/confirm-modal';
import { useRouter } from 'next/navigation';
import { ChuniPenguinIcon } from '@/components/chuni/chuni-penguin-icon';
export type UserProfile<V extends boolean> = Pick<UserPayload, 'username' | 'id' | 'uuid' | 'permissions'> & { visible: V; };
export type UserProfile<V extends boolean> = Pick<UserPayload, 'username' | 'id' | 'uuid' | 'permissions'> & Pick<DBUserPayload, 'created_date' | 'last_login_date'> & { visible: V; };
type UserFriend = Pick<DB['actaeon_user_friends'], 'chuniRival'> | null | undefined;
export type UserProfileProps<T extends boolean> = T extends false ? {
user: UserProfile<false>,
isFriend: boolean
friend: UserFriend,
pendingFriend: boolean;
} : {
user: UserProfile<true>,
chuniProfile: ChuniUserData,
isFriend: boolean
friend: UserFriend,
pendingFriend: boolean
};
const FORMAT = {
month: 'numeric',
day: 'numeric',
year: '2-digit',
hour: 'numeric',
minute: '2-digit'
} as const;
export const UserProfile = <T extends boolean>(props: UserProfileProps<T>) => {
const viewingUser = useUser();
const [pendingFriend, setPendingFriend] = useState(props.pendingFriend);
const confirm = useConfirmModal();
const header = (<>
<header className="flex flex-wrap w-full text-4xl font-bold mt-4 px-4 sm:mt-12 max-w-4xl mx-auto items-center gap-3">
<div className="flex items-center mx-auto sm:mx-0">
<div className="flex items-center mx-auto sm:mx-0 flex-wrap gap-y-2">
{props.friend?.chuniRival && <Tooltip content="Chunithm Rival">
<div><ChuniPenguinIcon className="h-8 mr-3 md:-ml-8 ml-2" /></div>
</Tooltip>}
<span>{props.user.username}</span>
{[...USER_PERMISSION_NAMES].filter(([permission]) => props.user.permissions! & (1 << permission))
.map(([permission]) => <PermissionIcon permission={permission} className="ml-2.5 h-7 w-7" />)}
@ -45,23 +66,36 @@ export const UserProfile = <T extends boolean>(props: UserProfileProps<T>) => {
</Tooltip>
</Link>}
{props.isFriend ? <Tooltip content={<span className="text-danger">Unfriend</span>}>
<Button isIconOnly size="lg" color="danger" variant="flat">
{viewingUser?.id !== props.user.id && <>{props.friend ? <Tooltip content={<span className="text-danger">Unfriend</span>}>
<Button isIconOnly size="lg" color="danger" variant="flat" onPress={() => confirm(`Do you want to unfriend ${props.user.username}?`, () => {
unfriend(props.user.id)
.then(() => location.reload());
})}>
<UserMinusIcon className="h-1/2" />
</Button>
</Tooltip> : <Tooltip content="Send friend request">
<Button isIconOnly size="lg">
<UserPlusIcon className="h-1/2" />
</Button>
</Tooltip>}
</Tooltip> : <Tooltip content={pendingFriend ? 'Friend request pending' : 'Send friend request'}>
<div>
<Button isIconOnly size="lg" isDisabled={pendingFriend} onPress={() => {
setPendingFriend(true);
sendFriendRequest(props.user.id);
}}>
<UserPlusIcon className="h-1/2" />
</Button>
</div>
</Tooltip>}</>}
</div>
</header>
<Divider className="sm:mt-12 sm:mb-12 my-4 max-w-7xl mx-auto" />
<div className="max-w-4xl mx-auto w-full flex mt-4 px-2">
<span>Joined {props.user.created_date?.toLocaleDateString()}</span>
<span className="ml-auto text-right">Last Seen {props.user.last_login_date?.toLocaleDateString(undefined, FORMAT)}</span>
</div>
<Divider className="sm:mt-8 sm:mb-12 my-4 max-w-7xl mx-auto" />
</>);
if (!props.user.visible)
return (<main>
{ header }
return (<main className="flex flex-col">
{header}
<div className="italic text-gray-500 mx-auto max-w-7xl w-full text-xl font-light pl-6">This profile is private</div>
</main>)
const { chuniProfile } = props as UserProfileProps<true>;

View File

@ -5,13 +5,20 @@
@import 'font';
@import 'scrollbar';
$header-height: 5.5rem;
// offsets for header size of 5.5rem
.h-fixed {
height: calc(100% - 5.5rem);
height: calc(100% - #{$header-height});
}
.pt-fixed {
padding-top: 5.5rem;
padding-top: $header-height;
}
.h-header {
height: $header-height;
max-height: $header-height;
}
.\@container-size {

View File

@ -16,7 +16,7 @@ export default async function RootLayout({children}: LayoutProps) {
<link rel="preload" href={getAssetUrl('/fonts/FOT-RodinProN-UB-en.woff2')} as="font" type="font/woff2" crossOrigin="anonymous" />
<link rel="preload" href={getAssetUrl('/fonts/HelveticaNowDisplay-ExtraBold.woff2')} as="font" type="font/woff2" crossOrigin="anonymous" />
</head>
<body className="h-full">
<body className="h-full">
<Providers>
{children}
</Providers>

View File

@ -6,6 +6,8 @@ import { cache } from 'react';
import { SelectQueryBuilder, sql } from 'kysely';
import { AimeUser } from '@/types/db';
import crypto from 'crypto';
import { createActaeonTeamsFromExistingTeams } from './data/team';
import { createActaeonFriendsFromExistingFriends } from './data/friend';
let basePath = process.env.BASE_PATH ?? '';
if (basePath.endsWith('/')) basePath = basePath.slice(0, -1);
@ -26,7 +28,7 @@ const selectUserProps = (builder: SelectQueryBuilder<GeneratedDB & { u: AimeUser
'ext.homepage',
'ext.team',
fn<boolean>('not isnull', ['chuni.id']).as('chuni')
])
] as const)
.executeTakeFirst();
const nextAuth = NextAuth({
@ -70,6 +72,11 @@ const nextAuth = NextAuth({
.executeTakeFirst();
(user as any).uuid = uuid;
(user as any).visibility = 0;
await Promise.all([
createActaeonTeamsFromExistingTeams().catch(console.error),
createActaeonFriendsFromExistingFriends().catch(console.error)
]);
}
const now = new Date();

View File

@ -0,0 +1,29 @@
export const ChuniPenguinIcon = ({ className }: { className?: string; }) => <svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlnsXlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 575.97 1066.56" enable-background="new 0 0 575.97 1066.56" xmlSpace="preserve" fill="currentColor" className={className}>
<path d="M221.68,0c26.2,53.56,47.17,108.69,50.4,170.46c12.03-22.99,25.9-43.3,35.81-65.84c0.55,0.09,1.11,0.18,1.66,0.28
c0.39,4.67,1.15,9.34,1.11,14c-0.27,36.51-8.89,71.22-23.11,104.65c-2.73,6.42-6.06,12.61-9.31,18.79
c-1.49,2.84-2.04,4.27,2.24,4.4c33.79,1.05,67.46,4.01,101.1,7.11c40.28,3.7,78.14,35.91,88.71,74.85
c2.54,9.34,4.83,18.73,5.25,28.42c0.23,5.27,1.53,11.24-5.11,14.24c-0.61,0.28-0.95,1.33-1.25,2.1
c-3.98,10.33-9.62,19.78-15.81,28.87c-6.47,9.5-10.78,19.85-13.62,30.92c-3.42,13.36-6.97,26.7-10.59,40.53
c2.16-0.73,2.81-2.31,3.87-3.43c34.4-36.21,73.61-65.43,120.82-82.99c4.54-1.69,9.22-2.89,14.14-2.74
c5.97,0.19,8.93,3.61,7.7,9.49c-0.6,2.9-1.8,5.72-3.08,8.41c-14.9,31.17-36.12,57.28-62.57,79.48
c-25.66,21.54-51.57,42.74-79.05,61.94c-3.19,2.23-3.79,5.27-4.02,8.64c-0.49,7.38,1,14.6,1.95,21.85
c1.73,13.23,2.04,26.56,2.69,39.85c0.81,16.63,1.37,33.31,1.18,49.95c-0.29,25.61-5.24,50.02-21.36,70.95
c-2.08,2.7-1.63,5.67-1.3,8.62c3.51,31.74,1.61,63.21-5.37,94.31c-6.59,29.32-22.61,53.87-40.63,77.22
c-13.54,17.55-30.65,30.92-50.2,41.25c-6.99,3.69-14.4,6.31-22.21,7.25c-4.29,0.52-4.01,2.26-3.29,5.55
c4.14,18.97,14.64,34.33,26.47,49.24c10.09,12.72,23.64,19.05,38.27,24.13c5.35,1.86,10.79,3.54,15.91,5.91
c6.25,2.9,6.31,7.24,0.59,11.17c-10.79,7.4-23.01,7.22-35.19,6.21c-9.12-0.76-18.07-2.79-26.9-5.35
c-8.22-2.39-14.37-7.21-19.56-13.89c-13.28-17.09-19.18-37.09-23.62-57.76c-1.58-7.37-2.06-14.84-2.35-22.32
c-0.12-3.18-1.43-4.08-4.42-4.63c-35.69-6.53-70.33-16.92-103.64-31.11c-29.5-12.57-55.91-30.67-79.54-52.14
c-33.9-30.81-57.32-68.72-69.26-113.09c-6.75-25.06-5.48-50.85-3.95-76.51c2.07-34.54,10.82-67.64,21.4-100.36
c1.76-5.45,1.94-5.55,6.47-2.07c9.12,7,18.19,14.07,27.29,21.09c1.15,0.89,2.23,2.54,3.81,1.92c1.94-0.76,1.26-2.87,1.34-4.44
c0.95-18.47,2.63-36.86,6.14-55.04c4.61-23.86,16.43-44.56,28.94-64.89c2.49-4.04,2.43-6.65-0.1-10.61
c-28.64-44.79-47.6-93.27-55.22-146.06c-2.81-19.45-4.95-38.96-6.55-58.54c-0.42-5.16-0.64-10.34,1.28-15.31
c2.25-5.84,6.39-7.36,11.95-4.51c2.27,1.17,3.99,2.97,5.83,4.67c19.95,18.47,34.4,41.04,47.76,64.38
c5.63,9.83,10.85,19.89,16.15,29.9c7.61,14.37,13.01,29.55,16.18,45.51c0.28,1.43,0.72,2.83,1.3,5.09
c3.4-7.96,6.01-15.28,8.25-22.75c5.88-19.62,12.46-39.03,18.53-58.59c5.95-19.17,17.46-33.74,33.1-45.71
c8.37-6.41,15.5-14.49,25.78-18.2c2.35-0.85,1.84-2.24,0.6-3.84c-35.87-46.59-55.6-100.48-70.69-156.46
c-0.22-0.8-0.38-1.62-0.48-2.44c-0.03-0.28,0.2-0.59,0.53-1.46c22.8,14.47,39.21,34.72,54.35,57.82
c-1.89-15.06-3.3-28.96-3.98-42.97c-0.96-19.68,1.65-38.87,5.8-57.94c3.5-16.11,7.73-32.04,13.71-47.45
C221.02,0,221.35,0,221.68,0z"/>
</svg>;

View File

@ -1,9 +1,10 @@
'use client';
import { useTheme } from 'next-themes';
import { Button, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, Switch } from '@nextui-org/react';
import { Button, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, Switch, SwitchProps } from '@nextui-org/react';
import { MoonIcon, SunIcon } from '@heroicons/react/24/outline';
import { useIsMounted } from 'usehooks-ts';
import { useEffect, useState } from 'react';
const THEME_SWITCHER_STYLE = { zIndex: 99999999999 } as const;
@ -30,12 +31,19 @@ export function ThemeSwitcherDropdown() {
</Dropdown>);
}
export function ThemeSwitcherSwitch() {
const { setTheme, theme } = useTheme();
const mounted = useIsMounted();
if (!mounted()) return null;
type ThemeSwitcherSwitchProps = {
size?: SwitchProps['size'],
className?: string
};
return (<Switch size="lg" isSelected={theme === 'dark'} thumbIcon={({ isSelected, className }) => isSelected ?
export function ThemeSwitcherSwitch({ size, className }: ThemeSwitcherSwitchProps) {
const { setTheme, theme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) return null;
return (<Switch size={size ?? 'lg'} className={className} isSelected={theme === 'dark'} thumbIcon={({ isSelected, className }) => isSelected ?
<MoonIcon className={className} /> :
<SunIcon className={className} /> } onChange={ev => setTheme(ev.target.checked ? 'dark' : 'light')} />);
<SunIcon className={className} /> } onValueChange={checked => setTheme(checked ? 'dark' : 'light')} />);
}

83
src/data/friend.ts Normal file
View File

@ -0,0 +1,83 @@
import { GeneratedDB, db } from '@/db';
import { Kysely, Transaction } from 'kysely';
export const createActaeonFriendsFromExistingFriends = async () => {
await db.insertInto('actaeon_user_friends')
.columns(['user1', 'user2', 'chuniRival'])
.expression(eb => eb.selectFrom('chuni_item_favorite as fav')
.where('fav.favKind', '=', 2)
.innerJoin('aime_user as u1', 'u1.id', 'fav.user')
.innerJoin('aime_user as u2', 'u2.id', 'fav.favId')
.where(({ not, exists, selectFrom }) => not(exists(selectFrom('actaeon_user_friends as actaeon_friends')
.whereRef('actaeon_friends.user1', '=', 'u1.id')
.whereRef('actaeon_friends.user2', '=', 'u2.id')
.select(eb => eb.lit(1).as('v'))
)))
.select(eb => ['u1.id as user1', 'u2.id as user2', eb.lit(1).as('chuniRival')] as const))
.execute();
};
export const withChuniRivalCount = (builder: Kysely<GeneratedDB> | Transaction<GeneratedDB> = db) =>
builder.with('chuni_rival_count', db => db
.selectFrom('chuni_item_favorite')
.where(({ eb, lit }) => eb('favKind', '=', lit(2)))
.groupBy('user')
.select(eb => [
'user', eb.fn.count('favId').as('rivalCount')
] as const))
.with('chuni_max_rival_count', db => db
.selectFrom(['chuni_rival_count as r1', 'chuni_rival_count as r2'])
.groupBy(['r1.user', 'r2.user'])
.select(({ fn }) => ['r1.user as user1', 'r2.user as user2',
fn<number>('greatest', ['r1.rivalCount', 'r2.rivalCount']).as('maxRivalCount')
] as const));
export const syncUserFriends = async (user: number, builder: Kysely<GeneratedDB> | Transaction<GeneratedDB> = db) => {
await builder.deleteFrom('chuni_item_favorite')
.where('favKind', '=', 2)
.where('user', '=', user)
.where(({ not, exists, selectFrom, and, eb }) => and([
not(exists(selectFrom('actaeon_user_friends as friends')
.whereRef('friends.user1', '=', 'user')
.whereRef('friends.user2', '=', 'favId')
.where('friends.chuniRival', '=', 1)
.select('chuniRival'))),
eb('version', '=', selectFrom('chuni_static_music')
.select(({ fn }) => fn.max('version').as('latest')))
]))
.execute();
await builder.insertInto('chuni_item_favorite')
.columns(['user', 'favId', 'favKind', 'version'])
.expression(eb => eb.selectFrom('actaeon_user_friends as friends')
.where('friends.user1', '=', user)
.where('friends.chuniRival', '=', 1)
.where(({ not, exists, selectFrom }) => not(exists(selectFrom('chuni_item_favorite as favorite')
.whereRef('favorite.user', '=', 'friends.user1')
.whereRef('favorite.favId', '=', 'friends.user2')
.where('favorite.favKind', '=', 2)
.select('favorite.id'))))
.select(eb => [
'friends.user1 as user', 'friends.user2 as favId',
eb.lit(2).as('favKind'),
eb.selectFrom('chuni_static_music')
.select(({ fn }) => fn.max('version').as('latest'))
.as('version')
] as const))
.execute();
const rivalCount = await withChuniRivalCount(builder)
.selectFrom('chuni_rival_count')
.where('chuni_rival_count.user', '=', user)
.select('chuni_rival_count.rivalCount')
.executeTakeFirst();
await db.updateTable('chuni_profile_data')
.where('user', '=', user)
.where(({ eb, selectFrom }) => eb('version', '=', selectFrom('chuni_static_music')
.select(({ fn }) => fn.max('version').as('latest'))))
.set({
friendCount: Number(rivalCount?.rivalCount ?? 0)
})
.executeTakeFirst();
};

View File

@ -7,7 +7,7 @@ import { UserPayload } from '@/types/user';
import { hasPermission } from '@/helpers/permissions';
import { UserPermissions } from '@/types/permissions';
const createActaeonTeamsFromExistingTeams = async () => {
export const createActaeonTeamsFromExistingTeams = async () => {
await db.transaction().execute(async trx => {
const chuniTeams = (await trx.selectFrom('chuni_profile_team as chuni')
.leftJoin('actaeon_teams as teams', 'teams.chuniTeam', 'chuni.id')

View File

@ -8,6 +8,14 @@ export async function register() {
const DBMigrate = await eval('imp' + 'ort("db-migrate")');
const dbmigrate = DBMigrate.getInstance(true);
await dbmigrate.up();
const { createActaeonTeamsFromExistingTeams } = await import('./data/team');
const { createActaeonFriendsFromExistingFriends } = await import('./data/friend');
await Promise.all([
createActaeonTeamsFromExistingTeams().catch(console.error),
createActaeonFriendsFromExistingFriends().catch(console.error)
]);
}
} else if (process.env.NEXT_RUNTIME === 'edge') {
(globalThis as any).bcrypt = {};

9
src/types/db.d.ts vendored
View File

@ -43,6 +43,13 @@ export interface ActaeonChuniStaticTrophies {
rareType: number | null;
}
export interface ActaeonFriendRequests {
createdDate: Date;
friend: number;
user: number;
uuid: string;
}
export interface ActaeonTeamJoinKeys {
id: string;
remainingUses: number | null;
@ -68,6 +75,7 @@ export interface ActaeonUserExt {
}
export interface ActaeonUserFriends {
chuniRival: number | null;
user1: number;
user2: number;
}
@ -3332,6 +3340,7 @@ export interface DB {
actaeon_chuni_static_name_plate: ActaeonChuniStaticNamePlate;
actaeon_chuni_static_system_voice: ActaeonChuniStaticSystemVoice;
actaeon_chuni_static_trophies: ActaeonChuniStaticTrophies;
actaeon_friend_requests: ActaeonFriendRequests;
actaeon_team_join_keys: ActaeonTeamJoinKeys;
actaeon_teams: ActaeonTeams;
actaeon_user_ext: ActaeonUserExt;