init project
Some checks failed
No response / noResponse (push) Has been cancelled
CI / Continuous releases (push) Has been cancelled
CI / test-dev (macos-latest) (push) Has been cancelled
CI / test-dev (ubuntu-latest) (push) Has been cancelled
CI / test-dev (windows-latest) (push) Has been cancelled
Maintenance / main (push) Has been cancelled
Scorecards supply-chain security / Scorecards analysis (push) Has been cancelled
CodeQL / Analyze (push) Has been cancelled

This commit is contained in:
how2ice
2025-12-12 14:26:25 +09:00
commit 005cf56baf
43188 changed files with 1079531 additions and 0 deletions

View File

@@ -0,0 +1,21 @@
import { CssVarsProvider } from '@mui/joy/styles';
import CssBaseline from '@mui/joy/CssBaseline';
import Box from '@mui/joy/Box';
import Sidebar from './components/Sidebar';
import Header from './components/Header';
import MyMessages from './components/MyMessages';
export default function JoyMessagesTemplate() {
return (
<CssVarsProvider disableTransitionOnChange>
<CssBaseline />
<Box sx={{ display: 'flex', minHeight: '100dvh' }}>
<Sidebar />
<Header />
<Box component="main" className="MainContent" sx={{ flex: 1 }}>
<MyMessages />
</Box>
</Box>
</CssVarsProvider>
);
}

View File

@@ -0,0 +1,23 @@
import Badge from '@mui/joy/Badge';
import Avatar, { AvatarProps } from '@mui/joy/Avatar';
type AvatarWithStatusProps = AvatarProps & {
online?: boolean;
};
export default function AvatarWithStatus(props: AvatarWithStatusProps) {
const { online = false, ...other } = props;
return (
<div>
<Badge
color={online ? 'success' : 'neutral'}
variant={online ? 'solid' : 'soft'}
size="sm"
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
badgeInset="4px 4px"
>
<Avatar size="sm" {...other} />
</Badge>
</div>
);
}

View File

@@ -0,0 +1,143 @@
import * as React from 'react';
import Avatar from '@mui/joy/Avatar';
import Box from '@mui/joy/Box';
import IconButton from '@mui/joy/IconButton';
import Stack from '@mui/joy/Stack';
import Sheet from '@mui/joy/Sheet';
import Typography from '@mui/joy/Typography';
import CelebrationOutlinedIcon from '@mui/icons-material/CelebrationOutlined';
import FavoriteBorderIcon from '@mui/icons-material/FavoriteBorder';
import InsertDriveFileRoundedIcon from '@mui/icons-material/InsertDriveFileRounded';
import { MessageProps } from '../types';
type ChatBubbleProps = MessageProps & {
variant: 'sent' | 'received';
};
export default function ChatBubble(props: ChatBubbleProps) {
const { content, variant, timestamp, attachment = undefined, sender } = props;
const isSent = variant === 'sent';
const [isHovered, setIsHovered] = React.useState<boolean>(false);
const [isLiked, setIsLiked] = React.useState<boolean>(false);
const [isCelebrated, setIsCelebrated] = React.useState<boolean>(false);
return (
<Box sx={{ maxWidth: '60%', minWidth: 'auto' }}>
<Stack
direction="row"
spacing={2}
sx={{ justifyContent: 'space-between', mb: 0.25 }}
>
<Typography level="body-xs">
{sender === 'You' ? sender : sender.name}
</Typography>
<Typography level="body-xs">{timestamp}</Typography>
</Stack>
{attachment ? (
<Sheet
variant="outlined"
sx={[
{
px: 1.75,
py: 1.25,
borderRadius: 'lg',
},
isSent ? { borderTopRightRadius: 0 } : { borderTopRightRadius: 'lg' },
isSent ? { borderTopLeftRadius: 'lg' } : { borderTopLeftRadius: 0 },
]}
>
<Stack direction="row" spacing={1.5} sx={{ alignItems: 'center' }}>
<Avatar color="primary" size="lg">
<InsertDriveFileRoundedIcon />
</Avatar>
<div>
<Typography sx={{ fontSize: 'sm' }}>{attachment.fileName}</Typography>
<Typography level="body-sm">{attachment.size}</Typography>
</div>
</Stack>
</Sheet>
) : (
<Box
sx={{ position: 'relative' }}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<Sheet
color={isSent ? 'primary' : 'neutral'}
variant={isSent ? 'solid' : 'soft'}
sx={[
{
p: 1.25,
borderRadius: 'lg',
},
isSent
? {
borderTopRightRadius: 0,
}
: {
borderTopRightRadius: 'lg',
},
isSent
? {
borderTopLeftRadius: 'lg',
}
: {
borderTopLeftRadius: 0,
},
isSent
? {
backgroundColor: 'var(--joy-palette-primary-solidBg)',
}
: {
backgroundColor: 'background.body',
},
]}
>
<Typography
level="body-sm"
sx={[
isSent
? {
color: 'var(--joy-palette-common-white)',
}
: {
color: 'var(--joy-palette-text-primary)',
},
]}
>
{content}
</Typography>
</Sheet>
{(isHovered || isLiked || isCelebrated) && (
<Stack
direction="row"
spacing={0.5}
sx={{
justifyContent: isSent ? 'flex-end' : 'flex-start',
position: 'absolute',
top: '50%',
p: 1.5,
}}
>
<IconButton
variant={isLiked ? 'soft' : 'plain'}
color={isLiked ? 'danger' : 'neutral'}
size="sm"
onClick={() => setIsLiked((prevState) => !prevState)}
>
{isLiked ? '❤️' : <FavoriteBorderIcon />}
</IconButton>
<IconButton
variant={isCelebrated ? 'soft' : 'plain'}
color={isCelebrated ? 'warning' : 'neutral'}
size="sm"
onClick={() => setIsCelebrated((prevState) => !prevState)}
>
{isCelebrated ? '🎉' : <CelebrationOutlinedIcon />}
</IconButton>
</Stack>
)}
</Box>
)}
</Box>
);
}

