add user profile viewing

This commit is contained in:
sk1982 2024-03-30 21:45:28 -04:00
parent c66055def1
commit 91e8b2a357
7 changed files with 198 additions and 24 deletions

View File

@ -9,7 +9,9 @@ import { UserPayload } from '@/types/user';
import { ItemKind } from '@/helpers/chuni/items'; import { ItemKind } from '@/helpers/chuni/items';
import { AvatarCategory } from '@/helpers/chuni/avatar'; import { AvatarCategory } from '@/helpers/chuni/avatar';
import { UserboxItems } from '@/actions/chuni/userbox'; 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 = { type RecentRating = {
scoreMax: string, 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() ?? ''); 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') 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_name_plate as nameplate', 'p.nameplateId', 'nameplate.id')
.leftJoin('actaeon_chuni_static_trophies as trophy', 'p.trophyId', 'trophy.id') .leftJoin('actaeon_chuni_static_trophies as trophy', 'p.trophyId', 'trophy.id')
@ -67,6 +69,13 @@ export async function getUserData(user: UserPayload) {
])) ]))
.executeTakeFirst(); .executeTakeFirst();
const requestingUser = await getUser();
if (requestingUser?.id !== user.id && res)
(Object.entries(res) as Entries<typeof res>).forEach(([key, val]) => {
if (!CHUNI_NAMEPLATE_PROFILE_KEYS.includes(key as any) && !avatarNames.find(n => key.startsWith(n)))
res[key] = null;
});
return res; return res;
} }

View File

