├── .github └── workflows │ ├── e2e.yaml │ ├── test.yaml │ └── update.yaml ├── .gitignore ├── .mise.toml ├── .prettierignore ├── .prettierrc.js ├── @types └── react-infinite-scroller.d.ts ├── LICENSE ├── README.md ├── app ├── layout.tsx └── page.tsx ├── components ├── atoms │ ├── button │ │ ├── index.test.tsx │ │ ├── index.tsx │ │ ├── link.test.tsx │ │ └── link.tsx │ ├── card │ │ ├── index.test.tsx │ │ └── index.tsx │ ├── color-sample │ │ ├── circle.test.tsx │ │ ├── circle.tsx │ │ ├── index.test.tsx │ │ └── index.tsx │ ├── input │ │ ├── index.test.tsx │ │ └── index.tsx │ ├── label │ │ ├── name.test.tsx │ │ ├── name.tsx │ │ ├── type.test.tsx │ │ └── type.tsx │ ├── select │ │ ├── index.test.tsx │ │ └── index.tsx │ └── title │ │ ├── index.test.tsx │ │ └── index.tsx ├── molecules │ ├── card-title │ │ ├── __snapshots__ │ │ │ └── index.test.tsx.snap │ │ ├── index.test.tsx │ │ └── index.tsx │ └── not-found-card │ │ ├── __snapshots__ │ │ └── index.test.tsx.snap │ │ ├── index.test.tsx │ │ └── index.tsx └── organisms │ ├── button │ ├── keep.tsx │ ├── move-top.tsx │ └── remove.tsx │ ├── color-card │ ├── __snapshots__ │ │ ├── index.test.tsx.snap │ │ └── mobile.test.tsx.snap │ ├── index.test.tsx │ ├── index.tsx │ ├── mobile.test.tsx │ ├── mobile.tsx │ └── props.ts │ ├── color-cards │ ├── __snapshots__ │ │ └── index.test.tsx.snap │ ├── index.test.tsx │ └── index.tsx │ ├── color-info │ ├── index.tsx │ ├── value.test.tsx │ └── value.tsx │ ├── footer │ └── index.tsx │ ├── header │ ├── github-corner.tsx │ └── index.tsx │ ├── search-colors │ ├── __snapshots__ │ │ ├── color-button.test.tsx.snap │ │ └── index.test.tsx.snap │ ├── color-button.test.tsx │ ├── color-button.tsx │ ├── index.test.tsx │ └── index.tsx │ ├── search │ ├── __snapshots__ │ │ └── index.test.tsx.snap │ ├── index.test.tsx │ └── index.tsx │ └── ui │ ├── index.test.tsx │ └── index.tsx ├── data ├── brands.ts ├── colors.ts ├── search-colors.ts └── seo.ts ├── e2e ├── filter.spec.ts ├── keep.spec.ts ├── search-brand.spec.ts ├── search-keyword.spec.ts └── utils.ts ├── eslint.config.cjs ├── hooks ├── useColorData.test.ts ├── useColorData.ts ├── useKeepId.test.ts └── useKeepId.ts ├── jest-setup.js ├── jest.config.js ├── next-env.d.ts ├── next.config.js ├── package.json ├── playwright.config.ts ├── pnpm-lock.yaml ├── postcss.config.js ├── public ├── favicon.ico └── ogp.png ├── renovate.json ├── styles └── globals.css ├── tools ├── create-colors.ts └── libs │ ├── color.ts │ ├── fetch-idols.ts │ ├── fetch-units.ts │ └── fetch.ts ├── tsconfig.json ├── types ├── color-detail.ts ├── color.ts ├── option.ts ├── search-color.ts └── seo.ts └── vercel.json /.github/workflows/e2e.yaml: -------------------------------------------------------------------------------- 1 | name: e2e 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | e2e: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v5 17 | 18 | - name: Setup pnpm 19 | uses: pnpm/action-setup@v4 20 | with: 21 | version: 10 22 | 23 | - name: Setup Node.js 24 | uses: actions/setup-node@v6 25 | with: 26 | node-version: '24' 27 | cache: 'pnpm' 28 | 29 | - name: Install dependencies 30 | run: pnpm install --frozen-lockfile 31 | 32 | - name: Setup playwright 33 | run: npx playwright install --with-deps 34 | 35 | - name: Run E2E test 36 | run: pnpm test:e2e 37 | 38 | - name: Upload screenshots 39 | uses: actions/upload-artifact@v5 40 | with: 41 | name: results 42 | path: e2e/results 43 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: push 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Checkout 10 | uses: actions/checkout@v5 11 | 12 | - name: Setup pnpm 13 | uses: pnpm/action-setup@v4 14 | with: 15 | version: 10 16 | 17 | - name: Setup Node.js 18 | uses: actions/setup-node@v6 19 | with: 20 | node-version: '24' 21 | cache: 'pnpm' 22 | 23 | - name: Install dependencies 24 | run: pnpm install --frozen-lockfile 25 | 26 | - name: Run test 27 | run: pnpm test 28 | -------------------------------------------------------------------------------- /.github/workflows/update.yaml: -------------------------------------------------------------------------------- 1 | name: update 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | # 日本時間の深夜0時 7 | - cron: '0 15 * * *' 8 | 9 | jobs: 10 | update: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Set branch name as environment variable 14 | run: echo "NEW_BRANCH_NAME=update-color-$(date '+%s')" >> $GITHUB_ENV 15 | 16 | - name: Checkout 17 | uses: actions/checkout@v5 18 | 19 | - name: Setup pnpm 20 | uses: pnpm/action-setup@v4 21 | with: 22 | version: 10 23 | 24 | - name: Setup Node.js 25 | uses: actions/setup-node@v6 26 | with: 27 | node-version: '24' 28 | cache: 'pnpm' 29 | 30 | - name: Install dependencies 31 | run: pnpm install --frozen-lockfile 32 | 33 | - name: Recreate color data 34 | run: pnpm create:colors 35 | 36 | - name: Format data 37 | run: pnpm fmt:colors 38 | 39 | - name: Create pull request 40 | uses: peter-evans/create-pull-request@v7 41 | with: 42 | commit-message: 🍱 色データを更新 43 | branch: ${{ env.NEW_BRANCH_NAME }} 44 | delete-branch: true 45 | title: '🍱 色データを更新' 46 | body: 'このPRは自動生成されました 🤖' 47 | add-paths: | 48 | data/*.ts 49 | env: 50 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | /e2e/results/ 9 | test-results/ 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | 25 | # local env files 26 | .env.local 27 | .env.development.local 28 | .env.test.local 29 | .env.production.local 30 | 31 | # vercel 32 | .vercel 33 | -------------------------------------------------------------------------------- /.mise.toml: -------------------------------------------------------------------------------- 1 | [tools] 2 | pnpm = "latest" 3 | node = "lts" 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .next 2 | node_modules 3 | package.json 4 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | // NOTE: pnpm で prettier のプラグインが解決できないため、暫定的に js で設定を書いてる 2 | // ref: https://zenn.dev/convcha/articles/6aa3dc18158a41 3 | module.exports = { 4 | plugins: [require('@trivago/prettier-plugin-sort-imports')], 5 | printWidth: 80, 6 | tabWidth: 2, 7 | useTabs: false, 8 | semi: false, 9 | singleQuote: true, 10 | trailingComma: 'none', 11 | bracketSpacing: true, 12 | arrowParens: 'always', 13 | importOrder: [ 14 | '', 15 | '^components/atoms/(.*)$', 16 | '^components/molecules/(.*)$', 17 | '^components/organisms/(.*)$', 18 | '^components/templates/(.*)$', 19 | '^hooks/(.*)$', 20 | '^data/(.*)$', 21 | '^types/(.*)$', 22 | '^styles/(.*)$', 23 | '^[./]' 24 | ], 25 | importOrderSeparation: false, 26 | importOrderSortSpecifiers: true 27 | } 28 | -------------------------------------------------------------------------------- /@types/react-infinite-scroller.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'react-infinite-scroller' { 2 | import React from 'react' 3 | 4 | interface Props { 5 | loadMore: () => void 6 | hasMore: boolean 7 | children?: React.ReactNode 8 | } 9 | 10 | export default InfiniteScroll 11 | } 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 arrow2nd 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # imas-palette 2 | 3 | 🎨 THE IDOLM@STER シリーズに登場するアイドル・ユニットのカラーが検索できる Web アプリ 4 | 5 | [![update](https://github.com/arrow2nd/imas-palette/actions/workflows/update.yaml/badge.svg)](https://github.com/arrow2nd/imas-palette/actions/workflows/update.yaml) 6 | [![test](https://github.com/arrow2nd/imas-palette/actions/workflows/test.yaml/badge.svg)](https://github.com/arrow2nd/imas-palette/actions/workflows/test.yaml) 7 | [![e2e](https://github.com/arrow2nd/imas-palette/actions/workflows/e2e.yaml/badge.svg)](https://github.com/arrow2nd/imas-palette/actions/workflows/e2e.yaml) 8 | [![Vercel](https://therealsujitk-vercel-badge.vercel.app/?app=imas-palette)](https://imas-palette.vercel.app) 9 | [![Powered by im@sparql](https://img.shields.io/badge/powered%20by-im%40sparql-F34F6D)](https://sparql.crssnky.xyz/imas/) 10 | 11 | https://github.com/user-attachments/assets/4f4429c1-d6bf-4b89-97fe-0f826f032271 12 | 13 | ## できること 14 | 15 | - アイドル名や色合いからイメージカラーを検索 16 | - RGB / HSV / HEX 形式でカラーコードをコピー 17 | - よく使う・お気に入りのカラーを Keep する 18 | 19 | ## 実行 20 | 21 | ``` 22 | pnpm dev 23 | # or 24 | pnpm build && pnpm start 25 | ``` 26 | 27 | ## Thanks! 28 | 29 | アイドルの色情報は [im@sparql](https://sparql.crssnky.xyz/imas/) より取得したデータを利用しています 30 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { M_PLUS_Rounded_1c } from 'next/font/google' 2 | import React from 'react' 3 | import 'styles/globals.css' 4 | 5 | const mPlusRounded1c = M_PLUS_Rounded_1c({ 6 | display: 'swap', 7 | weight: ['400'], 8 | subsets: ['latin'], 9 | adjustFontFallback: false 10 | }) 11 | 12 | export default function RootLayout({ 13 | children 14 | }: { 15 | children: React.ReactNode 16 | }) { 17 | return ( 18 | 19 | 20 | 25 | 26 | {children} 27 | 28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import { Metadata } from 'next/types' 2 | import Footer from 'components/organisms/footer' 3 | import Header from 'components/organisms/header' 4 | import UI from 'components/organisms/ui' 5 | import { seo } from 'data/seo' 6 | 7 | export async function generateMetadata(): Promise { 8 | const { title, description, url, ogpImgUrl } = seo 9 | 10 | return { 11 | title, 12 | description, 13 | metadataBase: new URL(url), 14 | openGraph: { 15 | title, 16 | description, 17 | url, 18 | siteName: title, 19 | type: 'website', 20 | images: [{ url: ogpImgUrl }] 21 | }, 22 | twitter: { 23 | card: 'summary_large_image', 24 | title, 25 | description, 26 | images: [{ url: ogpImgUrl }] 27 | } 28 | } 29 | } 30 | 31 | export default function Page() { 32 | return ( 33 |
34 |
35 | 36 |
37 |
38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /components/atoms/button/index.test.tsx: -------------------------------------------------------------------------------- 1 | import { act, fireEvent, render } from '@testing-library/react' 2 | import Button from './index' 3 | 4 | describe('Button', () => { 5 | const mock = jest.fn() 6 | const { getByRole } = render( 7 | 10 | ) 11 | const button = getByRole('button') 12 | 13 | test('クリック時にコールバックが呼ばれているか', () => { 14 | act(() => { 15 | fireEvent.click(button) 16 | }) 17 | 18 | expect(mock).toHaveBeenCalled() 19 | }) 20 | 21 | test('子要素が正しく表示されているか', () => { 22 | expect(button).toContainHTML('

click

') 23 | }) 24 | }) 25 | -------------------------------------------------------------------------------- /components/atoms/button/index.tsx: -------------------------------------------------------------------------------- 1 | type Props = { 2 | className?: string 3 | children: React.ReactNode 4 | onClick?: () => void 5 | dataTestId?: string 6 | } 7 | 8 | const Button = ({ className = '', children, onClick, dataTestId }: Props) => ( 9 | 16 | ) 17 | 18 | export default Button 19 | -------------------------------------------------------------------------------- /components/atoms/button/link.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react' 2 | import LinkButton from './link' 3 | 4 | describe('LinkButton', () => { 5 | const { getByRole } = render( 6 | 7 |

link

8 |
9 | ) 10 | const link = getByRole('link') 11 | 12 | test('hrefが正しく設定されているか', () => { 13 | expect(link).toHaveAttribute('href', 'http://example.com/') 14 | }) 15 | 16 | test('子要素が正しく表示されているか', () => { 17 | expect(link).toContainHTML('

link

') 18 | }) 19 | }) 20 | -------------------------------------------------------------------------------- /components/atoms/button/link.tsx: -------------------------------------------------------------------------------- 1 | type Props = { 2 | className?: string 3 | href: string 4 | children: React.ReactNode 5 | } 6 | 7 | const LinkButton = ({ className = '', href, children }: Props) => ( 8 | 14 | {children} 15 | 16 | ) 17 | 18 | export default LinkButton 19 | -------------------------------------------------------------------------------- /components/atoms/card/index.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react' 2 | import Card from './index' 3 | 4 | describe('Card', () => { 5 | test('子要素が正しく表示されているか', () => { 6 | const { container } = render( 7 | 8 |

test

9 |
10 | ) 11 | 12 | expect(container).toContainHTML('

test

') 13 | }) 14 | }) 15 | -------------------------------------------------------------------------------- /components/atoms/card/index.tsx: -------------------------------------------------------------------------------- 1 | type Props = { 2 | className?: string 3 | children: React.ReactNode 4 | dataTestId?: string 5 | } 6 | 7 | const Card = ({ className = '', children, dataTestId }: Props) => ( 8 |
12 | {children} 13 |
14 | ) 15 | 16 | export default Card 17 | -------------------------------------------------------------------------------- /components/atoms/color-sample/circle.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react' 2 | import ColorSampleCircle from './circle' 3 | 4 | describe('ColorSampleCircle', () => { 5 | const { container } = render() 6 | const colorSample = container.children[0] 7 | 8 | test('指定した色のCSSが設定されているか', () => { 9 | expect(colorSample).toHaveStyle('background-color: rgb(245, 66, 117)') 10 | }) 11 | 12 | test('title属性が設定されているか', () => { 13 | expect(colorSample).toHaveAttribute('title', 'amana') 14 | }) 15 | }) 16 | -------------------------------------------------------------------------------- /components/atoms/color-sample/circle.tsx: -------------------------------------------------------------------------------- 1 | type Props = { 2 | className?: string 3 | name: string 4 | hex: string 5 | } 6 | 7 | const ColorSampleCircle = ({ className = '', name, hex }: Props) => ( 8 |
14 | ) 15 | 16 | export default ColorSampleCircle 17 | -------------------------------------------------------------------------------- /components/atoms/color-sample/index.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react' 2 | import ColorSample from './index' 3 | 4 | describe('ColorSample', () => { 5 | const { container } = render() 6 | 7 | test('指定した色のCSSが設定されているか', () => { 8 | expect(container.children[0]).toHaveStyle( 9 | 'background-color: rgb(243, 1, 0)' 10 | ) 11 | }) 12 | }) 13 | -------------------------------------------------------------------------------- /components/atoms/color-sample/index.tsx: -------------------------------------------------------------------------------- 1 | type Props = { 2 | hex: string 3 | } 4 | 5 | const ColorSample = ({ hex }: Props) => ( 6 |
10 | ) 11 | 12 | export default ColorSample 13 | -------------------------------------------------------------------------------- /components/atoms/input/index.test.tsx: -------------------------------------------------------------------------------- 1 | import { act, fireEvent, render } from '@testing-library/react' 2 | import Input from './index' 3 | 4 | describe('Input', () => { 5 | test('入力確定時にコールバックが呼ばれるか', () => { 6 | const mock = jest.fn() 7 | const { getByTestId } = render( 8 | 14 | ) 15 | 16 | act(() => { 17 | const textbox = getByTestId('search-textbox') 18 | 19 | fireEvent.change(textbox, { target: { value: 'test' } }) 20 | fireEvent.keyDown(textbox, { key: 'Enter', code: 'Enter', charCode: 13 }) 21 | }) 22 | 23 | expect(mock).toHaveBeenCalled() 24 | }) 25 | 26 | test('プレースホルダが正しく設定されているか', () => { 27 | const { getByPlaceholderText } = render( 28 | 29 | ) 30 | 31 | expect(getByPlaceholderText('placeholder')).toBeTruthy() 32 | }) 33 | }) 34 | -------------------------------------------------------------------------------- /components/atoms/input/index.tsx: -------------------------------------------------------------------------------- 1 | import { KeyboardEventHandler, forwardRef, useState } from 'react' 2 | 3 | type Props = { 4 | className: string 5 | placeholder: string 6 | onSubmit: () => void 7 | dataTestId?: string 8 | } 9 | 10 | const Input = forwardRef(function InputContent( 11 | { className, placeholder, onSubmit, dataTestId }: Props, 12 | ref 13 | ) { 14 | const [isTyping, setIsTyping] = useState(false) 15 | 16 | const handleKeyDown: KeyboardEventHandler = (ev) => { 17 | if (!isTyping && ev.key === 'Enter') { 18 | ev.preventDefault() 19 | ev.currentTarget.blur() 20 | onSubmit() 21 | } 22 | } 23 | 24 | return ( 25 |
26 | setIsTyping(true)} 30 | onCompositionEnd={() => setIsTyping(false)} 31 | onKeyDown={handleKeyDown} 32 | data-testid={dataTestId} 33 | ref={ref} 34 | /> 35 |
36 | ) 37 | }) 38 | 39 | export default Input 40 | -------------------------------------------------------------------------------- /components/atoms/label/name.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react' 2 | import NameLabel from './name' 3 | 4 | describe('NameLabel', () => { 5 | test('ラベルが正しく表示されるか', () => { 6 | const { getByText } = render() 7 | 8 | expect(getByText('テスト')).toBeTruthy() 9 | expect(getByText('test')).toBeTruthy() 10 | }) 11 | }) 12 | -------------------------------------------------------------------------------- /components/atoms/label/name.tsx: -------------------------------------------------------------------------------- 1 | type Props = { 2 | className?: string 3 | name: string 4 | nameSuppl: string 5 | } 6 | 7 | const NameLabel = ({ className = '', name, nameSuppl }: Props) => ( 8 |
9 |

{name}

10 |

{nameSuppl}

11 |
12 | ) 13 | 14 | export default NameLabel 15 | -------------------------------------------------------------------------------- /components/atoms/label/type.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react' 2 | import TypeLabel from './type' 3 | 4 | describe('TypeLabel', () => { 5 | test('ラベルが正しく表示されるか', () => { 6 | const { getByText } = render() 7 | 8 | expect(getByText('RGB')).toBeTruthy() 9 | }) 10 | }) 11 | -------------------------------------------------------------------------------- /components/atoms/label/type.tsx: -------------------------------------------------------------------------------- 1 | type Props = { 2 | type: string 3 | } 4 | 5 | const TypeLabel = ({ type }: Props) => ( 6 |
7 | {type} 8 |
9 | ) 10 | 11 | export default TypeLabel 12 | -------------------------------------------------------------------------------- /components/atoms/select/index.test.tsx: -------------------------------------------------------------------------------- 1 | import { act, fireEvent, render } from '@testing-library/react' 2 | import Select from './index' 3 | 4 | describe('Select', () => { 5 | test('値の変更時にコールバックが呼ばれるか', () => { 6 | const mock = jest.fn() 7 | const { getByTestId } = render( 8 | 13 | ) 14 | 15 | act(() => { 16 | const select = getByTestId('search-select') 17 | fireEvent.change(select, { target: { value: 'opt2' } }) 18 | }) 19 | 20 | expect(mock).toHaveBeenCalled() 21 | }) 22 | }) 23 | -------------------------------------------------------------------------------- /components/atoms/select/index.tsx: -------------------------------------------------------------------------------- 1 | import { ChangeEvent } from 'react' 2 | 3 | type Props = { 4 | className?: string 5 | children: React.ReactNode 6 | onChange: (value: string) => void 7 | dataTestId?: string 8 | } 9 | 10 | const Select = ({ className = '', children, onChange, dataTestId }: Props) => { 11 | const handleChange = (ev: ChangeEvent) => { 12 | onChange(ev.target.value) 13 | } 14 | 15 | return ( 16 |
17 | 24 |
25 | ) 26 | } 27 | 28 | export default Select 29 | -------------------------------------------------------------------------------- /components/atoms/title/index.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react' 2 | import Title from './index' 3 | 4 | describe('Title', () => { 5 | test('タイトルが正しく表示できるか', () => { 6 | const { getByText } = render() 7 | 8 | expect(getByText('テスト')).toBeTruthy() 9 | }) 10 | }) 11 | -------------------------------------------------------------------------------- /components/atoms/title/index.tsx: -------------------------------------------------------------------------------- 1 | type Props = { 2 | text: string 3 | } 4 | 5 | const Title = ({ text }: Props) => ( 6 | <span className="text-3xl md:text-4xl tracking-widest">{text}</span> 7 | ) 8 | 9 | export default Title 10 | -------------------------------------------------------------------------------- /components/molecules/card-title/__snapshots__/index.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`CardTitle 見た目が変化していないか 1`] = ` 4 | <div> 5 | <div 6 | class="flex flex-row items-center" 7 | > 8 | <div 9 | class="flex-none w-16 h-16 rounded-xl border border-gray-200" 10 | style="background-color: rgb(217, 103, 163);" 11 | /> 12 | <div 13 | class="flex-1 ml-6" 14 | data-testid="name-label" 15 | > 16 | <p 17 | class="text-lg leading-6" 18 | > 19 | 小早川紗枝 20 | </p> 21 | <p 22 | class="text-sm tracking-wide" 23 | > 24 | Sae Kobayakawa 25 | </p> 26 | </div> 27 | </div> 28 | </div> 29 | `; 30 | -------------------------------------------------------------------------------- /components/molecules/card-title/index.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react' 2 | import CardTitle from './index' 3 | 4 | describe('CardTitle', () => { 5 | test('見た目が変化していないか', () => { 6 | const { container } = render( 7 | <CardTitle name="小早川紗枝" nameSuppl="Sae Kobayakawa" hex="#D967A3" /> 8 | ) 9 | 10 | expect(container).toMatchSnapshot() 11 | }) 12 | }) 13 | -------------------------------------------------------------------------------- /components/molecules/card-title/index.tsx: -------------------------------------------------------------------------------- 1 | import ColorSample from 'components/atoms/color-sample' 2 | import NameLabel from 'components/atoms/label/name' 3 | 4 | type Props = { 5 | name: string 6 | nameSuppl: string 7 | hex: string 8 | children?: React.ReactNode 9 | } 10 | 11 | const CardTitle = ({ name, nameSuppl, hex, children }: Props) => ( 12 | <div className="flex flex-row items-center"> 13 | <ColorSample hex={hex} /> 14 | <NameLabel className="flex-1 ml-6" name={name} nameSuppl={nameSuppl} /> 15 | {children} 16 | </div> 17 | ) 18 | 19 | export default CardTitle 20 | -------------------------------------------------------------------------------- /components/molecules/not-found-card/__snapshots__/index.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`NotFoundCard 見た目が変化していないか 1`] = ` 4 | <div> 5 | <div 6 | class="w-96 m-2 p-6 rounded-lg shadow-md bg-white flex flex-col" 7 | > 8 | <div 9 | class="flex flex-row items-center" 10 | > 11 | <svg 12 | fill="currentColor" 13 | height="1em" 14 | stroke="currentColor" 15 | stroke-width="0" 16 | version="1.1" 17 | viewBox="0 0 16 16" 18 | width="1em" 19 | xmlns="http://www.w3.org/2000/svg" 20 | > 21 | <path 22 | d="M8 16c4.418 0 8-3.582 8-8s-3.582-8-8-8-8 3.582-8 8 3.582 8 8 8zM8 1.5c3.59 0 6.5 2.91 6.5 6.5s-2.91 6.5-6.5 6.5-6.5-2.91-6.5-6.5 2.91-6.5 6.5-6.5z" 23 | /> 24 | <path 25 | d="M12.5 6h-2c-0.276 0-0.5-0.224-0.5-0.5s0.224-0.5 0.5-0.5h2c0.276 0 0.5 0.224 0.5 0.5s-0.224 0.5-0.5 0.5z" 26 | /> 27 | <path 28 | d="M5.5 6h-2c-0.276 0-0.5-0.224-0.5-0.5s0.224-0.5 0.5-0.5h2c0.276 0 0.5 0.224 0.5 0.5s-0.224 0.5-0.5 0.5z" 29 | /> 30 | <path 31 | d="M9.5 13.375c-0.128 0-0.256-0.049-0.354-0.146-0.072-0.072-0.46-0.229-1.146-0.229s-1.075 0.157-1.146 0.229c-0.195 0.195-0.512 0.195-0.707 0s-0.195-0.512 0-0.707c0.471-0.471 1.453-0.521 1.854-0.521s1.383 0.051 1.854 0.521c0.195 0.195 0.195 0.512 0 0.707-0.098 0.098-0.226 0.146-0.354 0.146z" 32 | /> 33 | <path 34 | d="M11.5 9c-0.276 0-0.5-0.224-0.5-0.5v-1c0-0.276 0.224-0.5 0.5-0.5s0.5 0.224 0.5 0.5v1c0 0.276-0.224 0.5-0.5 0.5z" 35 | /> 36 | <path 37 | d="M11.5 12c-0.276 0-0.5-0.224-0.5-0.5v-1c0-0.276 0.224-0.5 0.5-0.5s0.5 0.224 0.5 0.5v1c0 0.276-0.224 0.5-0.5 0.5z" 38 | /> 39 | <path 40 | d="M4.5 9c-0.276 0-0.5-0.224-0.5-0.5v-1c0-0.276 0.224-0.5 0.5-0.5s0.5 0.224 0.5 0.5v1c0 0.276-0.224 0.5-0.5 0.5z" 41 | /> 42 | <path 43 | d="M4.5 12c-0.276 0-0.5-0.224-0.5-0.5v-1c0-0.276 0.224-0.5 0.5-0.5s0.5 0.224 0.5 0.5v1c0 0.276-0.224 0.5-0.5 0.5z" 44 | /> 45 | </svg> 46 | <p 47 | class="ml-2 font-bold" 48 | > 49 | 見つかりませんでした… 50 | </p> 51 | </div> 52 | <p 53 | class="mt-2 text-sm text-natural-gray" 54 | > 55 | アイドル・ユニット名が間違っているか、 56 | <a 57 | class="underline hover:text-imas transition-colors" 58 | href="https://sparql.crssnky.xyz/imas/" 59 | rel="noopener noreferrer" 60 | target="_blank" 61 | > 62 | im@sparql 63 | </a> 64 | にイメージカラーの情報が登録されていないようです。 65 | </p> 66 | </div> 67 | </div> 68 | `; 69 | -------------------------------------------------------------------------------- /components/molecules/not-found-card/index.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react' 2 | import NotFoundCard from './index' 3 | 4 | describe('NotFoundCard', () => { 5 | test('見た目が変化していないか', () => { 6 | const { container } = render(<NotFoundCard />) 7 | 8 | expect(container).toMatchSnapshot() 9 | }) 10 | }) 11 | -------------------------------------------------------------------------------- /components/molecules/not-found-card/index.tsx: -------------------------------------------------------------------------------- 1 | import { ImCrying } from 'react-icons/im' 2 | import Card from 'components/atoms/card' 3 | 4 | const NotFoundCard = () => ( 5 | <Card className="flex flex-col"> 6 | <div className="flex flex-row items-center"> 7 | <ImCrying /> 8 | <p className="ml-2 font-bold">見つかりませんでした…</p> 9 | </div> 10 | <p className="mt-2 text-sm text-natural-gray"> 11 | アイドル・ユニット名が間違っているか、 12 | <a 13 | className="underline hover:text-imas transition-colors" 14 | href="https://sparql.crssnky.xyz/imas/" 15 | target="_blank" 16 | rel="noopener noreferrer" 17 | > 18 | im@sparql 19 | </a> 20 | にイメージカラーの情報が登録されていないようです。 21 | </p> 22 | </Card> 23 | ) 24 | 25 | export default NotFoundCard 26 | -------------------------------------------------------------------------------- /components/organisms/button/keep.tsx: -------------------------------------------------------------------------------- 1 | import { FiBookmark } from 'react-icons/fi' 2 | import Button from 'components/atoms/button' 3 | 4 | type Props = { 5 | className?: string 6 | onClick: () => void 7 | } 8 | 9 | const KeepButton = ({ className = '', onClick }: Props) => ( 10 | <Button 11 | className={`inline-flex text-sm ${className}`} 12 | onClick={onClick} 13 | dataTestId="keep-button" 14 | > 15 | <FiBookmark /> 16 | <span className="ml-2 tracking-wide">Keep</span> 17 | </Button> 18 | ) 19 | 20 | export default KeepButton 21 | -------------------------------------------------------------------------------- /components/organisms/button/move-top.tsx: -------------------------------------------------------------------------------- 1 | import { IoIosArrowUp } from 'react-icons/io' 2 | import { animateScroll } from 'react-scroll' 3 | import Button from 'components/atoms/button' 4 | 5 | type Props = { 6 | className?: string 7 | } 8 | 9 | const MoveTopButton = ({ className = '' }: Props) => { 10 | const scrollToTop = () => animateScroll.scrollToTop() 11 | 12 | return ( 13 | <Button 14 | className={`inline-flex text-sm shadow-xl ${className}`} 15 | onClick={scrollToTop} 16 | > 17 | <IoIosArrowUp /> 18 | <span className="ml-2 tracking-wide">Top</span> 19 | </Button> 20 | ) 21 | } 22 | 23 | export default MoveTopButton 24 | -------------------------------------------------------------------------------- /components/organisms/button/remove.tsx: -------------------------------------------------------------------------------- 1 | import { MdRemoveCircleOutline } from 'react-icons/md' 2 | import Button from 'components/atoms/button' 3 | 4 | type Props = { 5 | className?: string 6 | onClick: () => void 7 | } 8 | 9 | const RemoveButton = ({ className = '', onClick }: Props) => ( 10 | <Button 11 | className={`inline-flex text-sm ${className}`} 12 | onClick={onClick} 13 | dataTestId="remove-button" 14 | > 15 | <MdRemoveCircleOutline /> 16 | <span className="ml-2 tracking-wide">Remove</span> 17 | </Button> 18 | ) 19 | 20 | export default RemoveButton 21 | -------------------------------------------------------------------------------- /components/organisms/color-card/__snapshots__/index.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`CardDefault Keepボタンを押したときにコールバックが呼ばれるか 1`] = ` 4 | <div> 5 | <div 6 | class="w-96 m-2 p-6 rounded-lg shadow-md bg-white h-64" 7 | > 8 | <div 9 | class="flex flex-row items-center" 10 | > 11 | <div 12 | class="flex-none w-16 h-16 rounded-xl border border-gray-200" 13 | style="background-color: rgb(234, 226, 141);" 14 | /> 15 | <div 16 | class="flex-1 ml-6" 17 | data-testid="name-label" 18 | > 19 | <p 20 | class="text-lg leading-6" 21 | > 22 | 相葉夕美 23 | </p> 24 | <p 25 | class="text-sm tracking-wide" 26 | > 27 | Yumi Aiba 28 | </p> 29 | </div> 30 | <button 31 | class="px-3 py-1 text-center items-center border border-natural-black text-white hover:text-natural-black bg-natural-black hover:bg-natural-white rounded-lg transition-colors inline-flex text-sm ml-4" 32 | data-testid="keep-button" 33 | > 34 | <svg 35 | fill="none" 36 | height="1em" 37 | stroke="currentColor" 38 | stroke-linecap="round" 39 | stroke-linejoin="round" 40 | stroke-width="2" 41 | viewBox="0 0 24 24" 42 | width="1em" 43 | xmlns="http://www.w3.org/2000/svg" 44 | > 45 | <path 46 | d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" 47 | /> 48 | </svg> 49 | <span 50 | class="ml-2 tracking-wide" 51 | > 52 | Keep 53 | </span> 54 | </button> 55 | </div> 56 | <div 57 | class="mt-6" 58 | > 59 | <div 60 | class="flex flex-row items-center " 61 | > 62 | <div 63 | class="flex-none w-16 py-1 text-center text-sm tracking-wide rounded-lg bg-gray-200" 64 | > 65 | RGB 66 | </div> 67 | <button 68 | class="flex-1 flex flex-row justify-end items-center text-sm tracking-wide cursor-pointer" 69 | data-testid="copy-button" 70 | title="クリックでコピー" 71 | > 72 | rgb(234, 226, 141) 73 | <span 74 | class="ml-2" 75 | > 76 | <svg 77 | fill="none" 78 | height="1em" 79 | stroke="currentColor" 80 | stroke-linecap="round" 81 | stroke-linejoin="round" 82 | stroke-width="2" 83 | viewBox="0 0 24 24" 84 | width="1em" 85 | xmlns="http://www.w3.org/2000/svg" 86 | > 87 | <rect 88 | height="13" 89 | rx="2" 90 | ry="2" 91 | width="13" 92 | x="9" 93 | y="9" 94 | /> 95 | <path 96 | d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" 97 | /> 98 | </svg> 99 | </span> 100 | </button> 101 | </div> 102 | <div 103 | class="flex flex-row items-center mt-4" 104 | > 105 | <div 106 | class="flex-none w-16 py-1 text-center text-sm tracking-wide rounded-lg bg-gray-200" 107 | > 108 | HSV 109 | </div> 110 | <button 111 | class="flex-1 flex flex-row justify-end items-center text-sm tracking-wide cursor-pointer" 112 | data-testid="copy-button" 113 | title="クリックでコピー" 114 | > 115 | hsv(55, 40, 92) 116 | <span 117 | class="ml-2" 118 | > 119 | <svg 120 | fill="none" 121 | height="1em" 122 | stroke="currentColor" 123 | stroke-linecap="round" 124 | stroke-linejoin="round" 125 | stroke-width="2" 126 | viewBox="0 0 24 24" 127 | width="1em" 128 | xmlns="http://www.w3.org/2000/svg" 129 | > 130 | <rect 131 | height="13" 132 | rx="2" 133 | ry="2" 134 | width="13" 135 | x="9" 136 | y="9" 137 | /> 138 | <path 139 | d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" 140 | /> 141 | </svg> 142 | </span> 143 | </button> 144 | </div> 145 | <div 146 | class="flex flex-row items-center mt-4" 147 | > 148 | <div 149 | class="flex-none w-16 py-1 text-center text-sm tracking-wide rounded-lg bg-gray-200" 150 | > 151 | HEX 152 | </div> 153 | <button 154 | class="flex-1 flex flex-row justify-end items-center text-sm tracking-wide cursor-pointer" 155 | data-testid="copy-button" 156 | title="クリックでコピー" 157 | > 158 | #EAE28D 159 | <span 160 | class="ml-2" 161 | > 162 | <svg 163 | fill="none" 164 | height="1em" 165 | stroke="currentColor" 166 | stroke-linecap="round" 167 | stroke-linejoin="round" 168 | stroke-width="2" 169 | viewBox="0 0 24 24" 170 | width="1em" 171 | xmlns="http://www.w3.org/2000/svg" 172 | > 173 | <rect 174 | height="13" 175 | rx="2" 176 | ry="2" 177 | width="13" 178 | x="9" 179 | y="9" 180 | /> 181 | <path 182 | d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" 183 | /> 184 | </svg> 185 | </span> 186 | </button> 187 | </div> 188 | </div> 189 | </div> 190 | </div> 191 | `; 192 | 193 | exports[`CardDefault Removeボタンを押したときにコールバックが呼ばれるか 1`] = ` 194 | <div> 195 | <div 196 | class="w-96 m-2 p-6 rounded-lg shadow-md bg-white h-64" 197 | > 198 | <div 199 | class="flex flex-row items-center" 200 | > 201 | <div 202 | class="flex-none w-16 h-16 rounded-xl border border-gray-200" 203 | style="background-color: rgb(234, 226, 141);" 204 | /> 205 | <div 206 | class="flex-1 ml-6" 207 | data-testid="name-label" 208 | > 209 | <p 210 | class="text-lg leading-6" 211 | > 212 | 相葉夕美 213 | </p> 214 | <p 215 | class="text-sm tracking-wide" 216 | > 217 | Yumi Aiba 218 | </p> 219 | </div> 220 | <button 221 | class="px-3 py-1 text-center items-center border border-natural-black text-white hover:text-natural-black bg-natural-black hover:bg-natural-white rounded-lg transition-colors inline-flex text-sm ml-4" 222 | data-testid="remove-button" 223 | > 224 | <svg 225 | fill="currentColor" 226 | height="1em" 227 | stroke="currentColor" 228 | stroke-width="0" 229 | viewBox="0 0 24 24" 230 | width="1em" 231 | xmlns="http://www.w3.org/2000/svg" 232 | > 233 | <path 234 | d="M0 0h24v24H0z" 235 | fill="none" 236 | /> 237 | <path 238 | d="M7 11v2h10v-2H7zm5-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z" 239 | /> 240 | </svg> 241 | <span 242 | class="ml-2 tracking-wide" 243 | > 244 | Remove 245 | </span> 246 | </button> 247 | </div> 248 | <div 249 | class="mt-6" 250 | > 251 | <div 252 | class="flex flex-row items-center " 253 | > 254 | <div 255 | class="flex-none w-16 py-1 text-center text-sm tracking-wide rounded-lg bg-gray-200" 256 | > 257 | RGB 258 | </div> 259 | <button 260 | class="flex-1 flex flex-row justify-end items-center text-sm tracking-wide cursor-pointer" 261 | data-testid="copy-button" 262 | title="クリックでコピー" 263 | > 264 | rgb(234, 226, 141) 265 | <span 266 | class="ml-2" 267 | > 268 | <svg 269 | fill="none" 270 | height="1em" 271 | stroke="currentColor" 272 | stroke-linecap="round" 273 | stroke-linejoin="round" 274 | stroke-width="2" 275 | viewBox="0 0 24 24" 276 | width="1em" 277 | xmlns="http://www.w3.org/2000/svg" 278 | > 279 | <rect 280 | height="13" 281 | rx="2" 282 | ry="2" 283 | width="13" 284 | x="9" 285 | y="9" 286 | /> 287 | <path 288 | d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" 289 | /> 290 | </svg> 291 | </span> 292 | </button> 293 | </div> 294 | <div 295 | class="flex flex-row items-center mt-4" 296 | > 297 | <div 298 | class="flex-none w-16 py-1 text-center text-sm tracking-wide rounded-lg bg-gray-200" 299 | > 300 | HSV 301 | </div> 302 | <button 303 | class="flex-1 flex flex-row justify-end items-center text-sm tracking-wide cursor-pointer" 304 | data-testid="copy-button" 305 | title="クリックでコピー" 306 | > 307 | hsv(55, 40, 92) 308 | <span 309 | class="ml-2" 310 | > 311 | <svg 312 | fill="none" 313 | height="1em" 314 | stroke="currentColor" 315 | stroke-linecap="round" 316 | stroke-linejoin="round" 317 | stroke-width="2" 318 | viewBox="0 0 24 24" 319 | width="1em" 320 | xmlns="http://www.w3.org/2000/svg" 321 | > 322 | <rect 323 | height="13" 324 | rx="2" 325 | ry="2" 326 | width="13" 327 | x="9" 328 | y="9" 329 | /> 330 | <path 331 | d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" 332 | /> 333 | </svg> 334 | </span> 335 | </button> 336 | </div> 337 | <div 338 | class="flex flex-row items-center mt-4" 339 | > 340 | <div 341 | class="flex-none w-16 py-1 text-center text-sm tracking-wide rounded-lg bg-gray-200" 342 | > 343 | HEX 344 | </div> 345 | <button 346 | class="flex-1 flex flex-row justify-end items-center text-sm tracking-wide cursor-pointer" 347 | data-testid="copy-button" 348 | title="クリックでコピー" 349 | > 350 | #EAE28D 351 | <span 352 | class="ml-2" 353 | > 354 | <svg 355 | fill="none" 356 | height="1em" 357 | stroke="currentColor" 358 | stroke-linecap="round" 359 | stroke-linejoin="round" 360 | stroke-width="2" 361 | viewBox="0 0 24 24" 362 | width="1em" 363 | xmlns="http://www.w3.org/2000/svg" 364 | > 365 | <rect 366 | height="13" 367 | rx="2" 368 | ry="2" 369 | width="13" 370 | x="9" 371 | y="9" 372 | /> 373 | <path 374 | d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" 375 | /> 376 | </svg> 377 | </span> 378 | </button> 379 | </div> 380 | </div> 381 | </div> 382 | </div> 383 | `; 384 | -------------------------------------------------------------------------------- /components/organisms/color-card/__snapshots__/mobile.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`CardDefault Keepボタンを押したときにコールバックが呼ばれるか 1`] = ` 4 | <div> 5 | <div 6 | class="w-96 m-2 p-6 rounded-lg shadow-md bg-white " 7 | > 8 | <div 9 | class="flex flex-row items-center" 10 | > 11 | <div 12 | class="flex-none w-16 h-16 rounded-xl border border-gray-200" 13 | style="background-color: rgb(234, 226, 141);" 14 | /> 15 | <div 16 | class="flex-1 ml-6" 17 | data-testid="name-label" 18 | > 19 | <p 20 | class="text-lg leading-6" 21 | > 22 | 相葉夕美 23 | </p> 24 | <p 25 | class="text-sm tracking-wide" 26 | > 27 | Yumi Aiba 28 | </p> 29 | </div> 30 | </div> 31 | <div 32 | class="mt-6" 33 | > 34 | <div 35 | class="flex flex-row items-center " 36 | > 37 | <div 38 | class="flex-none w-16 py-1 text-center text-sm tracking-wide rounded-lg bg-gray-200" 39 | > 40 | RGB 41 | </div> 42 | <button 43 | class="flex-1 flex flex-row justify-end items-center text-sm tracking-wide cursor-pointer" 44 | data-testid="copy-button" 45 | title="クリックでコピー" 46 | > 47 | rgb(234, 226, 141) 48 | <span 49 | class="ml-2" 50 | > 51 | <svg 52 | fill="none" 53 | height="1em" 54 | stroke="currentColor" 55 | stroke-linecap="round" 56 | stroke-linejoin="round" 57 | stroke-width="2" 58 | viewBox="0 0 24 24" 59 | width="1em" 60 | xmlns="http://www.w3.org/2000/svg" 61 | > 62 | <rect 63 | height="13" 64 | rx="2" 65 | ry="2" 66 | width="13" 67 | x="9" 68 | y="9" 69 | /> 70 | <path 71 | d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" 72 | /> 73 | </svg> 74 | </span> 75 | </button> 76 | </div> 77 | <div 78 | class="flex flex-row items-center mt-4" 79 | > 80 | <div 81 | class="flex-none w-16 py-1 text-center text-sm tracking-wide rounded-lg bg-gray-200" 82 | > 83 | HSV 84 | </div> 85 | <button 86 | class="flex-1 flex flex-row justify-end items-center text-sm tracking-wide cursor-pointer" 87 | data-testid="copy-button" 88 | title="クリックでコピー" 89 | > 90 | hsv(55, 40, 92) 91 | <span 92 | class="ml-2" 93 | > 94 | <svg 95 | fill="none" 96 | height="1em" 97 | stroke="currentColor" 98 | stroke-linecap="round" 99 | stroke-linejoin="round" 100 | stroke-width="2" 101 | viewBox="0 0 24 24" 102 | width="1em" 103 | xmlns="http://www.w3.org/2000/svg" 104 | > 105 | <rect 106 | height="13" 107 | rx="2" 108 | ry="2" 109 | width="13" 110 | x="9" 111 | y="9" 112 | /> 113 | <path 114 | d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" 115 | /> 116 | </svg> 117 | </span> 118 | </button> 119 | </div> 120 | <div 121 | class="flex flex-row items-center mt-4" 122 | > 123 | <div 124 | class="flex-none w-16 py-1 text-center text-sm tracking-wide rounded-lg bg-gray-200" 125 | > 126 | HEX 127 | </div> 128 | <button 129 | class="flex-1 flex flex-row justify-end items-center text-sm tracking-wide cursor-pointer" 130 | data-testid="copy-button" 131 | title="クリックでコピー" 132 | > 133 | #EAE28D 134 | <span 135 | class="ml-2" 136 | > 137 | <svg 138 | fill="none" 139 | height="1em" 140 | stroke="currentColor" 141 | stroke-linecap="round" 142 | stroke-linejoin="round" 143 | stroke-width="2" 144 | viewBox="0 0 24 24" 145 | width="1em" 146 | xmlns="http://www.w3.org/2000/svg" 147 | > 148 | <rect 149 | height="13" 150 | rx="2" 151 | ry="2" 152 | width="13" 153 | x="9" 154 | y="9" 155 | /> 156 | <path 157 | d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" 158 | /> 159 | </svg> 160 | </span> 161 | </button> 162 | </div> 163 | </div> 164 | <button 165 | class="px-3 py-1 text-center items-center border border-natural-black text-white hover:text-natural-black bg-natural-black hover:bg-natural-white rounded-lg transition-colors inline-flex text-sm mt-6 w-full justify-center" 166 | data-testid="keep-button" 167 | > 168 | <svg 169 | fill="none" 170 | height="1em" 171 | stroke="currentColor" 172 | stroke-linecap="round" 173 | stroke-linejoin="round" 174 | stroke-width="2" 175 | viewBox="0 0 24 24" 176 | width="1em" 177 | xmlns="http://www.w3.org/2000/svg" 178 | > 179 | <path 180 | d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" 181 | /> 182 | </svg> 183 | <span 184 | class="ml-2 tracking-wide" 185 | > 186 | Keep 187 | </span> 188 | </button> 189 | </div> 190 | </div> 191 | `; 192 | 193 | exports[`CardDefault Removeボタンを押したときにコールバックが呼ばれるか 1`] = ` 194 | <div> 195 | <div 196 | class="w-96 m-2 p-6 rounded-lg shadow-md bg-white " 197 | > 198 | <div 199 | class="flex flex-row items-center" 200 | > 201 | <div 202 | class="flex-none w-16 h-16 rounded-xl border border-gray-200" 203 | style="background-color: rgb(234, 226, 141);" 204 | /> 205 | <div 206 | class="flex-1 ml-6" 207 | data-testid="name-label" 208 | > 209 | <p 210 | class="text-lg leading-6" 211 | > 212 | 相葉夕美 213 | </p> 214 | <p 215 | class="text-sm tracking-wide" 216 | > 217 | Yumi Aiba 218 | </p> 219 | </div> 220 | </div> 221 | <div 222 | class="mt-6" 223 | > 224 | <div 225 | class="flex flex-row items-center " 226 | > 227 | <div 228 | class="flex-none w-16 py-1 text-center text-sm tracking-wide rounded-lg bg-gray-200" 229 | > 230 | RGB 231 | </div> 232 | <button 233 | class="flex-1 flex flex-row justify-end items-center text-sm tracking-wide cursor-pointer" 234 | data-testid="copy-button" 235 | title="クリックでコピー" 236 | > 237 | rgb(234, 226, 141) 238 | <span 239 | class="ml-2" 240 | > 241 | <svg 242 | fill="none" 243 | height="1em" 244 | stroke="currentColor" 245 | stroke-linecap="round" 246 | stroke-linejoin="round" 247 | stroke-width="2" 248 | viewBox="0 0 24 24" 249 | width="1em" 250 | xmlns="http://www.w3.org/2000/svg" 251 | > 252 | <rect 253 | height="13" 254 | rx="2" 255 | ry="2" 256 | width="13" 257 | x="9" 258 | y="9" 259 | /> 260 | <path 261 | d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" 262 | /> 263 | </svg> 264 | </span> 265 | </button> 266 | </div> 267 | <div 268 | class="flex flex-row items-center mt-4" 269 | > 270 | <div 271 | class="flex-none w-16 py-1 text-center text-sm tracking-wide rounded-lg bg-gray-200" 272 | > 273 | HSV 274 | </div> 275 | <button 276 | class="flex-1 flex flex-row justify-end items-center text-sm tracking-wide cursor-pointer" 277 | data-testid="copy-button" 278 | title="クリックでコピー" 279 | > 280 | hsv(55, 40, 92) 281 | <span 282 | class="ml-2" 283 | > 284 | <svg 285 | fill="none" 286 | height="1em" 287 | stroke="currentColor" 288 | stroke-linecap="round" 289 | stroke-linejoin="round" 290 | stroke-width="2" 291 | viewBox="0 0 24 24" 292 | width="1em" 293 | xmlns="http://www.w3.org/2000/svg" 294 | > 295 | <rect 296 | height="13" 297 | rx="2" 298 | ry="2" 299 | width="13" 300 | x="9" 301 | y="9" 302 | /> 303 | <path 304 | d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" 305 | /> 306 | </svg> 307 | </span> 308 | </button> 309 | </div> 310 | <div 311 | class="flex flex-row items-center mt-4" 312 | > 313 | <div 314 | class="flex-none w-16 py-1 text-center text-sm tracking-wide rounded-lg bg-gray-200" 315 | > 316 | HEX 317 | </div> 318 | <button 319 | class="flex-1 flex flex-row justify-end items-center text-sm tracking-wide cursor-pointer" 320 | data-testid="copy-button" 321 | title="クリックでコピー" 322 | > 323 | #EAE28D 324 | <span 325 | class="ml-2" 326 | > 327 | <svg 328 | fill="none" 329 | height="1em" 330 | stroke="currentColor" 331 | stroke-linecap="round" 332 | stroke-linejoin="round" 333 | stroke-width="2" 334 | viewBox="0 0 24 24" 335 | width="1em" 336 | xmlns="http://www.w3.org/2000/svg" 337 | > 338 | <rect 339 | height="13" 340 | rx="2" 341 | ry="2" 342 | width="13" 343 | x="9" 344 | y="9" 345 | /> 346 | <path 347 | d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" 348 | /> 349 | </svg> 350 | </span> 351 | </button> 352 | </div> 353 | </div> 354 | <button 355 | class="px-3 py-1 text-center items-center border border-natural-black text-white hover:text-natural-black bg-natural-black hover:bg-natural-white rounded-lg transition-colors inline-flex text-sm mt-6 w-full justify-center" 356 | data-testid="remove-button" 357 | > 358 | <svg 359 | fill="currentColor" 360 | height="1em" 361 | stroke="currentColor" 362 | stroke-width="0" 363 | viewBox="0 0 24 24" 364 | width="1em" 365 | xmlns="http://www.w3.org/2000/svg" 366 | > 367 | <path 368 | d="M0 0h24v24H0z" 369 | fill="none" 370 | /> 371 | <path 372 | d="M7 11v2h10v-2H7zm5-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z" 373 | /> 374 | </svg> 375 | <span 376 | class="ml-2 tracking-wide" 377 | > 378 | Remove 379 | </span> 380 | </button> 381 | </div> 382 | </div> 383 | `; 384 | -------------------------------------------------------------------------------- /components/organisms/color-card/index.test.tsx: -------------------------------------------------------------------------------- 1 | import { act, fireEvent, render } from '@testing-library/react' 2 | import { ColorDetail } from 'types/color-detail' 3 | import CardDefault from './index' 4 | 5 | describe('CardDefault', () => { 6 | const idol: ColorDetail = { 7 | id: 'yumi_aiba_cinderellagirls', 8 | name: '相葉夕美', 9 | nameSuppl: 'Yumi Aiba', 10 | nameKana: 'あいばゆみ', 11 | brand: 'CinderellaGirls', 12 | color: { 13 | rgb: 'rgb(234, 226, 141)', 14 | hsv: 'hsv(55, 40, 92)', 15 | hex: '#EAE28D', 16 | similar: '#ffff00' 17 | } 18 | } 19 | 20 | test('Keepボタンを押したときにコールバックが呼ばれるか', () => { 21 | const mock = jest.fn() 22 | const { container, getByTestId } = render( 23 | <CardDefault 24 | data={idol} 25 | isKeep={false} 26 | onClickKeep={mock} 27 | onClickRemove={mock} 28 | /> 29 | ) 30 | 31 | act(() => { 32 | fireEvent.click(getByTestId('keep-button')) 33 | }) 34 | 35 | expect(container).toMatchSnapshot() 36 | expect(mock).toHaveBeenCalled() 37 | }) 38 | 39 | test('Removeボタンを押したときにコールバックが呼ばれるか', () => { 40 | const mock = jest.fn() 41 | const { container, getByTestId } = render( 42 | <CardDefault 43 | data={idol} 44 | isKeep={true} 45 | onClickKeep={mock} 46 | onClickRemove={mock} 47 | /> 48 | ) 49 | 50 | act(() => { 51 | fireEvent.click(getByTestId('remove-button')) 52 | }) 53 | 54 | expect(container).toMatchSnapshot() 55 | expect(mock).toHaveBeenCalled() 56 | }) 57 | }) 58 | -------------------------------------------------------------------------------- /components/organisms/color-card/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Card from 'components/atoms/card' 3 | import CardTitle from 'components/molecules/card-title' 4 | import KeepButton from 'components/organisms/button/keep' 5 | import RemoveButton from 'components/organisms/button/remove' 6 | import ColorInfo from 'components/organisms/color-info' 7 | import { Props } from './props' 8 | 9 | const CardDefault = ({ data, isKeep, onClickKeep, onClickRemove }: Props) => ( 10 | <Card className="h-64"> 11 | <CardTitle name={data.name} nameSuppl={data.nameSuppl} hex={data.color.hex}> 12 | {isKeep ? ( 13 | <RemoveButton className="ml-4" onClick={onClickRemove} /> 14 | ) : ( 15 | <KeepButton className="ml-4" onClick={onClickKeep} /> 16 | )} 17 | </CardTitle> 18 | <ColorInfo color={data.color} /> 19 | </Card> 20 | ) 21 | 22 | export default React.memo( 23 | CardDefault, 24 | (prev, next) => 25 | prev.isKeep === next.isKeep && 26 | prev.onClickKeep === next.onClickKeep && 27 | prev.onClickRemove === next.onClickRemove 28 | ) 29 | -------------------------------------------------------------------------------- /components/organisms/color-card/mobile.test.tsx: -------------------------------------------------------------------------------- 1 | import { act, fireEvent, render } from '@testing-library/react' 2 | import { ColorDetail } from 'types/color-detail' 3 | import CardMobile from './mobile' 4 | 5 | describe('CardDefault', () => { 6 | const idol: ColorDetail = { 7 | id: 'yumi_aiba_cinderellagirls', 8 | name: '相葉夕美', 9 | nameSuppl: 'Yumi Aiba', 10 | nameKana: 'あいばゆみ', 11 | brand: 'CinderellaGirls', 12 | color: { 13 | rgb: 'rgb(234, 226, 141)', 14 | hsv: 'hsv(55, 40, 92)', 15 | hex: '#EAE28D', 16 | similar: '#ffff00' 17 | } 18 | } 19 | 20 | test('Keepボタンを押したときにコールバックが呼ばれるか', () => { 21 | const mock = jest.fn() 22 | const { container, getByTestId } = render( 23 | <CardMobile 24 | data={idol} 25 | isKeep={false} 26 | onClickKeep={mock} 27 | onClickRemove={mock} 28 | /> 29 | ) 30 | 31 | act(() => { 32 | fireEvent.click(getByTestId('keep-button')) 33 | }) 34 | 35 | expect(container).toMatchSnapshot() 36 | expect(mock).toHaveBeenCalled() 37 | }) 38 | 39 | test('Removeボタンを押したときにコールバックが呼ばれるか', () => { 40 | const mock = jest.fn() 41 | const { container, getByTestId } = render( 42 | <CardMobile 43 | data={idol} 44 | isKeep={true} 45 | onClickKeep={mock} 46 | onClickRemove={mock} 47 | /> 48 | ) 49 | 50 | act(() => { 51 | fireEvent.click(getByTestId('remove-button')) 52 | }) 53 | 54 | expect(container).toMatchSnapshot() 55 | expect(mock).toHaveBeenCalled() 56 | }) 57 | }) 58 | -------------------------------------------------------------------------------- /components/organisms/color-card/mobile.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Card from 'components/atoms/card' 3 | import CardTitle from 'components/molecules/card-title' 4 | import KeepButton from 'components/organisms/button/keep' 5 | import RemoveButton from 'components/organisms/button/remove' 6 | import ColorInfo from 'components/organisms/color-info' 7 | import { Props } from './props' 8 | 9 | const CardMobile = ({ data, isKeep, onClickKeep, onClickRemove }: Props) => ( 10 | <Card> 11 | <CardTitle 12 | name={data.name} 13 | nameSuppl={data.nameSuppl} 14 | hex={data.color.hex} 15 | /> 16 | <ColorInfo color={data.color} /> 17 | {isKeep ? ( 18 | <RemoveButton 19 | className="mt-6 w-full justify-center" 20 | onClick={onClickRemove} 21 | /> 22 | ) : ( 23 | <KeepButton 24 | className="mt-6 w-full justify-center" 25 | onClick={onClickKeep} 26 | /> 27 | )} 28 | </Card> 29 | ) 30 | 31 | export default React.memo( 32 | CardMobile, 33 | (prev, next) => 34 | prev.isKeep === next.isKeep && 35 | prev.onClickKeep === next.onClickKeep && 36 | prev.onClickRemove === next.onClickRemove 37 | ) 38 | -------------------------------------------------------------------------------- /components/organisms/color-card/props.ts: -------------------------------------------------------------------------------- 1 | import { ColorDetail } from 'types/color-detail' 2 | 3 | export type Props = { 4 | data: ColorDetail 5 | isKeep: boolean 6 | onClickKeep: () => void 7 | onClickRemove: () => void 8 | } 9 | -------------------------------------------------------------------------------- /components/organisms/color-cards/__snapshots__/index.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`ColorCards 見た目が変化していないか 1`] = ` 4 | <div> 5 | <div 6 | class="flex flex-row flex-wrap justify-center " 7 | data-testid="card-area" 8 | > 9 | <div 10 | class="w-96 m-2 p-6 rounded-lg shadow-md bg-white h-64" 11 | > 12 | <div 13 | class="flex flex-row items-center" 14 | > 15 | <div 16 | class="flex-none w-16 h-16 rounded-xl border border-gray-200" 17 | style="background-color: rgb(59, 145, 196);" 18 | /> 19 | <div 20 | class="flex-1 ml-6" 21 | data-testid="name-label" 22 | > 23 | <p 24 | class="text-lg leading-6" 25 | > 26 | 三峰結華 27 | </p> 28 | <p 29 | class="text-sm tracking-wide" 30 | > 31 | Yuika Mitsumine 32 | </p> 33 | </div> 34 | <button 35 | class="px-3 py-1 text-center items-center border border-natural-black text-white hover:text-natural-black bg-natural-black hover:bg-natural-white rounded-lg transition-colors inline-flex text-sm ml-4" 36 | data-testid="keep-button" 37 | > 38 | <svg 39 | fill="none" 40 | height="1em" 41 | stroke="currentColor" 42 | stroke-linecap="round" 43 | stroke-linejoin="round" 44 | stroke-width="2" 45 | viewBox="0 0 24 24" 46 | width="1em" 47 | xmlns="http://www.w3.org/2000/svg" 48 | > 49 | <path 50 | d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" 51 | /> 52 | </svg> 53 | <span 54 | class="ml-2 tracking-wide" 55 | > 56 | Keep 57 | </span> 58 | </button> 59 | </div> 60 | <div 61 | class="mt-6" 62 | > 63 | <div 64 | class="flex flex-row items-center " 65 | > 66 | <div 67 | class="flex-none w-16 py-1 text-center text-sm tracking-wide rounded-lg bg-gray-200" 68 | > 69 | RGB 70 | </div> 71 | <button 72 | class="flex-1 flex flex-row justify-end items-center text-sm tracking-wide cursor-pointer" 73 | data-testid="copy-button" 74 | title="クリックでコピー" 75 | > 76 | rgb(59, 145, 196) 77 | <span 78 | class="ml-2" 79 | > 80 | <svg 81 | fill="none" 82 | height="1em" 83 | stroke="currentColor" 84 | stroke-linecap="round" 85 | stroke-linejoin="round" 86 | stroke-width="2" 87 | viewBox="0 0 24 24" 88 | width="1em" 89 | xmlns="http://www.w3.org/2000/svg" 90 | > 91 | <rect 92 | height="13" 93 | rx="2" 94 | ry="2" 95 | width="13" 96 | x="9" 97 | y="9" 98 | /> 99 | <path 100 | d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" 101 | /> 102 | </svg> 103 | </span> 104 | </button> 105 | </div> 106 | <div 107 | class="flex flex-row items-center mt-4" 108 | > 109 | <div 110 | class="flex-none w-16 py-1 text-center text-sm tracking-wide rounded-lg bg-gray-200" 111 | > 112 | HSV 113 | </div> 114 | <button 115 | class="flex-1 flex flex-row justify-end items-center text-sm tracking-wide cursor-pointer" 116 | data-testid="copy-button" 117 | title="クリックでコピー" 118 | > 119 | hsv(202, 70, 77) 120 | <span 121 | class="ml-2" 122 | > 123 | <svg 124 | fill="none" 125 | height="1em" 126 | stroke="currentColor" 127 | stroke-linecap="round" 128 | stroke-linejoin="round" 129 | stroke-width="2" 130 | viewBox="0 0 24 24" 131 | width="1em" 132 | xmlns="http://www.w3.org/2000/svg" 133 | > 134 | <rect 135 | height="13" 136 | rx="2" 137 | ry="2" 138 | width="13" 139 | x="9" 140 | y="9" 141 | /> 142 | <path 143 | d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" 144 | /> 145 | </svg> 146 | </span> 147 | </button> 148 | </div> 149 | <div 150 | class="flex flex-row items-center mt-4" 151 | > 152 | <div 153 | class="flex-none w-16 py-1 text-center text-sm tracking-wide rounded-lg bg-gray-200" 154 | > 155 | HEX 156 | </div> 157 | <button 158 | class="flex-1 flex flex-row justify-end items-center text-sm tracking-wide cursor-pointer" 159 | data-testid="copy-button" 160 | title="クリックでコピー" 161 | > 162 | #3B91C4 163 | <span 164 | class="ml-2" 165 | > 166 | <svg 167 | fill="none" 168 | height="1em" 169 | stroke="currentColor" 170 | stroke-linecap="round" 171 | stroke-linejoin="round" 172 | stroke-width="2" 173 | viewBox="0 0 24 24" 174 | width="1em" 175 | xmlns="http://www.w3.org/2000/svg" 176 | > 177 | <rect 178 | height="13" 179 | rx="2" 180 | ry="2" 181 | width="13" 182 | x="9" 183 | y="9" 184 | /> 185 | <path 186 | d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" 187 | /> 188 | </svg> 189 | </span> 190 | </button> 191 | </div> 192 | </div> 193 | </div> 194 | <div 195 | class="w-96 m-2 p-6 rounded-lg shadow-md bg-white h-64" 196 | > 197 | <div 198 | class="flex flex-row items-center" 199 | > 200 | <div 201 | class="flex-none w-16 h-16 rounded-xl border border-gray-200" 202 | style="background-color: rgb(253, 153, 225);" 203 | /> 204 | <div 205 | class="flex-1 ml-6" 206 | data-testid="name-label" 207 | > 208 | <p 209 | class="text-lg leading-6" 210 | > 211 | 水瀬伊織 212 | </p> 213 | <p 214 | class="text-sm tracking-wide" 215 | > 216 | Iori Minase 217 | </p> 218 | </div> 219 | <button 220 | class="px-3 py-1 text-center items-center border border-natural-black text-white hover:text-natural-black bg-natural-black hover:bg-natural-white rounded-lg transition-colors inline-flex text-sm ml-4" 221 | data-testid="keep-button" 222 | > 223 | <svg 224 | fill="none" 225 | height="1em" 226 | stroke="currentColor" 227 | stroke-linecap="round" 228 | stroke-linejoin="round" 229 | stroke-width="2" 230 | viewBox="0 0 24 24" 231 | width="1em" 232 | xmlns="http://www.w3.org/2000/svg" 233 | > 234 | <path 235 | d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" 236 | /> 237 | </svg> 238 | <span 239 | class="ml-2 tracking-wide" 240 | > 241 | Keep 242 | </span> 243 | </button> 244 | </div> 245 | <div 246 | class="mt-6" 247 | > 248 | <div 249 | class="flex flex-row items-center " 250 | > 251 | <div 252 | class="flex-none w-16 py-1 text-center text-sm tracking-wide rounded-lg bg-gray-200" 253 | > 254 | RGB 255 | </div> 256 | <button 257 | class="flex-1 flex flex-row justify-end items-center text-sm tracking-wide cursor-pointer" 258 | data-testid="copy-button" 259 | title="クリックでコピー" 260 | > 261 | rgb(253, 153, 225) 262 | <span 263 | class="ml-2" 264 | > 265 | <svg 266 | fill="none" 267 | height="1em" 268 | stroke="currentColor" 269 | stroke-linecap="round" 270 | stroke-linejoin="round" 271 | stroke-width="2" 272 | viewBox="0 0 24 24" 273 | width="1em" 274 | xmlns="http://www.w3.org/2000/svg" 275 | > 276 | <rect 277 | height="13" 278 | rx="2" 279 | ry="2" 280 | width="13" 281 | x="9" 282 | y="9" 283 | /> 284 | <path 285 | d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" 286 | /> 287 | </svg> 288 | </span> 289 | </button> 290 | </div> 291 | <div 292 | class="flex flex-row items-center mt-4" 293 | > 294 | <div 295 | class="flex-none w-16 py-1 text-center text-sm tracking-wide rounded-lg bg-gray-200" 296 | > 297 | HSV 298 | </div> 299 | <button 300 | class="flex-1 flex flex-row justify-end items-center text-sm tracking-wide cursor-pointer" 301 | data-testid="copy-button" 302 | title="クリックでコピー" 303 | > 304 | hsv(317, 40, 99) 305 | <span 306 | class="ml-2" 307 | > 308 | <svg 309 | fill="none" 310 | height="1em" 311 | stroke="currentColor" 312 | stroke-linecap="round" 313 | stroke-linejoin="round" 314 | stroke-width="2" 315 | viewBox="0 0 24 24" 316 | width="1em" 317 | xmlns="http://www.w3.org/2000/svg" 318 | > 319 | <rect 320 | height="13" 321 | rx="2" 322 | ry="2" 323 | width="13" 324 | x="9" 325 | y="9" 326 | /> 327 | <path 328 | d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" 329 | /> 330 | </svg> 331 | </span> 332 | </button> 333 | </div> 334 | <div 335 | class="flex flex-row items-center mt-4" 336 | > 337 | <div 338 | class="flex-none w-16 py-1 text-center text-sm tracking-wide rounded-lg bg-gray-200" 339 | > 340 | HEX 341 | </div> 342 | <button 343 | class="flex-1 flex flex-row justify-end items-center text-sm tracking-wide cursor-pointer" 344 | data-testid="copy-button" 345 | title="クリックでコピー" 346 | > 347 | #FD99E1 348 | <span 349 | class="ml-2" 350 | > 351 | <svg 352 | fill="none" 353 | height="1em" 354 | stroke="currentColor" 355 | stroke-linecap="round" 356 | stroke-linejoin="round" 357 | stroke-width="2" 358 | viewBox="0 0 24 24" 359 | width="1em" 360 | xmlns="http://www.w3.org/2000/svg" 361 | > 362 | <rect 363 | height="13" 364 | rx="2" 365 | ry="2" 366 | width="13" 367 | x="9" 368 | y="9" 369 | /> 370 | <path 371 | d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" 372 | /> 373 | </svg> 374 | </span> 375 | </button> 376 | </div> 377 | </div> 378 | </div> 379 | <div 380 | class="w-96 m-2 p-6 rounded-lg shadow-md bg-white h-64" 381 | > 382 | <div 383 | class="flex flex-row items-center" 384 | > 385 | <div 386 | class="flex-none w-16 h-16 rounded-xl border border-gray-200" 387 | style="background-color: rgb(1, 170, 165);" 388 | /> 389 | <div 390 | class="flex-1 ml-6" 391 | data-testid="name-label" 392 | > 393 | <p 394 | class="text-lg leading-6" 395 | > 396 | 三船美優 397 | </p> 398 | <p 399 | class="text-sm tracking-wide" 400 | > 401 | Miyu Mifune 402 | </p> 403 | </div> 404 | <button 405 | class="px-3 py-1 text-center items-center border border-natural-black text-white hover:text-natural-black bg-natural-black hover:bg-natural-white rounded-lg transition-colors inline-flex text-sm ml-4" 406 | data-testid="keep-button" 407 | > 408 | <svg 409 | fill="none" 410 | height="1em" 411 | stroke="currentColor" 412 | stroke-linecap="round" 413 | stroke-linejoin="round" 414 | stroke-width="2" 415 | viewBox="0 0 24 24" 416 | width="1em" 417 | xmlns="http://www.w3.org/2000/svg" 418 | > 419 | <path 420 | d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" 421 | /> 422 | </svg> 423 | <span 424 | class="ml-2 tracking-wide" 425 | > 426 | Keep 427 | </span> 428 | </button> 429 | </div> 430 | <div 431 | class="mt-6" 432 | > 433 | <div 434 | class="flex flex-row items-center " 435 | > 436 | <div 437 | class="flex-none w-16 py-1 text-center text-sm tracking-wide rounded-lg bg-gray-200" 438 | > 439 | RGB 440 | </div> 441 | <button 442 | class="flex-1 flex flex-row justify-end items-center text-sm tracking-wide cursor-pointer" 443 | data-testid="copy-button" 444 | title="クリックでコピー" 445 | > 446 | rgb(1, 170, 165) 447 | <span 448 | class="ml-2" 449 | > 450 | <svg 451 | fill="none" 452 | height="1em" 453 | stroke="currentColor" 454 | stroke-linecap="round" 455 | stroke-linejoin="round" 456 | stroke-width="2" 457 | viewBox="0 0 24 24" 458 | width="1em" 459 | xmlns="http://www.w3.org/2000/svg" 460 | > 461 | <rect 462 | height="13" 463 | rx="2" 464 | ry="2" 465 | width="13" 466 | x="9" 467 | y="9" 468 | /> 469 | <path 470 | d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" 471 | /> 472 | </svg> 473 | </span> 474 | </button> 475 | </div> 476 | <div 477 | class="flex flex-row items-center mt-4" 478 | > 479 | <div 480 | class="flex-none w-16 py-1 text-center text-sm tracking-wide rounded-lg bg-gray-200" 481 | > 482 | HSV 483 | </div> 484 | <button 485 | class="flex-1 flex flex-row justify-end items-center text-sm tracking-wide cursor-pointer" 486 | data-testid="copy-button" 487 | title="クリックでコピー" 488 | > 489 | hsv(178, 99, 67) 490 | <span 491 | class="ml-2" 492 | > 493 | <svg 494 | fill="none" 495 | height="1em" 496 | stroke="currentColor" 497 | stroke-linecap="round" 498 | stroke-linejoin="round" 499 | stroke-width="2" 500 | viewBox="0 0 24 24" 501 | width="1em" 502 | xmlns="http://www.w3.org/2000/svg" 503 | > 504 | <rect 505 | height="13" 506 | rx="2" 507 | ry="2" 508 | width="13" 509 | x="9" 510 | y="9" 511 | /> 512 | <path 513 | d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" 514 | /> 515 | </svg> 516 | </span> 517 | </button> 518 | </div> 519 | <div 520 | class="flex flex-row items-center mt-4" 521 | > 522 | <div 523 | class="flex-none w-16 py-1 text-center text-sm tracking-wide rounded-lg bg-gray-200" 524 | > 525 | HEX 526 | </div> 527 | <button 528 | class="flex-1 flex flex-row justify-end items-center text-sm tracking-wide cursor-pointer" 529 | data-testid="copy-button" 530 | title="クリックでコピー" 531 | > 532 | #01AAA5 533 | <span 534 | class="ml-2" 535 | > 536 | <svg 537 | fill="none" 538 | height="1em" 539 | stroke="currentColor" 540 | stroke-linecap="round" 541 | stroke-linejoin="round" 542 | stroke-width="2" 543 | viewBox="0 0 24 24" 544 | width="1em" 545 | xmlns="http://www.w3.org/2000/svg" 546 | > 547 | <rect 548 | height="13" 549 | rx="2" 550 | ry="2" 551 | width="13" 552 | x="9" 553 | y="9" 554 | /> 555 | <path 556 | d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" 557 | /> 558 | </svg> 559 | </span> 560 | </button> 561 | </div> 562 | </div> 563 | </div> 564 | </div> 565 | </div> 566 | `; 567 | -------------------------------------------------------------------------------- /components/organisms/color-cards/index.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react' 2 | import { ColorDetail } from 'types/color-detail' 3 | import ColorCards from './index' 4 | 5 | describe('ColorCards', () => { 6 | const props = { 7 | className: '', 8 | keepIdList: [], 9 | onAddKeepId: jest.fn(), 10 | onRemoveKeepId: jest.fn() 11 | } 12 | 13 | test('カードが無いときの表示が正しいか', () => { 14 | const { getByText } = render(<ColorCards {...props} items={[]} />) 15 | expect(getByText('見つかりませんでした…')).toBeTruthy() 16 | }) 17 | 18 | test('見た目が変化していないか', () => { 19 | const colorDetails: ColorDetail[] = [ 20 | { 21 | id: 'yuika_mitsumine_shinycolors', 22 | name: '三峰結華', 23 | nameSuppl: 'Yuika Mitsumine', 24 | nameKana: 'みつみねゆいか', 25 | brand: 'ShinyColors', 26 | color: { 27 | rgb: 'rgb(59, 145, 196)', 28 | hsv: 'hsv(202, 70, 77)', 29 | hex: '#3B91C4', 30 | similar: '#00ffff' 31 | } 32 | }, 33 | { 34 | id: 'iori_minase_765as', 35 | name: '水瀬伊織', 36 | nameSuppl: 'Iori Minase', 37 | nameKana: 'みなせいおり', 38 | brand: '765AS', 39 | color: { 40 | rgb: 'rgb(253, 153, 225)', 41 | hsv: 'hsv(317, 40, 99)', 42 | hex: '#FD99E1', 43 | similar: '#ffffff' 44 | } 45 | }, 46 | { 47 | id: 'miyu_mifune_cinderellagirls', 48 | name: '三船美優', 49 | nameSuppl: 'Miyu Mifune', 50 | nameKana: 'みふねみゆ', 51 | brand: 'CinderellaGirls', 52 | color: { 53 | rgb: 'rgb(1, 170, 165)', 54 | hsv: 'hsv(178, 99, 67)', 55 | hex: '#01AAA5', 56 | similar: '#00ffff' 57 | } 58 | } 59 | ] 60 | 61 | const { container } = render(<ColorCards {...props} items={colorDetails} />) 62 | 63 | expect(container).toMatchSnapshot() 64 | }) 65 | }) 66 | -------------------------------------------------------------------------------- /components/organisms/color-cards/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useMemo, useState } from 'react' 2 | import { isMobile } from 'react-device-detect' 3 | import InfiniteScroll from 'react-infinite-scroller' 4 | import NotFoundCard from 'components/molecules/not-found-card' 5 | import { ColorDetail } from 'types/color-detail' 6 | import CardDefault from '../color-card' 7 | import CardMobile from '../color-card/mobile' 8 | 9 | // 1度に読み込む数 10 | const LOAD_COUNT = 20 11 | 12 | type Props = { 13 | className: string 14 | items: ColorDetail[] 15 | keepIdList: string[] 16 | onAddKeepId: (addId: string) => void 17 | onRemoveKeepId: (removeId: string) => void 18 | } 19 | 20 | const ColorCards = ({ 21 | className, 22 | items, 23 | keepIdList, 24 | onAddKeepId, 25 | onRemoveKeepId 26 | }: Props) => { 27 | const [offset, setOffset] = useState(0) 28 | const [hasMore, setHasMore] = useState(true) 29 | 30 | // 初回表示 31 | useEffect(() => { 32 | setOffset(items.length < LOAD_COUNT ? items.length : LOAD_COUNT) 33 | setHasMore(true) 34 | }, [items]) 35 | 36 | const cards = useMemo( 37 | () => 38 | items.map((e) => { 39 | const isKeep = keepIdList.includes(e.id) 40 | const handleClickKeep = () => onAddKeepId(e.id) 41 | const handleClickRemove = () => onRemoveKeepId(e.id) 42 | 43 | return isMobile ? ( 44 | <CardMobile 45 | key={e.id} 46 | data={e} 47 | isKeep={isKeep} 48 | onClickKeep={handleClickKeep} 49 | onClickRemove={handleClickRemove} 50 | /> 51 | ) : ( 52 | <CardDefault 53 | key={e.id} 54 | data={e} 55 | isKeep={isKeep} 56 | onClickKeep={handleClickKeep} 57 | onClickRemove={handleClickRemove} 58 | /> 59 | ) 60 | }), 61 | [items, keepIdList, onAddKeepId, onRemoveKeepId] 62 | ) 63 | 64 | const handleLoadMore = () => { 65 | const nextOffset = offset + LOAD_COUNT 66 | 67 | // オフセットがデータ数を超えたら読み込みを終了 68 | if (nextOffset > items.length) { 69 | setHasMore(false) 70 | setOffset(items.length) 71 | return 72 | } 73 | 74 | setOffset(nextOffset) 75 | } 76 | 77 | return items.length === 0 ? ( 78 | <div className={`flex justify-center ${className}`} data-testid="card-area"> 79 | <NotFoundCard /> 80 | </div> 81 | ) : ( 82 | <InfiniteScroll 83 | className={`flex flex-row flex-wrap justify-center ${className}`} 84 | loadMore={handleLoadMore} 85 | hasMore={hasMore} 86 | data-testid="card-area" 87 | > 88 | {cards.slice(0, offset)} 89 | </InfiniteScroll> 90 | ) 91 | } 92 | export default ColorCards 93 | -------------------------------------------------------------------------------- /components/organisms/color-info/index.tsx: -------------------------------------------------------------------------------- 1 | import { Color } from 'types/color' 2 | import ColorValue from './value' 3 | 4 | type Props = { 5 | color: Color 6 | } 7 | 8 | const ColorInfo = ({ color }: Props) => ( 9 | <div className="mt-6"> 10 | <ColorValue type="RGB" value={color.rgb} /> 11 | <ColorValue className="mt-4" type="HSV" value={color.hsv} /> 12 | <ColorValue className="mt-4" type="HEX" value={color.hex} /> 13 | </div> 14 | ) 15 | 16 | export default ColorInfo 17 | -------------------------------------------------------------------------------- /components/organisms/color-info/value.test.tsx: -------------------------------------------------------------------------------- 1 | import { act, fireEvent, render, waitFor } from '@testing-library/react' 2 | import ColorValue from './value' 3 | 4 | describe('ColorValue', () => { 5 | const mock = jest.fn() 6 | 7 | beforeAll(() => { 8 | Object.assign(navigator, { 9 | clipboard: { 10 | writeText: mock 11 | } 12 | }) 13 | }) 14 | 15 | test('クリックで値がコピーされるか', async () => { 16 | const { getByTestId } = render(<ColorValue type="HEX" value="#F8C715" />) 17 | const button = getByTestId('copy-button') 18 | 19 | act(() => { 20 | fireEvent.click(button) 21 | }) 22 | 23 | await waitFor(() => expect(mock).toHaveBeenCalled()) 24 | }) 25 | }) 26 | -------------------------------------------------------------------------------- /components/organisms/color-info/value.tsx: -------------------------------------------------------------------------------- 1 | import { useReducer } from 'react' 2 | import { FiCopy } from 'react-icons/fi' 3 | import { RiCheckboxCircleFill } from 'react-icons/ri' 4 | import TypeLabel from 'components/atoms/label/type' 5 | 6 | type Props = { 7 | className?: string 8 | type: string 9 | value: string 10 | } 11 | 12 | const ColorValue = ({ className = '', type, value }: Props) => { 13 | const [isCopied, toggleCopied] = useReducer((prev) => !prev, false) 14 | 15 | const copyToClipboard = async () => { 16 | if (isCopied) return 17 | await navigator.clipboard.writeText(value) 18 | 19 | toggleCopied() 20 | setTimeout(() => toggleCopied(), 1500) 21 | } 22 | 23 | return ( 24 | <div className={`flex flex-row items-center ${className}`}> 25 | <TypeLabel type={type} /> 26 | <button 27 | className="flex-1 flex flex-row justify-end items-center text-sm tracking-wide cursor-pointer" 28 | title="クリックでコピー" 29 | data-testid="copy-button" 30 | onClick={() => copyToClipboard()} 31 | > 32 | {isCopied ? 'Copied!' : value} 33 | <span className="ml-2"> 34 | {isCopied ? <RiCheckboxCircleFill /> : <FiCopy />} 35 | </span> 36 | </button> 37 | </div> 38 | ) 39 | } 40 | 41 | export default ColorValue 42 | -------------------------------------------------------------------------------- /components/organisms/footer/index.tsx: -------------------------------------------------------------------------------- 1 | import { FiDatabase } from 'react-icons/fi' 2 | 3 | const Link = () => ( 4 | <a 5 | className="flex flex-row items-center text-xl hover:text-imas transition-colors" 6 | title="Database by im@sparql" 7 | href="https://sparql.crssnky.xyz/imas" 8 | target="_blank" 9 | rel="noopener noreferrer" 10 | > 11 | <FiDatabase /> 12 | <span className="ml-2 text-sm">by im@sparql</span> 13 | </a> 14 | ) 15 | 16 | const Footer = () => ( 17 | <div className="flex flex-col mt-20 items-center text-sm tracking-wide"> 18 | <Link /> 19 | <p className="mt-6 text-center"> 20 | The rights to all content related to THE IDOLM@STER belong to BANDAI NAMCO 21 | Entertainment Inc. 22 | </p> 23 | </div> 24 | ) 25 | 26 | export default Footer 27 | -------------------------------------------------------------------------------- /components/organisms/header/github-corner.tsx: -------------------------------------------------------------------------------- 1 | import { CSSProperties } from 'react' 2 | 3 | type Props = { 4 | href: string 5 | bannerColor: string 6 | octoColor: string 7 | size: number 8 | } 9 | 10 | /** 11 | * GitHub Corners 12 | * https://github.com/tholman/github-corners 13 | */ 14 | export default function GitHubCorner({ 15 | href, 16 | bannerColor, 17 | octoColor, 18 | size 19 | }: Props) { 20 | const svgStyle: CSSProperties = { 21 | fill: bannerColor, 22 | color: octoColor, 23 | position: 'absolute', 24 | top: 0, 25 | border: 0, 26 | right: 0 27 | } 28 | 29 | return ( 30 | <> 31 | <a className="github-corner" href={href}> 32 | <svg 33 | width={size} 34 | height={size} 35 | viewBox="0 0 250 250" 36 | style={svgStyle} 37 | aria-hidden="true" 38 | > 39 | <path d="M0,0 L115,115 L130,115 L142,142 L250,250 L250,0 Z"></path> 40 | <path 41 | className="octo-arm" 42 | style={{ transformOrigin: '130px 106px' }} 43 | fill="currentColor" 44 | d="M128.3,109.0 C113.8,99.7 119.0,89.6 119.0,89.6 C122.0,82.7 120.5,78.6 120.5,78.6 C119.2,72.0 123.4,76.3 123.4,76.3 C127.3,80.9 125.5,87.3 125.5,87.3 C122.9,97.6 130.6,101.9 134.4,103.2" 45 | ></path> 46 | <path 47 | className="octo-body" 48 | fill="currentColor" 49 | d="M115.0,115.0 C114.9,115.1 118.7,116.5 119.8,115.4 L133.7,101.6 C136.9,99.2 139.9,98.4 142.2,98.6 C133.8,88.0 127.5,74.4 143.8,58.0 C148.5,53.4 154.0,51.2 159.7,51.0 C160.3,49.4 163.2,43.6 171.4,40.1 C171.4,40.1 176.1,42.5 178.8,56.2 C183.1,58.6 187.2,61.8 190.9,65.4 C194.5,69.0 197.7,73.2 200.1,77.6 C213.8,80.2 216.3,84.9 216.3,84.9 C212.7,93.1 206.9,96.0 205.4,96.6 C205.1,102.4 203.0,107.8 198.3,112.5 C181.9,128.9 168.3,122.5 157.7,114.1 C157.9,116.9 156.7,120.9 152.7,124.9 L141.0,136.5 C139.8,137.7 141.6,141.9 141.8,141.8 Z" 50 | ></path> 51 | </svg> 52 | </a> 53 | <style> 54 | {`.github-corner:hover .octo-arm { 55 | animation:octocat-wave 560ms ease-in-out 56 | } 57 | @keyframes octocat-wave { 58 | 0%, 100% { 59 | transform:rotate(0) 60 | } 61 | 20%, 60% { 62 | transform:rotate(-25deg) 63 | } 64 | 40%, 80% { 65 | transform:rotate(10deg) 66 | } 67 | } 68 | @media (max-width:500px){ 69 | .github-corner:hover .octo-arm{ 70 | animation:none 71 | } 72 | .github-corner .octo-arm{ 73 | animation:octocat-wave 560ms ease-in-out 74 | } 75 | }`} 76 | </style> 77 | </> 78 | ) 79 | } 80 | -------------------------------------------------------------------------------- /components/organisms/header/index.tsx: -------------------------------------------------------------------------------- 1 | import TitleText from 'components/atoms/title' 2 | import GitHubCorner from './github-corner' 3 | 4 | type Props = { 5 | desc: string 6 | } 7 | 8 | const Header = ({ desc }: Props) => ( 9 | <header> 10 | <div className="text-center"> 11 | <a href=""> 12 | <TitleText text="im@s-palette" /> 13 | </a> 14 | <p className="mt-4 text-natural-gray text-sm md:text-base">{desc}</p> 15 | </div> 16 | <GitHubCorner 17 | href="https://github.com/arrow2nd/imas-palette" 18 | bannerColor="#1c1c1c" 19 | octoColor="#faf8f7" 20 | size={80} 21 | /> 22 | </header> 23 | ) 24 | 25 | export default Header 26 | -------------------------------------------------------------------------------- /components/organisms/search-colors/__snapshots__/color-button.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`ColorButton 色が指定されていない時の表示は正しいか 1`] = ` 4 | <div> 5 | <button 6 | class="mx-1 p-0 rounded-full border-2 border-transparent" 7 | > 8 | <div 9 | class="w-8 h-8 rounded-full border-2 bg-linear-to-r from-red-500 to-purple-500" 10 | title="" 11 | /> 12 | </button> 13 | </div> 14 | `; 15 | -------------------------------------------------------------------------------- /components/organisms/search-colors/__snapshots__/index.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`SearchColors 見た目が変化していないか 1`] = ` 4 | <div> 5 | <div 6 | class="flex flex-wrap mt-8 justify-center items-center" 7 | > 8 | <button 9 | class="mx-1 p-0 rounded-full border-2 border-gray-600" 10 | data-testid="search-color-button-0" 11 | > 12 | <div 13 | class="w-8 h-8 rounded-full border-2 bg-linear-to-r from-red-500 to-purple-500" 14 | title="全て" 15 | /> 16 | </button> 17 | <button 18 | class="mx-1 p-0 rounded-full border-2 border-transparent" 19 | data-testid="search-color-button-1" 20 | > 21 | <div 22 | class="w-8 h-8 rounded-full border-2 false" 23 | style="background-color: rgb(255, 0, 0);" 24 | title="赤" 25 | /> 26 | </button> 27 | <button 28 | class="mx-1 p-0 rounded-full border-2 border-transparent" 29 | data-testid="search-color-button-2" 30 | > 31 | <div 32 | class="w-8 h-8 rounded-full border-2 false" 33 | style="background-color: rgb(255, 165, 0);" 34 | title="橙" 35 | /> 36 | </button> 37 | <button 38 | class="mx-1 p-0 rounded-full border-2 border-transparent" 39 | data-testid="search-color-button-3" 40 | > 41 | <div 42 | class="w-8 h-8 rounded-full border-2 false" 43 | style="background-color: rgb(255, 255, 0);" 44 | title="黄" 45 | /> 46 | </button> 47 | <button 48 | class="mx-1 p-0 rounded-full border-2 border-transparent" 49 | data-testid="search-color-button-4" 50 | > 51 | <div 52 | class="w-8 h-8 rounded-full border-2 false" 53 | style="background-color: rgb(144, 226, 0);" 54 | title="黄緑" 55 | /> 56 | </button> 57 | <button 58 | class="mx-1 p-0 rounded-full border-2 border-transparent" 59 | data-testid="search-color-button-5" 60 | > 61 | <div 62 | class="w-8 h-8 rounded-full border-2 false" 63 | style="background-color: rgb(0, 128, 0);" 64 | title="緑" 65 | /> 66 | </button> 67 | <button 68 | class="mx-1 p-0 rounded-full border-2 border-transparent" 69 | data-testid="search-color-button-6" 70 | > 71 | <div 72 | class="w-8 h-8 rounded-full border-2 false" 73 | style="background-color: rgb(0, 255, 255);" 74 | title="水色" 75 | /> 76 | </button> 77 | <button 78 | class="mx-1 p-0 rounded-full border-2 border-transparent" 79 | data-testid="search-color-button-7" 80 | > 81 | <div 82 | class="w-8 h-8 rounded-full border-2 false" 83 | style="background-color: rgb(0, 0, 255);" 84 | title="青" 85 | /> 86 | </button> 87 | <button 88 | class="mx-1 p-0 rounded-full border-2 border-transparent" 89 | data-testid="search-color-button-8" 90 | > 91 | <div 92 | class="w-8 h-8 rounded-full border-2 false" 93 | style="background-color: rgb(128, 0, 128);" 94 | title="紫" 95 | /> 96 | </button> 97 | <button 98 | class="mx-1 p-0 rounded-full border-2 border-transparent" 99 | data-testid="search-color-button-9" 100 | > 101 | <div 102 | class="w-8 h-8 rounded-full border-2 false" 103 | style="background-color: rgb(255, 255, 255);" 104 | title="白" 105 | /> 106 | </button> 107 | <button 108 | class="mx-1 p-0 rounded-full border-2 border-transparent" 109 | data-testid="search-color-button-10" 110 | > 111 | <div 112 | class="w-8 h-8 rounded-full border-2 false" 113 | style="background-color: rgb(0, 0, 0);" 114 | title="黒" 115 | /> 116 | </button> 117 | </div> 118 | </div> 119 | `; 120 | -------------------------------------------------------------------------------- /components/organisms/search-colors/color-button.test.tsx: -------------------------------------------------------------------------------- 1 | import { act, fireEvent, render } from '@testing-library/react' 2 | import ColorButton from './color-button' 3 | 4 | describe('ColorButton', () => { 5 | const color = { 6 | name: '赤', 7 | hex: '#ff0000' 8 | } 9 | 10 | test('選択状態の表示が正しいか', () => { 11 | const props = { 12 | color: color, 13 | onClick: jest.fn() 14 | } 15 | 16 | const { container, rerender } = render( 17 | <ColorButton {...props} isSelected={false} /> 18 | ) 19 | const prevInnerHtml = container.innerHTML 20 | 21 | rerender(<ColorButton {...props} isSelected={true} />) 22 | 23 | expect(prevInnerHtml).not.toBe(container.innerHTML) 24 | }) 25 | 26 | test('色が指定されていない時の表示は正しいか', () => { 27 | const { container } = render( 28 | <ColorButton 29 | color={{ name: '', hex: '' }} 30 | isSelected={false} 31 | onClick={jest.fn()} 32 | /> 33 | ) 34 | 35 | expect(container).toMatchSnapshot() 36 | }) 37 | 38 | test('クリック時にコールバックが呼ばれるか', () => { 39 | const mock = jest.fn() 40 | const { getByTestId } = render( 41 | <ColorButton 42 | color={{ name: '', hex: '' }} 43 | isSelected={false} 44 | onClick={mock} 45 | dataTestId="search-color-button" 46 | /> 47 | ) 48 | 49 | act(() => { 50 | fireEvent.click(getByTestId('search-color-button')) 51 | }) 52 | 53 | expect(mock).toHaveBeenCalled() 54 | }) 55 | }) 56 | -------------------------------------------------------------------------------- /components/organisms/search-colors/color-button.tsx: -------------------------------------------------------------------------------- 1 | import { JSX } from 'react' 2 | import ColorSampleCircle from 'components/atoms/color-sample/circle' 3 | import { SearchColor } from 'types/search-color' 4 | 5 | type Props = { 6 | color: SearchColor 7 | isSelected: boolean 8 | onClick: (hex: string) => void 9 | dataTestId?: string 10 | } 11 | 12 | const ColorButton = ({ 13 | color, 14 | isSelected, 15 | onClick, 16 | dataTestId 17 | }: Props): JSX.Element => ( 18 | <button 19 | className={`mx-1 p-0 rounded-full border-2 ${ 20 | isSelected ? 'border-gray-600' : 'border-transparent' 21 | }`} 22 | onClick={() => onClick(color.hex)} 23 | data-testid={dataTestId} 24 | > 25 | <ColorSampleCircle 26 | className={`border-2 ${ 27 | color.hex === '' && 'bg-linear-to-r from-red-500 to-purple-500' 28 | }`} 29 | {...color} 30 | /> 31 | </button> 32 | ) 33 | 34 | export default ColorButton 35 | -------------------------------------------------------------------------------- /components/organisms/search-colors/index.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react' 2 | import SearchColors from './index' 3 | 4 | describe('SearchColors', () => { 5 | test('見た目が変化していないか', () => { 6 | const { container } = render( 7 | <SearchColors current="" onChange={jest.fn()} /> 8 | ) 9 | 10 | expect(container).toMatchSnapshot() 11 | }) 12 | }) 13 | -------------------------------------------------------------------------------- /components/organisms/search-colors/index.tsx: -------------------------------------------------------------------------------- 1 | import { JSX } from 'react' 2 | import { searchColors } from 'data/search-colors' 3 | import ColorButton from './color-button' 4 | 5 | type Props = { 6 | current: string 7 | onChange: (hex: string) => void 8 | } 9 | 10 | const SearchColors = ({ current, onChange }: Props): JSX.Element => { 11 | const buttons = searchColors.map((e, i) => ( 12 | <ColorButton 13 | color={e} 14 | isSelected={e.hex === current} 15 | onClick={onChange} 16 | dataTestId={`search-color-button-${i}`} 17 | key={e.hex} 18 | /> 19 | )) 20 | 21 | return ( 22 | <div className="flex flex-wrap mt-8 justify-center items-center"> 23 | {buttons} 24 | </div> 25 | ) 26 | } 27 | 28 | export default SearchColors 29 | -------------------------------------------------------------------------------- /components/organisms/search/__snapshots__/index.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Search 見た目が変化していないか 1`] = ` 4 | <div> 5 | <div> 6 | <div 7 | class="flex flex-wrap justify-center flex-col md:flex-row" 8 | > 9 | <div 10 | class="w-full md:w-64" 11 | > 12 | <select 13 | class="form-select block w-full border-0 focus:ring-1 focus:ring-natural-black rounded-lg shadow-md" 14 | data-testid="search-select" 15 | > 16 | <option 17 | class="font-sans" 18 | value="" 19 | > 20 | 全てのブランド 21 | </option> 22 | <option 23 | class="font-sans" 24 | value="765AS" 25 | > 26 | 765PRO ALLSTARS 27 | </option> 28 | <option 29 | class="font-sans" 30 | value="DearlyStars" 31 | > 32 | Dearly Stars 33 | </option> 34 | <option 35 | class="font-sans" 36 | value="MillionLive" 37 | > 38 | ミリオンライブ! 39 | </option> 40 | <option 41 | class="font-sans" 42 | value="CinderellaGirls" 43 | > 44 | シンデレラガールズ 45 | </option> 46 | <option 47 | class="font-sans" 48 | value="SideM" 49 | > 50 | SideM 51 | </option> 52 | <option 53 | class="font-sans" 54 | value="ShinyColors" 55 | > 56 | シャイニーカラーズ 57 | </option> 58 | <option 59 | class="font-sans" 60 | value="Gakuen" 61 | > 62 | 学園アイドルマスター 63 | </option> 64 | <option 65 | class="font-sans" 66 | value="va-liv" 67 | > 68 | vα-liv 69 | </option> 70 | <option 71 | class="font-sans" 72 | value="Other" 73 | > 74 | 未分類 75 | </option> 76 | <option 77 | class="font-sans" 78 | value="keep" 79 | > 80 | Keepしたカラー 81 | </option> 82 | </select> 83 | </div> 84 | <div 85 | class="mt-3 md:mt-0 ml-0 md:ml-5 w-full md:w-64" 86 | > 87 | <input 88 | class="form-input block w-full border-0 focus:ring-1 focus:ring-natural-black rounded-lg shadow-md" 89 | data-testid="search-textbox" 90 | placeholder="アイドル・ユニット名" 91 | /> 92 | </div> 93 | <button 94 | class="px-3 py-1 text-center items-center border border-natural-black text-white hover:text-natural-black bg-natural-black hover:bg-natural-white rounded-lg transition-colors mt-3 md:mt-0 ml-0 md:ml-5 w-full md:w-24 shadow-md" 95 | data-testid="search-button" 96 | > 97 | 検索 98 | </button> 99 | </div> 100 | <div 101 | class="flex flex-wrap mt-8 justify-center items-center" 102 | > 103 | <button 104 | class="mx-1 p-0 rounded-full border-2 border-gray-600" 105 | data-testid="search-color-button-0" 106 | > 107 | <div 108 | class="w-8 h-8 rounded-full border-2 bg-linear-to-r from-red-500 to-purple-500" 109 | title="全て" 110 | /> 111 | </button> 112 | <button 113 | class="mx-1 p-0 rounded-full border-2 border-transparent" 114 | data-testid="search-color-button-1" 115 | > 116 | <div 117 | class="w-8 h-8 rounded-full border-2 false" 118 | style="background-color: rgb(255, 0, 0);" 119 | title="赤" 120 | /> 121 | </button> 122 | <button 123 | class="mx-1 p-0 rounded-full border-2 border-transparent" 124 | data-testid="search-color-button-2" 125 | > 126 | <div 127 | class="w-8 h-8 rounded-full border-2 false" 128 | style="background-color: rgb(255, 165, 0);" 129 | title="橙" 130 | /> 131 | </button> 132 | <button 133 | class="mx-1 p-0 rounded-full border-2 border-transparent" 134 | data-testid="search-color-button-3" 135 | > 136 | <div 137 | class="w-8 h-8 rounded-full border-2 false" 138 | style="background-color: rgb(255, 255, 0);" 139 | title="黄" 140 | /> 141 | </button> 142 | <button 143 | class="mx-1 p-0 rounded-full border-2 border-transparent" 144 | data-testid="search-color-button-4" 145 | > 146 | <div 147 | class="w-8 h-8 rounded-full border-2 false" 148 | style="background-color: rgb(144, 226, 0);" 149 | title="黄緑" 150 | /> 151 | </button> 152 | <button 153 | class="mx-1 p-0 rounded-full border-2 border-transparent" 154 | data-testid="search-color-button-5" 155 | > 156 | <div 157 | class="w-8 h-8 rounded-full border-2 false" 158 | style="background-color: rgb(0, 128, 0);" 159 | title="緑" 160 | /> 161 | </button> 162 | <button 163 | class="mx-1 p-0 rounded-full border-2 border-transparent" 164 | data-testid="search-color-button-6" 165 | > 166 | <div 167 | class="w-8 h-8 rounded-full border-2 false" 168 | style="background-color: rgb(0, 255, 255);" 169 | title="水色" 170 | /> 171 | </button> 172 | <button 173 | class="mx-1 p-0 rounded-full border-2 border-transparent" 174 | data-testid="search-color-button-7" 175 | > 176 | <div 177 | class="w-8 h-8 rounded-full border-2 false" 178 | style="background-color: rgb(0, 0, 255);" 179 | title="青" 180 | /> 181 | </button> 182 | <button 183 | class="mx-1 p-0 rounded-full border-2 border-transparent" 184 | data-testid="search-color-button-8" 185 | > 186 | <div 187 | class="w-8 h-8 rounded-full border-2 false" 188 | style="background-color: rgb(128, 0, 128);" 189 | title="紫" 190 | /> 191 | </button> 192 | <button 193 | class="mx-1 p-0 rounded-full border-2 border-transparent" 194 | data-testid="search-color-button-9" 195 | > 196 | <div 197 | class="w-8 h-8 rounded-full border-2 false" 198 | style="background-color: rgb(255, 255, 255);" 199 | title="白" 200 | /> 201 | </button> 202 | <button 203 | class="mx-1 p-0 rounded-full border-2 border-transparent" 204 | data-testid="search-color-button-10" 205 | > 206 | <div 207 | class="w-8 h-8 rounded-full border-2 false" 208 | style="background-color: rgb(0, 0, 0);" 209 | title="黒" 210 | /> 211 | </button> 212 | </div> 213 | </div> 214 | </div> 215 | `; 216 | -------------------------------------------------------------------------------- /components/organisms/search/index.test.tsx: -------------------------------------------------------------------------------- 1 | import { act, fireEvent, render } from '@testing-library/react' 2 | import Search from './index' 3 | 4 | describe('Search', () => { 5 | test('見た目が変化していないか', () => { 6 | const { container } = render( 7 | <Search 8 | currentSimilarColor="" 9 | onChangeBrand={jest.fn()} 10 | onChangeName={jest.fn()} 11 | onChangeSimilarColor={jest.fn()} 12 | /> 13 | ) 14 | 15 | expect(container).toMatchSnapshot() 16 | }) 17 | 18 | test('検索ボタン押下で検索できるか', () => { 19 | const mock = jest.fn() 20 | const { getByTestId } = render( 21 | <Search 22 | currentSimilarColor="" 23 | onChangeBrand={jest.fn()} 24 | onChangeName={mock} 25 | onChangeSimilarColor={jest.fn()} 26 | /> 27 | ) 28 | 29 | act(() => { 30 | fireEvent.click(getByTestId('search-button')) 31 | }) 32 | 33 | expect(mock).toHaveBeenCalled() 34 | }) 35 | }) 36 | -------------------------------------------------------------------------------- /components/organisms/search/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo, useRef } from 'react' 2 | import Button from 'components/atoms/button' 3 | import Input from 'components/atoms/input' 4 | import Select from 'components/atoms/select' 5 | import { brands } from 'data/brands' 6 | import SearchColors from '../search-colors' 7 | 8 | type Props = { 9 | currentSimilarColor: string 10 | onChangeBrand: (brand: string) => void 11 | onChangeName: (name: string) => void 12 | onChangeSimilarColor: (hex: string) => void 13 | } 14 | 15 | const Search = ({ 16 | currentSimilarColor, 17 | onChangeBrand: onChangebrand, 18 | onChangeName, 19 | onChangeSimilarColor 20 | }: Props) => { 21 | const inputRef = useRef<HTMLInputElement>({} as HTMLInputElement) 22 | 23 | const options = useMemo( 24 | () => 25 | brands.map((e) => ( 26 | <option className="font-sans" key={e.value} value={e.value}> 27 | {e.title} 28 | </option> 29 | )), 30 | [] 31 | ) 32 | 33 | const handleChangebrand = (brand: string) => { 34 | inputRef.current.value = '' 35 | onChangeName('') 36 | onChangeSimilarColor('') 37 | onChangebrand(brand) 38 | } 39 | 40 | const handleSubmitName = () => { 41 | const name = inputRef.current?.value || '' 42 | onChangeName(name) 43 | } 44 | 45 | return ( 46 | <div> 47 | <div className="flex flex-wrap justify-center flex-col md:flex-row"> 48 | <Select 49 | className="w-full md:w-64" 50 | onChange={handleChangebrand} 51 | dataTestId="search-select" 52 | > 53 | {options} 54 | </Select> 55 | <Input 56 | className="mt-3 md:mt-0 ml-0 md:ml-5 w-full md:w-64" 57 | placeholder="アイドル・ユニット名" 58 | ref={inputRef} 59 | onSubmit={handleSubmitName} 60 | dataTestId="search-textbox" 61 | /> 62 | <Button 63 | className="mt-3 md:mt-0 ml-0 md:ml-5 w-full md:w-24 shadow-md" 64 | onClick={handleSubmitName} 65 | dataTestId="search-button" 66 | > 67 | 検索 68 | </Button> 69 | </div> 70 | <SearchColors 71 | current={currentSimilarColor} 72 | onChange={onChangeSimilarColor} 73 | /> 74 | </div> 75 | ) 76 | } 77 | 78 | export default React.memo(Search) 79 | -------------------------------------------------------------------------------- /components/organisms/ui/index.test.tsx: -------------------------------------------------------------------------------- 1 | import { act, fireEvent, render } from '@testing-library/react' 2 | import UI from './index' 3 | 4 | describe('UI', () => { 5 | const keyDownEnter = { 6 | key: 'Enter', 7 | code: 'Enter', 8 | charCode: 13 9 | } 10 | 11 | test('検索結果がない場合の表示が正しいか', () => { 12 | const { getByText, getByTestId } = render(<UI />) 13 | 14 | act(() => { 15 | fireEvent.change(getByTestId('search-textbox'), { 16 | target: { value: 'テスト' } 17 | }) 18 | fireEvent.click(getByTestId('search-button')) 19 | }) 20 | 21 | expect(getByText('見つかりませんでした…')).toBeTruthy() 22 | }) 23 | 24 | test('ブランドから検索ができるか', () => { 25 | const { getByText, getByTestId } = render(<UI />) 26 | 27 | act(() => { 28 | fireEvent.change(getByTestId('search-select'), { 29 | target: { value: 'DearlyStars' } 30 | }) 31 | }) 32 | 33 | expect(getByText('秋月涼')).toBeTruthy() 34 | expect(getByText('日高愛')).toBeTruthy() 35 | expect(getByText('水谷絵理')).toBeTruthy() 36 | }) 37 | 38 | test('アイドル名から検索できるか', () => { 39 | const { getByText, getByTestId } = render(<UI />) 40 | 41 | act(() => { 42 | const textbox = getByTestId('search-textbox') 43 | 44 | fireEvent.change(textbox, { target: { value: '智代子' } }) 45 | fireEvent.keyDown(textbox, keyDownEnter) 46 | }) 47 | 48 | expect(getByText('園田智代子')).toBeTruthy() 49 | }) 50 | 51 | test('色味での絞込ができるか', async () => { 52 | const { getByText, getByTestId } = render(<UI />) 53 | 54 | act(() => { 55 | const textbox = getByTestId('search-textbox') 56 | 57 | fireEvent.change(textbox, { target: { value: '菊' } }) 58 | fireEvent.keyDown(textbox, keyDownEnter) 59 | fireEvent.click(getByTestId('search-color-button-8')) 60 | }) 61 | 62 | expect(getByText('白菊ほたる')).toBeTruthy() 63 | }) 64 | }) 65 | -------------------------------------------------------------------------------- /components/organisms/ui/index.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useCallback, useState } from 'react' 4 | import { useColorData } from 'hooks/useColorData' 5 | import { useKeepId } from 'hooks/useKeepId' 6 | import MoveTopButton from '../button/move-top' 7 | import ColorCards from '../color-cards' 8 | import Search from '../search' 9 | 10 | const UI = () => { 11 | const [brand, setBrand] = useState('') 12 | const [name, setName] = useState('') 13 | const [similarColor, setSimilarColor] = useState('') 14 | const [keepIdList, handleAddKeepId, handleRemoveKeepId] = useKeepId() 15 | 16 | const searchResults = useColorData(brand, name, similarColor, keepIdList) 17 | 18 | const handleChangebrand = useCallback((brand: string) => setBrand(brand), []) 19 | const handleChangeName = useCallback((name: string) => setName(name), []) 20 | const handleChangeSimilarColor = useCallback( 21 | (hex: string) => setSimilarColor(hex), 22 | [] 23 | ) 24 | 25 | return ( 26 | <div className="flex-1 mt-12"> 27 | <Search 28 | currentSimilarColor={similarColor} 29 | onChangeBrand={handleChangebrand} 30 | onChangeName={handleChangeName} 31 | onChangeSimilarColor={handleChangeSimilarColor} 32 | /> 33 | <ColorCards 34 | className="mt-12" 35 | items={searchResults} 36 | keepIdList={keepIdList} 37 | onAddKeepId={handleAddKeepId} 38 | onRemoveKeepId={handleRemoveKeepId} 39 | /> 40 | <div className="m-5 fixed right-0 bottom-0"> 41 | <MoveTopButton /> 42 | </div> 43 | </div> 44 | ) 45 | } 46 | 47 | export default UI 48 | -------------------------------------------------------------------------------- /data/brands.ts: -------------------------------------------------------------------------------- 1 | import { Option } from 'types/option' 2 | 3 | export const brands: Option[] = [ 4 | { 5 | title: '全てのブランド', 6 | value: '' 7 | }, 8 | { 9 | title: '765PRO ALLSTARS', 10 | value: '765AS' 11 | }, 12 | { 13 | title: 'Dearly Stars', 14 | value: 'DearlyStars' 15 | }, 16 | { 17 | title: 'ミリオンライブ!', 18 | value: 'MillionLive' 19 | }, 20 | { 21 | title: 'シンデレラガールズ', 22 | value: 'CinderellaGirls' 23 | }, 24 | { 25 | title: 'SideM', 26 | value: 'SideM' 27 | }, 28 | { 29 | title: 'シャイニーカラーズ', 30 | value: 'ShinyColors' 31 | }, 32 | { 33 | title: '学園アイドルマスター', 34 | value: 'Gakuen' 35 | }, 36 | { 37 | title: 'vα-liv', 38 | value: 'va-liv' 39 | }, 40 | { 41 | title: '未分類', 42 | value: 'Other' 43 | }, 44 | { 45 | title: 'Keepしたカラー', 46 | value: 'keep' 47 | } 48 | ] 49 | -------------------------------------------------------------------------------- /data/search-colors.ts: -------------------------------------------------------------------------------- 1 | import { SearchColor } from 'types/search-color' 2 | 3 | export const searchColors: SearchColor[] = [ 4 | { 5 | name: '全て', 6 | hex: '' 7 | }, 8 | { 9 | name: '赤', 10 | hex: '#ff0000' 11 | }, 12 | { 13 | name: '橙', 14 | hex: '#ffa500' 15 | }, 16 | { 17 | name: '黄', 18 | hex: '#ffff00' 19 | }, 20 | { 21 | name: '黄緑', 22 | hex: '#90e200' 23 | }, 24 | { 25 | name: '緑', 26 | hex: '#008000' 27 | }, 28 | { 29 | name: '水色', 30 | hex: '#00ffff' 31 | }, 32 | { 33 | name: '青', 34 | hex: '#0000ff' 35 | }, 36 | { 37 | name: '紫', 38 | hex: '#800080' 39 | }, 40 | { 41 | name: '白', 42 | hex: '#ffffff' 43 | }, 44 | { 45 | name: '黒', 46 | hex: '#000000' 47 | } 48 | ] 49 | -------------------------------------------------------------------------------- /data/seo.ts: -------------------------------------------------------------------------------- 1 | import { Seo } from 'types/seo' 2 | 3 | export const seo: Seo = { 4 | title: 'im@s-palette | アイマスカラー検索', 5 | description: 6 | 'THE IDOLM@STERシリーズに登場するアイドル・ユニットのカラーが検索できるWebアプリ', 7 | url: 'https://imas-palette.vercel.app', 8 | ogpImgUrl: 'https://imas-palette.vercel.app/ogp.png' 9 | } 10 | -------------------------------------------------------------------------------- /e2e/filter.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/test' 2 | import { search } from './utils' 3 | 4 | test.describe('色味で絞り込める', () => { 5 | test.beforeEach(async ({ page }) => { 6 | await page.goto('/') 7 | }) 8 | 9 | test('正しく絞り込めている', async ({ page }) => { 10 | await page.selectOption('[data-testid="search-select"]', 'Dearly Stars') 11 | await page.click('[data-testid="search-color-button-1"]') 12 | await expect(page.getByTestId('card-area')).toContainText('日高愛') 13 | }) 14 | 15 | test('キーワードでさらに絞り込める', async ({ page }) => { 16 | await page.selectOption( 17 | '[data-testid="search-select"]', 18 | 'シャイニーカラーズ' 19 | ) 20 | await page.click('[data-testid="search-color-button-2"]') 21 | 22 | const cardArea = page.getByTestId('card-area') 23 | 24 | await search(page, '西城樹里') 25 | await expect(cardArea, 'ヒットする').toContainText('西城樹里') 26 | 27 | await search(page, '風野灯織') 28 | await expect(cardArea, '色味が異なるアイドルはヒットしない').toContainText( 29 | '見つかりませんでした' 30 | ) 31 | }) 32 | }) 33 | -------------------------------------------------------------------------------- /e2e/keep.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/test' 2 | import { search } from './utils' 3 | 4 | test('Keep', async ({ page }) => { 5 | await page.goto('/') 6 | 7 | await search(page, '白菊ほたる') 8 | await page.getByTestId('keep-button').click() 9 | 10 | await page.selectOption('[data-testid="search-select"]', 'Keepしたカラー') 11 | await expect(page.getByTestId('card-area'), '追加できる').toContainText( 12 | '白菊ほたる' 13 | ) 14 | 15 | await page.getByTestId('remove-button').click() 16 | await page.selectOption('[data-testid="search-select"]', 'Keepしたカラー') 17 | await expect(page.getByTestId('card-area'), '削除できる').toContainText( 18 | '見つかりませんでした' 19 | ) 20 | }) 21 | 22 | test('Keepしたカラーのキーワード検索', async ({ page }) => { 23 | await page.goto('/') 24 | 25 | // Keep colors: 七尾百合子, 佐竹美奈子, 高坂海美 26 | await search(page, '七尾百合子') 27 | await page.getByTestId('keep-button').click() 28 | await search(page, '佐竹美奈子') 29 | await page.getByTestId('keep-button').click() 30 | await search(page, '高坂海美') 31 | await page.getByTestId('keep-button').click() 32 | 33 | // Select "keep" in the search select box 34 | await page.selectOption('[data-testid="search-select"]', 'Keepしたカラー') 35 | 36 | // Enter "子" in the search text box 37 | await page.getByTestId('search-textbox').fill('子') 38 | 39 | // Click the search button 40 | await page.getByTestId('search-button').click() 41 | 42 | // Assertions 43 | const cardArea = page.getByTestId('card-area') 44 | await expect(cardArea).toContainText('七尾百合子') 45 | await expect(cardArea).toContainText('佐竹美奈子') 46 | await expect(cardArea).not.toContainText('高坂海美') 47 | }) 48 | -------------------------------------------------------------------------------- /e2e/search-brand.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/test' 2 | 3 | test.describe('ブランドで検索できる', () => { 4 | test.beforeEach(async ({ page }) => { 5 | await page.goto('/') 6 | }) 7 | 8 | const tests = [ 9 | ['765AS', '天海春香'], 10 | ['Dearly Stars', '日高愛'], 11 | ['ミリオンライブ!', '春日未来'], 12 | ['シンデレラガールズ', '相葉夕美'], 13 | ['SideM', '蒼井享介'], 14 | ['シャイニーカラーズ', '緋田美琴'], 15 | ['未分類', '亜夜'] 16 | ] 17 | 18 | for (const [value, want] of tests) { 19 | test(value, async ({ page }) => { 20 | await page.selectOption('[data-testid="search-select"]', value) 21 | await expect(page.getByTestId('card-area')).toContainText(want) 22 | }) 23 | } 24 | }) 25 | -------------------------------------------------------------------------------- /e2e/search-keyword.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/test' 2 | import { search } from './utils' 3 | 4 | test.describe('アイドル名で検索できる', () => { 5 | test.beforeEach(async ({ page }) => { 6 | await page.goto('/') 7 | }) 8 | 9 | test('漢字から', async ({ page }) => { 10 | await search(page, '天海春香') 11 | await expect(page.getByTestId('card-area')).toContainText('天海春香') 12 | }) 13 | 14 | test('ひらがなから', async ({ page }) => { 15 | await search(page, 'あまみはるか') 16 | await expect(page.getByTestId('card-area')).toContainText('天海春香') 17 | }) 18 | 19 | test('ブランド絞り込み', async ({ page }) => { 20 | await page.selectOption( 21 | '[data-testid="search-select"]', 22 | 'シンデレラガールズ' 23 | ) 24 | 25 | const cardArea = page.getByTestId('card-area') 26 | 27 | await search(page, '白菊ほたる') 28 | await expect( 29 | cardArea, 30 | 'シンデレラガールズのアイドルがヒットする' 31 | ).toContainText('白菊ほたる') 32 | 33 | await search(page, '天海春香') 34 | await expect(cardArea, '765ASのアイドルがヒットしない').toContainText( 35 | '見つかりませんでした' 36 | ) 37 | }) 38 | }) 39 | 40 | test('ユニット名で検索できる', async ({ page }) => { 41 | await page.goto('/') 42 | await search(page, '放課後クライマックスガールズ') 43 | await expect(page.getByTestId('card-area')).toContainText( 44 | '放課後クライマックスガールズ' 45 | ) 46 | }) 47 | -------------------------------------------------------------------------------- /e2e/utils.ts: -------------------------------------------------------------------------------- 1 | import { Page } from '@playwright/test' 2 | 3 | /** 4 | * 検索 5 | * @param page Page 6 | * @param keyword 検索キーワード 7 | */ 8 | export const search = async (page: Page, keyword: string) => { 9 | // キーワードを入力 10 | await page.click('[data-testid="search-textbox"]') 11 | await page.locator('[data-testid="search-textbox"]').fill(keyword) 12 | 13 | // 検索実行 14 | await page.click('[data-testid="search-button"]') 15 | } 16 | -------------------------------------------------------------------------------- /eslint.config.cjs: -------------------------------------------------------------------------------- 1 | const { fixupConfigRules } = require('@eslint/compat') 2 | const { FlatCompat } = require('@eslint/eslintrc') 3 | const prettier = require('eslint-config-prettier') 4 | 5 | const flatCompat = new FlatCompat() 6 | 7 | module.exports = [ 8 | ...fixupConfigRules( 9 | flatCompat.extends('next/core-web-vitals'), 10 | flatCompat.extends('next/typescript') 11 | ), 12 | prettier 13 | ] 14 | -------------------------------------------------------------------------------- /hooks/useColorData.test.ts: -------------------------------------------------------------------------------- 1 | import { renderHook } from '@testing-library/react' 2 | import { colors } from 'data/colors' 3 | import { useColorData } from './useColorData' 4 | 5 | describe('useColorData', () => { 6 | test('全てのブランドで検索できるか', () => { 7 | const { result } = renderHook(() => useColorData('', '', '', [])) 8 | 9 | expect(result.current).toHaveLength(colors.length) 10 | }) 11 | 12 | test.each` 13 | testName | brand | name | similarColor | keeps | expected 14 | ${'ブランド名'} | ${'DearlyStars'} | ${''} | ${''} | ${[]} | ${['秋月涼', '日高愛', '水谷絵理']} 15 | ${'アイドル名'} | ${''} | ${'菊地真'} | ${''} | ${[]} | ${['菊地真']} 16 | ${'ユニット名'} | ${''} | ${'じゅぴたー'} | ${''} | ${[]} | ${['Jupiter']} 17 | ${'色味'} | ${'DearlyStars'} | ${''} | ${'#ff0000'} | ${[]} | ${['日高愛']} 18 | ${'keep済み'} | ${'keep'} | ${''} | ${'#800080'} | ${['mei_izumi_shinycolors', 'hinana_ichikawa_shinycolors']} | ${['和泉愛依']} 19 | `( 20 | '$testNameからの検索ができるか', 21 | ({ brand, name, similarColor, keeps, expected }) => { 22 | const { result } = renderHook(() => 23 | useColorData(brand, name, similarColor, keeps) 24 | ) 25 | 26 | expect(result.current.map((e) => e.name)).toEqual(expected) 27 | } 28 | ) 29 | }) 30 | -------------------------------------------------------------------------------- /hooks/useColorData.ts: -------------------------------------------------------------------------------- 1 | import { colors } from 'data/colors' 2 | import { ColorDetail } from 'types/color-detail' 3 | 4 | export const useColorData = ( 5 | brand: string, 6 | name: string, 7 | similarColor: string, 8 | keepIdList: string[] 9 | ): ColorDetail[] => { 10 | // keep済みカラーIDで絞り込む 11 | const filterFromKeepId = ({ id }: ColorDetail) => keepIdList.includes(id) 12 | 13 | // 検索条件で絞り込む 14 | const filterFromCriteria = (e: ColorDetail) => 15 | (brand === '' || brand === e.brand) && 16 | (e.name.includes(name) || e.nameKana.includes(name)) 17 | 18 | const filterByName = (e: ColorDetail) => 19 | e.name.includes(name) || e.nameKana.includes(name) 20 | 21 | const results = 22 | brand === 'keep' 23 | ? colors.filter(filterFromKeepId).filter(filterByName) 24 | : colors.filter(filterFromCriteria) 25 | 26 | // 色味の指定がある場合さらに絞り込む 27 | return similarColor === '' 28 | ? results 29 | : results.filter(({ color }) => color.similar === similarColor) 30 | } 31 | -------------------------------------------------------------------------------- /hooks/useKeepId.test.ts: -------------------------------------------------------------------------------- 1 | import { act } from '@testing-library/react' 2 | import { renderHook } from '@testing-library/react' 3 | import { useKeepId } from './useKeepId' 4 | 5 | describe('useKeepId', () => { 6 | beforeAll(() => { 7 | localStorage.setItem('imas-palette', 'read-test-id') 8 | }) 9 | 10 | afterAll(() => { 11 | localStorage.clear() 12 | }) 13 | 14 | test('取得できるか', () => { 15 | const { result } = renderHook(() => useKeepId()) 16 | const [keepIds] = result.current 17 | 18 | expect(keepIds).toEqual(['read-test-id']) 19 | }) 20 | 21 | test('追加できるか', () => { 22 | const { result, rerender } = renderHook(() => useKeepId()) 23 | 24 | act(() => { 25 | const [, handleAddKeepId] = result.current 26 | handleAddKeepId('add-test-id') 27 | }) 28 | 29 | rerender() 30 | 31 | const [keepIds] = result.current 32 | expect(keepIds).toEqual(['read-test-id', 'add-test-id']) 33 | }) 34 | 35 | test('削除できるか', () => { 36 | const { result, rerender } = renderHook(() => useKeepId()) 37 | 38 | act(() => { 39 | const [, , handleRemoveKeepId] = result.current 40 | handleRemoveKeepId('read-test-id') 41 | }) 42 | 43 | rerender() 44 | 45 | const [keepIds] = result.current 46 | expect(keepIds).toEqual(['add-test-id']) 47 | }) 48 | }) 49 | -------------------------------------------------------------------------------- /hooks/useKeepId.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState } from 'react' 2 | 3 | const APP_KEY = 'imas-palette' 4 | 5 | type KeepIdType = [ 6 | keepIds: string[], 7 | handleAddKeepId: (addId: string) => void, 8 | handleRemoveKeepId: (removeId: string) => void 9 | ] 10 | 11 | export const useKeepId = (): KeepIdType => { 12 | const [keepIds, setKeepIds] = useState([] as string[]) 13 | 14 | const handleAddKeepId = useCallback( 15 | (addId: string) => setKeepIds([...keepIds, addId]), 16 | [keepIds] 17 | ) 18 | 19 | const handleRemoveKeepId = useCallback( 20 | (removeId: string) => { 21 | const newKeepIdList = keepIds.filter((id) => id !== removeId) 22 | setKeepIds(newKeepIdList) 23 | }, 24 | [keepIds] 25 | ) 26 | 27 | // LocalStrageから読み込み 28 | useEffect(() => { 29 | const items = localStorage.getItem(APP_KEY) 30 | if (items) { 31 | setKeepIds(items.split(',')) 32 | } 33 | }, []) 34 | 35 | // LocalStrageに書き込み 36 | useEffect(() => { 37 | localStorage.setItem(APP_KEY, keepIds.join(',')) 38 | }, [keepIds]) 39 | 40 | return [keepIds, handleAddKeepId, handleRemoveKeepId] 41 | } 42 | -------------------------------------------------------------------------------- /jest-setup.js: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom' 2 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const nextJest = require('next/jest') 2 | 3 | const createJestConfig = nextJest({ 4 | dir: './' 5 | }) 6 | 7 | const customJestConfig = { 8 | moduleDirectories: ['node_modules', '<rootDir>/'], 9 | setupFilesAfterEnv: ['<rootDir>/jest-setup.js'], 10 | testPathIgnorePatterns: ['/node_modules/', '/.next/', '/e2e/'], 11 | testEnvironment: 'jest-environment-jsdom' 12 | } 13 | 14 | module.exports = createJestConfig(customJestConfig) 15 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// <reference types="next" /> 2 | /// <reference types="next/image-types/global" /> 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. 6 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true 4 | // i18n: { 5 | // locales: ['ja-JP'], 6 | // defaultLocale: 'ja-JP' 7 | // } 8 | } 9 | 10 | module.exports = nextConfig 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "imas-palette", 3 | "private": true, 4 | "scripts": { 5 | "dev": "next dev", 6 | "build": "next build", 7 | "start": "next start", 8 | "test": "jest", 9 | "test:update": "jest -u", 10 | "test:e2e": "playwright test", 11 | "fmt": "prettier --write .", 12 | "fmt:colors": "prettier --write ./data/colors.ts", 13 | "create:colors": "ts-node ./tools/create-colors.ts" 14 | }, 15 | "dependencies": { 16 | "next": "16.0.1", 17 | "react": "19.2.0", 18 | "react-device-detect": "^2.2.3", 19 | "react-dom": "19.2.0", 20 | "react-github-corner": "^2.5.0", 21 | "react-icons": "^5.0.1", 22 | "react-infinite-scroller": "^1.2.6", 23 | "react-scroll": "^1.9.0", 24 | "use-media": "^1.5.0" 25 | }, 26 | "devDependencies": { 27 | "@eslint/compat": "^1.2.4", 28 | "@eslint/eslintrc": "^3.2.0", 29 | "@playwright/test": "^1.42.1", 30 | "@tailwindcss/forms": "^0.5.7", 31 | "@tailwindcss/postcss": "^4.0.0", 32 | "@testing-library/dom": "^10.4.0", 33 | "@testing-library/jest-dom": "^6.4.2", 34 | "@testing-library/react": "^16.0.0", 35 | "@trivago/prettier-plugin-sort-imports": "^5.0.0", 36 | "@types/color-convert": "^2.0.3", 37 | "@types/jest": "^30.0.0", 38 | "@types/node": "^24.0.0", 39 | "@types/react": "19.2.2", 40 | "@types/react-dom": "^19.0.0", 41 | "@types/react-scroll": "^1.8.10", 42 | "axios": "^1.6.7", 43 | "color-classifier": "^0.0.1", 44 | "color-convert": "^3.0.0", 45 | "eslint": "9.39.0", 46 | "eslint-config-next": "16.0.1", 47 | "eslint-config-prettier": "^10.0.0", 48 | "jest": "^30.0.0", 49 | "jest-environment-jsdom": "^30.0.0", 50 | "postcss": "^8.4.35", 51 | "prettier": "^3.2.5", 52 | "tailwindcss": "^4.0.0", 53 | "ts-node": "^10.9.2", 54 | "typescript": "5.9.3" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { PlaywrightTestConfig, devices } from '@playwright/test' 2 | 3 | const config: PlaywrightTestConfig = { 4 | testDir: './e2e', 5 | retries: 2, 6 | use: { 7 | baseURL: 'http://localhost:3000/' 8 | }, 9 | webServer: { 10 | command: 'pnpm build && pnpm start', 11 | port: 3000, 12 | timeout: 120 * 1000, 13 | reuseExistingServer: !process.env.CI 14 | }, 15 | projects: [ 16 | { 17 | name: 'chrome', 18 | use: { ...devices['Desktop Chrome'] } 19 | }, 20 | { 21 | name: 'edge', 22 | use: { ...devices['Desktop Edge'] } 23 | }, 24 | { 25 | name: 'firefox', 26 | use: { ...devices['Desktop Firefox'] } 27 | }, 28 | { 29 | name: 'safari', 30 | use: { ...devices['Desktop Safari'] } 31 | }, 32 | { 33 | name: 'android', 34 | use: { ...devices['Pixel 5'] } 35 | }, 36 | { 37 | name: 'iphone', 38 | use: { ...devices['iPhone 13'] } 39 | } 40 | ] 41 | } 42 | 43 | export default config 44 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | '@tailwindcss/postcss': {} 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arrow2nd/imas-palette/599ea1652794cafb8572ceda302361a98f7f0a95/public/favicon.ico -------------------------------------------------------------------------------- /public/ogp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arrow2nd/imas-palette/599ea1652794cafb8572ceda302361a98f7f0a95/public/ogp.png -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "automerge": true, 4 | "commitMessagePrefix": ":arrow_up: ", 5 | "unicodeEmoji": true, 6 | "extends": ["config:recommended"] 7 | } 8 | -------------------------------------------------------------------------------- /styles/globals.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss'; 2 | 3 | @plugin '@tailwindcss/forms'; 4 | 5 | @theme { 6 | --color-natural-white: #faf8f7; 7 | --color-natural-gray: #515151; 8 | --color-natural-black: #1c1c1c; 9 | --color-imas: #ff74b8; 10 | } 11 | 12 | /* 13 | The default border color has changed to `currentColor` in Tailwind CSS v4, 14 | so we've added these compatibility styles to make sure everything still 15 | looks the same as it did with Tailwind CSS v3. 16 | 17 | If we ever want to remove these styles, we need to add an explicit border 18 | color utility to any element that depends on these defaults. 19 | */ 20 | @layer base { 21 | *, 22 | ::after, 23 | ::before, 24 | ::backdrop, 25 | ::file-selector-button { 26 | border-color: var(--color-gray-200, currentColor); 27 | } 28 | } 29 | 30 | /* Windowsでのフォントジャギー対策 */ 31 | a, 32 | p, 33 | input, 34 | select, 35 | label, 36 | span { 37 | transform: rotate(0.05deg); 38 | } 39 | 40 | /* 選択範囲の色 */ 41 | ::selection, 42 | ::-moz-selection { 43 | background: #ff74b8; 44 | color: #fff; 45 | } 46 | -------------------------------------------------------------------------------- /tools/create-colors.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import { fetchIdols } from './libs/fetch-idols' 3 | import { fetchUnits } from './libs/fetch-units' 4 | 5 | ;(async () => { 6 | console.log('[ start ]') 7 | 8 | const data = await Promise.all([fetchIdols(), fetchUnits()]) 9 | 10 | // 50音順にソート 11 | const results = data 12 | .flat() 13 | .sort((a, b) => a.nameKana.localeCompare(b.nameKana, 'ja')) 14 | 15 | const json = JSON.stringify(results, null, '\t') 16 | const exportText = `import { ColorDetail } from 'types/color-detail'\n\nexport const colors: ColorDetail[] = ${json}` 17 | 18 | fs.writeFileSync('./data/colors.ts', exportText) 19 | 20 | console.log('[ success! ]') 21 | })() 22 | -------------------------------------------------------------------------------- /tools/libs/color.ts: -------------------------------------------------------------------------------- 1 | import convert from 'color-convert' 2 | import { Color } from 'types/color' 3 | import { searchColors } from '../../data/search-colors' 4 | 5 | const ColorClassifier = require('color-classifier') 6 | 7 | const colorClassifier = new ColorClassifier( 8 | searchColors.filter((e) => e.hex !== '').map((e) => e.hex), 9 | ColorClassifier.AlgorithmTypes.HSV 10 | ) 11 | 12 | export function createColor(hex: string): Color { 13 | const rgb = convert.hex.rgb(hex) 14 | 15 | const rgbStr = rgb.join(',') 16 | const hsvStr = convert.rgb.hsv(...rgb).join(',') 17 | 18 | return { 19 | rgb: `rgb(${rgbStr})`, 20 | hsv: `hsv(${hsvStr})`, 21 | hex: `#${hex}`, 22 | similar: colorClassifier.classify(`#${hex}`, 'hex') 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tools/libs/fetch-idols.ts: -------------------------------------------------------------------------------- 1 | import { ColorDetail } from 'types/color-detail' 2 | import { createColor } from './color' 3 | import { fetchIdolData } from './fetch' 4 | 5 | /** 6 | * SPARQLクエリ 7 | * (全アイドルの名前とイメージカラーを取得) 8 | */ 9 | const query = ` 10 | PREFIX schema: <http://schema.org/> 11 | PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> 12 | PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#> 13 | PREFIX imas: <https://sparql.crssnky.xyz/imasrdf/URIs/imas-schema.ttl#> 14 | 15 | SELECT DISTINCT ?nameJa ?nameEn ?nameKana ?brand ?hex 16 | WHERE { 17 | ?d rdf:type ?type; 18 | rdfs:label ?nameJa; 19 | imas:Brand ?brand; 20 | imas:Color ?hex. 21 | FILTER(?type = imas:Idol) 22 | OPTIONAL{ ?d schema:alternateName ?nameEn } 23 | OPTIONAL{ ?d schema:name ?nameEn } 24 | OPTIONAL{ ?d schema:givenName ?nameEn } 25 | FILTER(lang(?nameEn)="en") 26 | OPTIONAL{ ?d imas:alternateNameKana ?nameKana } 27 | OPTIONAL{ ?d imas:nameKana ?nameKana } 28 | OPTIONAL{ ?d imas:givenNameKana ?nameKana } 29 | } 30 | ORDER BY ?nameKana 31 | ` 32 | 33 | export async function fetchIdols() { 34 | const data = await fetchIdolData(query) 35 | 36 | const results = data.map( 37 | ({ nameJa, nameEn, nameKana, brand, hex }): ColorDetail => { 38 | const id = `${nameEn.value}_${brand.value}` 39 | .toLowerCase() 40 | .replace(/ /g, '_') 41 | 42 | return { 43 | id, 44 | name: nameJa.value, 45 | nameSuppl: nameEn.value, 46 | nameKana: nameKana.value.replace(/ /g, ''), 47 | brand: brand.value, 48 | color: createColor(hex.value) 49 | } 50 | } 51 | ) 52 | 53 | return results 54 | } 55 | -------------------------------------------------------------------------------- /tools/libs/fetch-units.ts: -------------------------------------------------------------------------------- 1 | import { ColorDetail } from 'types/color-detail' 2 | import { createColor } from './color' 3 | import { fetchIdolData } from './fetch' 4 | 5 | /** 6 | * SPARQLクエリ 7 | * (ユニットのイメージカラーを取得) 8 | */ 9 | const query = ` 10 | PREFIX schema: <http://schema.org/> 11 | PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> 12 | PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#> 13 | PREFIX imas: <https://sparql.crssnky.xyz/imasrdf/URIs/imas-schema.ttl#> 14 | 15 | SELECT DISTINCT ?resource ?name ?nameKana ?brand ?hex 16 | WHERE { 17 | ?resource rdf:type imas:Unit; 18 | rdfs:label ?name; 19 | imas:Color ?hex; 20 | imas:nameKana ?nameKana; 21 | schema:member ?member. 22 | ?member imas:Brand ?brand. 23 | } 24 | ORDER BY ?nameKana 25 | ` 26 | 27 | export async function fetchUnits() { 28 | const data = await fetchIdolData(query) 29 | 30 | const results = data.map( 31 | ({ resource, name, nameKana, brand, hex }): ColorDetail => { 32 | const resourceName = resource.value.match(/detail\/(.+)$/)?.[1] || name 33 | const id = `${resourceName}_${brand.value}` 34 | .toLowerCase() 35 | .replace(/ /g, '_') 36 | 37 | return { 38 | id, 39 | name: name.value, 40 | nameSuppl: '[ユニット]', 41 | nameKana: nameKana.value.replace(/ /g, ''), 42 | brand: brand.value, 43 | color: createColor(hex.value) 44 | } 45 | } 46 | ) 47 | 48 | return results 49 | } 50 | -------------------------------------------------------------------------------- /tools/libs/fetch.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | 3 | interface IImasAPIResponse { 4 | results: { 5 | bindings: any[] 6 | } 7 | } 8 | 9 | /** 10 | * imasparql にクエリを投げる 11 | * @param query SPARQLクエリ 12 | * @returns 検索結果配列 13 | */ 14 | export async function fetchIdolData(query: string) { 15 | const trimedQuery = query.replace(/[\n\r|\s+]/g, ' ') 16 | 17 | const url = new URL('https://sparql.crssnky.xyz/spql/imas/query?output=json') 18 | url.searchParams.append('query', trimedQuery) 19 | 20 | try { 21 | const res = await axios.get<IImasAPIResponse>(url.href, { timeout: 5000 }) 22 | return res.data.results.bindings 23 | } catch (err) { 24 | console.error(err) 25 | throw err 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "commonjs", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "baseUrl": ".", 18 | "plugins": [ 19 | { 20 | "name": "next" 21 | } 22 | ] 23 | }, 24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 25 | "exclude": ["node_modules"] 26 | } 27 | -------------------------------------------------------------------------------- /types/color-detail.ts: -------------------------------------------------------------------------------- 1 | import { Color } from './color' 2 | 3 | export type ColorDetail = { 4 | id: string 5 | name: string 6 | nameSuppl: string 7 | nameKana: string 8 | brand: string 9 | color: Color 10 | } 11 | -------------------------------------------------------------------------------- /types/color.ts: -------------------------------------------------------------------------------- 1 | export type Color = { 2 | rgb: string 3 | hsv: string 4 | hex: string 5 | similar: string 6 | } 7 | -------------------------------------------------------------------------------- /types/option.ts: -------------------------------------------------------------------------------- 1 | export type Option = { 2 | title: string 3 | value: string 4 | } 5 | -------------------------------------------------------------------------------- /types/search-color.ts: -------------------------------------------------------------------------------- 1 | export type SearchColor = { 2 | name: string 3 | hex: string 4 | } 5 | -------------------------------------------------------------------------------- /types/seo.ts: -------------------------------------------------------------------------------- 1 | export type Seo = { 2 | title: string 3 | description: string 4 | url: string 5 | ogpImgUrl: string 6 | } 7 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "regions": ["hnd1"] 3 | } 4 | --------------------------------------------------------------------------------