View File

@@ -0,0 +1,73 @@
import * as React from 'react';
import Box from '@mui/joy/Box';
import ListDivider from '@mui/joy/ListDivider';
import ListItem from '@mui/joy/ListItem';
import ListItemButton, { ListItemButtonProps } from '@mui/joy/ListItemButton';
import Stack from '@mui/joy/Stack';
import Typography from '@mui/joy/Typography';
import CircleIcon from '@mui/icons-material/Circle';
import AvatarWithStatus from './AvatarWithStatus';
import { ChatProps, MessageProps, UserProps } from '../types';
import { toggleMessagesPane } from '../utils';
type ChatListItemProps = ListItemButtonProps & {
id: string;
unread?: boolean;
sender: UserProps;
messages: MessageProps[];
selectedChatId?: string;
setSelectedChat: (chat: ChatProps) => void;
};
export default function ChatListItem(props: ChatListItemProps) {
const { id, sender, messages, selectedChatId, setSelectedChat } = props;
const selected = selectedChatId === id;
return (
<React.Fragment>
<ListItem>
<ListItemButton
onClick={() => {
toggleMessagesPane();
setSelectedChat({ id, sender, messages });
}}
selected={selected}
color="neutral"
sx={{ flexDirection: 'column', alignItems: 'initial', gap: 1 }}
>
<Stack direction="row" spacing={1.5}>
<AvatarWithStatus online={sender.online} src={sender.avatar} />
<Box sx={{ flex: 1 }}>
<Typography level="title-sm">{sender.name}</Typography>
<Typography level="body-sm">{sender.username}</Typography>
</Box>
<Box sx={{ lineHeight: 1.5, textAlign: 'right' }}>
{messages[0].unread && (
<CircleIcon sx={{ fontSize: 12 }} color="primary" />
)}
<Typography
level="body-xs"
noWrap
sx={{ display: { xs: 'none', md: 'block' } }}
>
5 mins ago
</Typography>
</Box>
</Stack>
<Typography
level="body-sm"
sx={{
display: '-webkit-box',
WebkitLineClamp: '2',
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{messages[0].content}
</Typography>
</ListItemButton>
</ListItem>
<ListDivider sx={{ margin: 0 }} />
</React.Fragment>
);
}

View File

@@ -0,0 +1,99 @@
import Stack from '@mui/joy/Stack';
import Sheet from '@mui/joy/Sheet';
import Typography from '@mui/joy/Typography';
import { Box, Chip, IconButton, Input } from '@mui/joy';
import List from '@mui/joy/List';
import EditNoteRoundedIcon from '@mui/icons-material/EditNoteRounded';
import SearchRoundedIcon from '@mui/icons-material/SearchRounded';
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
import ChatListItem from './ChatListItem';
import { ChatProps } from '../types';
import { toggleMessagesPane } from '../utils';
type ChatsPaneProps = {
chats: ChatProps[];
setSelectedChat: (chat: ChatProps) => void;
selectedChatId: string;
};
export default function ChatsPane(props: ChatsPaneProps) {
const { chats, setSelectedChat, selectedChatId } = props;
return (
<Sheet
sx={{
borderRight: '1px solid',
borderColor: 'divider',
height: { sm: 'calc(100dvh - var(--Header-height))', md: '100dvh' },
overflowY: 'auto',
}}
>
<Stack
direction="row"
spacing={1}
sx={{ alignItems: 'center', justifyContent: 'space-between', p: 2, pb: 1.5 }}
>
<Typography
component="h1"
endDecorator={
<Chip
variant="soft"
color="primary"
size="md"
slotProps={{ root: { component: 'span' } }}
>
4
</Chip>
}
sx={{ fontSize: { xs: 'md', md: 'lg' }, fontWeight: 'lg', mr: 'auto' }}
>
Messages
</Typography>
<IconButton
variant="plain"
aria-label="edit"
color="neutral"
size="sm"
sx={{ display: { xs: 'none', sm: 'unset' } }}
>
<EditNoteRoundedIcon />
</IconButton>
<IconButton
variant="plain"
aria-label="edit"
color="neutral"
size="sm"
onClick={() => {
toggleMessagesPane();
}}
sx={{ display: { sm: 'none' } }}
>
<CloseRoundedIcon />
</IconButton>
</Stack>
<Box sx={{ px: 2, pb: 1.5 }}>
<Input
size="sm"
startDecorator={<SearchRoundedIcon />}
placeholder="Search"
aria-label="Search"
/>
</Box>
<List
sx={{
py: 0,
'--ListItem-paddingY': '0.75rem',
'--ListItem-paddingX': '1rem',
}}
>
{chats.map((chat) => (
<ChatListItem
key={chat.id}
{...chat}
setSelectedChat={setSelectedChat}
selectedChatId={selectedChatId}
/>
))}
</List>
</Sheet>
);
}

View File

@@ -0,0 +1,56 @@
import * as React from 'react';
import { useColorScheme } from '@mui/joy/styles';
import IconButton, { IconButtonProps } from '@mui/joy/IconButton';
import DarkModeRoundedIcon from '@mui/icons-material/DarkModeRounded';
import LightModeIcon from '@mui/icons-material/LightMode';
export default function ColorSchemeToggle(props: IconButtonProps) {
const { onClick, sx, ...other } = props;
const { mode, setMode } = useColorScheme();
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return (
<IconButton
size="sm"
variant="outlined"
color="neutral"
{...other}
sx={sx}
disabled
/>
);
}
return (
<IconButton
data-screenshot="toggle-mode"
size="sm"
variant="outlined"
color="neutral"
{...other}
onClick={(event) => {
if (mode === 'light') {
setMode('dark');
} else {
setMode('light');
}
onClick?.(event);
}}
sx={[
mode === 'dark'
? { '& > *:first-of-type': { display: 'none' } }
: { '& > *:first-of-type': { display: 'initial' } },
mode === 'light'
? { '& > *:last-of-type': { display: 'none' } }
: { '& > *:last-of-type': { display: 'initial' } },
...(Array.isArray(sx) ? sx : [sx]),
]}
>
<DarkModeRoundedIcon />
<LightModeIcon />
</IconButton>
);
}

View File

@@ -0,0 +1,46 @@
import GlobalStyles from '@mui/joy/GlobalStyles';
import IconButton from '@mui/joy/IconButton';
import Sheet from '@mui/joy/Sheet';
import MenuRoundedIcon from '@mui/icons-material/MenuRounded';
import { toggleSidebar } from '../utils';
export default function Header() {
return (
<Sheet
sx={{
display: { sm: 'flex', md: 'none' },
alignItems: 'center',
justifyContent: 'space-between',
position: 'fixed',
top: 0,
width: '100vw',
height: 'var(--Header-height)',
zIndex: 9995,
p: 2,
gap: 1,
borderBottom: '1px solid',
borderColor: 'background.level1',
boxShadow: 'sm',
}}
>
<GlobalStyles
styles={(theme) => ({
':root': {
'--Header-height': '52px',
[theme.breakpoints.up('lg')]: {
'--Header-height': '0px',
},
},
})}
/>
<IconButton
onClick={() => toggleSidebar()}
variant="outlined"
color="neutral"
size="sm"
>
<MenuRoundedIcon />
</IconButton>
</Sheet>
);
}

View File

@@ -0,0 +1,94 @@
import * as React from 'react';
import Box from '@mui/joy/Box';
import Button from '@mui/joy/Button';
import FormControl from '@mui/joy/FormControl';
import Textarea from '@mui/joy/Textarea';
import { IconButton, Stack } from '@mui/joy';
import FormatBoldRoundedIcon from '@mui/icons-material/FormatBoldRounded';
import FormatItalicRoundedIcon from '@mui/icons-material/FormatItalicRounded';
import StrikethroughSRoundedIcon from '@mui/icons-material/StrikethroughSRounded';
import FormatListBulletedRoundedIcon from '@mui/icons-material/FormatListBulletedRounded';
import SendRoundedIcon from '@mui/icons-material/SendRounded';
export type MessageInputProps = {
textAreaValue: string;
setTextAreaValue: (value: string) => void;
onSubmit: () => void;
};
export default function MessageInput(props: MessageInputProps) {
const { textAreaValue, setTextAreaValue, onSubmit } = props;
const textAreaRef = React.useRef<HTMLDivElement>(null);
const handleClick = () => {
if (textAreaValue.trim() !== '') {
onSubmit();
setTextAreaValue('');
}
};
return (
<Box sx={{ px: 2, pb: 3 }}>
<FormControl>
<Textarea
placeholder="Type something here…"
aria-label="Message"
ref={textAreaRef}
onChange={(event) => {
setTextAreaValue(event.target.value);
}}
value={textAreaValue}
minRows={3}
maxRows={10}
endDecorator={
<Stack
direction="row"
sx={{
justifyContent: 'space-between',
alignItems: 'center',
flexGrow: 1,
py: 1,
pr: 1,
borderTop: '1px solid',
borderColor: 'divider',
}}
>
<div>
<IconButton size="sm" variant="plain" color="neutral">
<FormatBoldRoundedIcon />
</IconButton>
<IconButton size="sm" variant="plain" color="neutral">
<FormatItalicRoundedIcon />
</IconButton>
<IconButton size="sm" variant="plain" color="neutral">
<StrikethroughSRoundedIcon />
</IconButton>
<IconButton size="sm" variant="plain" color="neutral">
<FormatListBulletedRoundedIcon />
</IconButton>
</div>
<Button
size="sm"
color="primary"
sx={{ alignSelf: 'center', borderRadius: 'sm' }}
endDecorator={<SendRoundedIcon />}
onClick={handleClick}
>
Send
</Button>
</Stack>
}
onKeyDown={(event) => {
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
handleClick();
}
}}
sx={{
'& textarea:first-of-type': {
minHeight: 72,
},
}}
/>
</FormControl>
</Box>
);
}