@ -2,15 +2,14 @@
import { createUserWithAccessCode, deleteUser, setUserPermissions } from '@/actions/user'; import { createUserWithAccessCode, deleteUser, setUserPermissions } from '@/actions/user';
import { PermissionEditModal } from '@/components/permission-edit-modal'; 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 { 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 { ChevronDownIcon, CreditCardIcon, PencilSquareIcon, PlusIcon } from '@heroicons/react/24/outline';
import { usePromptModal } from '@/components/prompt-modal'; import { usePromptModal } from '@/components/prompt-modal';
import { ArrowPathIcon } from '@heroicons/react/24/outline'; import { ArrowPathIcon } from '@heroicons/react/24/outline';
import { generateAccessCode } from '@/helpers/access-code'; import { generateAccessCode } from '@/helpers/access-code';
import { useUser } from '@/helpers/use-user'; import { useUser } from '@/helpers/use-user';
import { TbBrandAppleArcade, TbCrown, TbFileSettings, TbUserShield } from 'react-icons/tb';
import { hasPermission } from '@/helpers/permissions'; import { hasPermission } from '@/helpers/permissions';
import { AimeCard } from '@/components/aime-card'; import { AimeCard } from '@/components/aime-card';
import { useErrorModal } from '@/components/error-modal'; import { useErrorModal } from '@/components/error-modal';
@ -18,13 +17,8 @@ import { AdminUser } from '@/data/user';
import { adminAddCardToUser } from '@/actions/card'; import { adminAddCardToUser } from '@/actions/card';
import { TrashIcon } from '@heroicons/react/24/outline'; import { TrashIcon } from '@heroicons/react/24/outline';
import { useConfirmModal } from '@/components/confirm-modal'; import { useConfirmModal } from '@/components/confirm-modal';
import Link from 'next/link';
const PERMISSION_ICONS = new Map([ import { PermissionIcon } from '@/components/permission-icon';
[UserPermissions.USERMOD, TbUserShield],
[UserPermissions.ACMOD, TbBrandAppleArcade],
[UserPermissions.SYSADMIN, TbFileSettings],
[UserPermissions.OWNER, TbCrown]
]);
const FORMAT = { const FORMAT = {
month: 'numeric', month: 'numeric',
@ -41,6 +35,12 @@ export const AdminUserList = ({ users: initialUsers }: { users: AdminUser[]; })
const prompt = usePromptModal(); const prompt = usePromptModal();
const setError = useErrorModal(); const setError = useErrorModal();
const confirm = useConfirmModal(); const confirm = useConfirmModal();
const [openUsers, setOpenUsers] = useState(new Set<string | number>());
useEffect(() => {
if (window.location.hash)
setOpenUsers(new Set([window.location.hash.slice(1)]));
}, []);
const promptAccessCode = (message: string, onConfirm: (val: string) => void) => { const promptAccessCode = (message: string, onConfirm: (val: string) => void) => {
prompt({ prompt({
@ -91,8 +91,13 @@ export const AdminUserList = ({ users: initialUsers }: { users: AdminUser[]; })
setUsers(u => u.map(u => u.id === id ? { ...u, permissions } : u)); setUsers(u => u.map(u => u.id === id ? { ...u, permissions } : u));
}} /> }} />
{users.map(userEntry => <Accordion key={userEntry.id} className="my-1 border-b sm:border-b-0 border-divider sm:bg-content1 sm:rounded-lg sm:px-4 overflow-hidden"> <Accordion selectedKeys={openUsers}
<AccordionItem key="1" indicator={({ isOpen }) => <Tooltip content="Show cards"> selectionMode="multiple"
onSelectionChange={s => 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 => (<AccordionItem key={userEntry.uuid}
id={userEntry.uuid ?? undefined} indicator={({ isOpen }) => <Tooltip content="Show cards">
<div className="flex items-center"> <div className="flex items-center">
<CreditCardIcon className="h-6 w-6 mr-1" /> <CreditCardIcon className="h-6 w-6 mr-1" />
<ChevronDownIcon className={`h-4 transition ${isOpen ? 'rotate-180' : ''}`} /> <ChevronDownIcon className={`h-4 transition ${isOpen ? 'rotate-180' : ''}`} />
@ -120,7 +125,7 @@ export const AdminUserList = ({ users: initialUsers }: { users: AdminUser[]; })
<TrashIcon className="w-5" /> <TrashIcon className="w-5" />
</div> </div>
</Tooltip>} </Tooltip>}
{hasPermission(user?.permissions, UserPermissions.OWNER) && <Tooltip content="Edit permissions"> {hasPermission(user?.permissions, UserPermissions.OWNER) && <Tooltip content="Edit permissions">
<div className="mr-1.5 p-1.5 rounded-lg transition bg-default hover:brightness-90" onClick={() => setEditingUser(userEntry)}> <div className="mr-1.5 p-1.5 rounded-lg transition bg-default hover:brightness-90" onClick={() => setEditingUser(userEntry)}>
<PencilSquareIcon className="w-5" /> <PencilSquareIcon className="w-5" />
@ -140,10 +145,8 @@ export const AdminUserList = ({ users: initialUsers }: { users: AdminUser[]; })
</span> </span>
} }
{[...PERMISSION_ICONS].filter(([permission]) => userEntry.permissions! & (1 << permission)) {[...USER_PERMISSION_NAMES].filter(([permission]) => userEntry.permissions! & (1 << permission))
.map(([permission, Icon]) => <Tooltip key={permission} content={USER_PERMISSION_NAMES.get(permission)!.title}> .map(([permission]) => <PermissionIcon className="w-6 h-6 ml-2" permission={permission} />)}
<div className="ml-2"><Icon className="h-6 w-6" /></div>
</Tooltip>)}
<Spacer className="flex-grow" /> <Spacer className="flex-grow" />
@ -179,7 +182,7 @@ export const AdminUserList = ({ users: initialUsers }: { users: AdminUser[]; })
</Button> </Button>
</Tooltip> </Tooltip>
</section> </section>
</AccordionItem> </AccordionItem>))}
</Accordion>)} </Accordion>
</main>); </main>);
}; };

View File

@ -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 (<UserProfile isFriend={isFriend} user={user as UserProfile<false>}/>);
const chuniProfile = await getChuniUserData(user);
return (<UserProfile isFriend={isFriend} user={user as UserProfile<true>} chuniProfile={chuniProfile} />);
}

View File

