add user profile viewing
This commit is contained in:
parent
c66055def1
commit
91e8b2a357
@ -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<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;
|
||||
}
|
||||
|
||||
|
@ -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<string | number>());
|
||||
|
||||
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 => <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">
|
||||
<AccordionItem key="1" indicator={({ isOpen }) => <Tooltip content="Show cards">
|
||||
<Accordion selectedKeys={openUsers}
|
||||
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">
|
||||
<CreditCardIcon className="h-6 w-6 mr-1" />
|
||||
<ChevronDownIcon className={`h-4 transition ${isOpen ? 'rotate-180' : ''}`} />
|
||||
@ -120,7 +125,7 @@ export const AdminUserList = ({ users: initialUsers }: { users: AdminUser[]; })
|
||||
<TrashIcon className="w-5" />
|
||||
</div>
|
||||
</Tooltip>}
|
||||
|
||||
|
||||
{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)}>
|
||||
<PencilSquareIcon className="w-5" />
|
||||
@ -140,10 +145,8 @@ export const AdminUserList = ({ users: initialUsers }: { users: AdminUser[]; })
|
||||
</span>
|
||||
}
|
||||
|
||||
{[...PERMISSION_ICONS].filter(([permission]) => userEntry.permissions! & (1 << permission))
|
||||
.map(([permission, Icon]) => <Tooltip key={permission} content={USER_PERMISSION_NAMES.get(permission)!.title}>
|
||||
<div className="ml-2"><Icon className="h-6 w-6" /></div>
|
||||
</Tooltip>)}
|
||||
{[...USER_PERMISSION_NAMES].filter(([permission]) => userEntry.permissions! & (1 << permission))
|
||||
.map(([permission]) => <PermissionIcon className="w-6 h-6 ml-2" permission={permission} />)}
|
||||
|
||||
<Spacer className="flex-grow" />
|
||||
|
||||
@ -179,7 +182,7 @@ export const AdminUserList = ({ users: initialUsers }: { users: AdminUser[]; })
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</section>
|
||||
</AccordionItem>
|
||||
</Accordion>)}
|
||||
</AccordionItem>))}
|
||||
</Accordion>
|
||||
</main>);
|
||||
};
|
38
src/app/(with-header)/user/[userId]/page.tsx
Normal file
38
src/app/(with-header)/user/[userId]/page.tsx
Normal 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} />);
|
||||
}
|
91
src/app/(with-header)/user/[userId]/user-profile.tsx
Normal file
91
src/app/(with-header)/user/[userId]/user-profile.tsx
Normal 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>);
|
||||
};
|
@ -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<ChuniUserData,
|
||||
'trophyName' | 'trophyRareType' | 'nameplateImage' | 'nameplateName' | 'teamName' | 'characterId' | 'level' | 'userName' | 'overPowerRate' | 'overPowerPoint' | 'lastPlayDate' | 'playerRating' | 'highestRating'>;
|
||||
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<ChuniUserData, (typeof CHUNI_NAMEPLATE_PROFILE_KEYS)[number]>;
|
||||
|
||||
export type ChuniNameplateProps = {
|
||||
className?: string,
|
||||
|
27
src/components/permission-icon.tsx
Normal file
27
src/components/permission-icon.tsx
Normal 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>)
|
||||
};
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user