feat: redesign settings page

This commit is contained in:
butterfly
2024-04-24 15:44:24 +08:00
parent f7074bba8c
commit c99086447e
55 changed files with 2603 additions and 1446 deletions

View File

@@ -2,11 +2,11 @@ import * as React from "react";
export type ButtonType = "primary" | "danger" | null;
export default function IconButton(props: {
export default function Btn(props: {
onClick?: () => void;
icon?: JSX.Element;
type?: ButtonType;
text?: string;
text?: React.ReactNode;
bordered?: boolean;
shadow?: boolean;
className?: string;
@@ -20,8 +20,6 @@ export default function IconButton(props: {
icon,
type,
text,
bordered,
shadow,
className,
title,
disabled,
@@ -29,18 +27,30 @@ export default function IconButton(props: {
autoFocus,
} = props;
let btnClassName;
switch (type) {
case "primary":
btnClassName = `${disabled ? "bg-blue-300" : "bg-blue-600"} text-white`;
break;
case "danger":
btnClassName = `${
disabled ? "bg-blue-300" : "bg-blue-600"
} text-text-danger`;
break;
default:
btnClassName = `${
disabled ? "bg-gray-100" : "bg-gray-300"
} text-gray-500`;
}
return (
<button
className={`
${className ?? ""}
py-2 px-3 flex items-center justify-center gap-1 rounded-action-btn shadow-btn transition-all duration-300 select-none
${
type === "primary"
? `${disabled ? "bg-blue-300" : "bg-blue-600"}`
: `${disabled ? "bg-gray-100" : "bg-gray-300"}`
}
${disabled ? "cursor-not-allowed" : "cursor-pointer"}
${type === "primary" ? `text-white` : `text-gray-500`}
${btnClassName}
`}
onClick={onClick}
title={title}

View File

@@ -0,0 +1,32 @@
import { ReactNode } from "react";
export interface CardProps {
className?: string;
children?: ReactNode;
title?: ReactNode;
inMobile?: boolean;
}
export default function Card(props: CardProps) {
const { className, children, title, inMobile } = props;
let titleClassName = "ml-4 mb-3";
if (inMobile) {
titleClassName = "ml-3 mb-3";
}
return (
<>
{title && (
<div
className={`capitalize font-black font-setting-card-title text-sm-mobile font-weight-setting-card-title ${titleClassName}`}
>
{title}
</div>
)}
<div className={`px-4 py-1 rounded-lg bg-white ${className}`}>
{children}
</div>
</>
);
}

View File

@@ -0,0 +1,82 @@
import PasswordVisible from "@/app/icons/passwordVisible.svg";
import PasswordInvisible from "@/app/icons/passwordInvisible.svg";
import {
DetailedHTMLProps,
InputHTMLAttributes,
useContext,
useEffect,
useLayoutEffect,
useState,
} from "react";
import List from "@/app/components/List";
export interface CommonInputProps
extends Omit<
DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>,
"onChange" | "type" | "value"
> {
className?: string;
}
export interface NumberInputProps {
onChange?: (v: number) => void;
type?: "number";
value?: number;
}
export interface TextInputProps {
onChange?: (v: string) => void;
type?: "text" | "password";
value?: string;
}
export interface InputProps {
onChange?: ((v: string) => void) | ((v: number) => void);
type?: "text" | "password" | "number";
value?: string | number;
}
export default function Input(
props: CommonInputProps & NumberInputProps,
): JSX.Element;
export default function Input(
props: CommonInputProps & TextInputProps,
): JSX.Element;
export default function Input(props: CommonInputProps & InputProps) {
const { value, type = "text", onChange, className, ...rest } = props;
const [show, setShow] = useState(false);
const internalType = (show && "text") || type;
const { update } = useContext(List.ListContext);
useLayoutEffect(() => {
update?.({ type: "input" });
}, []);
return (
<div
className={`w-[100%] rounded-chat-input bg-gray-100 flex gap-3 items-center px-3 py-2 ${className}`}
>
<input
{...rest}
className=" overflow-hidden text-black text-sm-title leading-input outline-none flex-1"
type={internalType}
value={value}
onChange={(e) => {
if (type === "number") {
const v = e.currentTarget.valueAsNumber;
(onChange as NumberInputProps["onChange"])?.(v);
} else {
const v = e.currentTarget.value;
(onChange as TextInputProps["onChange"])?.(v);
}
}}
/>
{type == "password" && (
<div onClick={() => setShow((pre) => !pre)}>
{show ? <PasswordVisible /> : <PasswordInvisible />}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,129 @@
import {
ReactNode,
createContext,
useCallback,
useContext,
useState,
} from "react";
interface WidgetStyle {
selectClassName?: string;
inputClassName?: string;
rangeClassName?: string;
switchClassName?: string;
inputNextLine?: boolean;
rangeNextLine?: boolean;
}
interface ChildrenMeta {
type?: "unknown" | "input" | "range";
}
export interface ListProps {
className?: string;
children?: ReactNode;
id?: string;
isMobileScreen?: boolean;
widgetStyle?: WidgetStyle;
}
export interface ListItemProps {
title: string;
subTitle?: string;
children?: JSX.Element | JSX.Element[];
className?: string;
onClick?: () => void;
nextline?: boolean;
}
export const ListContext = createContext<
{ isMobileScreen?: boolean; update?: (m: ChildrenMeta) => void } & WidgetStyle
>({ isMobileScreen: false });
export function ListItem(props: ListItemProps) {
const {
className = "",
onClick,
title,
subTitle,
children,
nextline,
} = props;
const context = useContext(ListContext);
const [childrenMeta, setMeta] = useState<ChildrenMeta>({});
const { isMobileScreen, inputNextLine, rangeNextLine } = context;
let containerClassName = "py-3";
let titleClassName = "";
if (isMobileScreen) {
containerClassName = "py-2";
titleClassName = "";
}
let internalNextLine;
switch (childrenMeta.type) {
case "input":
internalNextLine = !!(nextline || inputNextLine);
break;
case "range":
internalNextLine = !!(nextline || rangeNextLine);
break;
default:
internalNextLine = false;
}
if (childrenMeta.type === "input") {
console.log("===============", internalNextLine, nextline, inputNextLine);
}
const update = useCallback((m: ChildrenMeta) => {
setMeta(m);
}, []);
return (
<div
className={`relative after:h-[0.5px] after:bottom-0 after:w-[100%] after:left-0 after:absolute last:after:hidden after:bg-gray-100 ${
internalNextLine ? "" : "flex gap-3"
} justify-between items-center px-0 ${containerClassName} ${className}`}
onClick={onClick}
>
<div
className={`flex-1 flex flex-col justify-start gap-1 ${titleClassName}`}
>
<div className=" font-common text-sm-mobile font-weight-[500] line-clamp-1">
{title}
</div>
{subTitle && <div className={` text-sm text-gray-300`}>{subTitle}</div>}
</div>
<ListContext.Provider value={{ ...context, update }}>
<div
className={`${
internalNextLine ? "mt-[0.625rem]" : "max-w-[70%]"
} flex items-center justify-center`}
>
{children}
</div>
</ListContext.Provider>
</div>
);
}
function List(props: ListProps) {
const { className, children, id, widgetStyle } = props;
const { isMobileScreen } = useContext(ListContext);
return (
<ListContext.Provider value={{ isMobileScreen, ...widgetStyle }}>
<div className={`flex flex-col w-[100%] ${className}`} id={id}>
{children}
</div>
</ListContext.Provider>
);
}
List.ListItem = ListItem;
List.ListContext = ListContext;
export default List;

View File

@@ -1,16 +1,17 @@
import { useNavigate } from "react-router-dom";
import { Path } from "@/app/constant";
import useDragSideBar from "@/app/hooks/useDragSideBar";
import useMobileScreen from "@/app/hooks/useMobileScreen";
import {
ComponentType,
Context,
createContext,
useContext,
useState,
} from "react";
DEFAULT_SIDEBAR_WIDTH,
MAX_SIDEBAR_WIDTH,
MIN_SIDEBAR_WIDTH,
Path,
} from "@/app/constant";
import useDrag from "@/app/hooks/useDrag";
import useMobileScreen from "@/app/hooks/useMobileScreen";
import { updateGlobalCSSVars } from "@/app/utils/client";
import { ComponentType, useRef, useState } from "react";
import DragIcon from "@/app/icons/drag.svg";
import { useAppConfig } from "@/app/store/config";
export interface MenuWrapperInspectProps {
setShowPanel?: (v: boolean) => void;
@@ -28,10 +29,35 @@ export default function MenuLayout<
const [showPanel, setShowPanel] = useState(false);
const navigate = useNavigate();
const config = useAppConfig();
const isMobileScreen = useMobileScreen();
const startDragWidth = useRef(config.sidebarWidth ?? DEFAULT_SIDEBAR_WIDTH);
// drag side bar
const { onDragStart } = useDragSideBar();
const { onDragStart } = useDrag({
customToggle: () => {
config.update((config) => {
config.sidebarWidth = DEFAULT_SIDEBAR_WIDTH;
});
},
customDragMove: (nextWidth: number) => {
const { menuWidth } = updateGlobalCSSVars(nextWidth);
document.documentElement.style.setProperty(
"--menu-width",
`${menuWidth}px`,
);
config.update((config) => {
config.sidebarWidth = nextWidth;
});
},
customLimit: (x: number) =>
Math.max(
MIN_SIDEBAR_WIDTH,
Math.min(MAX_SIDEBAR_WIDTH, startDragWidth.current + x),
),
});
let containerClassName = "flex h-[100%] w-[100%]";
let listClassName =
@@ -64,7 +90,10 @@ export default function MenuLayout<
{!isMobileScreen && (
<div
className={`group absolute right-0 h-[100%] flex items-center`}
onPointerDown={(e) => onDragStart(e as any)}
onPointerDown={(e) => {
startDragWidth.current = config.sidebarWidth;
onDragStart(e as any);
}}
>
<div className="opacity-0 group-hover:bg-[rgba($color: #000000, $alpha: 0.01) group-hover:opacity-20">
<DragIcon />

View File

@@ -1,5 +1,7 @@
import useRelativePosition from "@/app/hooks/useRelativePosition";
import { getCSSVar } from "@/app/utils";
import { useMemo, useState } from "react";
import { createPortal } from "react-dom";
const ArrowIcon = ({ color }: { color: string }) => {
return (
@@ -19,6 +21,20 @@ const ArrowIcon = ({ color }: { color: string }) => {
};
const baseZIndex = 100;
const popoverRootName = "popoverRoot";
let popoverRoot = document.querySelector(
`#${popoverRootName}`,
) as HTMLDivElement;
if (!popoverRoot) {
popoverRoot = document.createElement("div");
document.body.appendChild(popoverRoot);
popoverRoot.style.height = "0px";
popoverRoot.style.width = "100%";
popoverRoot.style.position = "fixed";
popoverRoot.style.bottom = "0";
popoverRoot.style.zIndex = "100";
popoverRoot.id = "popoverRootName";
}
export default function Popover(props: {
content?: JSX.Element | string;
@@ -46,6 +62,21 @@ export default function Popover(props: {
} = props;
const [internalShow, setShow] = useState(false);
const { position, getRelativePosition } = useRelativePosition({
delay: 0,
});
const {
distanceToBottomBoundary = 0,
distanceToLeftBoundary = 0,
distanceToRightBoundary = -10000,
distanceToTopBoundary = 0,
targetH = 0,
targetW = 0,
} = position?.poi || {};
let placementStyle: React.CSSProperties = {};
const popoverCommonClass = `absolute p-2 box-border`;
const mergedShow = show ?? internalShow;
@@ -56,6 +87,13 @@ export default function Popover(props: {
switch (placement) {
case "b":
placementStyle = {
top: `calc(-${distanceToBottomBoundary}px + 0.5rem)`,
left: `calc(${distanceToLeftBoundary + targetW}px - ${
targetW * 0.02
}px)`,
transform: "translateX(-50%)",
};
placementClassName =
"top-[calc(100%+0.5rem)] left-[50%] translate-x-[calc(-50%)]";
arrowClassName += "top-[calc(100%+0.5rem)] translate-y-[calc(-100%)]";
@@ -67,32 +105,51 @@ export default function Popover(props: {
// placementClassName = '';
// break;
case "rb":
placementClassName = "top-[calc(100%+0.5rem)] translate-x-[calc(-2%)]";
arrowClassName += "top-[calc(100%+0.5rem)] translate-y-[calc(-100%)]";
break;
case "lt":
placementClassName =
"bottom-[calc(100%+0.5rem)] left-[100%] translate-x-[calc(-98%)]";
arrowClassName += "bottom-[calc(100%+0.5rem)] translate-y-[calc(100%)]";
break;
case "lb":
placementClassName =
"top-[calc(100%+0.5rem)] left-[100%] translate-x-[calc(-98%)]";
placementStyle = {
top: `calc(-${distanceToBottomBoundary}px + 0.5rem)`,
right: `calc(${distanceToRightBoundary}px - ${targetW * 0.02}px)`,
};
placementClassName = "top-[calc(100%+0.5rem)] right-[calc(-2%)]";
arrowClassName += "top-[calc(100%+0.5rem)] translate-y-[calc(-100%)]";
break;
case "rt":
placementClassName = "bottom-[calc(100%+0.5rem)] translate-x-[calc(-2%)]";
placementStyle = {
bottom: `calc(${distanceToBottomBoundary + targetH}px + 0.5rem)`,
right: `calc(${distanceToRightBoundary}px - ${targetW * 0.02}px)`,
};
placementClassName = "bottom-[calc(100%+0.5rem)] right-[calc(-2%)]";
arrowClassName += "bottom-[calc(100%+0.5rem)] translate-y-[calc(100%)]";
break;
case "lt":
placementStyle = {
bottom: `calc(${distanceToBottomBoundary + targetH}px + 0.5rem)`,
left: `calc(${distanceToLeftBoundary}px - ${targetW * 0.02}px)`,
};
placementClassName = "bottom-[calc(100%+0.5rem)] left-[calc(-2%)]";
arrowClassName += "bottom-[calc(100%+0.5rem)] translate-y-[calc(100%)]";
break;
case "lb":
placementStyle = {
top: `calc(-${distanceToBottomBoundary}px + 0.5rem)`,
left: `calc(${distanceToLeftBoundary}px - ${targetW * 0.02}px)`,
};
placementClassName = "top-[calc(100%+0.5rem)] left-[calc(-2%)]";
arrowClassName += "top-[calc(100%+0.5rem)] translate-y-[calc(-100%)]";
break;
case "t":
default:
placementStyle = {
bottom: `calc(${distanceToBottomBoundary + targetH}px + 0.5rem)`,
left: `calc(${distanceToLeftBoundary + targetW}px - ${
targetW * 0.02
}px)`,
transform: "translateX(-50%)",
};
placementClassName =
"bottom-[calc(100%+0.5rem)] left-[50%] translate-x-[calc(-50%)]";
arrowClassName += "bottom-[calc(100%+0.5rem)] translate-y-[calc(100%)]";
}
const popoverCommonClass = "absolute p-2 box-border";
if (noArrow) {
arrowClassName = "hidden";
}
@@ -109,6 +166,12 @@ export default function Popover(props: {
e.preventDefault();
onShow?.(!mergedShow);
setShow(!mergedShow);
if (!mergedShow) {
getRelativePosition(e.currentTarget, "");
window.document.documentElement.style.overflow = "hidden";
} else {
window.document.documentElement.style.overflow = "auto";
}
}}
>
{children}
@@ -119,12 +182,15 @@ export default function Popover(props: {
<ArrowIcon color={internalBgColor} />
</div>
)}
<div
className={`${popoverCommonClass} ${placementClassName} ${popoverClassName}`}
style={{ zIndex: baseZIndex + 1 }}
>
{content}
</div>
{createPortal(
<div
className={`${popoverCommonClass} ${popoverClassName}`}
style={{ zIndex: baseZIndex + 1, ...placementStyle }}
>
{content}
</div>,
popoverRoot,
)}
<div
className=" fixed w-[100%] h-[100%] top-0 left-0 right-0 bottom-0"
style={{ zIndex: baseZIndex }}

View File

@@ -0,0 +1,104 @@
import SelectIcon from "@/app/icons/downArrowIcon.svg";
import Popover from "@/app/components/Popover";
import React, { useContext, useMemo, useRef } from "react";
import useRelativePosition, {
Orientation,
} from "@/app/hooks/useRelativePosition";
import List from "@/app/components/List";
export type Option<Value> = {
value: Value;
label: string;
icon?: React.ReactNode;
};
export interface SearchProps<Value> {
value?: string;
onSelect?: (v: Value) => void;
options?: Option<Value>[];
inMobile?: boolean;
}
const Select = <Value extends number | string>(props: SearchProps<Value>) => {
const { value, onSelect, options = [], inMobile } = props;
const { isMobileScreen, selectClassName } = useContext(List.ListContext);
const optionsRef = useRef<Option<Value>[]>([]);
optionsRef.current = options;
const selectedOption = useMemo(
() => optionsRef.current.find((o) => o.value === value),
[value],
);
const contentRef = useRef<HTMLDivElement>(null);
const { position, getRelativePosition } = useRelativePosition({
delay: 0,
});
let headerH = 100;
let baseH = position?.poi.distanceToBottomBoundary || 0;
if (isMobileScreen) {
headerH = 60;
}
if (position?.poi.relativePosition[1] === Orientation.bottom) {
baseH = position?.poi.distanceToTopBoundary;
}
const maxHeight = `${baseH - headerH}px`;
const content = (
<div
className={`px-2 py-2 flex flex-col gap-1 overflow-y-auto overflow-x-hidden`}
style={{ maxHeight }}
>
{options?.map((o) => (
<div
key={o.value}
className={`flex items-center p-3 gap-2 ${
selectedOption?.value === o.value ? "bg-gray-100 rounded-md" : ""
}`}
onClick={() => {
onSelect?.(o.value);
}}
>
{!!o.icon && <div className="">{o.icon}</div>}
<div className={`flex-1`}>{o.label}</div>
</div>
))}
</div>
);
return (
<Popover
content={content}
trigger="click"
noArrow
placement={
position?.poi.relativePosition[1] !== Orientation.bottom ? "rb" : "rt"
}
popoverClassName="border-actions-popover border-gray-200 rounded-md shadow-actions-popover w-actions-popover bg-white"
onShow={(e) => {
getRelativePosition(contentRef.current!, "");
}}
>
<div
className={`flex items-center gap-3 py-2 px-3 bg-gray-100 rounded-action-btn font-time text-sm-title ${selectClassName}`}
ref={contentRef}
>
<div className={`flex items-center gap-2 flex-1`}>
{!!selectedOption?.icon && (
<div className={``}>{selectedOption?.icon}</div>
)}
<div className={`flex-1`}>{selectedOption?.label}</div>
</div>
<div className={``}>
<SelectIcon />
</div>
</div>
</Popover>
);
};
export default Select;

View File

@@ -0,0 +1,92 @@
import { useContext, useEffect, useRef } from "react";
import { ListContext } from "../List";
interface SlideRangeProps {
className?: string;
description?: string;
range?: {
start?: number;
stroke?: number;
};
onSlide?: (v: number) => void;
value?: number;
step?: number;
}
const margin = 15;
export default function SlideRange(props: SlideRangeProps) {
const {
className = "",
description = "",
range = {},
value,
onSlide,
step,
} = props;
const { start = 0, stroke = 1 } = range;
const { rangeClassName, update } = useContext(ListContext);
const slideRef = useRef<HTMLDivElement>(null);
const transformToWidth = (x: number = start) => {
const abs = x - start;
const maxWidth = (slideRef.current?.clientWidth || 1) - margin * 2;
const radio = stroke / maxWidth;
return abs / radio;
};
const setProperty = (value?: number) => {
const initWidth = transformToWidth(value);
slideRef.current?.style.setProperty(
"--slide-value-size",
`${initWidth + margin}px`,
);
};
useEffect(() => {
setProperty(value);
update?.({ type: "range" });
}, []);
return (
<div
className={`flex flex-col justify-center items-end gap-1 w-[100%] ${className} ${rangeClassName}`}
>
{!!description && (
<div className="text-text-hint text-common text-sm">{description}</div>
)}
<div
className="flex my-1.5 relative w-[100%] h-1.5 bg-gray-200 rounded-slide"
ref={slideRef}
>
<div className="absolute top-0 h-[100%] w-[var(--slide-value-size)] pointer-events-none bg-gray-500 rounded-slide">
&nbsp;
</div>
<div
className=" absolute w-[30px] top-[50%] translate-y-[-50%] left-[var(--slide-value-size)] translate-x-[-50%] pointer-events-none h-slide-btn leading-slide-btn text-sm-mobile text-center rounded-slide border-[1px] border-gray-300 bg-white"
// onPointerDown={onPointerDown}
>
{value}
</div>
<input
type="range"
className="w-[100%] h-[100%] opacity-0"
value={value}
min={start}
max={start + stroke}
step={step}
onChange={(e) => {
setProperty(e.target.valueAsNumber);
onSlide?.(e.target.valueAsNumber);
}}
style={{
marginLeft: margin,
marginRight: margin,
}}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,27 @@
import * as RadixSwitch from "@radix-ui/react-switch";
import { useContext } from "react";
import List from "../List";
interface SwitchProps {
value: boolean;
onChange: (v: boolean) => void;
}
export default function Switch(props: SwitchProps) {
const { value, onChange } = props;
const { switchClassName = "" } = useContext(List.ListContext);
return (
<RadixSwitch.Root
checked={value}
onCheckedChange={onChange}
className={` flex w-switch h-switch bg-gray-200 p-0.5 box-content rounded-md ${switchClassName} ${
value ? "bg-switch-checked justify-end" : "bg-gray-200 justify-start"
}`}
>
<RadixSwitch.Thumb
className={` bg-white block w-4 h-4 drop-shadow-sm rounded-md`}
/>
</RadixSwitch.Root>
);
}

View File

@@ -6,6 +6,8 @@
width: 100%;
flex-direction: column;
background-color: var(--white);
.auth-logo {
transform: scale(1.4);
}
@@ -33,4 +35,18 @@
margin-bottom: 10px;
}
}
input[type="number"],
input[type="text"],
input[type="password"] {
appearance: none;
border-radius: 10px;
border: var(--border-in-light);
min-height: 36px;
box-sizing: border-box;
background: var(--white);
color: var(--black);
padding: 0 10px;
max-width: 50%;
font-family: inherit;
}
}