├── .eslintrc.json ├── .gitignore ├── .prettierrc.json ├── LICENSE ├── README.md ├── app ├── (generator) │ └── page.tsx ├── api │ └── route.ts ├── favicon.ico ├── layout.tsx └── providers.tsx ├── bun.lockb ├── components.json ├── next.config.mjs ├── package.json ├── postcss.config.mjs ├── public └── logo.svg ├── script └── generate-icons.mjs ├── src ├── assets │ ├── items.ts │ ├── orbits.ts │ ├── tech-orbit.ts │ └── text.ts ├── atoms │ ├── duration.ts │ ├── selected-icons.ts │ ├── size.ts │ └── title.ts ├── components │ ├── add-icon-button.tsx │ ├── duration-slider.tsx │ ├── icon-select-section.tsx │ ├── icons-list.tsx │ ├── markdown-syntax-section.tsx │ ├── preview-section.tsx │ ├── remove-icon-button.tsx │ ├── search-icons.tsx │ ├── selected-icons.tsx │ ├── size-slider.tsx │ ├── slider-section.tsx │ ├── title-input-section.tsx │ └── ui │ │ ├── button.tsx │ │ ├── input.tsx │ │ └── slider.tsx ├── libs │ └── utils.ts ├── styles │ └── globals.css └── types │ └── icons.ts ├── tailwind.config.ts └── tsconfig.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next/core-web-vitals", "prettier"] 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | 38 | # etc 39 | /src/assets/icons.ts -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "plugins": ["prettier-plugin-tailwindcss"] 5 | } 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 조성원 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 | # tech-orbit 2 | 3 | "Show off your tech stack in style with tech-orbit!" 4 | 5 | ## Profile README Demo 6 | 7 | ### Markdown 8 | 9 | ```md 10 | ![Profile Demo](https://tech-orbit.wontory.dev/api?title=wontory&tech=Typescript,React,Next.js,Tailwind%20CSS&size=500) 11 | ``` 12 | 13 | ### Demo 14 | 15 | ![Profile Demo](https://tech-orbit.wontory.dev/api?tech=Typescript,React,Next.js,Tailwind%20CSS&size=500&title=wontory) 16 | 17 | ## Repository README Demo 18 | 19 | ### Markdown 20 | 21 | ```md 22 | Repository Demo 23 | ``` 24 | 25 | ### Demo 26 | 27 | Repository Demo 28 | -------------------------------------------------------------------------------- /app/(generator)/page.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image' 2 | 3 | import { MarkdownSyntaxSection } from '@/components/markdown-syntax-section' 4 | import { PreviewSection } from '@/components/preview-section' 5 | import { SliderSection } from '@/components/slider-section' 6 | import { TitleInputSection } from '@/components/title-input-section' 7 | import { IconSelectSection } from '@/components/icon-select-section' 8 | 9 | export default function Home() { 10 | return ( 11 |
12 |
13 | TECH-ORBIT 21 | TECH-ORBIT 22 | 23 | 24 | 25 |
26 |
27 | 28 | 29 |
30 |
31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /app/api/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server' 2 | 3 | import { techOrbit } from '@/assets/tech-orbit' 4 | 5 | export async function GET(req: NextRequest) { 6 | const { searchParams } = req.nextUrl 7 | const title = searchParams.get('title') || '' 8 | const tech = searchParams.get('tech') || '' 9 | const size = searchParams.get('size') || '500' 10 | const duration = searchParams.get('duration') || '20' 11 | 12 | const svg = techOrbit( 13 | Number(size), 14 | Number(duration), 15 | tech 16 | .toLowerCase() 17 | .split(',') 18 | .filter((item) => item !== ''), 19 | title, 20 | ) 21 | 22 | return new NextResponse(svg, { 23 | headers: { 24 | 'Content-Type': 'image/svg+xml', 25 | }, 26 | }) 27 | } 28 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wontory/tech-orbit/83c6bc23a82989c1f04bb241f6e5f7a20d1c46b7/app/favicon.ico -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next' 2 | import { Analytics } from '@vercel/analytics/react' 3 | import { GeistSans } from 'geist/font/sans' 4 | import { GeistMono } from 'geist/font/mono' 5 | import '@/styles/globals.css' 6 | 7 | import { cn } from '@/libs/utils' 8 | import Providers from './providers' 9 | 10 | export const metadata: Metadata = { 11 | title: 'tech-orbit', 12 | description: 'Show off your tech stack in style with tech-orbit!', 13 | } 14 | 15 | export default function RootLayout({ 16 | children, 17 | }: Readonly<{ 18 | children: React.ReactNode 19 | }>) { 20 | return ( 21 | 22 | 23 | {children} 24 | 25 | 26 | 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /app/providers.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Provider } from 'jotai' 4 | 5 | export default function Providers({ children }: { children: React.ReactNode }) { 6 | return {children} 7 | } 8 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wontory/tech-orbit/83c6bc23a82989c1f04bb241f6e5f7a20d1c46b7/bun.lockb -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "src/styles/globals.css", 9 | "baseColor": "slate", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/libs/utils" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | images: { 4 | dangerouslyAllowSVG: true, 5 | }, 6 | } 7 | 8 | export default nextConfig 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tech-orbit", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "prepare": "bun script/generate-icons.mjs", 7 | "dev": "next dev", 8 | "prebuild": "bun script/generate-icons.mjs", 9 | "build": "next build", 10 | "start": "next start", 11 | "lint": "next lint" 12 | }, 13 | "dependencies": { 14 | "@radix-ui/react-slider": "^1.2.0", 15 | "@radix-ui/react-slot": "^1.1.0", 16 | "@tanstack/react-virtual": "^3.10.6", 17 | "@vercel/analytics": "^1.5.0", 18 | "class-variance-authority": "^0.7.0", 19 | "clsx": "^2.1.1", 20 | "geist": "^1.3.0", 21 | "jotai": "^2.8.3", 22 | "lucide-react": "^0.482.0", 23 | "next": "14.2.24", 24 | "react": "^18.3.1", 25 | "react-dom": "^18.3.1", 26 | "simple-icons": "^14.0.0", 27 | "tailwind-merge": "^3.0.0", 28 | "tailwindcss-animate": "^1.0.7" 29 | }, 30 | "devDependencies": { 31 | "@types/node": "^22.0.0", 32 | "@types/react": "^18.3.3", 33 | "@types/react-dom": "^18.3.0", 34 | "eslint": "^9.0.0", 35 | "eslint-config-next": "14.2.24", 36 | "eslint-config-prettier": "^10.0.0", 37 | "postcss": "^8.4.38", 38 | "prettier": "^3.3.2", 39 | "prettier-plugin-tailwindcss": "^0.6.5", 40 | "tailwindcss": "^3.4.4", 41 | "typescript": "^5.5.2" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | } 7 | 8 | export default config 9 | -------------------------------------------------------------------------------- /public/logo.svg: -------------------------------------------------------------------------------- 1 | 7 | 17 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /script/generate-icons.mjs: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import * as simpleIcons from 'simple-icons' 3 | 4 | const iconsObject = {} 5 | 6 | Object.keys(simpleIcons).forEach((key) => { 7 | const icon = simpleIcons[key] 8 | 9 | iconsObject[icon.title.toLowerCase()] = { 10 | title: icon.title, 11 | svg: icon.svg, 12 | hex: icon.hex, 13 | } 14 | }) 15 | 16 | const iconsJson = JSON.stringify(iconsObject) 17 | 18 | fs.writeFileSync( 19 | './src/assets/icons.ts', 20 | `const icons = ${iconsJson}; export { icons };`, 21 | ) 22 | 23 | console.log('Icons data has been generated and saved to iconsData.json') 24 | -------------------------------------------------------------------------------- /src/assets/items.ts: -------------------------------------------------------------------------------- 1 | import { icons } from '@/assets/icons' 2 | import type { IconsKeyType } from '@/types/icons' 3 | 4 | function items(size: number, data: string[], duration: number) { 5 | return data.map((item, index) => { 6 | const orbitOrder = Math.floor(index / 2) 7 | const orbitRadius = 80 + 110 * orbitOrder 8 | const itemRadius = 30 + 20 * orbitOrder 9 | const initialAngle = (index % 2) * -180 10 | 11 | const icon = icons[item as IconsKeyType] 12 | 13 | return ` 14 | 28 | 36 | ${icon.svg} 37 | 38 | ` 39 | }) 40 | } 41 | 42 | export { items } 43 | -------------------------------------------------------------------------------- /src/assets/orbits.ts: -------------------------------------------------------------------------------- 1 | function orbits(size: number, length: number) { 2 | const orbitTotal = Math.ceil(length / 2) 3 | 4 | return Array.from( 5 | { length: orbitTotal }, 6 | (_, index) => ` 7 | 16 | `, 17 | ) 18 | } 19 | 20 | export { orbits } 21 | -------------------------------------------------------------------------------- /src/assets/tech-orbit.ts: -------------------------------------------------------------------------------- 1 | import { orbits } from '@/assets/orbits' 2 | import { items } from '@/assets/items' 3 | import { text } from '@/assets/text' 4 | 5 | function techOrbit( 6 | size: number, 7 | duration: number, 8 | data: string[], 9 | title: string, 10 | ) { 11 | return ` 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | ${orbits(size, data.length)} 21 | ${items(size, data, duration)} 22 | ${text(title)} 23 | 24 | ` 25 | } 26 | 27 | export { techOrbit } 28 | -------------------------------------------------------------------------------- /src/assets/text.ts: -------------------------------------------------------------------------------- 1 | function text(title: string) { 2 | return ` 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 15 | 16 | ${title} 17 | 18 | ` 19 | } 20 | 21 | export { text } 22 | -------------------------------------------------------------------------------- /src/atoms/duration.ts: -------------------------------------------------------------------------------- 1 | import { atom } from 'jotai' 2 | 3 | const durationAtom = atom([20]) 4 | 5 | export { durationAtom } 6 | -------------------------------------------------------------------------------- /src/atoms/selected-icons.ts: -------------------------------------------------------------------------------- 1 | import { atom } from 'jotai' 2 | 3 | const selectedIconsAtom = atom([]) 4 | 5 | export { selectedIconsAtom } 6 | -------------------------------------------------------------------------------- /src/atoms/size.ts: -------------------------------------------------------------------------------- 1 | import { atom } from 'jotai' 2 | 3 | const sizeAtom = atom([500]) 4 | 5 | export { sizeAtom } 6 | -------------------------------------------------------------------------------- /src/atoms/title.ts: -------------------------------------------------------------------------------- 1 | import { atom } from 'jotai' 2 | 3 | const titleAtom = atom('') 4 | 5 | export { titleAtom } 6 | -------------------------------------------------------------------------------- /src/components/add-icon-button.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useSetAtom } from 'jotai' 4 | 5 | import { Button } from '@/components/ui/button' 6 | import { selectedIconsAtom } from '@/atoms/selected-icons' 7 | import type { IconType } from '@/types/icons' 8 | 9 | function AddIconButton({ icon }: { icon: IconType }) { 10 | const setSelectedIcons = useSetAtom(selectedIconsAtom) 11 | 12 | const handleClick = () => setSelectedIcons((prev) => [...prev, icon.title]) 13 | 14 | return ( 15 | 28 | ) 29 | } 30 | 31 | export { AddIconButton } 32 | -------------------------------------------------------------------------------- /src/components/duration-slider.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useAtom } from 'jotai' 4 | 5 | import { Slider } from '@/components/ui/slider' 6 | import { durationAtom } from '@/atoms/duration' 7 | 8 | function DurationSlider() { 9 | const [duration, setDuration] = useAtom(durationAtom) 10 | 11 | return ( 12 | setDuration(value)} 18 | className="py-1.5" 19 | /> 20 | ) 21 | } 22 | 23 | export { DurationSlider } 24 | -------------------------------------------------------------------------------- /src/components/icon-select-section.tsx: -------------------------------------------------------------------------------- 1 | import { SelectedIcons } from '@/components/selected-icons' 2 | import { SearchIcons } from '@/components/search-icons' 3 | 4 | function IconSelectSection() { 5 | return ( 6 |
7 |

Icons

8 | 9 | 10 |
11 | ) 12 | } 13 | 14 | export { IconSelectSection } 15 | -------------------------------------------------------------------------------- /src/components/icons-list.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useRef } from 'react' 4 | import { useVirtualizer } from '@tanstack/react-virtual' 5 | 6 | import { AddIconButton } from '@/components/add-icon-button' 7 | import { icons } from '@/assets/icons' 8 | 9 | function IconsList({ searchValue }: { searchValue: string }) { 10 | const parentRef = useRef(null) 11 | 12 | const filteredIcons = Object.entries(icons).filter((icon) => 13 | icon[1].title.toLowerCase().includes(searchValue), 14 | ) 15 | 16 | const rowVirtualizer = useVirtualizer({ 17 | count: filteredIcons.length, 18 | getScrollElement: () => parentRef.current, 19 | estimateSize: () => 66, 20 | gap: 16, 21 | }) 22 | 23 | return ( 24 |
28 |
34 | {rowVirtualizer.getVirtualItems().map((virtualRow) => ( 35 |
43 | 44 |
45 | ))} 46 |
47 |
48 | ) 49 | } 50 | 51 | export { IconsList } 52 | -------------------------------------------------------------------------------- /src/components/markdown-syntax-section.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useAtomValue } from 'jotai' 4 | 5 | import { titleAtom } from '@/atoms/title' 6 | import { selectedIconsAtom } from '@/atoms/selected-icons' 7 | import { sizeAtom } from '@/atoms/size' 8 | import { durationAtom } from '@/atoms/duration' 9 | 10 | function MarkdownSyntaxSection() { 11 | const title = useAtomValue(titleAtom) 12 | const selectedIcons = useAtomValue(selectedIconsAtom) 13 | const size = useAtomValue(sizeAtom) 14 | const duration = useAtomValue(durationAtom) 15 | 16 | const transTitle = title.replaceAll(' ', '%20') 17 | const transIcons = selectedIcons.join(',').replaceAll(' ', '%20') 18 | 19 | return ( 20 |
21 |

Markdown Syntax

22 |
23 |         {`![${title}](https://tech-orbit.wontory.dev/api?title=${transTitle}&tech=${transIcons}&size=${size}&duration=${duration})`}
24 |       
25 |
26 | ) 27 | } 28 | 29 | export { MarkdownSyntaxSection } 30 | -------------------------------------------------------------------------------- /src/components/preview-section.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useAtomValue } from 'jotai' 4 | 5 | import { titleAtom } from '@/atoms/title' 6 | import { selectedIconsAtom } from '@/atoms/selected-icons' 7 | import { sizeAtom } from '@/atoms/size' 8 | import { durationAtom } from '@/atoms/duration' 9 | 10 | function PreviewSection() { 11 | const title = useAtomValue(titleAtom) 12 | const selectedIcons = useAtomValue(selectedIconsAtom) 13 | const size = useAtomValue(sizeAtom) 14 | const duration = useAtomValue(durationAtom) 15 | 16 | const transTitle = title.replaceAll(' ', '%20') 17 | const transIcons = selectedIcons.join(',').replaceAll(' ', '%20') 18 | 19 | return ( 20 |
21 |

Preview

22 |
23 | {title} 28 |
29 |
30 | ) 31 | } 32 | 33 | export { PreviewSection } 34 | -------------------------------------------------------------------------------- /src/components/remove-icon-button.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useSetAtom } from 'jotai' 4 | 5 | import { Button } from '@/components/ui/button' 6 | import { selectedIconsAtom } from '@/atoms/selected-icons' 7 | import type { IconType } from '@/types/icons' 8 | 9 | function RemoveIconButton({ index, icon }: { index: number; icon: IconType }) { 10 | const setSelectedIcons = useSetAtom(selectedIconsAtom) 11 | 12 | const handleClick = (index: number) => { 13 | setSelectedIcons((prev) => prev.filter((_, i) => i !== index)) 14 | } 15 | 16 | return ( 17 | 30 | ) 31 | } 32 | 33 | export { RemoveIconButton } 34 | -------------------------------------------------------------------------------- /src/components/search-icons.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useState } from 'react' 4 | 5 | import { Input } from '@/components/ui/input' 6 | import { IconsList } from './icons-list' 7 | 8 | function SearchIcons() { 9 | const [searchValue, setSearchValue] = useState('') 10 | 11 | const handleSearch = (event: React.ChangeEvent) => { 12 | setSearchValue(event.target.value.toLowerCase()) 13 | } 14 | 15 | return ( 16 | <> 17 |

Search

18 | 19 | 20 | 21 | ) 22 | } 23 | 24 | export { SearchIcons } 25 | -------------------------------------------------------------------------------- /src/components/selected-icons.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useAtomValue } from 'jotai' 4 | 5 | import { RemoveIconButton } from '@/components/remove-icon-button' 6 | import { selectedIconsAtom } from '@/atoms/selected-icons' 7 | import { icons } from '@/assets/icons' 8 | import type { IconsKeyType } from '@/types/icons' 9 | 10 | function SelectedIcons() { 11 | const selectedIcons = useAtomValue(selectedIconsAtom) 12 | 13 | return ( 14 | <> 15 |

Selected

16 |
17 | {selectedIcons.length === 0 ? ( 18 |

19 | No Selected Icons 20 |

21 | ) : ( 22 | selectedIcons.map((selectedIcon, index) => ( 23 | 28 | )) 29 | )} 30 |
31 | 32 | ) 33 | } 34 | 35 | export { SelectedIcons } 36 | -------------------------------------------------------------------------------- /src/components/size-slider.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useAtom } from 'jotai' 4 | 5 | import { Slider } from '@/components/ui/slider' 6 | import { sizeAtom } from '@/atoms/size' 7 | 8 | function SizeSlider() { 9 | const [size, setSize] = useAtom(sizeAtom) 10 | 11 | return ( 12 | setSize(value)} 18 | className="py-1.5" 19 | /> 20 | ) 21 | } 22 | 23 | export { SizeSlider } 24 | -------------------------------------------------------------------------------- /src/components/slider-section.tsx: -------------------------------------------------------------------------------- 1 | import { DurationSlider } from '@/components/duration-slider' 2 | import { SizeSlider } from '@/components/size-slider' 3 | 4 | function SliderSection() { 5 | return ( 6 |
7 |

Size

8 | 9 |

Duration

10 | 11 |
12 | ) 13 | } 14 | 15 | export { SliderSection } 16 | -------------------------------------------------------------------------------- /src/components/title-input-section.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useSetAtom } from 'jotai' 4 | 5 | import { Input } from '@/components/ui/input' 6 | import { titleAtom } from '@/atoms/title' 7 | 8 | function TitleInputSection() { 9 | const setTitle = useSetAtom(titleAtom) 10 | 11 | const handleEditTitle = (event: React.ChangeEvent) => { 12 | setTitle(event.target.value) 13 | } 14 | 15 | return ( 16 |
17 |

Title

18 | 19 |
20 | ) 21 | } 22 | 23 | export { TitleInputSection } 24 | -------------------------------------------------------------------------------- /src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { Slot } from '@radix-ui/react-slot' 3 | import { cva, type VariantProps } from 'class-variance-authority' 4 | 5 | import { cn } from '@/libs/utils' 6 | 7 | const buttonVariants = cva( 8 | 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50', 9 | { 10 | variants: { 11 | variant: { 12 | default: 'bg-primary text-primary-foreground hover:bg-primary/90', 13 | destructive: 14 | 'bg-destructive text-destructive-foreground hover:bg-destructive/90', 15 | outline: 16 | 'border border-input bg-background hover:bg-accent hover:text-accent-foreground', 17 | secondary: 18 | 'bg-secondary text-secondary-foreground hover:bg-secondary/80', 19 | ghost: 'hover:bg-accent hover:text-accent-foreground', 20 | link: 'text-primary underline-offset-4 hover:underline', 21 | }, 22 | size: { 23 | default: 'h-10 px-4 py-2', 24 | sm: 'h-9 rounded-md px-3', 25 | lg: 'h-11 rounded-md px-8', 26 | icon: 'h-10 w-10', 27 | }, 28 | }, 29 | defaultVariants: { 30 | variant: 'default', 31 | size: 'default', 32 | }, 33 | }, 34 | ) 35 | 36 | export interface ButtonProps 37 | extends React.ButtonHTMLAttributes, 38 | VariantProps { 39 | asChild?: boolean 40 | } 41 | 42 | const Button = React.forwardRef( 43 | ({ className, variant, size, asChild = false, ...props }, ref) => { 44 | const Comp = asChild ? Slot : 'button' 45 | return ( 46 | 51 | ) 52 | }, 53 | ) 54 | Button.displayName = 'Button' 55 | 56 | export { Button, buttonVariants } 57 | -------------------------------------------------------------------------------- /src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | import { cn } from '@/libs/utils' 4 | 5 | export interface InputProps 6 | extends React.InputHTMLAttributes {} 7 | 8 | const Input = React.forwardRef( 9 | ({ className, type, ...props }, ref) => { 10 | return ( 11 | 20 | ) 21 | }, 22 | ) 23 | Input.displayName = 'Input' 24 | 25 | export { Input } 26 | -------------------------------------------------------------------------------- /src/components/ui/slider.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import * as SliderPrimitive from '@radix-ui/react-slider' 3 | 4 | import { cn } from '@/libs/utils' 5 | 6 | const Slider = React.forwardRef< 7 | React.ElementRef, 8 | React.ComponentPropsWithoutRef 9 | >(({ className, ...props }, ref) => ( 10 | 18 | 19 | 20 | 21 | 22 | 23 | )) 24 | Slider.displayName = SliderPrimitive.Root.displayName 25 | 26 | export { Slider } 27 | -------------------------------------------------------------------------------- /src/libs/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from 'clsx' 2 | import { twMerge } from 'tailwind-merge' 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /src/styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 222.2 84% 4.9%; 9 | 10 | --card: 0 0% 100%; 11 | --card-foreground: 222.2 84% 4.9%; 12 | 13 | --popover: 0 0% 100%; 14 | --popover-foreground: 222.2 84% 4.9%; 15 | 16 | --primary: 222.2 47.4% 11.2%; 17 | --primary-foreground: 210 40% 98%; 18 | 19 | --secondary: 210 40% 96.1%; 20 | --secondary-foreground: 222.2 47.4% 11.2%; 21 | 22 | --muted: 210 40% 96.1%; 23 | --muted-foreground: 215.4 16.3% 46.9%; 24 | 25 | --accent: 210 40% 96.1%; 26 | --accent-foreground: 222.2 47.4% 11.2%; 27 | 28 | --destructive: 0 84.2% 60.2%; 29 | --destructive-foreground: 210 40% 98%; 30 | 31 | --border: 214.3 31.8% 91.4%; 32 | --input: 214.3 31.8% 91.4%; 33 | --ring: 222.2 84% 4.9%; 34 | 35 | --radius: 0.5rem; 36 | } 37 | 38 | .dark { 39 | --background: 222.2 84% 4.9%; 40 | --foreground: 210 40% 98%; 41 | 42 | --card: 222.2 84% 4.9%; 43 | --card-foreground: 210 40% 98%; 44 | 45 | --popover: 222.2 84% 4.9%; 46 | --popover-foreground: 210 40% 98%; 47 | 48 | --primary: 210 40% 98%; 49 | --primary-foreground: 222.2 47.4% 11.2%; 50 | 51 | --secondary: 217.2 32.6% 17.5%; 52 | --secondary-foreground: 210 40% 98%; 53 | 54 | --muted: 217.2 32.6% 17.5%; 55 | --muted-foreground: 215 20.2% 65.1%; 56 | 57 | --accent: 217.2 32.6% 17.5%; 58 | --accent-foreground: 210 40% 98%; 59 | 60 | --destructive: 0 62.8% 30.6%; 61 | --destructive-foreground: 210 40% 98%; 62 | 63 | --border: 217.2 32.6% 17.5%; 64 | --input: 217.2 32.6% 17.5%; 65 | --ring: 212.7 26.8% 83.9%; 66 | } 67 | } 68 | 69 | @layer base { 70 | * { 71 | @apply border-border; 72 | } 73 | body { 74 | @apply bg-background text-foreground; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/types/icons.ts: -------------------------------------------------------------------------------- 1 | import { icons } from '@/assets/icons' 2 | 3 | type IconsKeyType = keyof typeof icons 4 | 5 | type IconType = { 6 | title: string 7 | svg: string 8 | hex: string 9 | } 10 | 11 | export type { IconType, IconsKeyType } 12 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'tailwindcss' 2 | 3 | const config = { 4 | darkMode: ['class'], 5 | content: [ 6 | './pages/**/*.{ts,tsx}', 7 | './components/**/*.{ts,tsx}', 8 | './app/**/*.{ts,tsx}', 9 | './src/**/*.{ts,tsx}', 10 | ], 11 | prefix: '', 12 | theme: { 13 | container: { 14 | center: true, 15 | padding: '2rem', 16 | screens: { 17 | '2xl': '1400px', 18 | }, 19 | }, 20 | extend: { 21 | fontFamily: { 22 | sans: ['var(--font-geist-sans)'], 23 | mono: ['var(--font-geist-mono)'], 24 | }, 25 | colors: { 26 | border: 'hsl(var(--border))', 27 | input: 'hsl(var(--input))', 28 | ring: 'hsl(var(--ring))', 29 | background: 'hsl(var(--background))', 30 | foreground: 'hsl(var(--foreground))', 31 | primary: { 32 | DEFAULT: 'hsl(var(--primary))', 33 | foreground: 'hsl(var(--primary-foreground))', 34 | }, 35 | secondary: { 36 | DEFAULT: 'hsl(var(--secondary))', 37 | foreground: 'hsl(var(--secondary-foreground))', 38 | }, 39 | destructive: { 40 | DEFAULT: 'hsl(var(--destructive))', 41 | foreground: 'hsl(var(--destructive-foreground))', 42 | }, 43 | muted: { 44 | DEFAULT: 'hsl(var(--muted))', 45 | foreground: 'hsl(var(--muted-foreground))', 46 | }, 47 | accent: { 48 | DEFAULT: 'hsl(var(--accent))', 49 | foreground: 'hsl(var(--accent-foreground))', 50 | }, 51 | popover: { 52 | DEFAULT: 'hsl(var(--popover))', 53 | foreground: 'hsl(var(--popover-foreground))', 54 | }, 55 | card: { 56 | DEFAULT: 'hsl(var(--card))', 57 | foreground: 'hsl(var(--card-foreground))', 58 | }, 59 | }, 60 | borderRadius: { 61 | lg: 'var(--radius)', 62 | md: 'calc(var(--radius) - 2px)', 63 | sm: 'calc(var(--radius) - 4px)', 64 | }, 65 | keyframes: { 66 | 'accordion-down': { 67 | from: { height: '0' }, 68 | to: { height: 'var(--radix-accordion-content-height)' }, 69 | }, 70 | 'accordion-up': { 71 | from: { height: 'var(--radix-accordion-content-height)' }, 72 | to: { height: '0' }, 73 | }, 74 | }, 75 | animation: { 76 | 'accordion-down': 'accordion-down 0.2s ease-out', 77 | 'accordion-up': 'accordion-up 0.2s ease-out', 78 | }, 79 | }, 80 | }, 81 | plugins: [require('tailwindcss-animate')], 82 | } satisfies Config 83 | 84 | export default config 85 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "preserve", 14 | "incremental": true, 15 | "plugins": [ 16 | { 17 | "name": "next" 18 | } 19 | ], 20 | "paths": { 21 | "@/*": ["./src/*"] 22 | } 23 | }, 24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 25 | "exclude": ["node_modules"] 26 | } 27 | --------------------------------------------------------------------------------