├── public ├── robots.txt ├── favicon.ico ├── favicon-16x16.png ├── favicon-32x32.png ├── apple-touch-icon.png ├── icons │ ├── mstile-150x150.png │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ └── safari-pinned-tab.svg ├── browserconfig.xml ├── manifest.json └── index.html ├── src ├── vite-env.d.ts ├── components │ ├── app │ │ ├── index.ts │ │ ├── app.scss │ │ └── app.tsx │ ├── header │ │ ├── index.ts │ │ ├── header-button.tsx │ │ ├── header-icon.tsx │ │ ├── header.tsx │ │ └── header.scss │ ├── sidebar │ │ ├── index.ts │ │ ├── main-menu-item.tsx │ │ ├── sub-menu.tsx │ │ ├── sidebar-footer.tsx │ │ ├── color-history.tsx │ │ ├── menu-item.tsx │ │ ├── color-name-menu.tsx │ │ ├── sidebar.tsx │ │ ├── sidebar.scss │ │ └── help-menu.tsx │ ├── body-content │ │ ├── index.ts │ │ ├── body-content.tsx │ │ └── body-content.scss │ ├── color-input │ │ ├── index.ts │ │ └── color-input.tsx │ ├── color-square │ │ ├── index.ts │ │ ├── color-square.scss │ │ └── color-square.tsx │ ├── kofi-button │ │ ├── index.ts │ │ └── kofi-button.tsx │ ├── plus-button │ │ ├── index.ts │ │ ├── plus-button.tsx │ │ └── plus-button.scss │ └── hamburger-button │ │ ├── index.ts │ │ ├── hamburger-button.tsx │ │ └── hamburger-button.scss ├── styles │ ├── _general.scss │ └── _colors.scss ├── types │ ├── color-name-list.d.ts │ └── app.ts ├── fonts.ts ├── hooks │ └── use-online.ts ├── images │ └── clear-input.svg ├── index.css ├── utils │ ├── url.ts │ └── color.ts ├── main.tsx └── contexts │ ├── history-context.tsx │ ├── sidebar-context.tsx │ ├── split-view-context.tsx │ └── input-context.tsx ├── .prettierrc.json ├── .firebaserc ├── github ├── ko-fi.png ├── formula.png └── demo_small.gif ├── tsconfig.json ├── vite.config.ts ├── firebase.json ├── .gitignore ├── tsconfig.node.json ├── .github └── workflows │ ├── firebase-hosting-merge.yml │ └── firebase-hosting-pull-request.yml ├── tsconfig.app.json ├── LICENSE ├── package.json ├── eslint.config.js ├── index.html └── README.md /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/components/app/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './app'; 2 | -------------------------------------------------------------------------------- /src/components/header/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './header'; 2 | -------------------------------------------------------------------------------- /src/components/sidebar/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './sidebar'; 2 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "singleQuote": true 4 | } 5 | -------------------------------------------------------------------------------- /src/components/body-content/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './body-content'; 2 | -------------------------------------------------------------------------------- /src/components/color-input/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './color-input'; 2 | -------------------------------------------------------------------------------- /src/components/color-square/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './color-square'; 2 | -------------------------------------------------------------------------------- /src/components/kofi-button/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './kofi-button'; 2 | -------------------------------------------------------------------------------- /src/components/plus-button/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './plus-button'; 2 | -------------------------------------------------------------------------------- /.firebaserc: -------------------------------------------------------------------------------- 1 | { 2 | "projects": { 3 | "default": "gradient-generator" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /github/ko-fi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/csandman/shade-generator/HEAD/github/ko-fi.png -------------------------------------------------------------------------------- /src/components/hamburger-button/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './hamburger-button'; 2 | -------------------------------------------------------------------------------- /github/formula.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/csandman/shade-generator/HEAD/github/formula.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/csandman/shade-generator/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /github/demo_small.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/csandman/shade-generator/HEAD/github/demo_small.gif -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/csandman/shade-generator/HEAD/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/csandman/shade-generator/HEAD/public/favicon-32x32.png -------------------------------------------------------------------------------- /src/styles/_general.scss: -------------------------------------------------------------------------------- 1 | // General variables for use across the application 2 | $transition-time: 150ms; 3 | -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/csandman/shade-generator/HEAD/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/icons/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/csandman/shade-generator/HEAD/public/icons/mstile-150x150.png -------------------------------------------------------------------------------- /public/icons/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/csandman/shade-generator/HEAD/public/icons/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/icons/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/csandman/shade-generator/HEAD/public/icons/android-chrome-512x512.png -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /src/types/color-name-list.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'color-name-list' { 2 | interface ColorName { 3 | name: string; 4 | hex: string; 5 | } 6 | 7 | export const colornames: ColorName[]; 8 | } 9 | -------------------------------------------------------------------------------- /src/styles/_colors.scss: -------------------------------------------------------------------------------- 1 | // Color variables for use across the application 2 | $color: #222222; 3 | $contrast: #7a7a7a; 4 | $opposite-contrast: #181818; 5 | $high-contrast: #c2c2c2; 6 | $hover: #1a1a1a; 7 | -------------------------------------------------------------------------------- /src/types/app.ts: -------------------------------------------------------------------------------- 1 | import type { ColorInfo } from 'utils/color'; 2 | 3 | export type BodyNumber = 1 | 2; 4 | 5 | export type ColorCallback = ( 6 | color: ColorInfo | string, 7 | colorNum: BodyNumber, 8 | ) => void; 9 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import react from '@vitejs/plugin-react'; 3 | import tsconfigPaths from 'vite-tsconfig-paths'; 4 | 5 | // https://vite.dev/config/ 6 | export default defineConfig({ 7 | plugins: [react(), tsconfigPaths()], 8 | }); 9 | -------------------------------------------------------------------------------- /firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "hosting": { 3 | "public": "dist", 4 | "ignore": ["firebase.json", "**/.*", "**/node_modules/**"], 5 | "rewrites": [ 6 | { 7 | "source": "**", 8 | "destination": "/index.html" 9 | } 10 | ] 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /public/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #2b5797 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/fonts.ts: -------------------------------------------------------------------------------- 1 | // Import Nunito font 2 | import '@fontsource/nunito/300.css'; 3 | import '@fontsource/nunito/400.css'; 4 | import '@fontsource/nunito/600.css'; 5 | import '@fontsource/nunito/700.css'; 6 | import '@fontsource/nunito/300-italic.css'; 7 | import '@fontsource/nunito/400-italic.css'; 8 | import '@fontsource/nunito/600-italic.css'; 9 | import '@fontsource/nunito/700-italic.css'; 10 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Shade Gen", 3 | "name": "Shade Generator", 4 | "icons": [ 5 | { 6 | "src": "/icons/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/icons/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "start_url": ".", 17 | "display": "minimal-ui", 18 | "theme_color": "#222222", 19 | "background_color": "#222222" 20 | } 21 | -------------------------------------------------------------------------------- /src/components/plus-button/plus-button.tsx: -------------------------------------------------------------------------------- 1 | import './plus-button.scss'; 2 | 3 | interface PlusButtonProps { 4 | className?: string; 5 | open?: boolean; 6 | color?: string; 7 | } 8 | 9 | const PlusButton = ({ className, open, color }: PlusButtonProps) => ( 10 |
11 | 12 | 13 |
14 | ); 15 | 16 | export default PlusButton; 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | # misc 27 | .DS_Store 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | .env 33 | .env.production 34 | 35 | # Firebase cache 36 | .firebase/ 37 | -------------------------------------------------------------------------------- /src/components/sidebar/main-menu-item.tsx: -------------------------------------------------------------------------------- 1 | import type { IconType } from 'react-icons'; 2 | 3 | interface MainMenuItemProps { 4 | id: string; 5 | icon: IconType; 6 | label: string; 7 | onClick: (id: string) => void; 8 | } 9 | 10 | const MainMenuItem = ({ 11 | id, 12 | icon: Icon, 13 | label, 14 | onClick, 15 | }: MainMenuItemProps) => ( 16 |
onClick(e.currentTarget.id)} 20 | > 21 | 22 | {label} 23 |
24 | ); 25 | 26 | export default MainMenuItem; 27 | -------------------------------------------------------------------------------- /src/hooks/use-online.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | 3 | const useOnline = () => { 4 | const [isOnline, setNetwork] = useState(globalThis.navigator.onLine); 5 | 6 | useEffect(() => { 7 | const updateNetwork = () => { 8 | setNetwork(globalThis.navigator.onLine); 9 | }; 10 | 11 | globalThis.addEventListener('offline', updateNetwork); 12 | globalThis.addEventListener('online', updateNetwork); 13 | 14 | return () => { 15 | globalThis.removeEventListener('offline', updateNetwork); 16 | globalThis.removeEventListener('online', updateNetwork); 17 | }; 18 | }, []); 19 | 20 | return isOnline; 21 | }; 22 | 23 | export default useOnline; 24 | -------------------------------------------------------------------------------- /src/images/clear-input.svg: -------------------------------------------------------------------------------- 1 | Svg Vector Icons : http://www.onlinewebfonts.com/icon -------------------------------------------------------------------------------- /src/components/sidebar/sub-menu.tsx: -------------------------------------------------------------------------------- 1 | import { FaArrowLeft } from 'react-icons/fa'; 2 | import type { ReactNode } from 'react'; 3 | 4 | interface SubMenuProps { 5 | isOpen: boolean; 6 | title: string; 7 | onBack: () => void; 8 | children: ReactNode; 9 | id?: string; 10 | } 11 | 12 | const SubMenu = ({ isOpen, title, onBack, children, id }: SubMenuProps) => ( 13 |
14 | 18 |
{children}
19 |
20 | ); 21 | 22 | export default SubMenu; 23 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 4 | "target": "ES2023", 5 | "lib": ["ES2023"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "verbatimModuleSyntax": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | 16 | /* Linting */ 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "erasableSyntaxOnly": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "noUncheckedSideEffectImports": true 23 | }, 24 | "include": ["vite.config.ts"] 25 | } 26 | -------------------------------------------------------------------------------- /src/components/plus-button/plus-button.scss: -------------------------------------------------------------------------------- 1 | .plus-button { 2 | width: 22px; 3 | height: 22px; 4 | position: relative; 5 | .line { 6 | display: block; 7 | width: 100%; 8 | height: 4px; 9 | background: #000; 10 | position: absolute; 11 | transition: all 250ms; 12 | border-radius: 2px; 13 | opacity: 1; 14 | top: 50%; 15 | transform: translateY(-50%); 16 | 17 | &:nth-child(1) { 18 | transform: translateY(-50%) rotate(-90deg); 19 | } 20 | } 21 | &.close { 22 | .line { 23 | &:nth-child(1) { 24 | transform: translateY(-50%) rotate(180deg); 25 | } 26 | 27 | &:nth-child(2) { 28 | transform: translateY(-50%) rotate(180deg); 29 | } 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/components/sidebar/sidebar-footer.tsx: -------------------------------------------------------------------------------- 1 | import { FaGithub } from 'react-icons/fa'; 2 | import KofiButton from 'components/kofi-button'; 3 | 4 | const SidebarFooter = () => ( 5 |
6 | 12 | 13 | 14 | 20 | 21 | 22 |
23 | ); 24 | 25 | export default SidebarFooter; 26 | -------------------------------------------------------------------------------- /.github/workflows/firebase-hosting-merge.yml: -------------------------------------------------------------------------------- 1 | # This file was auto-generated by the Firebase CLI 2 | # https://github.com/firebase/firebase-tools 3 | 4 | name: Deploy to Firebase Hosting on merge 5 | on: 6 | push: 7 | branches: 8 | - main 9 | jobs: 10 | build_and_deploy: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - run: npm ci && npm run build 15 | env: 16 | VITE_APP_GA_CODE: ${{ secrets.VITE_APP_GA_CODE }} 17 | - uses: FirebaseExtended/action-hosting-deploy@v0 18 | with: 19 | repoToken: ${{ secrets.GITHUB_TOKEN }} 20 | firebaseServiceAccount: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_GRADIENT_GENERATOR }} 21 | channelId: live 22 | projectId: gradient-generator 23 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: 5 | 'Nunito', 6 | -apple-system, 7 | BlinkMacSystemFont, 8 | 'Segoe UI', 9 | 'Roboto', 10 | 'Oxygen', 11 | 'Ubuntu', 12 | 'Cantarell', 13 | 'Fira Sans', 14 | 'Droid Sans', 15 | 'Helvetica Neue', 16 | sans-serif; 17 | -webkit-font-smoothing: antialiased; 18 | -moz-osx-font-smoothing: grayscale; 19 | } 20 | 21 | body * { 22 | font-family: 23 | 'Nunito', 24 | -apple-system, 25 | BlinkMacSystemFont, 26 | 'Segoe UI', 27 | 'Roboto', 28 | 'Oxygen', 29 | 'Ubuntu', 30 | 'Cantarell', 31 | 'Fira Sans', 32 | 'Droid Sans', 33 | 'Helvetica Neue', 34 | sans-serif; 35 | } 36 | 37 | code { 38 | font-family: 39 | source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; 40 | } 41 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 4 | "target": "ES2022", 5 | "useDefineForClassFields": true, 6 | "lib": ["ES2022", "DOM", "DOM.Iterable"], 7 | "module": "ESNext", 8 | "skipLibCheck": true, 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "bundler", 12 | "allowImportingTsExtensions": true, 13 | "verbatimModuleSyntax": true, 14 | "moduleDetection": "force", 15 | "noEmit": true, 16 | "jsx": "react-jsx", 17 | 18 | /* Linting */ 19 | "strict": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "erasableSyntaxOnly": true, 23 | "noFallthroughCasesInSwitch": true, 24 | "noUncheckedSideEffectImports": true, 25 | 26 | "allowJs": true, 27 | "baseUrl": "src" 28 | }, 29 | "include": ["src"] 30 | } 31 | -------------------------------------------------------------------------------- /src/utils/url.ts: -------------------------------------------------------------------------------- 1 | export type ParsedURL = [string, string, boolean]; 2 | 3 | export interface UrlState { 4 | hex1?: string; 5 | hex2?: string; 6 | } 7 | 8 | export const parseURL = (): ParsedURL => { 9 | const path = globalThis.location.pathname.slice(1); 10 | if (path.length) { 11 | const splitUrl = globalThis.location.pathname 12 | .slice(1) 13 | .toUpperCase() 14 | .split('-'); 15 | 16 | if (splitUrl.length === 1 && /^[0-9A-F]{6}$/.test(splitUrl[0])) { 17 | return [`#${splitUrl[0]}`, '', false]; 18 | } 19 | if ( 20 | splitUrl.length === 2 && 21 | /^[0-9A-F]{6}$/.test(splitUrl[0]) && 22 | /^[0-9A-F]{6}$/.test(splitUrl[1]) 23 | ) { 24 | return [`#${splitUrl[0]}`, `#${splitUrl[1]}`, true]; 25 | } 26 | globalThis.history.pushState({}, 'Shade Generator', ''); 27 | } 28 | 29 | return ['', '', false]; 30 | }; 31 | -------------------------------------------------------------------------------- /.github/workflows/firebase-hosting-pull-request.yml: -------------------------------------------------------------------------------- 1 | # This file was auto-generated by the Firebase CLI 2 | # https://github.com/firebase/firebase-tools 3 | 4 | name: Deploy to Firebase Hosting on PR 5 | on: pull_request 6 | permissions: 7 | checks: write 8 | contents: read 9 | pull-requests: write 10 | jobs: 11 | build_and_preview: 12 | if: ${{ github.event.pull_request.head.repo.full_name == github.repository }} 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | - run: npm ci && npm run build 17 | env: 18 | VITE_APP_GA_CODE: ${{ secrets.VITE_APP_GA_CODE }} 19 | - uses: FirebaseExtended/action-hosting-deploy@v0 20 | with: 21 | repoToken: ${{ secrets.GITHUB_TOKEN }} 22 | firebaseServiceAccount: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_GRADIENT_GENERATOR }} 23 | projectId: gradient-generator 24 | -------------------------------------------------------------------------------- /src/components/kofi-button/kofi-button.tsx: -------------------------------------------------------------------------------- 1 | const KofiButton = ({ 2 | className, 3 | height = 50, 4 | }: React.SVGProps) => ( 5 | 6 | Ko-fi icon 7 | 8 | 9 | ); 10 | 11 | export default KofiButton; 12 | -------------------------------------------------------------------------------- /src/components/hamburger-button/hamburger-button.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from 'react'; 2 | import { useSidebar } from 'contexts/sidebar-context'; 3 | import './hamburger-button.scss'; 4 | 5 | interface HamburgerButtonProps { 6 | color: string; 7 | className?: string; 8 | } 9 | 10 | const HamburgerButton = ({ color, className = '' }: HamburgerButtonProps) => { 11 | const { isMenuOpen, toggleMenu } = useSidebar(); 12 | 13 | return ( 14 | 25 | ); 26 | }; 27 | 28 | export default memo(HamburgerButton); 29 | -------------------------------------------------------------------------------- /src/components/sidebar/color-history.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/no-array-index-key */ 2 | import { useHistory } from 'contexts/history-context'; 3 | import type { ColorCallback } from 'types/app'; 4 | import MenuItem from './menu-item'; 5 | 6 | interface ColorHistoryProps { 7 | handleColorClick: ColorCallback; 8 | } 9 | 10 | const ColorHistory = ({ handleColorClick }: ColorHistoryProps) => { 11 | const { recentColors } = useHistory(); 12 | 13 | return ( 14 |
15 | {recentColors.map((item, i) => ( 16 | 26 | ))} 27 |
28 | ); 29 | }; 30 | 31 | export default ColorHistory; 32 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | import './fonts'; 4 | import App from 'components/app'; 5 | import { InputProvider } from 'contexts/input-context'; 6 | import { SidebarProvider } from 'contexts/sidebar-context'; 7 | import { SplitViewProvider } from 'contexts/split-view-context'; 8 | import { HistoryProvider } from 'contexts/history-context'; 9 | import './index.css'; 10 | 11 | createRoot(document.querySelector('#root')!).render( 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | , 23 | ); 24 | 25 | if ('serviceWorker' in navigator) { 26 | // eslint-disable-next-line unicorn/prefer-top-level-await 27 | navigator.serviceWorker.ready.then((registration) => { 28 | registration.unregister(); 29 | }); 30 | } 31 | -------------------------------------------------------------------------------- /src/components/hamburger-button/hamburger-button.scss: -------------------------------------------------------------------------------- 1 | .hamburger-button { 2 | width: 32px; 3 | height: 28px; 4 | position: relative; 5 | cursor: pointer; 6 | border: 0; 7 | outline: none; 8 | padding: 0; 9 | background: transparent; 10 | 11 | .line { 12 | display: block; 13 | width: 100%; 14 | height: 6px; 15 | background: #000; 16 | position: absolute; 17 | transition: all 300ms; 18 | border-radius: 2px; 19 | opacity: 1; 20 | &:nth-child(1) { 21 | top: 0; 22 | } 23 | &:nth-child(2) { 24 | top: 50%; 25 | transform: translateY(-50%); 26 | } 27 | &:nth-child(3) { 28 | bottom: 0; 29 | } 30 | } 31 | &.close { 32 | .line { 33 | &:nth-child(1) { 34 | top: 0; 35 | transform: translateY(11px) rotate(-45deg); 36 | } 37 | &:nth-child(2) { 38 | opacity: 0; 39 | transform: translateX(20px) translateY(-50%); 40 | } 41 | &:nth-child(3) { 42 | transform: translateY(-11px) rotate(45deg); 43 | } 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /public/icons/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | Created by potrace 1.11, written by Peter Selinger 2001-2013 -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Christopher Sandvik 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 | -------------------------------------------------------------------------------- /src/components/header/header-button.tsx: -------------------------------------------------------------------------------- 1 | import { useSplitView } from 'contexts/split-view-context'; 2 | import type { IconType } from 'react-icons'; 3 | import type { ColorInfo } from 'utils/color'; 4 | 5 | interface HeaderButtonProps { 6 | action: () => void; 7 | className: string; 8 | colorData: ColorInfo; 9 | buttonText: string; 10 | icon: IconType; 11 | textClassName: string; 12 | name: string; 13 | } 14 | 15 | const HeaderButton = ({ 16 | action, 17 | className, 18 | colorData, 19 | buttonText, 20 | icon: Icon, 21 | textClassName, 22 | name, 23 | }: HeaderButtonProps) => { 24 | const { splitView, splitViewDisabled } = useSplitView(); 25 | 26 | const colorStyles = 27 | !splitView || splitViewDisabled 28 | ? { 29 | borderColor: colorData.contrast, 30 | color: colorData.contrast, 31 | } 32 | : {}; 33 | 34 | return ( 35 | 48 | ); 49 | }; 50 | 51 | export default HeaderButton; 52 | -------------------------------------------------------------------------------- /src/components/header/header-icon.tsx: -------------------------------------------------------------------------------- 1 | import { useSplitView } from 'contexts/split-view-context'; 2 | import type { ColorInfo } from 'utils/color'; 3 | 4 | interface HeaderIconProps { 5 | getRandomColors: () => void; 6 | colorData: ColorInfo; 7 | } 8 | 9 | const HeaderIcon = ({ getRandomColors, colorData }: HeaderIconProps) => { 10 | const { splitView, splitViewDisabled } = useSplitView(); 11 | 12 | return ( 13 |
14 |
23 |
30 |
37 |
46 |
47 | ); 48 | }; 49 | 50 | export default HeaderIcon; 51 | -------------------------------------------------------------------------------- /src/contexts/history-context.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useCallback, useContext, useMemo } from 'react'; 2 | import useLocalStorageState from 'use-local-storage-state'; 3 | import type { ColorInfo } from 'utils/color'; 4 | 5 | interface HistoryContextValue { 6 | recentColors: ColorInfo[]; 7 | updateRecentColors: (newColor: ColorInfo) => void; 8 | } 9 | 10 | const HistoryContext = createContext({ 11 | recentColors: [], 12 | updateRecentColors: () => {}, 13 | }); 14 | 15 | interface HistoryProviderProps { 16 | children: React.ReactNode; 17 | } 18 | 19 | export const HistoryProvider = ({ children }: HistoryProviderProps) => { 20 | const [recentColors, setRecentColors] = useLocalStorageState( 21 | 'recentColors', 22 | { 23 | defaultValue: [], 24 | }, 25 | ); 26 | 27 | const updateRecentColors = useCallback( 28 | (newColor: ColorInfo) => { 29 | setRecentColors((prevRecentColors) => [ 30 | newColor, 31 | ...prevRecentColors.slice(0, 99), 32 | ]); 33 | }, 34 | [setRecentColors], 35 | ); 36 | 37 | const contextValue = useMemo( 38 | () => ({ recentColors, updateRecentColors }), 39 | [recentColors, updateRecentColors], 40 | ); 41 | 42 | return ( 43 | 44 | {children} 45 | 46 | ); 47 | }; 48 | 49 | export const useHistory = () => useContext(HistoryContext); 50 | 51 | export default HistoryContext; 52 | -------------------------------------------------------------------------------- /src/contexts/sidebar-context.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | createContext, 3 | useState, 4 | useContext, 5 | useMemo, 6 | useCallback, 7 | } from 'react'; 8 | 9 | interface SidebarContextValue { 10 | isMenuOpen: boolean; 11 | closeMenu: () => void; 12 | openMenu: () => void; 13 | toggleMenu: () => void; 14 | } 15 | 16 | const SidebarContext = createContext({ 17 | isMenuOpen: false, 18 | closeMenu: () => {}, 19 | openMenu: () => {}, 20 | toggleMenu: () => {}, 21 | }); 22 | 23 | interface SidebarProviderProps { 24 | children: React.ReactNode; 25 | } 26 | 27 | export const SidebarProvider = ({ children }: SidebarProviderProps) => { 28 | const [isMenuOpen, setIsMenuOpen] = useState(false); 29 | 30 | const toggleMenu = useCallback(() => { 31 | setIsMenuOpen(!isMenuOpen); 32 | }, [isMenuOpen]); 33 | 34 | const openMenu = useCallback(() => { 35 | setIsMenuOpen(true); 36 | }, []); 37 | 38 | const closeMenu = useCallback(() => { 39 | setIsMenuOpen(false); 40 | }, []); 41 | 42 | const contextValue = useMemo( 43 | (): SidebarContextValue => ({ 44 | isMenuOpen, 45 | toggleMenu, 46 | openMenu, 47 | closeMenu, 48 | }), 49 | [isMenuOpen, toggleMenu, openMenu, closeMenu], 50 | ); 51 | 52 | return ( 53 | 54 | {children} 55 | 56 | ); 57 | }; 58 | 59 | export const useSidebar = () => useContext(SidebarContext); 60 | 61 | export default SidebarContext; 62 | -------------------------------------------------------------------------------- /src/components/body-content/body-content.tsx: -------------------------------------------------------------------------------- 1 | import ColorSquare from 'components/color-square'; 2 | import ColorInput from 'components/color-input'; 3 | import { useSplitView } from 'contexts/split-view-context'; 4 | import './body-content.scss'; 5 | import type { ColorInfo } from 'utils/color'; 6 | import type { BodyNumber } from 'types/app'; 7 | 8 | interface BodyContentProps { 9 | handleSubmit: (bodyNum: BodyNumber, inputValue: string) => void; 10 | bodyNum: BodyNumber; 11 | colorData: ColorInfo; 12 | } 13 | 14 | const BodyContent = ({ 15 | handleSubmit, 16 | bodyNum, 17 | colorData, 18 | }: BodyContentProps) => { 19 | const { splitView, splitViewDisabled } = useSplitView(); 20 | 21 | return ( 22 |
26 |
31 |
32 | 38 |
39 | {colorData.name} 40 |
41 |
42 |
43 | {colorData.shades.map((color, index) => ( 44 | 50 | ))} 51 |
52 |
53 |
54 | ); 55 | }; 56 | 57 | export default BodyContent; 58 | -------------------------------------------------------------------------------- /src/components/color-input/color-input.tsx: -------------------------------------------------------------------------------- 1 | import { useInput } from 'contexts/input-context'; 2 | import type { BodyNumber } from 'types/app'; 3 | 4 | interface ColorInputProps { 5 | bodyNum: BodyNumber; 6 | handleSubmit: (bodyNum: BodyNumber, inputValue: string) => void; 7 | contrast: string; 8 | oppositeContrast: string; 9 | } 10 | 11 | const ColorInput = ({ 12 | bodyNum, 13 | handleSubmit, 14 | contrast, 15 | oppositeContrast, 16 | }: ColorInputProps) => { 17 | const inputContext = useInput(); 18 | 19 | const inputValue = 20 | bodyNum === 1 ? inputContext.inputValue1 : inputContext.inputValue2; 21 | const { updateInputValue } = inputContext; 22 | 23 | const handleKeyDown = (e: React.KeyboardEvent) => { 24 | if (e.key === 'Enter' && document.activeElement?.id !== 'color-search') { 25 | handleSubmit(bodyNum, inputValue); 26 | } 27 | }; 28 | 29 | return ( 30 |
31 | 34 | { 40 | updateInputValue(bodyNum, e.target.value); 41 | }} 42 | onKeyDown={handleKeyDown} 43 | value={inputValue} 44 | style={{ borderColor: contrast }} 45 | /> 46 | 58 |
59 | ); 60 | }; 61 | 62 | export default ColorInput; 63 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "shade-generator", 4 | "version": "0.1.0", 5 | "license": "MIT", 6 | "type": "module", 7 | "scripts": { 8 | "build": "tsc -b && vite build", 9 | "dev": "vite", 10 | "lint": "eslint .", 11 | "preview": "vite preview" 12 | }, 13 | "dependencies": { 14 | "@fontsource/nunito": "^5.2.6", 15 | "@tippyjs/react": "^4.2.6", 16 | "clipboard-polyfill": "^4.1.1", 17 | "color": "^5.0.0", 18 | "color-name-list": "^11.24.0", 19 | "firebase": "^11.10.0", 20 | "nearest-color": "^0.4.4", 21 | "parse-color": "^1.0.0", 22 | "react": "^19.1.0", 23 | "react-dom": "^19.1.0", 24 | "react-ga4": "^2.1.0", 25 | "react-icons": "^5.5.0", 26 | "sass": "^1.89.2", 27 | "tippy.js": "^6.3.7", 28 | "use-local-storage-state": "^19.5.0" 29 | }, 30 | "devDependencies": { 31 | "@eslint/compat": "^1.3.1", 32 | "@eslint/js": "^9.31.0", 33 | "@stylistic/eslint-plugin": "^3.1.0", 34 | "@types/nearest-color": "^0.4.1", 35 | "@types/parse-color": "^1.0.3", 36 | "@types/react": "^19.1.8", 37 | "@types/react-dom": "^19.1.6", 38 | "@vitejs/plugin-react": "^4.6.0", 39 | "eslint": "^9.31.0", 40 | "eslint-config-airbnb-extended": "^2.1.2", 41 | "eslint-config-prettier": "^10.1.5", 42 | "eslint-import-resolver-typescript": "^4.4.4", 43 | "eslint-plugin-import-x": "^4.16.1", 44 | "eslint-plugin-jsx-a11y": "^6.10.2", 45 | "eslint-plugin-prettier": "^5.5.1", 46 | "eslint-plugin-react": "^7.37.5", 47 | "eslint-plugin-react-hooks": "^5.2.0", 48 | "eslint-plugin-react-refresh": "^0.4.20", 49 | "eslint-plugin-unicorn": "^59.0.1", 50 | "firebase-tools": "^14.10.1", 51 | "globals": "^16.3.0", 52 | "prettier": "^3.6.2", 53 | "typescript": "~5.8.3", 54 | "typescript-eslint": "^8.37.0", 55 | "vite": "^7.0.4", 56 | "vite-tsconfig-paths": "^5.1.4" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/contexts/split-view-context.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | createContext, 3 | useState, 4 | useCallback, 5 | useEffect, 6 | useContext, 7 | useMemo, 8 | } from 'react'; 9 | 10 | interface SplitViewContextValue { 11 | splitView: boolean | null; 12 | splitViewDisabled: boolean; 13 | setSplitView: (newSplitView: boolean) => void; 14 | toggleSplitView: () => void; 15 | } 16 | 17 | const SplitViewContext = createContext({ 18 | splitView: false, 19 | splitViewDisabled: false, 20 | setSplitView: () => {}, 21 | toggleSplitView: () => {}, 22 | }); 23 | 24 | const isSplitViewDisabled = () => window.innerWidth <= 600; 25 | 26 | interface SplitViewProviderProps { 27 | children: React.ReactNode; 28 | } 29 | 30 | export const SplitViewProvider = ({ children }: SplitViewProviderProps) => { 31 | const [splitView, setSplitView] = useState(null); 32 | const [splitViewDisabled, setSplitViewDisabled] = useState( 33 | isSplitViewDisabled(), 34 | ); 35 | 36 | const toggleSplitView = useCallback(() => { 37 | setSplitView((prevSplitView) => !prevSplitView); 38 | }, []); 39 | 40 | useEffect(() => { 41 | const handleResize = () => { 42 | setSplitViewDisabled(isSplitViewDisabled()); 43 | }; 44 | 45 | window.addEventListener('resize', handleResize); 46 | return () => { 47 | window.removeEventListener('resize', handleResize); 48 | }; 49 | }, []); 50 | 51 | const contextValue = useMemo( 52 | (): SplitViewContextValue => ({ 53 | splitView, 54 | splitViewDisabled, 55 | setSplitView, 56 | toggleSplitView, 57 | }), 58 | [splitView, splitViewDisabled, setSplitView, toggleSplitView], 59 | ); 60 | 61 | return ( 62 | 63 | {children} 64 | 65 | ); 66 | }; 67 | 68 | export const useSplitView = () => useContext(SplitViewContext); 69 | 70 | export default SplitViewContext; 71 | -------------------------------------------------------------------------------- /src/components/sidebar/menu-item.tsx: -------------------------------------------------------------------------------- 1 | import { useSidebar } from 'contexts/sidebar-context'; 2 | import { useSplitView } from 'contexts/split-view-context'; 3 | import type { BodyNumber, ColorCallback } from 'types/app'; 4 | import type { ColorInfo } from 'utils/color'; 5 | 6 | interface MenuItemProps { 7 | color: string; 8 | onClick: ColorCallback; 9 | contrast: string; 10 | name: string; 11 | textBottomLeft?: string; 12 | textBottomRight?: string; 13 | item: ColorInfo; 14 | } 15 | 16 | const MenuItem = ({ 17 | color, 18 | onClick, 19 | contrast, 20 | name, 21 | textBottomLeft, 22 | textBottomRight, 23 | item, 24 | }: MenuItemProps) => { 25 | const { closeMenu } = useSidebar(); 26 | const { splitView } = useSplitView(); 27 | 28 | const handleMainClick = () => { 29 | closeMenu(); 30 | onClick(item, 1); 31 | }; 32 | 33 | const handleSubClick = ( 34 | e: React.MouseEvent, 35 | num: BodyNumber, 36 | ) => { 37 | closeMenu(); 38 | onClick(item, num); 39 | e.stopPropagation(); 40 | }; 41 | 42 | return ( 43 |
49 |
{name}
50 |
{color}
51 |
{textBottomLeft}
52 |
{textBottomRight}
53 | {splitView && ( 54 |
55 |
handleSubClick(e, 1)}> 56 | Apply to Left 57 |
58 |
handleSubClick(e, 2)}> 59 | Apply to Right 60 |
61 |
62 | )} 63 |
64 | ); 65 | }; 66 | 67 | export default MenuItem; 68 | -------------------------------------------------------------------------------- /src/contexts/input-context.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | createContext, 3 | useState, 4 | useContext, 5 | useRef, 6 | useEffect, 7 | useMemo, 8 | useCallback, 9 | } from 'react'; 10 | import type { BodyNumber } from 'types/app'; 11 | 12 | type UpdateInputValue = (inputNum: BodyNumber, value: string) => void; 13 | 14 | interface InputContextValue { 15 | inputValue1: string; 16 | inputValue2: string; 17 | updateInputValue: UpdateInputValue; 18 | } 19 | 20 | const InputContext = createContext({ 21 | inputValue1: '', 22 | inputValue2: '', 23 | updateInputValue: () => {}, 24 | }); 25 | 26 | interface InputProviderProps { 27 | children: React.ReactNode; 28 | } 29 | 30 | export const InputProvider = ({ children }: InputProviderProps) => { 31 | const [inputValues, setInputValues] = useState({ 32 | inputValue1: '', 33 | inputValue2: '', 34 | }); 35 | 36 | const updateInputValue = useCallback( 37 | (inputNum: BodyNumber, value: string) => { 38 | setInputValues((prevInputValues) => ({ 39 | ...prevInputValues, 40 | [`inputValue${inputNum}`]: value, 41 | })); 42 | }, 43 | [], 44 | ); 45 | 46 | const contextValue = useMemo( 47 | () => ({ ...inputValues, updateInputValue }), 48 | [inputValues, updateInputValue], 49 | ); 50 | 51 | return ( 52 | 53 | {children} 54 | 55 | ); 56 | }; 57 | 58 | export const useInput = () => useContext(InputContext); 59 | 60 | interface InputUpdaterProps { 61 | inputValue1: string; 62 | inputValue2: string; 63 | } 64 | 65 | export const InputUpdater = ({ 66 | inputValue1, 67 | inputValue2, 68 | }: InputUpdaterProps) => { 69 | const prevInput1 = useRef(''); 70 | const prevInput2 = useRef(''); 71 | const { updateInputValue } = useInput(); 72 | 73 | useEffect(() => { 74 | if (inputValue1 !== prevInput1.current) { 75 | updateInputValue(1, inputValue1); 76 | prevInput1.current = inputValue1; 77 | } 78 | if (inputValue2 !== prevInput2.current) { 79 | updateInputValue(2, inputValue2); 80 | prevInput2.current = inputValue2; 81 | } 82 | }, [inputValue1, inputValue2, updateInputValue]); 83 | 84 | return null; 85 | }; 86 | 87 | export default InputContext; 88 | -------------------------------------------------------------------------------- /src/components/color-square/color-square.scss: -------------------------------------------------------------------------------- 1 | @use '../../styles/general' as general; 2 | 3 | .color-square { 4 | & > div { 5 | display: block !important; 6 | } 7 | 8 | .color-tile { 9 | padding: 0.5rem 1rem; 10 | width: 6rem; 11 | height: 6rem; 12 | border-radius: 5px; 13 | cursor: pointer; 14 | // transition: $transition-time background-color ease-in-out; 15 | background-color: #222; 16 | border: 0; 17 | outline: none; 18 | } 19 | 20 | @media only screen and (max-width: 1250px) { 21 | .color-tile { 22 | width: 5rem; 23 | height: 5rem; 24 | } 25 | } 26 | 27 | @media only screen and (max-width: 650px) { 28 | .color-tile { 29 | border-radius: 4px; 30 | width: calc(100vw / 4 - 5 / 4 * 1rem); 31 | height: calc(100vw / 4 - 5 / 4 * 1rem); 32 | } 33 | } 34 | } 35 | 36 | .body-content.split { 37 | .color-tile { 38 | width: 5rem; 39 | height: 5rem; 40 | } 41 | 42 | @media only screen and (max-width: 1150px) { 43 | .color-tile { 44 | border-radius: 4px; 45 | width: calc(50vw / 4 - 5 / 4 * 1rem); 46 | height: calc(50vw / 4 - 5 / 4 * 1rem); 47 | } 48 | } 49 | 50 | @media only screen and (max-width: 720px) { 51 | .color-tile { 52 | width: calc(50vw / 3 - 4 / 3 * 0.75rem); 53 | height: calc(50vw / 3 - 4 / 3 * 0.75rem); 54 | } 55 | } 56 | } 57 | 58 | .tooltip-title { 59 | font-size: 18px; 60 | margin-top: 13px; 61 | margin-bottom: 11px; 62 | font-weight: 600; 63 | letter-spacing: 1px; 64 | text-align: center; 65 | } 66 | 67 | .tooltip-sub-title { 68 | letter-spacing: 1px; 69 | margin-bottom: 0.2rem; 70 | } 71 | 72 | .popup-button { 73 | margin: 11px; 74 | margin-bottom: 0; 75 | 76 | &:last-of-type { 77 | margin-bottom: 11px; 78 | } 79 | 80 | button { 81 | width: 14rem; 82 | letter-spacing: 1px; 83 | transition: 80ms background-color linear; 84 | 85 | &:hover { 86 | background-color: #222; 87 | } 88 | } 89 | } 90 | 91 | .tippy-content { 92 | padding: 0; 93 | } 94 | 95 | .tippy-box { 96 | background-clip: padding-box; 97 | border: 2px solid white; 98 | } 99 | 100 | .tippy-box[data-placement^='bottom'] > .tippy-arrow:before { 101 | border-bottom-color: white; 102 | top: -8px; 103 | } 104 | -------------------------------------------------------------------------------- /src/components/header/header.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from 'react'; 2 | import { useSplitView } from 'contexts/split-view-context'; 3 | import HamburgerButton from 'components/hamburger-button'; 4 | import type { ColorInfo } from 'utils/color'; 5 | import { FaColumns, FaRandom } from 'react-icons/fa'; 6 | import HeaderIcon from './header-icon'; 7 | import HeaderButton from './header-button'; 8 | import './header.scss'; 9 | 10 | interface HeaderProps { 11 | colorData: ColorInfo; 12 | getRandomColors: () => void; 13 | } 14 | 15 | const Header = ({ colorData, getRandomColors }: HeaderProps) => { 16 | const { splitView, splitViewDisabled, toggleSplitView } = useSplitView(); 17 | 18 | return ( 19 | 70 | ); 71 | }; 72 | 73 | export default memo(Header); 74 | -------------------------------------------------------------------------------- /src/components/app/app.scss: -------------------------------------------------------------------------------- 1 | @use '../../styles/general' as general; 2 | 3 | * { 4 | box-sizing: border-box; 5 | 6 | &:focus { 7 | outline: none; 8 | } 9 | 10 | &::-webkit-scrollbar { 11 | display: none; 12 | } 13 | } 14 | 15 | html { 16 | font-size: 14px; 17 | 18 | @media only screen and (min-width: 2000px) { 19 | font-size: 16px; 20 | } 21 | } 22 | 23 | input { 24 | font-size: 16px; 25 | } 26 | 27 | input[type='search']::-webkit-search-decoration, 28 | input[type='search']::-webkit-search-results-button, 29 | input[type='search']::-webkit-search-results-decoration, 30 | input[type='search']::-webkit-search-cancel-button { 31 | -webkit-appearance: none; 32 | } 33 | 34 | input, 35 | input:before, 36 | input:after { 37 | user-select: initial; 38 | } 39 | 40 | #App { 41 | transition: general.$transition-time background-color ease-in-out; 42 | will-change: background-color; 43 | background-color: #222; 44 | } 45 | 46 | .main-container { 47 | position: relative; 48 | } 49 | 50 | .button { 51 | font-size: 1.2rem; 52 | padding: 0.5rem 1rem; 53 | background-color: transparent; 54 | border: 2px solid; 55 | color: white; 56 | border-radius: 5px; 57 | overflow: hidden; 58 | cursor: pointer; 59 | } 60 | 61 | .button-group { 62 | margin: 1rem !important; 63 | display: flex !important; 64 | > * { 65 | margin: 0 !important; 66 | border-radius: 0 !important; 67 | border-width: 2px 0 2px 2px !important; 68 | 69 | &:first-child { 70 | border-top-left-radius: 5px !important; 71 | border-bottom-left-radius: 5px !important; 72 | } 73 | 74 | &:last-child { 75 | border-top-right-radius: 5px !important; 76 | border-bottom-right-radius: 5px !important; 77 | border-right-width: 2px !important; 78 | } 79 | } 80 | } 81 | 82 | .page { 83 | display: flex; 84 | min-height: 100vh; 85 | justify-content: space-evenly; 86 | align-items: stretch; 87 | padding-top: 5rem; 88 | } 89 | 90 | .color-name { 91 | font-size: 2rem; 92 | font-weight: 600; 93 | text-transform: uppercase; 94 | letter-spacing: 2px; 95 | line-height: 2rem; 96 | text-align: center; 97 | transition: general.$transition-time color ease-in-out; 98 | } 99 | 100 | label { 101 | border: 0; 102 | clip: rect(0 0 0 0); 103 | height: 1px; 104 | margin: -1px; 105 | overflow: hidden; 106 | padding: 0; 107 | position: absolute; 108 | width: 1px; 109 | } 110 | 111 | .italic { 112 | font-style: italic; 113 | } 114 | 115 | @media only screen and (max-width: 650px) { 116 | .page { 117 | padding-top: 4rem; 118 | } 119 | 120 | .content-background { 121 | min-height: calc(100vh - 4rem); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/components/color-square/color-square.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useState, useRef } from 'react'; 2 | import { writeText as writeTextToClipboard } from 'clipboard-polyfill'; 3 | import Tooltip from '@tippyjs/react'; 4 | import type { ColorShade } from 'utils/color'; 5 | import type { BodyNumber } from 'types/app'; 6 | import 'tippy.js/dist/tippy.css'; 7 | import './color-square.scss'; 8 | 9 | interface ColorSquareProps { 10 | color: ColorShade; 11 | squareNumber: number; 12 | bodyNum: BodyNumber; 13 | } 14 | 15 | const ColorSquare = ({ 16 | color: { rgb, hex }, 17 | squareNumber, 18 | bodyNum, 19 | }: ColorSquareProps) => { 20 | const hexTimeout = useRef(null); 21 | const rgbTimeout = useRef(null); 22 | const [r, g, b] = rgb; 23 | const rgbStr = `rgb(${r}, ${g}, ${b})`; 24 | const hexStr = hex.toUpperCase(); 25 | 26 | const [rgbBtnTxt, setRgbBtnTxt] = useState(rgbStr); 27 | const [hexBtnTxt, setHexBtnTxt] = useState(hexStr); 28 | 29 | const background = 30 | squareNumber <= 18 31 | ? `rgba(255,255,255,${(95 - squareNumber * 5) / 100})` 32 | : `rgba(0,0,0,${((squareNumber - 18) * 5) / 100})`; 33 | 34 | const copyHexCode = () => { 35 | writeTextToClipboard(hexStr); 36 | setHexBtnTxt('Copied!'); 37 | if (hexTimeout.current) { 38 | clearTimeout(hexTimeout.current); 39 | } 40 | hexTimeout.current = setTimeout(() => { 41 | setHexBtnTxt(hexStr); 42 | }, 1200); 43 | }; 44 | 45 | const copyRgb = () => { 46 | writeTextToClipboard(rgbStr); 47 | setRgbBtnTxt('Copied!'); 48 | if (rgbTimeout.current) { 49 | clearTimeout(rgbTimeout.current); 50 | } 51 | rgbTimeout.current = setTimeout(() => { 52 | setRgbBtnTxt(rgbStr); 53 | }, 1200); 54 | }; 55 | 56 | return ( 57 |
58 | 69 |
CLICK TO COPY
70 |
71 | 74 |
75 |
76 | 79 |
80 |
81 | } 82 | > 83 |
89 | 90 |
91 | ); 92 | }; 93 | 94 | export default memo(ColorSquare); 95 | -------------------------------------------------------------------------------- /src/components/sidebar/color-name-menu.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useRef, useEffect, useMemo } from 'react'; 2 | import Color from 'color'; 3 | import { colornames, type ColorName } from 'color-name-list'; 4 | import { useSidebar } from 'contexts/sidebar-context'; 5 | import { getContrastColor } from 'utils/color'; 6 | import type { ColorCallback } from 'types/app'; 7 | import { FaSearch } from 'react-icons/fa'; 8 | 9 | const SEARCH_INPUT_ID = 'color-search'; 10 | 11 | interface ColorNameWithContrast extends ColorName { 12 | contrast: string; 13 | } 14 | 15 | interface ColorNameMenuProps { 16 | handleColorClick: ColorCallback; 17 | isOpen: boolean; 18 | } 19 | 20 | const ColorNameMenu = ({ handleColorClick, isOpen }: ColorNameMenuProps) => { 21 | const { closeMenu } = useSidebar(); 22 | 23 | const inputEl = useRef(null); 24 | const inputElTimeout = useRef(null); 25 | 26 | useEffect(() => { 27 | if (inputElTimeout.current) { 28 | clearTimeout(inputElTimeout.current); 29 | } 30 | if (isOpen && inputEl.current) { 31 | inputElTimeout.current = setTimeout(() => { 32 | inputEl.current?.select(); 33 | }, 310); 34 | } else { 35 | inputEl.current?.blur(); 36 | } 37 | }, [isOpen]); 38 | 39 | const [searchInput, setSearchInput] = useState(''); 40 | 41 | const colorNamesWithContrast = useMemo(() => { 42 | if (!searchInput) { 43 | return colornames.slice(0, 50).map((color) => ({ 44 | ...color, 45 | contrast: getContrastColor(Color(color.hex)).hex(), 46 | })); 47 | } 48 | return colornames 49 | .filter((color) => 50 | color.name 51 | .replace(/\s/g, '') 52 | .toLowerCase() 53 | .includes(searchInput.replace(/\s/g, '').toLowerCase()), 54 | ) 55 | .slice(0, 100) 56 | .map((color) => ({ 57 | ...color, 58 | contrast: getContrastColor(Color(color.hex)).hex(), 59 | })); 60 | }, [searchInput]); 61 | 62 | return ( 63 | <> 64 |
65 | 66 | 67 | setSearchInput(e.target.value)} 74 | /> 75 |
76 | 77 |
78 |
79 | {colorNamesWithContrast.map((color) => ( 80 |
{ 85 | handleColorClick(color.hex, 1); 86 | closeMenu(); 87 | }} 88 | > 89 | {color.name} 90 |
91 | ))} 92 |
93 |
94 | 95 | ); 96 | }; 97 | 98 | export default ColorNameMenu; 99 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js'; 2 | import globals from 'globals'; 3 | import reactRefresh from 'eslint-plugin-react-refresh'; 4 | import tseslint from 'typescript-eslint'; 5 | import { globalIgnores } from 'eslint/config'; 6 | import { 7 | configs as airbnbConfigs, 8 | plugins as airbnbPlugins, 9 | } from 'eslint-config-airbnb-extended'; 10 | import prettierConfig from 'eslint-config-prettier'; 11 | import eslintPluginUnicorn from 'eslint-plugin-unicorn'; 12 | 13 | const jsConfig = [ 14 | // ESLint Recommended Rules 15 | { 16 | name: 'js/config', 17 | ...js.configs.recommended, 18 | }, 19 | // Stylistic Plugin 20 | airbnbPlugins.stylistic, 21 | // Import X Plugin 22 | airbnbPlugins.importX, 23 | // Airbnb Base Recommended Config 24 | ...airbnbConfigs.base.recommended, 25 | ]; 26 | 27 | const reactConfig = [ 28 | // React Plugin 29 | airbnbPlugins.react, 30 | // React Hooks Plugin 31 | airbnbPlugins.reactHooks, 32 | // React JSX A11y Plugin 33 | airbnbPlugins.reactA11y, 34 | // Airbnb React Recommended Config 35 | ...airbnbConfigs.react.recommended, 36 | ]; 37 | 38 | const typescriptConfig = [ 39 | // TypeScript ESLint Plugin 40 | airbnbPlugins.typescriptEslint, 41 | // Airbnb Base TypeScript Config 42 | ...airbnbConfigs.base.typescript, 43 | // Airbnb React TypeScript Config 44 | ...airbnbConfigs.react.typescript, 45 | ]; 46 | 47 | export default tseslint.config([ 48 | globalIgnores(['dist']), 49 | { 50 | files: ['**/*.{ts,tsx}'], 51 | extends: [ 52 | jsConfig, 53 | js.configs.recommended, 54 | tseslint.configs.recommended, 55 | reactConfig, 56 | typescriptConfig, 57 | reactRefresh.configs.vite, 58 | eslintPluginUnicorn.configs.recommended, 59 | prettierConfig, 60 | ], 61 | languageOptions: { 62 | ecmaVersion: 2020, 63 | globals: globals.browser, 64 | }, 65 | rules: { 66 | '@typescript-eslint/consistent-type-imports': [ 67 | 'warn', 68 | { 69 | prefer: 'type-imports', 70 | disallowTypeAnnotations: true, 71 | fixStyle: 'inline-type-imports', 72 | }, 73 | ], 74 | curly: 'error', 75 | 'import-x/prefer-default-export': 'off', 76 | 'no-restricted-syntax': 'off', 77 | 'no-restricted-exports': 'off', 78 | 'react-refresh/only-export-components': 'off', 79 | 'react/react-in-jsx-scope': 'off', 80 | 'react/require-default-props': 'off', 81 | 'react/function-component-definition': [ 82 | 'error', 83 | { 84 | namedComponents: 'arrow-function', 85 | unnamedComponents: 'arrow-function', 86 | }, 87 | ], 88 | // These should be enabled 89 | 'jsx-a11y/click-events-have-key-events': 'off', 90 | 'jsx-a11y/no-static-element-interactions': 'off', 91 | 'jsx-a11y/no-noninteractive-element-interactions': 'off', 92 | 'jsx-a11y/label-has-associated-control': 'off', 93 | // Turn off Unicorn rules 94 | 'unicorn/prevent-abbreviations': 'off', 95 | 'unicorn/explicit-length-check': 'off', 96 | 'unicorn/no-array-reduce': 'off', 97 | 'unicorn/no-null': 'off', 98 | 'unicorn/prefer-object-from-entries': 'off', 99 | 'unicorn/prefer-string-replace-all': 'off', 100 | }, 101 | }, 102 | ]); 103 | -------------------------------------------------------------------------------- /src/components/sidebar/sidebar.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import { useSidebar } from 'contexts/sidebar-context'; 3 | import { FaHistory, FaQuestionCircle, FaSearch } from 'react-icons/fa'; 4 | import type { ColorCallback } from 'types/app'; 5 | import HelpMenu from './help-menu'; 6 | import ColorHistory from './color-history'; 7 | import ColorNameMenu from './color-name-menu'; 8 | import MainMenuItem from './main-menu-item'; 9 | import SubMenu from './sub-menu'; 10 | import SidebarFooter from './sidebar-footer'; 11 | import './sidebar.scss'; 12 | 13 | const initialMenuStates = { 14 | isMainMenuOpen: true, 15 | isHistoryMenuOpen: false, 16 | isSearchMenuOpen: false, 17 | isTopColorsMenuOpen: false, 18 | isHelpMenuOpen: false, 19 | }; 20 | 21 | interface SidebarProps { 22 | handleColorClick: ColorCallback; 23 | } 24 | 25 | const Sidebar = ({ handleColorClick }: SidebarProps) => { 26 | const { isMenuOpen, closeMenu } = useSidebar(); 27 | 28 | useEffect(() => { 29 | const handleKeyPress = (e: KeyboardEvent) => { 30 | if (e.code === 'Escape') { 31 | closeMenu(); 32 | } 33 | }; 34 | 35 | globalThis.addEventListener('keydown', handleKeyPress); 36 | return () => { 37 | globalThis.removeEventListener('keydown', handleKeyPress); 38 | }; 39 | }, [closeMenu]); 40 | 41 | const [menuStates, updateMenuStates] = useState(initialMenuStates); 42 | 43 | const openMenu = (menuId: string) => { 44 | const newMenuStates = { 45 | ...initialMenuStates, 46 | isMainMenuOpen: false, 47 | }; 48 | newMenuStates[`is${menuId}Open` as keyof typeof newMenuStates] = true; 49 | updateMenuStates(newMenuStates); 50 | }; 51 | 52 | const closeSubMenu = () => { 53 | if (document.activeElement instanceof HTMLElement) { 54 | document.activeElement.blur(); 55 | } 56 | updateMenuStates({ ...initialMenuStates }); 57 | }; 58 | 59 | return ( 60 |