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 { 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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>);
|
||||||
};
|
};
|
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 { 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,
|
||||||
|
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 { 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
|
||||||
|
Loading…
Reference in New Issue
Block a user