first commit

This commit is contained in:
2025-10-19 13:31:11 +00:00
commit 8bfc183b66
1248 changed files with 195992 additions and 0 deletions

View File

@@ -0,0 +1,160 @@
import { Avatar, Skeleton } from "@mui/material";
import { grey } from "@mui/material/colors";
import { bindHover, bindPopover } from "material-ui-popup-state";
import { usePopupState } from "material-ui-popup-state/hooks";
import { memo, useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useInView } from "react-intersection-observer";
import { ApiPrefix } from "../../../api/request.ts";
import { User } from "../../../api/user.ts";
import { useAppDispatch } from "../../../redux/hooks.ts";
import { loadUserInfo } from "../../../redux/thunks/session.ts";
import InPrivate from "../../Icons/InPrivate.tsx";
import UserPopover from "./UserPopover.tsx";
export interface UserAvatarProps {
user?: User;
uid?: string;
overwriteTextSize?: boolean;
onUserLoaded?: (user: User) => void;
enablePopover?: boolean;
square?: boolean;
cacheKey?: string;
[key: string]: any;
}
function stringToColor(string: string) {
let hash = 0;
let i;
/* eslint-disable no-bitwise */
for (i = 0; i < string.length; i += 1) {
hash = string.charCodeAt(i) + ((hash << 5) - hash);
}
let color = "#";
for (i = 0; i < 3; i += 1) {
const value = (hash >> (i * 8)) & 0xff;
color += `00${value.toString(16)}`.slice(-2);
}
/* eslint-enable no-bitwise */
return color;
}
export const AnonymousUser: User = {
id: "",
nickname: "",
created_at: "",
};
const UserAvatar = memo(
({
user,
key,
overwriteTextSize,
onUserLoaded,
uid,
sx,
square,
cacheKey,
enablePopover,
...rest
}: UserAvatarProps) => {
const [loadedUser, setLoadedUser] = useState<User | undefined>(undefined);
const dispatch = useAppDispatch();
const popupState = usePopupState({
variant: "popover",
popupId: "user",
});
const { t } = useTranslation();
const { ref, inView } = useInView({
triggerOnce: true,
rootMargin: "200px 0px",
skip: !!user,
});
useEffect(() => {
if (inView && !loadedUser) {
if (uid) {
loadUser(uid);
}
}
}, [inView]);
useEffect(() => {
if (user) {
setLoadedUser(user);
}
}, [user]);
const loadUser = useCallback(
async (uid: string) => {
try {
const u = await dispatch(loadUserInfo(uid));
setLoadedUser(u);
if (onUserLoaded) {
onUserLoaded(u);
}
} catch (e) {
console.warn("Failed to load user info", e);
}
},
[dispatch, setLoadedUser, onUserLoaded],
);
const avatarUrl = useMemo(() => {
if (loadedUser) {
return ApiPrefix + `/user/avatar/${loadedUser.id}${cacheKey ? `?nocache=1&key=${cacheKey ?? 0}` : ""}`;
}
return undefined;
}, [loadedUser, cacheKey]);
return (
<>
{loadedUser && (
<>
<Avatar
alt={loadedUser.nickname ?? t("modals.anonymous")}
src={avatarUrl}
slotProps={{
img: {
loading: "lazy",
alt: "",
},
}}
{...rest}
{...bindHover(popupState)}
sx={[
{
bgcolor: loadedUser.nickname ? stringToColor(loadedUser.nickname) : grey[500],
...sx,
},
overwriteTextSize && {
fontSize: `${sx.width * 0.6}px!important`,
},
square && {
borderRadius: (theme) => `${theme.shape.borderRadius}px`,
},
]}
>
{loadedUser.id == "" ? (
<InPrivate
sx={{
...(overwriteTextSize
? {
fontSize: `${sx.width * 0.6}px`,
}
: {}),
}}
/>
) : undefined}
</Avatar>
{enablePopover && user && <UserPopover user={user} {...bindPopover(popupState)} />}
</>
)}
{!loadedUser && <Skeleton ref={ref} variant={"circular"} sx={{ ...sx }} {...rest} />}
</>
);
},
);
export default UserAvatar;

View File