View File

@@ -0,0 +1,86 @@
import * as React from 'react';
import Box from '@mui/joy/Box';
import Sheet from '@mui/joy/Sheet';
import Stack from '@mui/joy/Stack';
import AvatarWithStatus from './AvatarWithStatus';
import ChatBubble from './ChatBubble';
import MessageInput from './MessageInput';
import MessagesPaneHeader from './MessagesPaneHeader';
import { ChatProps, MessageProps } from '../types';
type MessagesPaneProps = {
chat: ChatProps;
};
export default function MessagesPane(props: MessagesPaneProps) {
const { chat } = props;
const [chatMessages, setChatMessages] = React.useState(chat.messages);
const [textAreaValue, setTextAreaValue] = React.useState('');
React.useEffect(() => {
setChatMessages(chat.messages);
}, [chat.messages]);
return (
<Sheet
sx={{
height: { xs: 'calc(100dvh - var(--Header-height))', md: '100dvh' },
display: 'flex',
flexDirection: 'column',
backgroundColor: 'background.level1',
}}
>
<MessagesPaneHeader sender={chat.sender} />
<Box
sx={{
display: 'flex',
flex: 1,
minHeight: 0,
px: 2,
py: 3,
overflowY: 'scroll',
flexDirection: 'column-reverse',
}}
>
<Stack spacing={2} sx={{ justifyContent: 'flex-end' }}>
{chatMessages.map((message: MessageProps, index: number) => {
const isYou = message.sender === 'You';
return (
<Stack
key={index}
direction="row"
spacing={2}
sx={{ flexDirection: isYou ? 'row-reverse' : 'row' }}
>
{message.sender !== 'You' && (
<AvatarWithStatus
online={message.sender.online}
src={message.sender.avatar}
/>
)}
<ChatBubble variant={isYou ? 'sent' : 'received'} {...message} />
</Stack>
);
})}
</Stack>
</Box>
<MessageInput
textAreaValue={textAreaValue}
setTextAreaValue={setTextAreaValue}
onSubmit={() => {
const newId = chatMessages.length + 1;
const newIdString = newId.toString();
setChatMessages([
...chatMessages,
{
id: newIdString,
sender: 'You',
content: textAreaValue,
timestamp: 'Just now',
},
]);
}}
/>
</Sheet>
);
}

