From 91e8b2a357ee6afcbde59514b0716609758b1345 Mon Sep 17 00:00:00 2001 From: sk1982 Date: Sat, 30 Mar 2024 21:45:28 -0400 Subject: [PATCH] add user profile viewing --- src/actions/chuni/profile.ts | 13 ++- .../admin/users/admin-user-list.tsx | 41 +++++---- src/app/(with-header)/user/[userId]/page.tsx | 38 ++++++++ .../user/[userId]/user-profile.tsx | 91 +++++++++++++++++++ src/components/chuni/nameplate.tsx | 10 +- src/components/permission-icon.tsx | 27 ++++++ src/data/user.ts | 2 + 7 files changed, 198 insertions(+), 24 deletions(-) create mode 100644 src/app/(with-header)/user/[userId]/page.tsx create mode 100644 src/app/(with-header)/user/[userId]/user-profile.tsx create mode 100644 src/components/permission-icon.tsx diff --git a/src/actions/chuni/profile.ts b/src/actions/chuni/profile.ts index 79bcac7..ba460ce 100644 --- a/src/actions/chuni/profile.ts +++ b/src/actions/chuni/profile.ts @@ -9,7 +9,9 @@ import { UserPayload } from '@/types/user'; import { ItemKind } from '@/helpers/chuni/items'; import { AvatarCategory } from '@/helpers/chuni/avatar'; import { UserboxItems } from '@/actions/chuni/userbox'; -import { requireUser } from '@/actions/auth'; +import { getUser, requireUser } from '@/actions/auth'; +import { Entries } from 'type-fest'; +import { CHUNI_NAMEPLATE_PROFILE_KEYS } from '@/components/chuni/nameplate'; type RecentRating = { scoreMax: string, @@ -23,7 +25,7 @@ const avatarNames = ['avatarBack', 'avatarFace', 'avatarItem', 'avatarWear', 'av const ALLOW_EQUIP_UNEARNED = ['true', '1', 'yes'].includes(process.env.CHUNI_ALLOW_EQUIP_UNEARNED?.toLowerCase() ?? ''); -export async function getUserData(user: UserPayload) { +export async function getUserData(user: { id: number }) { const res = await db.selectFrom('chuni_profile_data as p') .leftJoin('actaeon_chuni_static_name_plate as nameplate', 'p.nameplateId', 'nameplate.id') .leftJoin('actaeon_chuni_static_trophies as trophy', 'p.trophyId', 'trophy.id') @@ -67,6 +69,13 @@ export async function getUserData(user: UserPayload) { ])) .executeTakeFirst(); + const requestingUser = await getUser(); + if (requestingUser?.id !== user.id && res) + (Object.entries(res) as Entries).forEach(([key, val]) => { + if (!CHUNI_NAMEPLATE_PROFILE_KEYS.includes(key as any) && !avatarNames.find(n => key.startsWith(n))) + res[key] = null; + }); + return res; } diff --git a/src/app/(with-header)/admin/users/admin-user-list.tsx b/src/app/(with-header)/admin/users/admin-user-list.tsx index 6442965..56276c9 100644 --- a/src/app/(with-header)/admin/users/admin-user-list.tsx +++ b/src/app/(with-header)/admin/users/admin-user-list.tsx @@ -2,15 +2,14 @@ import { createUserWithAccessCode, deleteUser, setUserPermissions } from '@/actions/user'; import { PermissionEditModal } from '@/components/permission-edit-modal'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { USER_PERMISSION_NAMES, UserPermissions } from '@/types/permissions'; -import { Button, Divider, Tooltip, Input, Accordion, AccordionItem, Link, Spacer } from '@nextui-org/react'; +import { Button, Divider, Tooltip, Input, Accordion, AccordionItem, Spacer } from '@nextui-org/react'; import { ChevronDownIcon, CreditCardIcon, PencilSquareIcon, PlusIcon } from '@heroicons/react/24/outline'; import { usePromptModal } from '@/components/prompt-modal'; import { ArrowPathIcon } from '@heroicons/react/24/outline'; import { generateAccessCode } from '@/helpers/access-code'; import { useUser } from '@/helpers/use-user'; -import { TbBrandAppleArcade, TbCrown, TbFileSettings, TbUserShield } from 'react-icons/tb'; import { hasPermission } from '@/helpers/permissions'; import { AimeCard } from '@/components/aime-card'; import { useErrorModal } from '@/components/error-modal'; @@ -18,13 +17,8 @@ import { AdminUser } from '@/data/user'; import { adminAddCardToUser } from '@/actions/card'; import { TrashIcon } from '@heroicons/react/24/outline'; import { useConfirmModal } from '@/components/confirm-modal'; - -const PERMISSION_ICONS = new Map([ - [UserPermissions.USERMOD, TbUserShield], - [UserPermissions.ACMOD, TbBrandAppleArcade], - [UserPermissions.SYSADMIN, TbFileSettings], - [UserPermissions.OWNER, TbCrown] -]); +import Link from 'next/link'; +import { PermissionIcon } from '@/components/permission-icon'; const FORMAT = { month: 'numeric', @@ -41,6 +35,12 @@ export const AdminUserList = ({ users: initialUsers }: { users: AdminUser[]; }) const prompt = usePromptModal(); const setError = useErrorModal(); const confirm = useConfirmModal(); + const [openUsers, setOpenUsers] = useState(new Set()); + + useEffect(() => { + if (window.location.hash) + setOpenUsers(new Set([window.location.hash.slice(1)])); + }, []); const promptAccessCode = (message: string, onConfirm: (val: string) => void) => { prompt({ @@ -91,8 +91,13 @@ export const AdminUserList = ({ users: initialUsers }: { users: AdminUser[]; }) setUsers(u => u.map(u => u.id === id ? { ...u, permissions } : u)); }} /> - {users.map(userEntry => - + typeof s !== 'string' && setOpenUsers(s)} + className="my-1 border-b sm:border-b-0 border-divider sm:bg-content1 sm:rounded-lg sm:px-4 overflow-hidden"> + + {users.map(userEntry => (
@@ -120,7 +125,7 @@ export const AdminUserList = ({ users: initialUsers }: { users: AdminUser[]; })
} - + {hasPermission(user?.permissions, UserPermissions.OWNER) &&
setEditingUser(userEntry)}> @@ -140,10 +145,8 @@ export const AdminUserList = ({ users: initialUsers }: { users: AdminUser[]; }) } - {[...PERMISSION_ICONS].filter(([permission]) => userEntry.permissions! & (1 << permission)) - .map(([permission, Icon]) => -
-
)} + {[...USER_PERMISSION_NAMES].filter(([permission]) => userEntry.permissions! & (1 << permission)) + .map(([permission]) => )} @@ -179,7 +182,7 @@ export const AdminUserList = ({ users: initialUsers }: { users: AdminUser[]; }) - - )} + ))} + ); }; \ No newline at end of file diff --git a/src/app/(with-header)/user/[userId]/page.tsx b/src/app/(with-header)/user/[userId]/page.tsx new file mode 100644 index 0000000..308175b --- /dev/null +++ b/src/app/(with-header)/user/[userId]/page.tsx @@ -0,0 +1,38 @@ +import { getUser } from '@/actions/auth'; +import { userIsVisible, withUsersVisibleTo } from '@/data/user'; +import { notFound } from 'next/navigation'; +import { getUserData as getChuniUserData } from '@/actions/chuni/profile'; +import { UserProfile } from './user-profile'; +import { db } from '@/db'; + +export default async function UserProfilePage({ params }: { params: { userId: string; }; }) { + const viewingUser = await getUser(); + const user = await withUsersVisibleTo(viewingUser) + .selectFrom('aime_user as u') + .innerJoin('actaeon_user_ext as ext', 'ext.userId', 'u.id') + .where('ext.uuid', '=', params.userId) + .select([ + 'u.username', + 'u.id', + 'ext.uuid', + 'u.permissions', + 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()); + + if (!user.visible) + return (}/>); + + const chuniProfile = await getChuniUserData(user); + + 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 new file mode 100644 index 0000000..b05c506 --- /dev/null +++ b/src/app/(with-header)/user/[userId]/user-profile.tsx @@ -0,0 +1,91 @@ +'use client'; + +import { ChuniUserData } from '@/actions/chuni/profile'; +import { ChuniAvatar } from '@/components/chuni/avatar'; +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 { ArrowUpRightIcon } from '@heroicons/react/16/solid'; +import { UserIcon, UserMinusIcon, UserPlusIcon } from '@heroicons/react/24/outline'; +import { Button, Divider, Tooltip } from '@nextui-org/react'; +import Link from 'next/link'; +import { PermissionIcon } from '@/components/permission-icon'; + +export type UserProfile = Pick & { visible: V; }; + +export type UserProfileProps = T extends false ? { + user: UserProfile, + isFriend: boolean +} : { + user: UserProfile, + chuniProfile: ChuniUserData, + isFriend: boolean +}; + +export const UserProfile = (props: UserProfileProps) => { + const viewingUser = useUser(); + + const header = (<> +
+
+ {props.user.username} + {[...USER_PERMISSION_NAMES].filter(([permission]) => props.user.permissions! & (1 << permission)) + .map(([permission]) => )} +
+ +
+ {hasPermission(viewingUser?.permissions, UserPermissions.USERMOD) && + + + + } + + {props.isFriend ? Unfriend}> + + : + + } +
+
+ + ); + + if (!props.user.visible) + return (
+ { header } +
) + + const { chuniProfile } = props as UserProfileProps; + + return (
+ {header} + {chuniProfile &&
+
Chunithm Profile
+
+
+ +
+
+ +
+
+ +
} +
); +}; diff --git a/src/components/chuni/nameplate.tsx b/src/components/chuni/nameplate.tsx index 71a1566..58199c9 100644 --- a/src/components/chuni/nameplate.tsx +++ b/src/components/chuni/nameplate.tsx @@ -1,12 +1,16 @@ import { ChuniUserData } from '@/actions/chuni/profile'; -import { getImageUrl } from '@/helpers/assets'; +import { getImageUrl } from '@/helpers/assets'; import { ChuniTrophy } from '@/components/chuni/trophy'; import { PickNullable } from '@/types/pick-nullable'; import { ChuniRating } from '@/components/chuni/rating'; import { formatJst } from '@/helpers/format-jst'; -export type Profile = PickNullable; +export const CHUNI_NAMEPLATE_PROFILE_KEYS = [ + 'trophyName', 'trophyRareType', 'nameplateImage', 'nameplateName', 'teamName', 'characterId', 'level', + 'userName', 'overPowerRate', 'overPowerPoint', 'lastPlayDate', 'playerRating', 'highestRating' +] as const; + +export type Profile = PickNullable; export type ChuniNameplateProps = { className?: string, diff --git a/src/components/permission-icon.tsx b/src/components/permission-icon.tsx new file mode 100644 index 0000000..9b0ca5f --- /dev/null +++ b/src/components/permission-icon.tsx @@ -0,0 +1,27 @@ +import { USER_PERMISSION_NAMES, UserPermissions } from '@/types/permissions'; +import { Tooltip } from '@nextui-org/react'; +import { TbBrandAppleArcade, TbCrown, TbFileSettings, TbUserShield } from 'react-icons/tb'; + +const PERMISSION_ICONS = new Map([ + [UserPermissions.USERMOD, TbUserShield], + [UserPermissions.ACMOD, TbBrandAppleArcade], + [UserPermissions.SYSADMIN, TbFileSettings], + [UserPermissions.OWNER, TbCrown] +]); + +type PermissionIconsProps = { + permission: UserPermissions, + className?: string +}; + +export const PermissionIcon = ({ permission, className }: PermissionIconsProps) => { + const Icon = PERMISSION_ICONS.get(permission); + + if (!Icon) return null; + + return ( +
+ +
+
) +}; \ No newline at end of file diff --git a/src/data/user.ts b/src/data/user.ts index c0c1f6a..1180c26 100644 --- a/src/data/user.ts +++ b/src/data/user.ts @@ -5,6 +5,8 @@ import { sql } from 'kysely'; import { db } from '@/db'; import { jsonObjectArray } from '@/types/json-object-array'; import { parseJsonResult } from '@/helpers/parse-json-result'; +import { getUser } from '@/actions/auth'; +import { CHUNI_NAMEPLATE_PROFILE_KEYS } from '@/components/chuni/nameplate'; type WithUsersVisibleToOptions = { // ignore targets's visibility settings and always show if they share a team with the user