@@ -0,0 +1,52 @@
import UserAvatar, { UserAvatarProps } from "./UserAvatar.tsx";
import { Skeleton, TypographyProps } from "@mui/material";
import { useState } from "react";
import { BadgeText, DefaultButton } from "../StyledComponents.tsx";
import { usePopupState } from "material-ui-popup-state/hooks";
import UserPopover from "./UserPopover.tsx";
import { bindHover, bindPopover } from "material-ui-popup-state";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
export interface UserBadgeProps extends UserAvatarProps {
textProps?: TypographyProps;
}
const UserBadge = ({ textProps, user, uid, ...rest }: UserBadgeProps) => {
const { t } = useTranslation();
const [userLoaded, setUserLoaded] = useState(user);
const navigate = useNavigate();
const popupState = usePopupState({
variant: "popover",
popupId: "user",
});
return (
<>
<DefaultButton
{...bindHover(popupState)}
onClick={() => navigate(`/profile/${user?.id ?? uid}`)}
sx={{
display: "flex",
alignItems: "center",
maxWidth: "150px",
}}
>
<UserAvatar overwriteTextSize user={user} onUserLoaded={(u) => setUserLoaded(u)} uid={uid} {...rest} />
<BadgeText {...textProps}>
{userLoaded ? (
userLoaded.id ? (
userLoaded.nickname
) : (
t("application:modals.anonymous")
)
) : (
<Skeleton width={60} />
)}
</BadgeText>
</DefaultButton>
{userLoaded && <UserPopover user={userLoaded} {...bindPopover(popupState)} />}
</>
);
};
export default UserBadge;

View File

@@ -0,0 +1,122 @@
import { Box, Button, PopoverProps, styled, Tooltip, Typography } from "@mui/material";
import HoverPopover from "material-ui-popup-state/HoverPopover";
import { useCallback, useEffect, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { User } from "../../../api/user.ts";
import UserAvatar from "./UserAvatar.tsx";
import { useAppDispatch } from "../../../redux/hooks.ts";
import { loadUserInfo } from "../../../redux/thunks/session.ts";
import MailOutlined from "../../Icons/MailOutlined.tsx";
import HomeOutlined from "../../Icons/HomeOutlined.tsx";
import { useNavigate } from "react-router-dom";
import TimeBadge from "../TimeBadge.tsx";
interface UserPopoverProps extends PopoverProps {
user: User;
}
const ActionButton = styled(Button)({
minWidth: "initial",
});
export const UserProfile = ({ user, open, displayOnly }: { user: User; open: boolean; displayOnly?: boolean }) => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const navigate = useNavigate();
const [loadedUser, setLoadedUser] = useState(user);
useEffect(() => {
if (open && user.id && !user.group) {
dispatch(loadUserInfo(user.id)).then((u) => {
if (u) {
setLoadedUser(u);
}
});
}
}, [open]);
return (
<Box
sx={{
display: "flex",
}}
>
<UserAvatar overwriteTextSize user={user} sx={{ width: 80, height: 80 }} />
<Box sx={{ ml: 2 }}>
<Box sx={{ display: "flex", alignItems: "center" }}>
<Typography variant={"h6"} fontWeight={600}>
{user.id ? user.nickname : t("application:modals.anonymous")}
</Typography>
{displayOnly && (
<Typography variant={"body2"} sx={{ ml: 1 }} color={"text.secondary"}>
{loadedUser?.group ? loadedUser.group.name : ""}
</Typography>
)}
</Box>
{!displayOnly && (
<Typography variant={"body2"} color={"text.secondary"}>
{loadedUser?.group ? loadedUser.group.name : ""}
</Typography>
)}
{displayOnly && loadedUser?.created_at && (
<Typography variant={"body2"} color={"text.secondary"}>
<Trans
i18nKey={"setting.accountCreatedAt"}
ns={"application"}
components={[<TimeBadge variant={"inherit"} datetime={loadedUser.created_at} />]}
/>
</Typography>
)}
<Box
sx={{
mt: 0.5,
position: "relative",
left: -10,
display: "flex",
}}
>
{!displayOnly && user.id && (
<Tooltip title={t("application:setting.profilePage")}>
<ActionButton onClick={() => navigate(`/profile/${user.id}`)}>
<HomeOutlined />
</ActionButton>
</Tooltip>
)}
{user.email && (
<Tooltip title={user.email}>
<ActionButton onClick={() => window.open(`mailto:${user.email}`)}>
<MailOutlined />
</ActionButton>
</Tooltip>
)}
</Box>
</Box>
</Box>
);
};
const UserPopover = ({ user, open, ...rest }: UserPopoverProps) => {
const stopPropagation = useCallback((e: any) => e.stopPropagation(), []);
return (
<HoverPopover
onMouseDown={stopPropagation}
onMouseUp={stopPropagation}
onClick={stopPropagation}
open={open}
{...rest}
anchorOrigin={{
vertical: "bottom",
horizontal: "left",
}}
transformOrigin={{
vertical: "top",
horizontal: "left",
}}
>
<Box sx={{ minWidth: "300px", p: 2 }}>
<UserProfile user={user} open={open} />
</Box>
</HoverPopover>
);
};
export default UserPopover;