diff --git a/migrations/20240331021022-create-friend-requests.js b/migrations/20240331021022-create-friend-requests.js new file mode 100644 index 0000000..5312e4e --- /dev/null +++ b/migrations/20240331021022-create-friend-requests.js @@ -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 +}; diff --git a/migrations/20240331061344-add-rival-to-user-friends.js b/migrations/20240331061344-add-rival-to-user-friends.js new file mode 100644 index 0000000..1ff05dc --- /dev/null +++ b/migrations/20240331061344-add-rival-to-user-friends.js @@ -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 +}; diff --git a/migrations/sqls/20240331021022-create-friend-requests-down.sql b/migrations/sqls/20240331021022-create-friend-requests-down.sql new file mode 100644 index 0000000..6eea914 --- /dev/null +++ b/migrations/sqls/20240331021022-create-friend-requests-down.sql @@ -0,0 +1 @@ +DROP TABLE actaeon_friend_requests; \ No newline at end of file diff --git a/migrations/sqls/20240331021022-create-friend-requests-up.sql b/migrations/sqls/20240331021022-create-friend-requests-up.sql new file mode 100644 index 0000000..d277bf8 --- /dev/null +++ b/migrations/sqls/20240331021022-create-friend-requests-up.sql @@ -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) +); diff --git a/migrations/sqls/20240331061344-add-rival-to-user-friends-down.sql b/migrations/sqls/20240331061344-add-rival-to-user-friends-down.sql new file mode 100644 index 0000000..38288c7 --- /dev/null +++ b/migrations/sqls/20240331061344-add-rival-to-user-friends-down.sql @@ -0,0 +1,2 @@ +ALTER TABLE actaeon_user_friends +DROP COLUMN chuniRival; diff --git a/migrations/sqls/20240331061344-add-rival-to-user-friends-up.sql b/migrations/sqls/20240331061344-add-rival-to-user-friends-up.sql new file mode 100644 index 0000000..5e41d5e --- /dev/null +++ b/migrations/sqls/20240331061344-add-rival-to-user-friends-up.sql @@ -0,0 +1,2 @@ +ALTER TABLE actaeon_user_friends +ADD COLUMN chuniRival TINYINT(1); diff --git a/src/actions/auth.ts b/src/actions/auth.ts index b9449aa..77255db 100644 --- a/src/actions/auth.ts +++ b/src/actions/auth.ts @@ -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 }; }; diff --git a/src/actions/friend.ts b/src/actions/friend.ts new file mode 100644 index 0000000..46b6761 --- /dev/null +++ b/src/actions/friend.ts @@ -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>[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('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 ? { [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(); +}; diff --git a/src/actions/user.ts b/src/actions/user.ts index 5df1ee2..847cf4f 100644 --- a/src/actions/user.ts +++ b/src/actions/user.ts @@ -68,6 +68,11 @@ export const deleteUser = async (user: number): Promise => { .where('id', '=', user) .executeTakeFirst(); + await db.deleteFrom('chuni_item_favorite') + .where('favKind', '=', 2) + .where('favId', '=', user) + .executeTakeFirst(); + return {}; }; diff --git a/src/app/(with-header)/header-sidebar.tsx b/src/app/(with-header)/header-sidebar.tsx index f2c0a30..48a945a 100644 --- a/src/app/(with-header)/header-sidebar.tsx +++ b/src/app/(with-header)/header-sidebar.tsx @@ -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([]); + 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) => { {route.routes.filter(filter) - .map(route => ( ( { - router.push(route.url); - cookies.set('actaeon-navigated-from', routeGroup.title); - }} - onMouseEnter={() => router.prefetch(route.url)}> - {route.name} - ))} + router.push(route.url); + cookies.set('actaeon-navigated-from', routeGroup.title); + }}> + {route.name} + ))} ); @@ -67,20 +113,63 @@ export const HeaderSidebar = ({ children }: HeaderSidebarProps) => { className={`mr-4 transition ${linkStyle}`}> {route.name} ); - } + }; + + const notifications = useMemo(() => { + if (!friendRequests.length) return null; + + return friendRequests.map(req => { + const removeRequest = () => { + setFriendRequests(r => r.filter(r => r.reqUuid !== req.reqUuid)); + } + + return (
+ +
+ New friend request from { + setNotificationsOpen(false); + }}>{req.username} +
+
+ + +
+
); + }); + }, [friendRequests]); return (<> {/* begin sidebar */}
-
setMenuOpen(false)} /> +
{ + setMenuOpen(false); + router.back(); + }} /> +
-
@@ -136,10 +225,50 @@ export const HeaderSidebar = ({ children }: HeaderSidebarProps) => {
{/* end sidebar */} + {/* being mobile notifications */} +
+
+
+ setNotificationsOpen(false)}> + + + {user?.username} + + + setNotificationsOpen(false)}> + + + +
+ + + +
+
Notifications
+ {!friendRequests.length && No notifications} +
+ {notifications} +
+
+ + +
+
+ {/* end mobile notifications */} + {/* begin top navbar */}
-
+
} - - - -
+
} + {user ? <> + {friendRequests.length ? + +
+ +
+ +
+
+
+
+ + { notifications } + +
: No notifications}> + + } + + + + { + if (!o || breakpoint) + setUserDropdownOpen(o); + else + setNotificationsOpen(true); + }}> + +
+ + + +
+
+ + + {user.username} + + + + Settings + + + +
setTheme(theme === 'dark' ? 'light' : 'dark')}> + Dark Theme + + +
+
+ +
logout({ redirectTo: '/' })}> + Logout +
+
+
+
+ : + + + }
-
+
{children}
diff --git a/src/app/(with-header)/user/[userId]/page.tsx b/src/app/(with-header)/user/[userId]/page.tsx index 308175b..dc4e385 100644 --- a/src/app/(with-header)/user/[userId]/page.tsx +++ b/src/app/(with-header)/user/[userId]/page.tsx @@ -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 (}/>); + return (}/>); const chuniProfile = await getChuniUserData(user); - return (} chuniProfile={chuniProfile} />); + return (} chuniProfile={chuniProfile} />); } diff --git a/src/app/(with-header)/user/[userId]/user-profile.tsx b/src/app/(with-header)/user/[userId]/user-profile.tsx index b05c506..587e65e 100644 --- a/src/app/(with-header)/user/[userId]/user-profile.tsx +++ b/src/app/(with-header)/user/[userId]/user-profile.tsx @@ -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 = Pick & { visible: V; }; +export type UserProfile = Pick & Pick & { visible: V; }; +type UserFriend = Pick | null | undefined; export type UserProfileProps = T extends false ? { user: UserProfile, - isFriend: boolean + friend: UserFriend, + pendingFriend: boolean; } : { user: UserProfile, 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 = (props: UserProfileProps) => { const viewingUser = useUser(); + const [pendingFriend, setPendingFriend] = useState(props.pendingFriend); + const confirm = useConfirmModal(); const header = (<>
-
+
+ {props.friend?.chuniRival && +
+
} {props.user.username} {[...USER_PERMISSION_NAMES].filter(([permission]) => props.user.permissions! & (1 << permission)) .map(([permission]) => )} @@ -45,23 +66,36 @@ export const UserProfile = (props: UserProfileProps) => { } - {props.isFriend ? Unfriend}> - - : - - } + : +
+ +
+
}}
- +
+ Joined {props.user.created_date?.toLocaleDateString()} + Last Seen {props.user.last_login_date?.toLocaleDateString(undefined, FORMAT)} +
+ ); if (!props.user.visible) - return (
- { header } + return (
+ {header} +
This profile is private
) const { chuniProfile } = props as UserProfileProps; diff --git a/src/app/globals.scss b/src/app/globals.scss index 7fa4eb9..a7a208c 100644 --- a/src/app/globals.scss +++ b/src/app/globals.scss @@ -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 { diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 201cd08..588d25e 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -16,7 +16,7 @@ export default async function RootLayout({children}: LayoutProps) { - + {children} diff --git a/src/auth.ts b/src/auth.ts index 32435a0..3671a08 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -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('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(); diff --git a/src/components/chuni/chuni-penguin-icon.tsx b/src/components/chuni/chuni-penguin-icon.tsx new file mode 100644 index 0000000..2c04171 --- /dev/null +++ b/src/components/chuni/chuni-penguin-icon.tsx @@ -0,0 +1,29 @@ +export const ChuniPenguinIcon = ({ className }: { className?: string; }) => + +; diff --git a/src/components/theme-switcher.tsx b/src/components/theme-switcher.tsx index 983b916..80be40f 100644 --- a/src/components/theme-switcher.tsx +++ b/src/components/theme-switcher.tsx @@ -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() { ); } -export function ThemeSwitcherSwitch() { - const { setTheme, theme } = useTheme(); - const mounted = useIsMounted(); - if (!mounted()) return null; +type ThemeSwitcherSwitchProps = { + size?: SwitchProps['size'], + className?: string +}; - return ( isSelected ? +export function ThemeSwitcherSwitch({ size, className }: ThemeSwitcherSwitchProps) { + const { setTheme, theme } = useTheme(); + const [mounted, setMounted] = useState(false); + useEffect(() => setMounted(true), []); + + if (!mounted) return null; + + return ( isSelected ? : - } onChange={ev => setTheme(ev.target.checked ? 'dark' : 'light')} />); + } onValueChange={checked => setTheme(checked ? 'dark' : 'light')} />); } diff --git a/src/data/friend.ts b/src/data/friend.ts new file mode 100644 index 0000000..889d8c4 --- /dev/null +++ b/src/data/friend.ts @@ -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 | Transaction = 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('greatest', ['r1.rivalCount', 'r2.rivalCount']).as('maxRivalCount') + ] as const)); + +export const syncUserFriends = async (user: number, builder: Kysely | Transaction = 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(); +}; diff --git a/src/data/team.ts b/src/data/team.ts index 1bb46a1..6abb8bc 100644 --- a/src/data/team.ts +++ b/src/data/team.ts @@ -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') diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 68e5ab4..0298e02 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -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 = {}; diff --git a/src/types/db.d.ts b/src/types/db.d.ts index 6e5d25b..0822bdf 100644 --- a/src/types/db.d.ts +++ b/src/types/db.d.ts @@ -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;