View File

@@ -0,0 +1,98 @@
import Avatar from '@mui/joy/Avatar';
import Button from '@mui/joy/Button';
import Chip from '@mui/joy/Chip';
import IconButton from '@mui/joy/IconButton';
import Stack from '@mui/joy/Stack';
import Typography from '@mui/joy/Typography';
import CircleIcon from '@mui/icons-material/Circle';
import ArrowBackIosNewRoundedIcon from '@mui/icons-material/ArrowBackIosNewRounded';
import PhoneInTalkRoundedIcon from '@mui/icons-material/PhoneInTalkRounded';
import MoreVertRoundedIcon from '@mui/icons-material/MoreVertRounded';
import { UserProps } from '../types';
import { toggleMessagesPane } from '../utils';
type MessagesPaneHeaderProps = {
sender: UserProps;
};
export default function MessagesPaneHeader(props: MessagesPaneHeaderProps) {
const { sender } = props;
return (
<Stack
direction="row"
sx={{
justifyContent: 'space-between',
py: { xs: 2, md: 2 },
px: { xs: 1, md: 2 },
borderBottom: '1px solid',
borderColor: 'divider',
backgroundColor: 'background.body',
}}
>
<Stack
direction="row"
spacing={{ xs: 1, md: 2 }}
sx={{ alignItems: 'center' }}
>
<IconButton
variant="plain"
color="neutral"
size="sm"
sx={{ display: { xs: 'inline-flex', sm: 'none' } }}
onClick={() => toggleMessagesPane()}
>
<ArrowBackIosNewRoundedIcon />
</IconButton>
<Avatar size="lg" src={sender.avatar} />
<div>
<Typography
component="h2"
noWrap
endDecorator={
sender.online ? (
<Chip
variant="outlined"
size="sm"
color="neutral"
sx={{ borderRadius: 'sm' }}
startDecorator={
<CircleIcon sx={{ fontSize: 8 }} color="success" />
}
slotProps={{ root: { component: 'span' } }}
>
Online
</Chip>
) : undefined
}
sx={{ fontWeight: 'lg', fontSize: 'lg' }}
>
{sender.name}
</Typography>
<Typography level="body-sm">{sender.username}</Typography>
</div>
</Stack>
<Stack spacing={1} direction="row" sx={{ alignItems: 'center' }}>
<Button
startDecorator={<PhoneInTalkRoundedIcon />}
color="neutral"
variant="outlined"
size="sm"
sx={{ display: { xs: 'none', md: 'inline-flex' } }}
>
Call
</Button>
<Button
color="neutral"
variant="outlined"
size="sm"
sx={{ display: { xs: 'none', md: 'inline-flex' } }}
>
View profile
</Button>
<IconButton size="sm" variant="plain" color="neutral">
<MoreVertRoundedIcon />
</IconButton>
</Stack>
</Stack>
);
}

View File