@ -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<V extends boolean> = Pick<UserPayload, 'username' | 'id' | 'uuid' | 'permissions'> & { visible: V; };
export type UserProfileProps<T extends boolean> = T extends false ? {
user: UserProfile<false>,
isFriend: boolean
} : {
user: UserProfile<true>,
chuniProfile: ChuniUserData,
isFriend: boolean
};
export const UserProfile = <T extends boolean>(props: UserProfileProps<T>) => {
const viewingUser = useUser();
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">
<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" />)}
</div>
<div className="ml-auto flex gap-2">
{hasPermission(viewingUser?.permissions, UserPermissions.USERMOD) && <Link href={`/admin/users#${props.user.uuid}`}>
<Tooltip content="View user in admin panel">
<Button isIconOnly size="lg" className="relative">
<UserIcon className="w-[47%] -translate-x-0.5" />
<ArrowUpRightIcon className="w-[33%] absolute top-2.5 right-2" />
</Button>
</Tooltip>
</Link>}
{props.isFriend ? <Tooltip content={<span className="text-danger">Unfriend</span>}>
<Button isIconOnly size="lg" color="danger" variant="flat">
<UserMinusIcon className="h-1/2" />
</Button>
</Tooltip> : <Tooltip content="Send friend request">
<Button isIconOnly size="lg">
<UserPlusIcon className="h-1/2" />
</Button>
</Tooltip>}
</div>
</header>
<Divider className="sm:mt-12 sm:mb-12 my-4 max-w-7xl mx-auto" />
</>);
if (!props.user.visible)
return (<main>
{ header }
</main>)
const { chuniProfile } = props as UserProfileProps<true>;
return (<main className="flex flex-col">
{header}
{chuniProfile && <section className="w-full flex flex-col max-w-7xl mx-auto">
<header className="mb-8 font-semibold text-2xl px-4">Chunithm Profile</header>
<section className="md:h-96 flex flex-col md:flex-row gap-x-5 gap-y-2 justify-center items-center">
<div className="w-full md:w-auto md:h-full flex-grow-[2.75]">
<ChuniNameplate profile={chuniProfile} className="w-full" />
</div>
<div className="w-full md:w-auto max-w-80 sm:max-w-72 md:max-w-none md:h-full flex-grow">
<ChuniAvatar className="w-full"
wear={chuniProfile.avatarWearTexture}
head={chuniProfile.avatarHeadTexture}
face={chuniProfile.avatarFaceTexture}
skin={chuniProfile.avatarSkinTexture}
item={chuniProfile.avatarItemTexture}
back={chuniProfile.avatarBackTexture}
/>
</div>
</section>
</section>}
</main>);
};

View File

@ -1,12 +1,16 @@
import { ChuniUserData } from '@/actions/chuni/profile'; import { ChuniUserData } from '@/actions/chuni/profile';
import { getImageUrl } from '@/helpers/assets'; import { getImageUrl } from '@/helpers/assets';
import { ChuniTrophy } from '@/components/chuni/trophy'; import { ChuniTrophy } from '@/components/chuni/trophy';
import { PickNullable } from '@/types/pick-nullable'; import { PickNullable } from '@/types/pick-nullable';
import { ChuniRating } from '@/components/chuni/rating'; import { ChuniRating } from '@/components/chuni/rating';
import { formatJst } from '@/helpers/format-jst'; import { formatJst } from '@/helpers/format-jst';
export type Profile = PickNullable<ChuniUserData, export const CHUNI_NAMEPLATE_PROFILE_KEYS = [
'trophyName' | 'trophyRareType' | 'nameplateImage' | 'nameplateName' | 'teamName' | 'characterId' | 'level' | 'userName' | 'overPowerRate' | 'overPowerPoint' | 'lastPlayDate' | 'playerRating' | 'highestRating'>; 'trophyName', 'trophyRareType', 'nameplateImage', 'nameplateName', 'teamName', 'characterId', 'level',
'userName', 'overPowerRate', 'overPowerPoint', 'lastPlayDate', 'playerRating', 'highestRating'
] as const;
export type Profile = PickNullable<ChuniUserData, (typeof CHUNI_NAMEPLATE_PROFILE_KEYS)[number]>;
export type ChuniNameplateProps = { export type ChuniNameplateProps = {
className?: string, className?: string,

View File

@ -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 (<Tooltip content={USER_PERMISSION_NAMES.get(permission)?.title!}>
<div>
<Icon className={className} />
</div>
</Tooltip>)
};

View File

@ -5,6 +5,8 @@ import { sql } from 'kysely';
import { db } from '@/db'; import { db } from '@/db';
import { jsonObjectArray } from '@/types/json-object-array'; import { jsonObjectArray } from '@/types/json-object-array';
import { parseJsonResult } from '@/helpers/parse-json-result'; import { parseJsonResult } from '@/helpers/parse-json-result';
import { getUser } from '@/actions/auth';
import { CHUNI_NAMEPLATE_PROFILE_KEYS } from '@/components/chuni/nameplate';
type WithUsersVisibleToOptions = { type WithUsersVisibleToOptions = {
// ignore targets's visibility settings and always show if they share a team with the user // ignore targets's visibility settings and always show if they share a team with the user