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 (
+
+
+
+ );
+}
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) => (
+
+
+
+ ))}
+
+
+
+ );
+}