@@ -0,0 +1,34 @@
import AspectRatio, { AspectRatioProps } from '@mui/joy/AspectRatio';
export default function MuiLogo(props: AspectRatioProps) {
const { sx, ...other } = props;
return (
<AspectRatio
ratio="1"
variant="plain"
{...other}
sx={[
{
width: 36,
borderRadius: 'sm',
},
...(Array.isArray(sx) ? sx : [sx]),
]}
>
<div>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="20"
viewBox="0 0 36 32"
fill="none"
>
<path
d="M30.343 21.976a1 1 0 00.502-.864l.018-5.787a1 1 0 01.502-.864l3.137-1.802a1 1 0 011.498.867v10.521a1 1 0 01-.502.867l-11.839 6.8a1 1 0 01-.994.001l-9.291-5.314a1 1 0 01-.504-.868v-5.305c0-.006.007-.01.013-.007.005.003.012 0 .012-.007v-.006c0-.004.002-.008.006-.01l7.652-4.396c.007-.004.004-.015-.004-.015a.008.008 0 01-.008-.008l.015-5.201a1 1 0 00-1.5-.87l-5.687 3.277a1 1 0 01-.998 0L6.666 9.7a1 1 0 00-1.499.866v9.4a1 1 0 01-1.496.869l-3.166-1.81a1 1 0 01-.504-.87l.028-16.43A1 1 0 011.527.86l10.845 6.229a1 1 0 00.996 0L24.21.86a1 1 0 011.498.868v16.434a1 1 0 01-.501.867l-5.678 3.27a1 1 0 00.004 1.735l3.132 1.783a1 1 0 00.993-.002l6.685-3.839zM31 7.234a1 1 0 001.514.857l3-1.8A1 1 0 0036 5.434V1.766A1 1 0 0034.486.91l-3 1.8a1 1 0 00-.486.857v3.668z"
fill="#007FFF"
/>
</svg>
</div>
</AspectRatio>
);
}

View File

@@ -0,0 +1,47 @@
import * as React from 'react';
import Sheet from '@mui/joy/Sheet';
import MessagesPane from './MessagesPane';
import ChatsPane from './ChatsPane';
import { ChatProps } from '../types';
import { chats } from '../data';
export default function MyProfile() {
const [selectedChat, setSelectedChat] = React.useState<ChatProps>(chats[0]);
return (
<Sheet
sx={{
flex: 1,
width: '100%',
mx: 'auto',
pt: { xs: 'var(--Header-height)', md: 0 },
display: 'grid',
gridTemplateColumns: {
xs: '1fr',
sm: 'minmax(min-content, min(30%, 400px)) 1fr',
},
}}
>
<Sheet
sx={{
position: { xs: 'fixed', sm: 'sticky' },
transform: {
xs: 'translateX(calc(100% * (var(--MessagesPane-slideIn, 0) - 1)))',
sm: 'none',
},
transition: 'transform 0.4s, width 0.4s',
zIndex: 100,
width: '100%',
top: 52,
}}
>
<ChatsPane
chats={chats}
selectedChatId={selectedChat.id}
setSelectedChat={setSelectedChat}
/>
</Sheet>
<MessagesPane chat={selectedChat} />
</Sheet>
);
}

View File

