Compare commits

...

10 Commits

Author SHA1 Message Date
徐涛
9606106c45 增加自定义Scheme的激活和删除功能。 2025-01-24 14:53:43 +08:00
徐涛
3882ae764f 增加ActionIcon组件。 2025-01-24 11:26:25 +08:00
徐涛
2b17a5de0f 重构对于Scheme类型的展示。 2025-01-24 10:48:36 +08:00
徐涛
b2357811b6 缩小Badge组件的留白尺寸。 2025-01-24 10:46:10 +08:00
徐涛
32273256c0 调整Badge圆角大小。 2025-01-24 10:30:04 +08:00
徐涛
7e2132662f 增加Badge组件。 2025-01-24 10:09:14 +08:00
徐涛
cff2ad0439 重构创建Scheme功能支持多种Scheme类型选择。 2025-01-24 09:11:00 +08:00
徐涛
dc411987bf 增加选择器对于额外配置样式的支持。 2025-01-24 09:00:23 +08:00
徐涛
26ebc3c7e3 更新前端Scheme类型定义。 2025-01-23 16:54:08 +08:00
徐涛
ab4e0b440c 修正一处拼写错误。 2025-01-23 16:02:46 +08:00
18 changed files with 298 additions and 41 deletions

View File

@ -29,6 +29,7 @@ const routes = createBrowserRouter([
path: 'schemes',
element: <Schemes />,
children: [
{ index: true, element: <div /> },
{ path: 'new', element: <NewScheme /> },
{ path: 'not-found', element: <SchemeNotFound /> },
{ path: ':id', element: <SchemeDetail /> },

View File

@ -0,0 +1,10 @@
@layer components {
.action_icon {
padding: var(--spacing-xs);
border: none;
border-radius: var(--border-radius-xxs);
line-height: 1em;
.icon {
}
}
}

View File

@ -0,0 +1,25 @@
import { Icon, IconProps } from '@iconify/react/dist/iconify.js';
import cx from 'clsx';
import { MouseEventHandler, useCallback } from 'react';
import styles from './ActionIcon.module.css';
type ActionIconProps = {
icon: IconProps['icon'];
onClick?: MouseEventHandler<HTMLButtonElement>;
extendClassName?: HTMLButtonElement['className'];
};
export function ActionIcon({ icon, onClick, extendClassName }: ActionIconProps) {
const handleClick = useCallback(
(event: MouseEvent<HTMLButtonElement>) => {
onClick?.(event);
},
[onClick],
);
return (
<button onClick={handleClick} className={cx(styles.action_icon, extendClassName)}>
<Icon icon={icon} className={styles.icon} />
</button>
);
}

View File

@ -0,0 +1,12 @@
@layer components {
.badge {
padding: var(--spacing-xxs) var(--spacing-xs);
border-radius: var(--border-radius-xxs);
flex: 0 0 auto;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
line-height: 1.2em;
}
}

12
src/components/Badge.tsx Normal file
View File

@ -0,0 +1,12 @@
import cx from 'clsx';
import { ReactNode } from 'react';
import styles from './Badge.module.css';
type BadgeProps = {
extendClassName?: HTMLDivElement['className'];
children?: ReactNode;
};
export function Badge({ extendClassName, children }: BadgeProps) {
return <div className={cx(styles.badge, extendClassName)}>{children}</div>;
}

View File

@ -8,9 +8,15 @@ type HSegmentedControlProps = {
options?: Option[];
value?: Option['value'];
onChange?: (value: Option['value']) => void;
extendClassName?: HTMLDivElement['className'];
};
export function HSegmentedControl({ options = [], value, onChange }: HSegmentedControlProps) {
export function HSegmentedControl({
options = [],
value,
onChange,
extendClassName,
}: HSegmentedControlProps) {
const [selected, setSelected] = useState(value ?? options[0].value ?? null);
const [sliderPosition, setSliderPosition] = useState(0);
const [sliderWidth, setSliderWidth] = useState(0);
@ -28,7 +34,7 @@ export function HSegmentedControl({ options = [], value, onChange }: HSegmentedC
}, []);
return (
<div className={styles.segmented_control}>
<div className={cx(styles.segmented_control, extendClassName)}>
<div className={styles.options}>
{options.map((option, index) => (
<div

View File

@ -0,0 +1,21 @@
@layer components {
.badge {
font-size: var(--font-size-xs);
color: var(--color-yuebai);
background-color: var(--color-mose);
&.q {
background-color: var(--color-mantianxingzi);
}
&.swatch {
background-color: var(--color-pinlan);
}
&.m2 {
background-color: #03dac6;
color: var(--color-qihei);
}
&.m3 {
background-color: #a78fff;
color: var(--color-qihei);
}
}
}

View File

@ -0,0 +1,33 @@
import cx from 'clsx';
import { isNil } from 'lodash-es';
import { useMemo } from 'react';
import { schemeType, SchemeType } from '../models';
import { Badge } from './Badge';
import styles from './SchemeSign.module.css';
type SchemeSignProps = {
scheme?: SchemeType;
short?: boolean;
};
export function SchemeSign({ scheme, short = false }: SchemeSignProps) {
const schemeName = schemeType(scheme, short);
const signColorStyles = useMemo(() => {
switch (scheme) {
case 'q_scheme':
return styles.q;
case 'swatch_scheme':
return styles.swatch;
case 'material_2':
return styles.m2;
case 'material_3':
return styles.m3;
}
}, [scheme]);
return (
!isNil(scheme) && (
<Badge extendClassName={cx(styles.badge, signColorStyles)}>{schemeName}</Badge>
)
);
}

View File

@ -8,9 +8,15 @@ type VSegmentedControlProps = {
options?: Option[];
value?: Option['value'];
onChange?: (value: Option['value']) => void;
extendClassName?: HTMLDivElement['className'];
};
export function VSegmentedControl({ options = [], value, onChange }: VSegmentedControlProps) {
export function VSegmentedControl({
options = [],
value,
onChange,
extendClassName,
}: VSegmentedControlProps) {
const [selected, setSelected] = useState(value ?? options[0].value ?? null);
const [sliderPosition, setSliderPosition] = useState(0);
const [sliderHeight, setSliderHeight] = useState(0);
@ -28,7 +34,7 @@ export function VSegmentedControl({ options = [], value, onChange }: VSegmentedC
}, []);
return (
<div className={styles.segmented_control}>
<div className={cx(styles.segmented_control, extendClassName)}>
<div className={styles.options}>
{options.map((option, index) => (
<div

View File

@ -28,7 +28,7 @@ export type MaterialDesign2SchemeSource = {
custom_colors: Record<string, string>;
};
export type Materialdesign2SchemeStorage = {
export type MaterialDesign2SchemeStorage = {
source: MaterialDesign2SchemeSource;
scheme: MaterialDesign2Scheme;
};

View File

@ -1,3 +1,9 @@
import { find, isNil } from 'lodash-es';
import { MaterialDesign2SchemeStorage } from './material-2-scheme';
import { MaterialDesign3SchemeStorage } from './material-3-scheme';
import { QSchemeStorage } from './q-scheme';
import { SwatchSchemeStorage } from './swatch_scheme';
export type Option = {
label: string;
value: string | number | null;
@ -22,6 +28,30 @@ export type ColorDescription = {
};
export type SchemeType = 'q_scheme' | 'swatch_scheme' | 'material_2' | 'material_3';
export type SchemeTypeOption = {
label: string;
short: string;
value: SchemeType;
};
export const SchemeTypeOptions: SchemeTypeOption[] = [
{ label: 'Q Scheme', short: 'Q', value: 'q_scheme' },
{ label: 'Swatch Scheme', short: 'Swatch', value: 'swatch_scheme' },
{ label: 'Material Design 2 Scheme', short: 'M2', value: 'material_2' },
{ label: 'Material Design 3 Scheme', short: 'M3', value: 'material_3' },
];
export function schemeType(
value?: SchemeTypeOption['value'] | null,
short?: boolean,
): string | null {
const useShort = short ?? false;
const foundType = find(SchemeTypeOptions, { value }) as SchemeTypeOption | undefined;
if (isNil(foundType)) {
return null;
}
return useShort ? foundType.short : foundType.label;
}
export type SchemeContent<SchemeStorage> = {
id: string;
name: string;
@ -35,3 +65,9 @@ export type ColorShifting = {
chroma: number;
lightness: number;
};
export type SchemeStorage =
| QSchemeStorage
| SwatchSchemeStorage
| MaterialDesign2SchemeStorage
| MaterialDesign3SchemeStorage;

View File

@ -20,6 +20,7 @@
display: flex;
flex-direction: column;
align-items: stretch;
gap: var(--spacing-s);
.scheme_item {
padding: calc(var(--spacing) * 2) calc(var(--spacing) * 8);
&.selected {
@ -53,6 +54,42 @@
font-size: var(--font-size-xs);
color: var(--color-accent);
}
.delete_btn {
font-size: var(--font-size-xs);
color: var(--color-yuebai);
background-color: oklch(from var(--color-danger) l c h / 0.25);
&:hover {
background-color: oklch(from var(--color-danger-hover) l c h / 0.65);
}
&:active {
background-color: oklch(from var(--color-danger-active) l c h / 0.65);
}
}
.active_btn {
font-size: var(--font-size-xs);
color: var(--color-yuebai);
background-color: oklch(from var(--color-info) l c h / 0.25);
&:hover {
background-color: oklch(from var(--color-info-hover) l c h / 0.65);
}
&:active {
background-color: oklch(from var(--color-info-active) l c h / 0.65);
}
&.deactive {
background-color: oklch(from var(--color-warn) l c h / 0.25);
&:hover {
background-color: oklch(from var(--color-warn-hover) l c h / 0.65);
}
&:active {
background-color: oklch(from var(--color-warn-active) l c h / 0.65);
}
}
}
.active_badge {
font-size: var(--font-size-xs);
color: var(--color-yuebai);
background-color: var(--color-success);
}
}
.empty_prompt {
padding-inline: calc(var(--spacing) * 6);

View File

@ -1,11 +1,12 @@
import { Icon } from '@iconify/react/dist/iconify.js';
import cx from 'clsx';
import dayjs from 'dayjs';
import { useAtomValue } from 'jotai';
import { useAtom } from 'jotai';
import { isEmpty, isEqual } from 'lodash-es';
import { useMemo } from 'react';
import { useCallback, useMemo } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { activeSchemeAtom, useSchemeList } from '../../stores/schemes';
import { ActionIcon } from '../../components/ActionIcon';
import { Badge } from '../../components/Badge';
import { SchemeSign } from '../../components/SchemeSign';
import { activeSchemeAtom, useRemoveScheme, useSchemeList } from '../../stores/schemes';
import styles from './SchemeList.module.css';
function OperateButtons() {
@ -25,9 +26,20 @@ type SchemeItemProps = {
function SchemeItem({ item }: SchemeItemProps) {
const navParams = useParams();
const navigate = useNavigate();
const activedScheme = useAtomValue(activeSchemeAtom);
const [activedScheme, setActiveScheme] = useAtom(activeSchemeAtom);
const removeScheme = useRemoveScheme(item.id);
const isActived = useMemo(() => isEqual(activedScheme, item.id), [activedScheme, item.id]);
const isSelected = useMemo(() => isEqual(navParams['id'], item.id), [navParams, item.id]);
const handleActiveScheme = useCallback(() => {
setActiveScheme((prev) => (prev ? null : item.id));
}, [item]);
const handleRemoveScheme = useCallback(() => {
removeScheme();
if (isActived) {
setActiveScheme(null);
}
navigate(-1);
}, [item, isActived]);
return (
<div
@ -35,11 +47,23 @@ function SchemeItem({ item }: SchemeItemProps) {
onClick={() => navigate(item.id)}>
<div className={styles.name}>{item.name}</div>
<div className={styles.status}>
<div className={styles.create_time}>
created at {dayjs(item.createdAt).format('YYYY-MM-DD')}
</div>
<SchemeSign scheme={item.type} short />
{isActived && <Badge extendClassName={styles.active_badge}>ACTIVE</Badge>}
<div className="spacer"></div>
{isActived && <Icon icon="tabler:check" className={styles.active_icon} />}
{isSelected && (
<>
<ActionIcon
icon="tabler:trash"
extendClassName={styles.delete_btn}
onClick={handleRemoveScheme}
/>
<ActionIcon
icon="tabler:checkbox"
extendClassName={cx(styles.active_btn, isActived && styles.deactive)}
onClick={handleActiveScheme}
/>
</>
)}
</div>
</div>
);

View File

@ -25,4 +25,7 @@
}
}
}
.custom_segment {
font-size: var(--font-size-s);
}
}

View File

@ -1,13 +1,16 @@
import cx from 'clsx';
import { isEmpty, isNil } from 'lodash-es';
import { useActionState } from 'react';
import { useActionState, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { VSegmentedControl } from '../components/VSegmentedControl';
import { SchemeTypeOption, SchemeTypeOptions } from '../models';
import { useCreateScheme } from '../stores/schemes';
import styles from './NewScheme.module.css';
export function NewScheme() {
const createScheme = useCreateScheme();
const navigate = useNavigate();
const [schemeType, setSchemeType] = useState<SchemeTypeOption['value']>('q_scheme');
const [errors, formAction] = useActionState((prevState, formData) => {
try {
const name = formData.get('name') as string;
@ -15,7 +18,8 @@ export function NewScheme() {
throw { name: 'Name is required' };
}
const description = (formData.get('description') ?? null) as string | null;
const newId = createScheme(name, description);
const schemeType = (formData.get('type') ?? 'q_scheme') as SchemeTypeOption['value'];
const newId = createScheme(name, schemeType, description);
navigate(`../${newId}`);
} catch (error) {
return error;
@ -25,6 +29,18 @@ export function NewScheme() {
return (
<form action={formAction} className={styles.create_scheme_form_layout}>
<div className={styles.form_row}>
<label>
Scheme Type <span>*</span>
</label>
<VSegmentedControl
options={SchemeTypeOptions}
extendClassName={styles.custom_segment}
value={schemeType}
onChange={setSchemeType}
/>
<input type="hidden" name="type" value={schemeType} />
</div>
<div className={styles.form_row}>
<label>
Name <span>*</span>

View File

@ -7,5 +7,16 @@
align-items: stretch;
gap: var(--spacing-m);
overflow: hidden;
.badge_layout {
padding: var(--spacing-xs) var(--spacing-m);
display: flex;
flex-direction: row;
align-items: center;
gap: var(--spacing-m);
.create_time {
font-size: var(--font-size-xs);
color: var(--color-neutral);
}
}
}
}

View File

@ -1,10 +1,10 @@
import dayjs from 'dayjs';
import { isNil, set } from 'lodash-es';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { EditableDescription } from '../components/EditableDescription';
import { EditableTitle } from '../components/EditableTitle';
import { Tab } from '../components/Tab';
import { SchemeView } from '../page-components/scheme/SchemeView';
import { SchemeSign } from '../components/SchemeSign';
import { useScheme, useUpdateScheme } from '../stores/schemes';
import styles from './SchemeDetail.module.css';
@ -13,7 +13,6 @@ export function SchemeDetail() {
const scheme = useScheme(id);
const navigate = useNavigate();
const updateScheme = useUpdateScheme(id);
const [showScheme, setShowScheme] = useState<'light' | 'dark'>('light');
const updateTitle = useCallback(
(newTitle: string) => {
@ -43,15 +42,13 @@ export function SchemeDetail() {
return (
<div className={styles.scheme_detail_layout}>
<EditableTitle title={scheme?.name} onChange={updateTitle} />
<div className={styles.badge_layout}>
<SchemeSign scheme={scheme?.type} />
<div className={styles.create_time}>
created at {dayjs(scheme?.createdAt).format('YYYY-MM-DD')}
</div>
</div>
<EditableDescription content={scheme?.description} onChange={updateDescription} />
<Tab
tabs={[
{ title: 'Light Scheme', id: 'light' },
{ title: 'Dark Scheme', id: 'dark' },
]}
onActive={(tabId) => setShowScheme(tabId as 'light' | 'dark')}
/>
<SchemeView scheme={scheme?.[`${showScheme}Scheme`]} />
</div>
);
}

View File

@ -4,6 +4,7 @@ import { atomWithStorage } from 'jotai/utils';
import { isEqual, reduce } from 'lodash-es';
import { useCallback, useMemo } from 'react';
import { v4 } from 'uuid';
import { SchemeContent, SchemeStorage, SchemeType } from '../models';
type ColorSet = {
normal?: string | null;
@ -38,38 +39,42 @@ export type SchemeSet = {
darkScheme: Scheme;
};
const schemesAtom = atomWithStorage<SchemeSet[]>('schemes', []);
const schemesAtom = atomWithStorage<SchemeContent<SchemeStorage>[]>('schemes', []);
export const activeSchemeAtom = atomWithStorage<string | null>('activeScheme', null);
export function useSchemeList(): Pick<SchemeSet, 'id' | 'name' | 'createdAt'>[] {
export function useSchemeList(): Pick<SchemeContent<SchemeStorage>, 'id' | 'name' | 'createdAt'>[] {
const schemes = useAtomValue(schemesAtom);
const sortedSchemes = useMemo(
() =>
schemes
.sort((a, b) => dayjs(b.createdAt).diff(dayjs(a.createdAt)))
.map(({ id, name, createdAt }) => ({ id, name, createdAt })),
.map(({ id, name, createdAt, type }) => ({ id, name, createdAt, type })),
[schemes],
);
return sortedSchemes;
}
export function useScheme(id: string): SchemeSet | null {
export function useScheme(id: string): SchemeContent<SchemeStorage> | null {
const schemes = useAtomValue(schemesAtom);
const scheme = useMemo(() => schemes.find((s) => isEqual(id, s.id)) ?? null, [schemes, id]);
return scheme;
}
export function useActiveScheme(): SchemeSet | null {
export function useActiveScheme(): SchemeContent<SchemeStorage> | null {
const activeSchemeId = useAtomValue(activeSchemeAtom);
const activeScheme = useScheme(activeSchemeId ?? 'UNEXISTS');
return activeScheme;
}
export function useCreateScheme(): (name: string, description?: string) => string {
export function useCreateScheme(): (
name: string,
type: SchemeType,
description?: string,
) => string {
const updateSchemes = useSetAtom(schemesAtom);
const createSchemeAction = useCallback(
(name: string, description?: string) => {
(name: string, type: SchemeType, description?: string) => {
const newId = v4();
updateSchemes((prev) => [
...prev,
@ -78,8 +83,8 @@ export function useCreateScheme(): (name: string, description?: string) => strin
name,
createdAt: dayjs().toISOString(),
description: description ?? null,
lightScheme: {},
darkScheme: {},
type,
schemeStorage: {},
},
]);
return newId;
@ -90,10 +95,12 @@ export function useCreateScheme(): (name: string, description?: string) => strin
return createSchemeAction;
}
export function useUpdateScheme(id: string): (updater: (prev: SchemeSet) => SchemeSet) => void {
export function useUpdateScheme(
id: string,
): (updater: (prev: SchemeContent<SchemeStorage>) => SchemeContent<SchemeStorage>) => void {
const updateSchemes = useSetAtom(schemesAtom);
const updateAction = useCallback(
(updater: (prev: SchemeSet) => SchemeSet) => {
(updater: (prev: SchemeContent<SchemeStorage>) => SchemeContent<SchemeStorage>) => {
updateSchemes((prev) =>
reduce(
prev,
@ -105,7 +112,7 @@ export function useUpdateScheme(id: string): (updater: (prev: SchemeSet) => Sche
}
return acc;
},
[] as SchemeSet[],
[] as SchemeContent<SchemeStorage>[],
),
);
},