diff --git a/src/App.tsx b/src/App.tsx index c73fc51..98c5a64 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,8 @@ import { createBrowserRouter, RouterProvider } from 'react-router-dom'; import { ColorFunctionProvider } from './ColorFunctionContext'; import { Notifications } from './components/Notifications'; +import { ColorCards } from './pages/Cards'; +import { CardsDetail } from './pages/CardsDetail'; import { Harmonies } from './pages/Harmonies'; import { Home } from './pages/Home'; import { LightenDarken } from './pages/LightenDarken'; @@ -37,6 +39,14 @@ const routes = createBrowserRouter([ { path: 'lighten-darken', element: }, { path: 'mixer', element: }, { path: 'wacg', element: }, + { + path: 'cards', + element: , + children: [ + { path: 'chinese', element: }, + { path: 'japanese', element: }, + ], + }, ], }, ]); diff --git a/src/component.css b/src/component.css index c184b79..8c17371 100644 --- a/src/component.css +++ b/src/component.css @@ -170,7 +170,7 @@ } /* 输入框以及输入框组合体默认样式 */ - :where(input, textarea) { + :where(input, textarea, select) { border: 1px solid oklch(from var(--color-bg) calc(l + (1 - l) * 0.1) c h); border-radius: var(--border-radius-xxs); padding: calc(var(--spacing) * 2) calc(var(--spacing) * 4); diff --git a/src/models.ts b/src/models.ts index e7fa3b7..df75921 100644 --- a/src/models.ts +++ b/src/models.ts @@ -7,3 +7,16 @@ export type HarmonyColor = { color: string; ratio: number; }; + +export type ColorDescription = { + name: string; + pinyin: string[]; + hue: number; + lightness: number; + category: string; + tags: string[]; + rgb: [number, number, number]; + hsl: [number, number, number]; + lab: [number, number, number]; + oklch: [number, number, number]; +}; diff --git a/src/page-components/cards-detail/ColorCard.module.css b/src/page-components/cards-detail/ColorCard.module.css new file mode 100644 index 0000000..13767cd --- /dev/null +++ b/src/page-components/cards-detail/ColorCard.module.css @@ -0,0 +1,42 @@ +@layer pages { + .card { + width: 100%; + display: flex; + flex-direction: column; + gap: var(--spacing-n); + font-size: var(--font-size-xxs); + line-height: var(--font-size-xxs); + border: 1px solid var(--color-border); + border-radius: var(--border-radius-xs); + cursor: pointer; + } + .color_block { + width: 100%; + height: 5em; + } + .description_line { + display: flex; + flex-direction: row; + align-items: center; + padding: var(--spacing-xs) var(--spacing-s); + } + .title { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--spacing-xxs); + flex: 1; + .name { + font-size: var(--font-size-xs); + line-height: var(--font-size-xs); + font-weight: bold; + } + .en_name { + font-style: italic; + color: var(--color-neutral-focus); + } + } + .color_value { + text-transform: uppercase; + } +} diff --git a/src/page-components/cards-detail/ColorCard.tsx b/src/page-components/cards-detail/ColorCard.tsx new file mode 100644 index 0000000..5ace071 --- /dev/null +++ b/src/page-components/cards-detail/ColorCard.tsx @@ -0,0 +1,74 @@ +import { capitalize } from 'lodash-es'; +import { useCallback, useMemo } from 'react'; +import { useColorFunction } from '../../ColorFunctionContext'; +import { useCopyColor } from '../../hooks/useCopyColor'; +import { ColorDescription } from '../../models'; +import styles from './ColorCard.module.css'; + +type ColorCardProps = { + color: ColorDescription; + copyMode?: 'hex' | 'rgb' | 'hsl' | 'lab' | 'oklch'; +}; + +export function ColorCard({ color, copyMode }: ColorCardProps) { + const { colorFn } = useColorFunction(); + const copytToClipboard = useCopyColor(); + const colorHex = useMemo(() => { + const [r, g, b] = color.rgb; + if (colorFn) { + try { + const hex = colorFn.rgb_to_hex(r, g, b); + return hex; + } catch (e) { + console.error('[Convert RGB]', e); + } + } + return `${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`; + }, [colorFn, color]); + const handleCopy = useCallback(() => { + switch (copyMode) { + case 'rgb': + copytToClipboard(`rgb(${color.rgb[0]}, ${color.rgb[1]}, ${color.rgb[2]})`); + break; + case 'hsl': + copytToClipboard( + `hsl(${color.hsl[0].toFixed(1)}, ${(color.hsl[1] * 100).toFixed(2)}%, ${( + color.hsl[2] * 100 + ).toFixed(2)}%)`, + ); + break; + case 'lab': + copytToClipboard( + `lab(${color.lab[0].toFixed(1)}, ${color.lab[1].toFixed(2)}, ${color.lab[2].toFixed(2)})`, + ); + break; + case 'oklch': + copytToClipboard( + `oklch(${(color.oklch[0] * 100).toFixed(2)}%, ${color.oklch[1].toFixed( + 4, + )}, ${color.oklch[2].toFixed(1)})`, + ); + break; + case 'hex': + default: + copytToClipboard(`#${colorHex}`); + break; + } + }, [copytToClipboard, color, copyMode, colorHex]); + + return ( +
+
+
+
+ {color.name} + {color.pinyin.map(capitalize).join(' ')} +
+
#{colorHex}
+
+
+ ); +} diff --git a/src/page-components/color-cards/CardNavigation.tsx b/src/page-components/color-cards/CardNavigation.tsx new file mode 100644 index 0000000..bb6b41b --- /dev/null +++ b/src/page-components/color-cards/CardNavigation.tsx @@ -0,0 +1,26 @@ +import cx from 'clsx'; +import { NavLink } from 'react-router-dom'; +import styles from './CardsNavigation.module.css'; + +export function CardsNavigation() { + return ( +
+ +
  • + cx(styles.nav_link, isActive && styles.active)}> + Chinese Traditional + +
  • +
  • + cx(styles.nav_link, isActive && styles.active)}> + Japanese Traditional + +
  • +
    +
    + ); +} diff --git a/src/page-components/color-cards/CardsNavigation.module.css b/src/page-components/color-cards/CardsNavigation.module.css new file mode 100644 index 0000000..ec7d1bb --- /dev/null +++ b/src/page-components/color-cards/CardsNavigation.module.css @@ -0,0 +1,33 @@ +@layer pages { + .cards_list { + max-width: calc(var(--spacing) * 125); + flex: 1 1 calc(var(--spacing) * 125); + padding: calc(var(--spacing) * 4) 0; + box-shadow: 2px 0 8px oklch(from var(--color-black) l c h / 65%); + z-index: 40; + } + .nav_menu { + flex: 1 0; + padding: var(--spacing-n); + padding-block-start: var(--spacing-s); + display: flex; + flex-direction: column; + align-items: stretch; + gap: var(--spacing-xs); + li { + list-style: none; + a.nav_link { + display: inline-block; + width: 100%; + padding-inline: var(--spacing-l); + padding-block: var(--spacing-s); + &.active { + background-color: var(--color-primary-active); + } + &:hover { + background-color: var(--color-primary-hover); + } + } + } + } +} diff --git a/src/pages/Cards.module.css b/src/pages/Cards.module.css new file mode 100644 index 0000000..f14ab60 --- /dev/null +++ b/src/pages/Cards.module.css @@ -0,0 +1,13 @@ +@layer pages { + .cards_workspace { + height: 100%; + width: 100%; + overflow: hidden; + display: flex; + flex-direction: row; + align-items: stretch; + } + .cards_container { + flex: 1 0; + } +} diff --git a/src/pages/Cards.tsx b/src/pages/Cards.tsx new file mode 100644 index 0000000..1d5a380 --- /dev/null +++ b/src/pages/Cards.tsx @@ -0,0 +1,14 @@ +import { Outlet } from 'react-router-dom'; +import { CardsNavigation } from '../page-components/color-cards/CardNavigation'; +import styles from './Cards.module.css'; + +export function ColorCards() { + return ( +
    + +
    + +
    +
    + ); +} diff --git a/src/pages/CardsDetail.module.css b/src/pages/CardsDetail.module.css new file mode 100644 index 0000000..0b5fe37 --- /dev/null +++ b/src/pages/CardsDetail.module.css @@ -0,0 +1,28 @@ +@layer pages { + .cards_workspace { + padding: var(--spacing-l) var(--spacing-m); + flex-direction: column; + .filters { + display: flex; + flex-direction: row; + align-items: center; + gap: var(--spacing-s); + .cate_select { + padding: var(--spacing-xxs) var(--spacing-s); + min-width: 7em; + font-size: var(--font-size-s); + } + } + .cards_container { + width: 100%; + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: var(--spacing-s); + padding: 0 var(--spacing-s); + .card { + flex-basis: calc((100% - var(--spacing-s) * 4) / 5); + } + } + } +} diff --git a/src/pages/CardsDetail.tsx b/src/pages/CardsDetail.tsx new file mode 100644 index 0000000..a8c94c0 --- /dev/null +++ b/src/pages/CardsDetail.tsx @@ -0,0 +1,102 @@ +import cx from 'clsx'; +import { isEqual } from 'lodash-es'; +import { ChangeEvent, useMemo, useState } from 'react'; +import { useColorFunction } from '../ColorFunctionContext'; +import { HSegmentedControl } from '../components/HSegmentedControl'; +import { ScrollArea } from '../components/ScrollArea'; +import { ColorDescription } from '../models'; +import { ColorCard } from '../page-components/cards-detail/ColorCard'; +import styles from './CardsDetail.module.css'; + +type CardsDetailProps = { + mainTag: string; +}; + +export function CardsDetail({ mainTag }: CardsDetailProps) { + const { colorFn } = useColorFunction(); + const categories = useMemo(() => { + if (!colorFn) { + return []; + } + try { + const embededCategories = colorFn.color_categories() as { label: string; value: string }[]; + return embededCategories.filter((cate) => !isEqual(cate.get('value'), 'unknown')); + } catch (e) { + console.error('[Fetch color categories]', e); + } + return []; + }, [colorFn]); + const [colorCategory, setCategory] = useState('null'); + const handleSelectCategory = (e: ChangeEvent) => { + const selectedValue = e.target.value; + setCategory(selectedValue); + }; + const [mode, setMode] = useState<'hex' | 'rgb' | 'hsl' | 'lab' | 'oklch'>('hex'); + const colors = useMemo(() => { + if (!colorFn) { + return []; + } + try { + const colorCate = isEqual(colorCategory, 'null') ? undefined : colorCategory; + let tag = ''; + switch (mainTag) { + case 'japanese': + tag = 'japanese_traditional'; + break; + case 'chinese': + tag = 'chinese_traditional'; + break; + default: + tag = ''; + break; + } + const embedColors = colorFn.search_color_cards(tag, colorCate) as ColorDescription[]; + console.debug('[Fetch cards]', embedColors); + return embedColors; + } catch (e) { + console.error('[Fetch colors]', e); + } + return []; + }, [colorFn, mainTag, colorCategory]); + + return ( +
    +
    + Show + + colors. +
    Copy color value in
    + +
    + +
    + {colors.map((c, index) => ( +
    + +
    + ))} +
    +
    +
    + ); +}