@@ -0,0 +1,332 @@
import * as React from 'react';
import GlobalStyles from '@mui/joy/GlobalStyles';
import Avatar from '@mui/joy/Avatar';
import Box from '@mui/joy/Box';
import Button from '@mui/joy/Button';
import Card from '@mui/joy/Card';
import Chip from '@mui/joy/Chip';
import Divider from '@mui/joy/Divider';
import IconButton from '@mui/joy/IconButton';
import Input from '@mui/joy/Input';
import LinearProgress from '@mui/joy/LinearProgress';
import List from '@mui/joy/List';
import ListItem from '@mui/joy/ListItem';
import ListItemButton, { listItemButtonClasses } from '@mui/joy/ListItemButton';
import ListItemContent from '@mui/joy/ListItemContent';
import Typography from '@mui/joy/Typography';
import Sheet from '@mui/joy/Sheet';
import Stack from '@mui/joy/Stack';
import SearchRoundedIcon from '@mui/icons-material/SearchRounded';
import HomeRoundedIcon from '@mui/icons-material/HomeRounded';
import DashboardRoundedIcon from '@mui/icons-material/DashboardRounded';
import ShoppingCartRoundedIcon from '@mui/icons-material/ShoppingCartRounded';
import AssignmentRoundedIcon from '@mui/icons-material/AssignmentRounded';
import QuestionAnswerRoundedIcon from '@mui/icons-material/QuestionAnswerRounded';
import GroupRoundedIcon from '@mui/icons-material/GroupRounded';
import SupportRoundedIcon from '@mui/icons-material/SupportRounded';
import SettingsRoundedIcon from '@mui/icons-material/SettingsRounded';
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
import LogoutRoundedIcon from '@mui/icons-material/LogoutRounded';
import BrightnessAutoRoundedIcon from '@mui/icons-material/BrightnessAutoRounded';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import ColorSchemeToggle from './ColorSchemeToggle';
import { closeSidebar } from '../utils';
function Toggler(props: {
defaultExpanded?: boolean;
children: React.ReactNode;
renderToggle: (params: {
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
}) => React.ReactNode;
}) {
const { defaultExpanded = false, renderToggle, children } = props;
const [open, setOpen] = React.useState(defaultExpanded);
return (
<React.Fragment>
{renderToggle({ open, setOpen })}
<Box
sx={[
{
display: 'grid',
transition: '0.2s ease',
'& > *': {
overflow: 'hidden',
},
},
open ? { gridTemplateRows: '1fr' } : { gridTemplateRows: '0fr' },
]}
>
{children}
</Box>
</React.Fragment>
);
}
export default function Sidebar() {
return (
<Sheet
className="Sidebar"
sx={{
position: { xs: 'fixed', md: 'sticky' },
transform: {
xs: 'translateX(calc(100% * (var(--SideNavigation-slideIn, 0) - 1)))',
md: 'none',
},
transition: 'transform 0.4s, width 0.4s',
zIndex: 10000,
height: '100dvh',
width: 'var(--Sidebar-width)',
top: 0,
p: 2,
flexShrink: 0,
display: 'flex',
flexDirection: 'column',
gap: 2,
borderRight: '1px solid',
borderColor: 'divider',
}}
>
<GlobalStyles
styles={(theme) => ({
':root': {
'--Sidebar-width': '220px',
[theme.breakpoints.up('lg')]: {
'--Sidebar-width': '240px',
},
},
})}
/>
<Box
className="Sidebar-overlay"
sx={{
position: 'fixed',
zIndex: 9998,
top: 0,
left: 0,
width: '100vw',
height: '100vh',
opacity: 'var(--SideNavigation-slideIn)',
backgroundColor: 'var(--joy-palette-background-backdrop)',
transition: 'opacity 0.4s',
transform: {
xs: 'translateX(calc(100% * (var(--SideNavigation-slideIn, 0) - 1) + var(--SideNavigation-slideIn, 0) * var(--Sidebar-width, 0px)))',
lg: 'translateX(-100%)',
},
}}
onClick={() => closeSidebar()}
/>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<IconButton variant="soft" color="primary" size="sm">
<BrightnessAutoRoundedIcon />
</IconButton>
<Typography level="title-lg">Acme Co.</Typography>
<ColorSchemeToggle sx={{ ml: 'auto' }} />
</Box>
<Input size="sm" startDecorator={<SearchRoundedIcon />} placeholder="Search" />
<Box
sx={{
minHeight: 0,
overflow: 'hidden auto',
flexGrow: 1,
display: 'flex',
flexDirection: 'column',
[`& .${listItemButtonClasses.root}`]: {
gap: 1.5,
},
}}
>
<List
size="sm"
sx={{
gap: 1,
'--List-nestedInsetStart': '30px',
'--ListItem-radius': (theme) => theme.vars.radius.sm,
}}
>
<ListItem>
<ListItemButton>
<HomeRoundedIcon />
<ListItemContent>
<Typography level="title-sm">Home</Typography>
</ListItemContent>
</ListItemButton>
</ListItem>
<ListItem>
<ListItemButton>
<DashboardRoundedIcon />
<ListItemContent>
<Typography level="title-sm">Dashboard</Typography>
</ListItemContent>
</ListItemButton>
</ListItem>
<ListItem>
<ListItemButton
role="menuitem"
component="a"
href="/joy-ui/getting-started/templates/order-dashboard/"
>
<ShoppingCartRoundedIcon />
<ListItemContent>
<Typography level="title-sm">Orders</Typography>
</ListItemContent>
</ListItemButton>
</ListItem>
<ListItem nested>
<Toggler
renderToggle={({ open, setOpen }) => (
<ListItemButton onClick={() => setOpen(!open)}>
<AssignmentRoundedIcon />
<ListItemContent>
<Typography level="title-sm">Tasks</Typography>
</ListItemContent>
<KeyboardArrowDownIcon
sx={[
open
? {
transform: 'rotate(180deg)',
}
: {
transform: 'none',
},
]}
/>
</ListItemButton>
)}
>
<List sx={{ gap: 0.5 }}>
<ListItem sx={{ mt: 0.5 }}>
<ListItemButton>All tasks</ListItemButton>
</ListItem>
<ListItem>
<ListItemButton>Backlog</ListItemButton>
</ListItem>
<ListItem>
<ListItemButton>In progress</ListItemButton>
</ListItem>
<ListItem>
<ListItemButton>Done</ListItemButton>
</ListItem>
</List>
</Toggler>
</ListItem>
<ListItem>
<ListItemButton selected>
<QuestionAnswerRoundedIcon />
<ListItemContent>
<Typography level="title-sm">Messages</Typography>
</ListItemContent>
<Chip size="sm" color="primary" variant="solid">
4
</Chip>
</ListItemButton>
</ListItem>
<ListItem nested>
<Toggler
renderToggle={({ open, setOpen }) => (
<ListItemButton onClick={() => setOpen(!open)}>
<GroupRoundedIcon />
<ListItemContent>
<Typography level="title-sm">Users</Typography>
</ListItemContent>
<KeyboardArrowDownIcon
sx={[
open
? {
transform: 'rotate(180deg)',
}
: {
transform: 'none',
},
]}
/>
</ListItemButton>
)}
>
<List sx={{ gap: 0.5 }}>
<ListItem sx={{ mt: 0.5 }}>
<ListItemButton
role="menuitem"
component="a"
href="/joy-ui/getting-started/templates/profile-dashboard/"
>
My profile
</ListItemButton>
</ListItem>
<ListItem>
<ListItemButton>Create a new user</ListItemButton>
</ListItem>
<ListItem>
<ListItemButton>Roles & permission</ListItemButton>
</ListItem>
</List>
</Toggler>
</ListItem>
</List>
<List
size="sm"
sx={{
mt: 'auto',
flexGrow: 0,
'--ListItem-radius': (theme) => theme.vars.radius.sm,
'--List-gap': '8px',
mb: 2,
}}
>
<ListItem>
<ListItemButton>
<SupportRoundedIcon />
Support
</ListItemButton>
</ListItem>
<ListItem>
<ListItemButton>
<SettingsRoundedIcon />
Settings
</ListItemButton>
</ListItem>
</List>
<Card
invertedColors
variant="soft"
color="warning"
size="sm"
sx={{ boxShadow: 'none' }}
>
<Stack
direction="row"
sx={{ justifyContent: 'space-between', alignItems: 'center' }}
>
<Typography level="title-sm">Used space</Typography>
<IconButton size="sm">
<CloseRoundedIcon />
</IconButton>
</Stack>
<Typography level="body-xs">
Your team has used 80% of your available space. Need more?
</Typography>
<LinearProgress variant="outlined" value={80} determinate sx={{ my: 1 }} />
<Button size="sm" variant="solid">
Upgrade plan
</Button>
</Card>
</Box>
<Divider />
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<Avatar
variant="outlined"
size="sm"
src="https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?auto=format&fit=crop&w=286"
/>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography level="title-sm">Siriwat K.</Typography>
<Typography level="body-xs">siriwatk@test.com</Typography>
</Box>
<IconButton size="sm" variant="plain" color="neutral">
<LogoutRoundedIcon />
</IconButton>
</Box>
</Sheet>
);
}

