add friending and initial rival support
This commit is contained in:
parent
91e8b2a357
commit
1a39d5bb3a
53
migrations/20240331021022-create-friend-requests.js
Normal file
53
migrations/20240331021022-create-friend-requests.js
Normal 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
|
||||
};
|
53
migrations/20240331061344-add-rival-to-user-friends.js
Normal file
53
migrations/20240331061344-add-rival-to-user-friends.js
Normal 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
|
||||
};
|
@ -0,0 +1 @@
|
||||
DROP TABLE actaeon_friend_requests;
|
12
migrations/sqls/20240331021022-create-friend-requests-up.sql
Normal file
12
migrations/sqls/20240331021022-create-friend-requests-up.sql
Normal 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)
|
||||
);
|
@ -0,0 +1,2 @@
|
||||
ALTER TABLE actaeon_user_friends
|
||||
DROP COLUMN chuniRival;
|
@ -0,0 +1,2 @@
|
||||
ALTER TABLE actaeon_user_friends
|
||||
ADD COLUMN chuniRival TINYINT(1);
|
@ -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
133
src/actions/friend.ts
Normal 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();
|
||||
};
|
@ -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 {};
|
||||
};
|
||||
|
||||
|
@ -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>
|
||||
|
@ -16,6 +16,8 @@ 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();
|
||||
@ -23,16 +25,23 @@ export default async function UserProfilePage({ params }: { params: { userId: st
|
||||
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} />);
|
||||
}
|
||||
|
@ -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>;
|
||||
|
@ -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 {
|
||||
|
@ -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>
|
||||
|
@ -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();
|
||||
|
29
src/components/chuni/chuni-penguin-icon.tsx
Normal file
29
src/components/chuni/chuni-penguin-icon.tsx
Normal 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>;
|
@ -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
83
src/data/friend.ts
Normal 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();
|
||||
};
|
@ -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')
|
||||
|
@ -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
9
src/types/db.d.ts
vendored
@ -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;
|
||||
|
Loading…
Reference in New Issue
Block a user