Complete main UI #37

Merged
FatttSnake merged 192 commits from FatttSnake into dev 2024-02-23 16:31:17 +08:00
10 changed files with 597 additions and 524 deletions
Showing only changes of commit 14380d42dc - Show all commits

View File

@@ -0,0 +1,301 @@
@use "@/assets/css/constants" as constants;
@use "@/assets/css/mixins" as mixins;
.sidebar {
display: flex;
flex-direction: column;
height: 100%;
user-select: none;
transition: all .3s;
white-space: nowrap;
.title {
display: flex;
align-items: center;
font-weight: bold;
padding: 10px 14px;
color: constants.$main-color;
overflow: hidden;
.icon-box {
display: flex;
justify-content: center;
align-items: center;
padding: 10px;
width: 40px;
height: 40px;
font-size: constants.$SIZE_ICON_SM;
border-radius: 8px;
cursor: pointer;
span {
transform: rotateZ(180deg);
transition: all .3s;
}
&:hover {
background-color: constants.$background-color;
}
}
.text {
flex: 1;
font-size: 2em;
text-align: center;
letter-spacing: 0.2em;
transform: translateX(0.1em);
}
}
.content {
display: flex;
min-height: 0;
flex-direction: column;
flex: 1;
.scroll {
min-height: 0;
flex: 1;
}
ul {
> li {
&.item {
position: relative;
margin: 4px 14px;
font-size: 1.4em;
>.menu-bt {
border-radius: 8px;
overflow: hidden;
height: 40px;
.icon-box {
display: flex;
justify-content: center;
align-items: center;
padding: 0 10px;
width: 40px;
height: 40px;
font-size: constants.$SIZE_ICON_SM;
cursor: pointer;
}
a {
display: flex;
align-items: center;
height: 100%;
width: 100%;
transition: all 0.2s;
.text {
flex: 1;
padding-left: 8px;
}
&.active {
color: constants.$origin-color;
background-color: constants.$main-color !important;
}
}
}
.submenu {
display: none;
position: fixed;
padding-left: 20px;
z-index: 10000;
.content {
display: flex;
flex-direction: column;
gap: 2px;
padding: 10px 10px;
background-color: constants.$origin-color;
border-radius: 8px;
.item {
border-radius: 8px;
white-space: nowrap;
overflow: hidden;
a {
display: block;
padding: 8px 16px;
transition: all 0.2s;
&.active {
color: constants.$origin-color;
background-color: constants.$main-color !important;
}
}
&:hover a {
background-color: constants.$background-color;
}
}
}
}
&:hover {
>.menu-bt {
a {
background-color: constants.$background-color;
}
}
.submenu {
display: block;
animation: 0.3s ease;
@include mixins.unique-keyframes {
0% {
transform: translateX(-10px);
opacity: 0;
}
100% {
transform: translateX(0);
opacity: 1;
}
}
}
}
}
}
}
}
.separate {
height: 0;
margin: 10px 5px;
border: {
width: 1px;
color: constants.$font-secondary-color;
style: solid;
};
opacity: 0.4;
}
.footer {
display: flex;
align-items: center;
font-weight: bold;
padding: 8px 14px;
color: constants.$main-color;
.icon-user {
display: flex;
justify-content: center;
align-items: center;
margin-left: 4px;
padding: 10px;
width: 36px;
height: 36px;
font-size: constants.$SIZE_ICON_XS;
border: 2px constants.$font-secondary-color solid;
color: constants.$font-secondary-color;
border-radius: 50%;
cursor: pointer;
}
.text {
flex: 1;
padding-left: 10px;
font-size: 1.4em;
color: constants.$font-main-color;
user-select: text;
a{
color: constants.$main-color;
text-decoration: underline;
}
}
.icon-exit {
font-size: constants.$SIZE_ICON_XS;
color: constants.$error-color;
padding: 6px 10px;
cursor: pointer;
&:hover {
border-radius: 8px;
background-color: constants.$background-color;
}
}
}
&.hide {
width: 68px !important;
.title {
.icon-box {
span {
transform: rotateZ(360deg);
transition: all .3s;
}
}
.text {
display: none;
}
}
.menu-bt {
.text {
display: none;
}
}
.submenu {
.menu-bt {
.text {
display: block;
}
}
}
.footer {
position: relative;
.text {
display: none;
}
.submenu-exit {
display: none;
position: absolute;
padding-left: 6px;
left: 100%;
.content {
padding: 8px;
border-radius: 8px;
background-color: constants.$origin-color;
.icon-exit {
padding: 4px 8px;
&:hover {
border-radius: 8px;
background-color: constants.$background-color;
}
}
}
&.hide {
display: none!important;
}
}
&:hover .submenu-exit {
display: block;
animation: 0.3s ease;
@include mixins.unique-keyframes {
0% {
transform: translateX(-10px);
opacity: 0;
}
100% {
transform: translateX(0);
opacity: 1;
}
}
}
}
}
}