View File

@@ -0,0 +1,272 @@
import { ChatProps, UserProps } from './types';
export const users: UserProps[] = [
{
name: 'Steve E.',
username: '@steveEberger',
avatar: '/static/images/avatar/2.jpg',
online: true,
},
{
name: 'Katherine Moss',
username: '@kathy',
avatar: '/static/images/avatar/3.jpg',
online: false,
},
{
name: 'Phoenix Baker',
username: '@phoenix',
avatar: '/static/images/avatar/1.jpg',
online: true,
},
{
name: 'Eleanor Pena',
username: '@eleanor',
avatar: '/static/images/avatar/4.jpg',
online: false,
},
{
name: 'Kenny Peterson',
username: '@kenny',
avatar: '/static/images/avatar/5.jpg',
online: true,
},
{
name: 'Al Sanders',
username: '@al',
avatar: '/static/images/avatar/6.jpg',
online: true,
},
{
name: 'Melissa Van Der Berg',
username: '@melissa',
avatar: '/static/images/avatar/7.jpg',
online: false,
},
];
export const chats: ChatProps[] = [
{
id: '1',
sender: users[0],
messages: [
{
id: '1',
content: 'Hi Olivia, I am currently working on the project.',
timestamp: 'Wednesday 9:00am',
sender: users[0],
},
{
id: '2',
content: 'That sounds great, Mabel! Keep up the good work.',
timestamp: 'Wednesday 9:10am',
sender: 'You',
},
{
id: '3',
timestamp: 'Wednesday 11:30am',
sender: users[0],
content: 'I will send the draft by end of the day.',
},
{
id: '4',
timestamp: 'Wednesday 2:00pm',
sender: 'You',
content: 'Sure, I will be waiting for it.',
},
{
id: '5',
timestamp: 'Wednesday 4:30pm',
sender: users[0],
content: 'Just a heads up, I am about to send the draft.',
},
{
id: '6',
content:
"Thanks Olivia! Almost there. I'll work on making those changes you suggested and will shoot it over.",
timestamp: 'Thursday 10:16am',
sender: users[0],
},
{
id: '7',
content:
"Hey Olivia, I've finished with the requirements doc! I made some notes in the gdoc as well for Phoenix to look over.",
timestamp: 'Thursday 11:40am',
sender: users[0],
},
{
id: '3',
timestamp: 'Thursday 11:40am',
sender: users[0],
content: 'Tech requirements.pdf',
attachment: {
fileName: 'Tech requirements.pdf',
type: 'pdf',
size: '1.2 MB',
},
},
{
id: '8',
timestamp: 'Thursday 11:41am',
sender: 'You',
content: "Awesome! Thanks. I'll look at this today.",
},
{
id: '9',
timestamp: 'Thursday 11:44am',
sender: users[0],
content: "No rush though — we still have to wait for Lana's designs.",
},
{
id: '10',
timestamp: 'Today 2:20pm',
sender: users[0],
content: 'Hey Olivia, can you please review the latest design when you can?',
},
{
id: '11',
timestamp: 'Just now',
sender: 'You',
content: "Sure thing, I'll have a look today. They're looking great!",
},
],
},
{
id: '2',
sender: users[1],
messages: [
{
id: '1',
content: 'Hi Olivia, I am thinking about taking a vacation.',
timestamp: 'Wednesday 9:00am',
sender: users[1],
},
{
id: '2',
content:
'That sounds like a great idea, Katherine! Any idea where you want to go?',
timestamp: 'Wednesday 9:05am',
sender: 'You',
},
{
id: '3',
content: 'I am considering a trip to the beach.',
timestamp: 'Wednesday 9:30am',
sender: users[1],
},
{
id: '4',
content: 'The beach sounds perfect this time of year!',
timestamp: 'Wednesday 9:35am',
sender: 'You',
},
{
id: '5',
content: 'Yes, I agree. It will be a much-needed break.',
timestamp: 'Wednesday 10:00am',
sender: users[1],
},
{
id: '6',
content: 'Make sure to take lots of pictures!',
timestamp: 'Wednesday 10:05am',
sender: 'You',
},
],
},
{
id: '3',
sender: users[2],
messages: [
{
id: '1',
content: 'Hey!',
timestamp: '5 mins ago',
sender: users[2],
unread: true,
},
],
},
{
id: '4',
sender: users[3],
messages: [
{
id: '1',
content:
'Hey Olivia, I was thinking about doing some home improvement work.',
timestamp: 'Wednesday 9:00am',
sender: users[3],
},
{
id: '2',
content:
'That sounds interesting! What kind of improvements are you considering?',
timestamp: 'Wednesday 9:05am',
sender: 'You',
},
{
id: '3',
content: 'I am planning to repaint the walls and replace the old furniture.',
timestamp: 'Wednesday 9:15am',
sender: users[3],
},
{
id: '4',
content:
'That will definitely give your house a fresh look. Do you need help with anything?',
timestamp: 'Wednesday 9:20am',
sender: 'You',
},
{
id: '5',
content:
'I might need some help with picking the right paint colors. Can we discuss this over the weekend?',
timestamp: 'Wednesday 9:30am',
sender: users[3],
},
],
},
{
id: '5',
sender: users[4],
messages: [
{
id: '1',
content: 'Sup',
timestamp: '5 mins ago',
sender: users[4],
unread: true,
},
],
},
{
id: '6',
sender: users[5],
messages: [
{
id: '1',
content: 'Heyo',
timestamp: '5 mins ago',
sender: 'You',
unread: true,
},
],
},
{
id: '7',
sender: users[6],
messages: [
{
id: '1',
content:
"Hey Olivia, I've finished with the requirements doc! I made some notes in the gdoc as well for Phoenix to look over.",
timestamp: '5 mins ago',
sender: users[6],
unread: true,
},
],
},
];