View File

@@ -6,295 +6,7 @@ body {
}
.left-panel {
display: flex;
flex-direction: column;
width: clamp(180px, 20vw, 240px);
background-color: constants.$origin-color;
user-select: none;
transition: all .3s;
white-space: nowrap;
.title {
display: flex;
align-items: center;
font-weight: bold;
padding: 10px 14px;
color: constants.$main-color;
overflow: hidden;
.icon-box {
display: flex;
justify-content: center;
align-items: center;
padding: 10px;
width: 40px;
height: 40px;
font-size: constants.$SIZE_ICON_SM;
border-radius: 8px;
cursor: pointer;
span {
transform: rotateZ(180deg);
transition: all .3s;
}
&:hover {
background-color: constants.$background-color;
}
}
.text {
flex: 1;
font-size: 2em;
text-align: center;
letter-spacing: 0.6em;
transform: translateX(0.3em);
}
}
.content {
display: flex;
min-height: 0;
flex-direction: column;
flex: 1;
.toolsMenu {
min-height: 0;
flex: 1;
}
ul {
> li {
&.item {
position: relative;
margin: 4px 14px;
font-size: 1.4em;
.menu-bt {
border-radius: 8px;
overflow: hidden;
height: 40px;
.icon-box {
display: flex;
justify-content: center;
align-items: center;
padding: 0 10px;
width: 40px;
height: 40px;
font-size: constants.$SIZE_ICON_SM;
cursor: pointer;
}
a {
display: flex;
align-items: center;
height: 100%;
width: 100%;
transition: all 0.2s;
.text {
flex: 1;
padding-left: 8px;
}
&.active {
color: constants.$origin-color;
background-color: constants.$main-color !important;
}
}
}
.submenu {
display: none;
position: fixed;
padding-left: 20px;
z-index: 10000;
.content {
display: flex;
flex-direction: column;
gap: 2px;
padding: 10px 10px;
background-color: constants.$origin-color;
border-radius: 8px;
.item {
border-radius: 8px;
white-space: nowrap;
overflow: hidden;
a {
display: block;
padding: 8px 16px;
transition: all 0.2s;
&.active {
color: constants.$origin-color;
background-color: constants.$main-color !important;
}
}
&:hover a {
background-color: constants.$background-color;
}
}
}
}
&:hover {
.menu-bt {
a {
background-color: constants.$background-color;
}
}
.submenu {
display: block;
animation: 0.3s ease;
@include mixins.unique-keyframes {
0% {
transform: translateX(-10px);
opacity: 0;
}
100% {
transform: translateX(0);
opacity: 1;
}
}
}
}
}
}
}
}
.separate {
height: 0;
margin: 10px 5px;
border: {
width: 1px;
color: constants.$font-secondary-color;
style: solid;
};
opacity: 0.4;
}
.footer {
display: flex;
align-items: center;
font-weight: bold;
padding: 8px 14px;
color: constants.$main-color;
.icon-user {
display: flex;
justify-content: center;
align-items: center;
margin-left: 4px;
padding: 10px;
width: 36px;
height: 36px;
font-size: constants.$SIZE_ICON_XS;
border: 2px constants.$font-secondary-color solid;
color: constants.$font-secondary-color;
border-radius: 50%;
cursor: pointer;
}
.text {
flex: 1;
padding-left: 10px;
font-size: 1.4em;
color: constants.$font-main-color;
user-select: text;
a{
color: constants.$main-color;
text-decoration: underline;
}
}
.icon-exit {
font-size: constants.$SIZE_ICON_XS;
color: constants.$error-color;
padding: 6px 10px;
cursor: pointer;
&:hover {
border-radius: 8px;
background-color: constants.$background-color;
}
}
}
&.hide {
width: 68px;
.title {
.icon-box {
span {
transform: rotateZ(360deg);
transition: all .3s;
}
}
.text {
display: none;
}
}
.menu-bt {
.text {
display: none;
}
}
.footer {
position: relative;
.text {
display: none;
}
.submenu-exit {
display: none;
position: absolute;
padding-left: 6px;
left: 100%;
.content {
padding: 8px;
border-radius: 8px;
background-color: constants.$origin-color;
.icon-exit {
padding: 4px 8px;
&:hover {
border-radius: 8px;
background-color: constants.$background-color;
}
}
}
&.hide {
display: none!important;
}
}
&:hover .submenu-exit {
display: block;
animation: 0.3s ease;
@include mixins.unique-keyframes {
0% {
transform: translateX(-10px);
opacity: 0;
}
100% {
transform: translateX(0);
opacity: 1;
}
}
}
}
}
}
.right-panel {

View File

@@ -0,0 +1,64 @@
import React from 'react'
import Icon from '@ant-design/icons'
import { getLoginStatus, logout } from '@/utils/auth'
import { getRedirectUrl } from '@/utils/common'
const SidebarFooter: React.FC = () => {
const matches = useMatches()
const lastMatch = matches.reduce((_, second) => second)
const location = useLocation()
const navigate = useNavigate()
const [exiting, setExiting] = useState(false)
const handleClickAvatar = () => {
if (getLoginStatus()) {
navigate('/user')
} else {
navigate(getRedirectUrl('/login', `${lastMatch.pathname}${location.search}`))
}
}
const handleLogout = () => {
if (exiting) {
return
}
setExiting(true)
void logout().finally(() => {
setTimeout(() => {
window.location.reload()
}, 1500)
})
}
return (
<div className={'footer'}>
<span className={'icon-user'} onClick={handleClickAvatar}>
<Icon component={IconFatwebUser} />
</span>
<span hidden={getLoginStatus()} className={'text'}>
<NavLink to={getRedirectUrl('/login', `${lastMatch.pathname}${location.search}`)}>
</NavLink>
</span>
<span hidden={!getLoginStatus()} className={'text'}>
</span>
<div
hidden={!getLoginStatus()}
className={`submenu-exit${!getLoginStatus() ? ' hide' : ''}`}
>
<div className={'content'}>
<span hidden={!getLoginStatus()} className={'icon-exit'} onClick={handleLogout}>
<Icon
component={exiting ? IconFatwebLoading : IconFatwebExit}
spin={exiting}
/>
</span>
</div>
</div>
</div>
)
}
export default SidebarFooter

View File

@@ -0,0 +1,60 @@
import React from 'react'
import Icon from '@ant-design/icons'
import SidebarSubmenu from '@/components/common/sidebar/SidebarSubmenu'
type ItemProps = {
icon?: IconComponent
text?: string
path: string
children?: React.ReactNode
}
const SidebarItem: React.FC<ItemProps> = (props) => {
const [submenuTop, setSubmenuTop] = useState(0)
const [submenuLeft, setSubmenuLeft] = useState(0)
const showSubmenu = (e: React.MouseEvent) => {
const parentElement = e.currentTarget.parentElement
if (parentElement && parentElement.childElementCount === 2) {
const parentClientRect = parentElement.getBoundingClientRect()
if (parentClientRect.top <= screen.height / 2) {
setSubmenuTop(parentClientRect.top)
} else {
setSubmenuTop(
parentClientRect.top -
(parentElement.lastElementChild?.clientHeight ?? 0) +
e.currentTarget.clientHeight
)
}
setSubmenuLeft(parentClientRect.right)
}
}
return (
<li className={'item'}>
<div className={'menu-bt'} onMouseEnter={showSubmenu}>
<NavLink
to={props.path}
end
className={({ isActive, isPending }) =>
isPending ? 'pending' : isActive ? 'active' : ''
}
>
<div className={'icon-box'}>
{props.icon ? (
<Icon className={'icon'} component={props.icon} />
) : undefined}
</div>
<span className={'text'}>{props.text}</span>
</NavLink>
</div>
{props.children ? (
<SidebarSubmenu submenuTop={submenuTop} submenuLeft={submenuLeft}>
{props.children}
</SidebarSubmenu>
) : undefined}
</li>
)
}
export default SidebarItem

View File

@@ -0,0 +1,7 @@
import React from 'react'
const SidebarItemList: React.FC<React.PropsWithChildren> = (props) => {
return <ul>{props.children}</ul>
}
export default SidebarItemList

View File

@@ -0,0 +1,34 @@
import React, { useImperativeHandle } from 'react'
import HideScrollbar, { HideScrollbarElement } from '@/components/common/HideScrollbar'
export interface SidebarScrollElement {
refreshLayout(): void
}
const SidebarScroll = forwardRef<SidebarScrollElement, React.PropsWithChildren>((props, ref) => {
useImperativeHandle<SidebarScrollElement, SidebarScrollElement>(ref, () => {
return {
refreshLayout() {
hideScrollbarRef.current?.refreshLayout()
}
}
})
const hideScrollbarRef = useRef<HideScrollbarElement>(null)
return (
<div className={'scroll'}>
<HideScrollbar
isShowVerticalScrollbar={true}
scrollbarWidth={2}
animationTransitionTime={300}
autoHideWaitingTime={800}
ref={hideScrollbarRef}
>
{props.children}
</HideScrollbar>
</div>
)
})
export default SidebarScroll

View File

@@ -0,0 +1,11 @@
import React from 'react'
const SidebarSeparate: React.FC<
React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>
> = (props) => {
const { className, ..._props } = props
return <div className={`separate ${className ? ` ${className}` : ''}`} {..._props} />
}
export default SidebarSeparate

View File

@@ -0,0 +1,22 @@
import React from 'react'
interface SidebarSubmenuProps extends React.PropsWithChildren {
submenuTop: number
submenuLeft: number
}
const SidebarSubmenu: React.FC<SidebarSubmenuProps> = (props) => {
return (
<ul
className={'submenu'}
style={{
top: props.submenuTop,
left: props.submenuLeft
}}
>
<div className={'content'}>{props.children}</div>
</ul>
)
}
export default SidebarSubmenu

View File

@@ -0,0 +1,44 @@
import React from 'react'
import Icon from '@ant-design/icons'
import '@/assets/css/components/common/sidebar.scss'
import SidebarSeparate from '@/components/common/sidebar/SidebarSeparate'
import SidebarFooter from '@/components/common/sidebar/SidebarFooter'
import { getLocalStorage, setLocalStorage } from '@/utils/common'
interface SidebarProps extends React.PropsWithChildren {
title: string
width?: string
onSidebarSwitch?: (hidden: boolean) => void
}
const Sidebar: React.FC<SidebarProps> = (props) => {
const [hideSidebar, setHideSidebar] = useState(getLocalStorage('hideSidebar') === 'false')
const switchSidebar = () => {
setHideSidebar(!hideSidebar)
setLocalStorage('hideSidebar', hideSidebar ? 'true' : 'false')
props.onSidebarSwitch && props.onSidebarSwitch(hideSidebar)
}
return (
<>
<div
className={`sidebar${hideSidebar ? ' hide' : ''}`}
style={{ width: props.width ?? 'clamp(180px, 20vw, 240px)' }}
>
<div className={'title'}>
<span className={'icon-box'} onClick={switchSidebar}>
<Icon component={IconFatwebExpand} />
</span>
<span className={'text'}>{props.title}</span>
</div>
<SidebarSeparate style={{ marginTop: 0 }} />
<div className={'content'}>{props.children}</div>
<SidebarSeparate style={{ marginTop: 0, marginBottom: 0 }} />
<SidebarFooter />
</div>
</>
)
}
export default Sidebar

View File

@@ -1,251 +1,69 @@
import React from 'react'
import FitFullScreen from '@/components/common/FitFullScreen'
import '@/assets/css/pages/tools-framework.scss'
import Icon from '@ant-design/icons'
import { tools } from '@/router/tools'
import HideScrollbar, { HideScrollbarElement } from '@/components/common/HideScrollbar'
import { getLocalStorage, getRedirectUrl, setLocalStorage } from '@/utils/common'
import { getLoginStatus, logout } from '@/utils/auth'
import { NavLink, Outlet } from 'react-router-dom'
import '@/assets/css/pages/tools-framework.scss'
import FitFullScreen from '@/components/common/FitFullScreen'
import SidebarScroll, { SidebarScrollElement } from '@/components/common/sidebar/SidebarScroll'
import Sidebar from '@/components/common/sidebar'
import SidebarItemList from '@/components/common/sidebar/SidebarItemList'
import SidebarItem from '@/components/common/sidebar/SidebarItem'
import SidebarSeparate from '@/components/common/sidebar/SidebarSeparate'
const ToolsFramework: React.FC = () => {
const matches = useMatches()
const lastMatch = matches.reduce((_, second) => second)
const location = useLocation()
const navigate = useNavigate()
const hideScrollbarRef = useRef<HideScrollbarElement>(null)
const [submenuTop, setSubmenuTop] = useState(0)
const [submenuLeft, setSubmenuLeft] = useState(0)
const [hideSidebar, setHideSidebar] = useState(getLocalStorage('hideSidebar') === 'false')
const [exiting, setExiting] = useState(false)
const sidebarScrollRef = useRef<SidebarScrollElement>(null)
const switchSidebar = () => {
setHideSidebar(!hideSidebar)
setLocalStorage('hideSidebar', hideSidebar ? 'true' : 'false')
const handleOnSidebarSwitch = () => {
setTimeout(() => {
hideScrollbarRef.current?.refreshLayout()
sidebarScrollRef.current?.refreshLayout()
}, 300)
}
const showSubmenu = (e: React.MouseEvent) => {
const parentElement = e.currentTarget.parentElement
if (parentElement && parentElement.childElementCount === 2) {
const parentClientRect = parentElement.getBoundingClientRect()
if (parentClientRect.top <= screen.height / 2) {
setSubmenuTop(parentClientRect.top)
} else {
setSubmenuTop(
parentClientRect.top -
(parentElement.lastElementChild?.clientHeight ?? 0) +
e.currentTarget.clientHeight
)
}
setSubmenuLeft(parentClientRect.right)
}
}
const handleClickAvatar = () => {
if (getLoginStatus()) {
navigate(`${lastMatch.pathname}${location.search}`)
} else {
navigate(getRedirectUrl('/login', `${lastMatch.pathname}${location.search}`))
}
}
const handleLogout = () => {
if (exiting) {
return
}
setExiting(true)
void logout().finally(() => {
setTimeout(() => {
window.location.reload()
}, 1500)
})
}
return (
<>
<FitFullScreen className={'flex-horizontal'}>
<div className={`left-panel${hideSidebar ? ' hide' : ''}`}>
<div className={'title'}>
<span className={'icon-box'} onClick={switchSidebar}>
<Icon component={IconFatwebExpand} />
</span>
<span className={'text'}></span>
</div>
<div style={{ marginTop: '0' }} className={'separate'} />
<div className={'content'}>
<ul>
<li className={'item'}>
<div className={'menu-bt'}>
<NavLink
to={''}
end
className={({ isActive, isPending }) =>
isPending ? 'pending' : isActive ? 'active' : ''
}
>
<div className={'icon-box'}>
{tools[0].icon ? (
<Icon
className={'icon'}
component={tools[0].icon}
/>
) : undefined}
</div>
<span className={'text'}>{tools[0].name}</span>
</NavLink>
</div>
</li>
<li className={'item'}>
<div className={'menu-bt'}>
<NavLink
to={'all'}
className={({ isActive, isPending }) =>
isPending ? ' pending' : isActive ? ' active' : ''
}
>
<div className={'icon-box'}>
{tools[1].icon ? (
<Icon
className={'icon'}
component={tools[1].icon}
/>
) : undefined}
</div>
<span className={'text'}>{tools[1].name}</span>
</NavLink>
</div>
</li>
<li>
<div className={'separate'} style={{ marginBottom: 0 }} />
</li>
</ul>
<div className={'toolsMenu'}>
<HideScrollbar
isShowVerticalScrollbar={true}
scrollbarWidth={2}
animationTransitionTime={300}
autoHideWaitingTime={800}
ref={hideScrollbarRef}
>
<ul>
<div className={'left-panel'}>
<Sidebar title={'氮工具'} onSidebarSwitch={handleOnSidebarSwitch}>
<SidebarItemList>
<SidebarItem
path={''}
icon={tools[0].icon}
text={tools[0].name}
></SidebarItem>
<SidebarItem
path={'all'}
icon={tools[1].icon}
text={tools[1].name}
></SidebarItem>
</SidebarItemList>
<SidebarSeparate style={{ marginBottom: 0 }} />
<SidebarScroll ref={sidebarScrollRef}>
<SidebarItemList>
{tools.map((tool) => {
return tool.menu &&
tool.id !== 'tools' &&
tool.id !== 'tools-all' ? (
<li className={'item'} key={tool.id}>
<div
className={'menu-bt'}
onMouseEnter={showSubmenu}
<SidebarItem
path={tool.path}
icon={tool.icon}
text={tool.name}
key={tool.id}
>
<NavLink
to={tool.path}
className={({ isActive, isPending }) =>
isPending
? 'pending'
: isActive
? 'active'
: ''
}
>
<div className={'icon-box'}>
{tool.icon ? (
<Icon
className={'icon'}
component={tool.icon}
{tool.children
? tool.children.map((subTool) => {
return (
<SidebarItem
path={`${tool.path}/${subTool.path}`}
text={subTool.name}
key={tool.id}
/>
) : undefined}
</div>
<span className={'text'}>{tool.name}</span>
</NavLink>
</div>
{tool.children ? (
<ul
className={'submenu'}
style={{
top: submenuTop,
left: submenuLeft
}}
>
<div className={'content'}>
{tool.children.map((subTool) => {
return subTool.menu ? (
<li
className={'item'}
key={subTool.id}
>
<NavLink
to={`${tool.path}/${subTool.path}`}
className={({
isActive,
isPending
}) =>
isPending
? 'pending'
: isActive
? 'active'
: ''
}
>
<span
className={'text'}
>
{subTool.name}
</span>
</NavLink>
</li>
)
})
: undefined}
</SidebarItem>
) : undefined
})}
</div>
</ul>
) : undefined}
</li>
) : undefined
})}
</ul>
</HideScrollbar>
</div>
</div>
<div className={'separate'} style={{ marginTop: 0, marginBottom: 0 }} />
<div className={'footer'}>
<span className={'icon-user'} onClick={handleClickAvatar}>
<Icon component={IconFatwebUser} />
</span>
<span hidden={getLoginStatus()} className={'text'}>
<NavLink
to={getRedirectUrl(
'/login',
`${lastMatch.pathname}${location.search}`
)}
>
</NavLink>
</span>
<span hidden={!getLoginStatus()} className={'text'}>
</span>
<div
hidden={!getLoginStatus()}
className={`submenu-exit${!getLoginStatus() ? ' hide' : ''}`}
>
<div className={'content'}>
<span
hidden={!getLoginStatus()}
className={'icon-exit'}
onClick={handleLogout}
>
<Icon
component={exiting ? IconFatwebLoading : IconFatwebExit}
spin={exiting}
/>
</span>
</div>
</div>
</div>
</SidebarItemList>
</SidebarScroll>
</Sidebar>
</div>
<div className={'right-panel'}>
<Outlet />