View File

@@ -0,0 +1,25 @@
export type UserProps = {
name: string;
username: string;
avatar: string;
online: boolean;
};
export type MessageProps = {
id: string;
content: string;
timestamp: string;
unread?: boolean;
sender: UserProps | 'You';
attachment?: {
fileName: string;
type: string;
size: string;
};
};
export type ChatProps = {
id: string;
sender: UserProps;
messages: MessageProps[];
};

View File

@@ -0,0 +1,99 @@
import * as React from 'react';
export type UseScriptStatus = 'idle' | 'loading' | 'ready' | 'error';
// Cached script statuses
const cachedScriptStatuses: Record<string, UseScriptStatus | undefined> = {};
/**
* Simplified version of https://usehooks-ts.com/react-hook/use-script
*/
function getScriptNode(src: string) {
const node: HTMLScriptElement | null = document.querySelector(`script[src="${src}"]`);
const status = node?.getAttribute('data-status') as UseScriptStatus | undefined;
return {
node,
status,
};
}
export default function useScript(src: string): UseScriptStatus {
const [status, setStatus] = React.useState<UseScriptStatus>(() => {
if (typeof window === 'undefined') {
// SSR Handling - always return 'loading'
return 'loading';
}
return cachedScriptStatuses[src] ?? 'loading';
});
React.useEffect(() => {
const cachedScriptStatus = cachedScriptStatuses[src];
if (cachedScriptStatus === 'ready' || cachedScriptStatus === 'error') {
// If the script is already cached, set its status immediately
setStatus(cachedScriptStatus);
return;
}
// Fetch existing script element by src
// It may have been added by another instance of this hook
const script = getScriptNode(src);
let scriptNode = script.node;
if (!scriptNode) {
// Create script element and add it to document body
scriptNode = document.createElement('script');
scriptNode.src = src;
scriptNode.async = true;
scriptNode.setAttribute('data-status', 'loading');
document.body.appendChild(scriptNode);
// Store status in attribute on script
// This can be read by other instances of this hook
const setAttributeFromEvent = (event: Event) => {
const scriptStatus: UseScriptStatus = event.type === 'load' ? 'ready' : 'error';
scriptNode?.setAttribute('data-status', scriptStatus);
};
scriptNode.addEventListener('load', setAttributeFromEvent);
scriptNode.addEventListener('error', setAttributeFromEvent);
} else {
// Grab existing script status from attribute and set to state.
setStatus(script.status ?? cachedScriptStatus ?? 'loading');
}
// Script event handler to update status in state
// Note: Even if the script already exists we still need to add
// event handlers to update the state for *this* hook instance.
const setStateFromEvent = (event: Event) => {
const newStatus = event.type === 'load' ? 'ready' : 'error';
setStatus(newStatus);
cachedScriptStatuses[src] = newStatus;
};
// Add event listeners
scriptNode.addEventListener('load', setStateFromEvent);
scriptNode.addEventListener('error', setStateFromEvent);
// Remove event listeners on cleanup
// eslint-disable-next-line consistent-return
return () => {
if (scriptNode) {
scriptNode.removeEventListener('load', setStateFromEvent);
scriptNode.removeEventListener('error', setStateFromEvent);
}
if (scriptNode) {
try {
scriptNode.remove();
} catch (error) {
// ignore error
}
}
};
}, [src]);
return status;
}

View File

@@ -0,0 +1,53 @@
export function openSidebar() {
if (typeof window !== 'undefined') {
document.body.style.overflow = 'hidden';
document.documentElement.style.setProperty('--SideNavigation-slideIn', '1');
}
}
export function closeSidebar() {
if (typeof window !== 'undefined') {
document.documentElement.style.removeProperty('--SideNavigation-slideIn');
document.body.style.removeProperty('overflow');
}
}
export function toggleSidebar() {
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
const slideIn = window
.getComputedStyle(document.documentElement)
.getPropertyValue('--SideNavigation-slideIn');
if (slideIn) {
closeSidebar();
} else {
openSidebar();
}
}
}
export function openMessagesPane() {
if (typeof window !== 'undefined') {
document.body.style.overflow = 'hidden';
document.documentElement.style.setProperty('--MessagesPane-slideIn', '1');
}
}
export function closeMessagesPane() {
if (typeof window !== 'undefined') {
document.documentElement.style.removeProperty('--MessagesPane-slideIn');
document.body.style.removeProperty('overflow');
}
}
export function toggleMessagesPane() {
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
const slideIn = window
.getComputedStyle(document.documentElement)
.getPropertyValue('--MessagesPane-slideIn');
if (slideIn) {
closeMessagesPane();
} else {
openMessagesPane();
}
}
}