diff --git a/.storybook/preview.js b/.storybook/preview.js index c0d8562..0179af4 100644 --- a/.storybook/preview.js +++ b/.storybook/preview.js @@ -1,4 +1,5 @@ import * as NextImage from 'next/image'; +import '../public/global.css'; // new `next/image` support const OriginalNextImage = NextImage.default; diff --git a/public/global.css b/public/global.css new file mode 100644 index 0000000..d4d0158 --- /dev/null +++ b/public/global.css @@ -0,0 +1,10 @@ +*, +*:before, +*:after { + font-family: Pretendard; + box-sizing: border-box; +} + +body { + margin: 0; +} diff --git a/src/components/github-contest/Grass/Grass.css.ts b/src/components/github-contest/Grass/Grass.css.ts new file mode 100644 index 0000000..903a662 --- /dev/null +++ b/src/components/github-contest/Grass/Grass.css.ts @@ -0,0 +1,16 @@ +import { style } from '@vanilla-extract/css'; + +export const grassWrapperStyle = style({ + position: 'relative', + maxWidth: '1060px', + display: 'flex', + flexDirection: 'column', + backgroundColor: 'transparent', + margin: '0 10px', +}); + +// TODO: style, 반응형, constant +export const grassInfoStyle = style({ + fontSize: '18px', + color: '#D6D6D6', +}); diff --git a/src/components/github-contest/Grass/Grass.stories.tsx b/src/components/github-contest/Grass/Grass.stories.tsx new file mode 100644 index 0000000..bbd16db --- /dev/null +++ b/src/components/github-contest/Grass/Grass.stories.tsx @@ -0,0 +1,22 @@ +import { ComponentStory, ComponentMeta } from '@storybook/react'; + +import Grass, { GrassPropsType } from './Grass'; + +// dummy data +const dummyData = [] as any[]; +for (let i = 1; i <= 365; i++) { + const dayObj = { date: new Date(new Date().setDate(i)), count: i % 5 }; + dummyData.push(dayObj); +} + +export default { + title: 'github-contest/Grass', + component: Grass, +} as ComponentMeta; + +const Template: ComponentStory = ({ data }: GrassPropsType) => ( + +); + +export const Default = Template.bind({}); +Default.args = { data: dummyData }; diff --git a/src/components/github-contest/Grass/Grass.tsx b/src/components/github-contest/Grass/Grass.tsx new file mode 100644 index 0000000..298712d --- /dev/null +++ b/src/components/github-contest/Grass/Grass.tsx @@ -0,0 +1,66 @@ +import { useCallback, useState } from 'react'; + +import { CountType } from '@/constants/github'; +import GrassGraph, { GrassGraphDataType } from './GrassGraph'; +import GrassTip, { GrassTipDataType } from './GrassTip'; +import GrassExplain from './GrassExplain'; + +import * as styles from './Grass.css'; + +export interface GrassPropsType { + data: GrassGraphDataType[]; +} + +const Grass = ({ data }: GrassPropsType) => { + const [grassTipData, setGrassTipData] = useState({ + x: 0, + y: 0, + infoText: '', + isHover: false, + }); + + const totalCount = data.reduce((acc, cur) => { + return acc + cur.count; + }, 0); + + const onMouseEnter = useCallback( + (x?: number, y?: number, infoText?: string) => { + if (x === undefined || y === undefined || infoText === undefined) { + return; + } + + setGrassTipData((prev) => ({ + ...prev, + x, + y, + infoText, + isHover: true, + })); + }, + [], + ); + + const onMouseLeave = useCallback(() => { + setGrassTipData((prev) => ({ + ...prev, + isHover: false, + })); + }, []); + + return ( +
+
{`${totalCount} ${CountType}s in the last year`}
+ + + +
+ ); +}; + +export default Grass; diff --git a/src/components/github-contest/Grass/GrassExplain/GrassExplain.css.ts b/src/components/github-contest/Grass/GrassExplain/GrassExplain.css.ts new file mode 100644 index 0000000..53b016f --- /dev/null +++ b/src/components/github-contest/Grass/GrassExplain/GrassExplain.css.ts @@ -0,0 +1,33 @@ +import { style } from '@vanilla-extract/css'; + +// TODO: style(사이즈), 반응형, constant +export const explainWrapper = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + width: '100%', + fontSize: '18px', + color: '#D6D6D6', + paddingLeft: '40px', + marginTop: '3px', +}); + +export const explainLinkStyle = style({ + textDecoration: 'none', + color: '#D6D6D6', + + ':hover': { + color: '#4286F5', + }, +}); + +export const colorExampleWrapperStyle = style({ + display: 'flex', + alignItems: 'center', +}); + +export const colorExampleStyle = style({ + width: '91px', + height: '15px', + margin: '0 8px', +}); diff --git a/src/components/github-contest/Grass/GrassExplain/GrassExplain.stories.tsx b/src/components/github-contest/Grass/GrassExplain/GrassExplain.stories.tsx new file mode 100644 index 0000000..e04312f --- /dev/null +++ b/src/components/github-contest/Grass/GrassExplain/GrassExplain.stories.tsx @@ -0,0 +1,8 @@ +import GrassExplain from './GrassExplain'; + +export default { + title: 'github-contest/Grass/GrassExplain', + component: GrassExplain, +}; + +export const Default = GrassExplain; diff --git a/src/components/github-contest/Grass/GrassExplain/GrassExplain.tsx b/src/components/github-contest/Grass/GrassExplain/GrassExplain.tsx new file mode 100644 index 0000000..529a383 --- /dev/null +++ b/src/components/github-contest/Grass/GrassExplain/GrassExplain.tsx @@ -0,0 +1,33 @@ +import Link from 'next/link'; +import GrassGraphItem, { + GrassLevelType, +} from '@/components/github-contest/Grass/GrassGraph/GrassGraphItem'; +import * as styles from './GrassExplain.css'; + +const GrassExplain = () => { + const levels = [0, 1, 2, 3, 4] as GrassLevelType[]; + + // TODO: 커밋 개수 가져오는 로직 설명 링크 추가 (노션 or new page) + const explainURL = '/'; + + return ( +
+ + Learn how we count commits + +
+ Less + + {levels.map((level) => { + return ( + + ); + })} + + More +
+
+ ); +}; + +export default GrassExplain; diff --git a/src/components/github-contest/Grass/GrassExplain/index.ts b/src/components/github-contest/Grass/GrassExplain/index.ts new file mode 100644 index 0000000..7eb9fd7 --- /dev/null +++ b/src/components/github-contest/Grass/GrassExplain/index.ts @@ -0,0 +1,4 @@ +import GrassExplain from './GrassExplain'; + +export * from './GrassExplain'; +export default GrassExplain; diff --git a/src/components/github-contest/Grass/GrassGraph/GrassGraph.css.ts b/src/components/github-contest/Grass/GrassGraph/GrassGraph.css.ts new file mode 100644 index 0000000..ad148f2 --- /dev/null +++ b/src/components/github-contest/Grass/GrassGraph/GrassGraph.css.ts @@ -0,0 +1,22 @@ +import { style } from '@vanilla-extract/css'; + +// TODO: stlye(font size), constant, 반응형 +export const grassGraphFontStyle = style({ + fontSize: '18px', + fill: '#D6D6D6', +}); + +export const grassGraphBaseStyle = style({ + position: 'relative', + width: '100%', + overflowX: 'scroll', + + '::-webkit-scrollbar': { + height: '0px', + }, +}); + +export const grassGraphSvgStyle = style({ + minWidth: '1060px', + height: '160px', +}); diff --git a/src/components/github-contest/Grass/GrassGraph/GrassGraph.stories.tsx b/src/components/github-contest/Grass/GrassGraph/GrassGraph.stories.tsx new file mode 100644 index 0000000..1e2872c --- /dev/null +++ b/src/components/github-contest/Grass/GrassGraph/GrassGraph.stories.tsx @@ -0,0 +1,22 @@ +import { ComponentStory, ComponentMeta } from '@storybook/react'; + +import GrassGraph, { GrassGraphPropsType } from './GrassGraph'; + +// dummy data +const dummyData = [] as any[]; +for (let i = 1; i <= 365; i++) { + const dayObj = { date: new Date(new Date().setDate(i)), count: i % 5 }; + dummyData.push(dayObj); +} + +export default { + title: 'github-contest/Grass/GrassGraph', + component: GrassGraph, +} as ComponentMeta; + +const Template: ComponentStory = ({ + data, +}: GrassGraphPropsType) => ; + +export const Default = Template.bind({}); +Default.args = { data: dummyData }; diff --git a/src/components/github-contest/Grass/GrassGraph/GrassGraph.tsx b/src/components/github-contest/Grass/GrassGraph/GrassGraph.tsx new file mode 100644 index 0000000..31455aa --- /dev/null +++ b/src/components/github-contest/Grass/GrassGraph/GrassGraph.tsx @@ -0,0 +1,115 @@ +import { DayInfo, MonthInfo, MonthTxtTrans } from '@/constants/date'; +import { CountType } from '@/constants/github'; +import GrassGraphItem from './GrassGraphItem'; +import * as styles from './GrassGraph.css'; + +export interface GrassGraphDataType { + date: Date; + count: number; +} + +export interface GrassGraphPropsType { + data: GrassGraphDataType[]; + onMouseEnter?: (x?: number, y?: number, infoText?: string) => void; + onMouseLeave?: () => void; +} + +function getLevel(count: number) { + if (count === 0) return 0; + if (count <= 2) return 1; + if (count <= 5) return 2; + if (count <= 8) return 3; + if (count >= 9) return 4; + return 0; +} + +// TODO: 디자인에 따라 rectX, rectY 이동값 반영 +const GrassGraph = ({ + data, + onMouseEnter, + onMouseLeave, +}: GrassGraphPropsType) => { + const grass = () => { + let rectX = 45; + + const monthArea = [] as JSX.Element[]; // 상단의 월 표시 + const dayArea = [] as JSX.Element[]; // 좌측의 요일 표시 + + const grassGraphArea = data?.map(({ date, count }, idx) => { + const thisYear = date.getFullYear(); + const thisMonth = date.getMonth(); + const thisDate = date.getDate(); + const thisDay = date.getDay(); + + // 텍스트 (2 commits on January 19, 2023) + const dateInfo = `${MonthTxtTrans[thisMonth]} ${thisDate}, ${thisYear}`; + const countInfo = + count > 1 ? `${count} ${CountType}s` : `${count} ${CountType}`; // 복수 처리 + const infoText = `${countInfo} on ${dateInfo}`; + + // 위치 + if (thisDay === 0 && idx !== 0) { + rectX += 19; // 일요일 나올 때마다 우로 이동 + } + const rectY = 30 + thisDay * 19; + const monthY = 20; + const dayY = rectY + 10; + + // 색상 기준 레벨 + const level = getLevel(count); + if ((thisDay === 1 || thisDay === 3 || thisDay === 5) && idx < 7) { + dayArea.push( + + {DayInfo[thisDay]} + , + ); + } + + if (thisDate === 1) { + monthArea.push( + + {MonthInfo[thisMonth]} + , + ); + } + + return ( + + ); + }); + + return ( + <> + {dayArea} + {monthArea} + {grassGraphArea} + + ); + }; + + return ( +
+ {grass()} +
+ ); +}; + +export default GrassGraph; diff --git a/src/components/github-contest/Grass/GrassGraph/GrassGraphItem/GrassGraphItem.css.ts b/src/components/github-contest/Grass/GrassGraph/GrassGraphItem/GrassGraphItem.css.ts new file mode 100644 index 0000000..6430126 --- /dev/null +++ b/src/components/github-contest/Grass/GrassGraph/GrassGraphItem/GrassGraphItem.css.ts @@ -0,0 +1,16 @@ +import { style, styleVariants } from '@vanilla-extract/css'; + +export const base = style({ + outline: '1px solid rgba(27, 31, 35, 0.06);', + outlineOffset: '-1px', + borderRadius: '2px', +}); + +// TODO: constant +export const grassGraphItemStyle = styleVariants({ + '0': [base, { fill: '#EDEDED' }], + '1': [base, { fill: '#AAFFFF' }], + '2': [base, { fill: '#58C4C4' }], + '3': [base, { fill: '#00A4CA' }], + '4': [base, { fill: '#00688F' }], +}); diff --git a/src/components/github-contest/Grass/GrassGraph/GrassGraphItem/GrassGraphItem.tsx b/src/components/github-contest/Grass/GrassGraph/GrassGraphItem/GrassGraphItem.tsx new file mode 100644 index 0000000..76628fb --- /dev/null +++ b/src/components/github-contest/Grass/GrassGraph/GrassGraphItem/GrassGraphItem.tsx @@ -0,0 +1,50 @@ +import * as styles from './GrassGraphItem.css'; + +export type GrassLevelType = 0 | 1 | 2 | 3 | 4; + +export interface GrassGraphItemPropsType { + level: GrassLevelType; + infoText?: string; + x?: number; + y?: number; + onMouseEnter?: (x?: number, y?: number, infoText?: string) => void; + onMouseLeave?: () => void; +} + +const GrassGraphItem = ({ + level, + infoText, + x, + y, + onMouseEnter, + onMouseLeave, +}: GrassGraphItemPropsType) => { + const handleMouseEnter = ( + e: React.MouseEvent, + ) => { + const bounds = e.currentTarget.getBoundingClientRect(); + console.log(bounds.left, bounds.top); + const x = bounds.left + bounds.width * 0.5; + const y = bounds.top + bounds.height * 0.5; + onMouseEnter?.(x, y, infoText); + }; + + return ( + + {infoText} + + ); +}; + +export default GrassGraphItem; diff --git a/src/components/github-contest/Grass/GrassGraph/GrassGraphItem/index.ts b/src/components/github-contest/Grass/GrassGraph/GrassGraphItem/index.ts new file mode 100644 index 0000000..a1ecbe6 --- /dev/null +++ b/src/components/github-contest/Grass/GrassGraph/GrassGraphItem/index.ts @@ -0,0 +1,4 @@ +import GrassGraphItem from './GrassGraphItem'; + +export * from './GrassGraphItem'; +export default GrassGraphItem; diff --git a/src/components/github-contest/Grass/GrassGraph/index.ts b/src/components/github-contest/Grass/GrassGraph/index.ts new file mode 100644 index 0000000..9d03b3a --- /dev/null +++ b/src/components/github-contest/Grass/GrassGraph/index.ts @@ -0,0 +1,4 @@ +import GrassGraph from './GrassGraph'; + +export * from './GrassGraph'; +export default GrassGraph; diff --git a/src/components/github-contest/Grass/GrassTip/GrassTip.css.ts b/src/components/github-contest/Grass/GrassTip/GrassTip.css.ts new file mode 100644 index 0000000..c3ffaa5 --- /dev/null +++ b/src/components/github-contest/Grass/GrassTip/GrassTip.css.ts @@ -0,0 +1,30 @@ +import { style, styleVariants } from '@vanilla-extract/css'; + +// TODO: style +export const base = style({ + position: 'fixed', + zIndex: '999', + fontSize: '16px', + background: 'black', + textAlign: 'center', + padding: '8px 16px', + color: 'white', + borderRadius: '6px', + pointerEvents: 'none', + whiteSpace: 'nowrap', +}); + +export const grassTipStyle = styleVariants({ + block: [base, { display: 'block' }], + none: [base, { display: 'none' }], +}); + +export const pointStyle = style({ + position: 'fixed', + zIndex: '999', + background: 'black', + width: '10px', + height: '10px', + transform: 'rotate(45deg)', + border: 'none', +}); diff --git a/src/components/github-contest/Grass/GrassTip/GrassTip.stories.tsx b/src/components/github-contest/Grass/GrassTip/GrassTip.stories.tsx new file mode 100644 index 0000000..eaec5f2 --- /dev/null +++ b/src/components/github-contest/Grass/GrassTip/GrassTip.stories.tsx @@ -0,0 +1,22 @@ +import { ComponentStory, ComponentMeta } from '@storybook/react'; + +import GrassTip, { GrassTipPropsType } from './GrassTip'; + +const dummyData = { + x: 0, + y: 0, + infoText: '2 commits on January 19, 2023', + isHover: true, +}; + +export default { + title: 'github-contest/Grass/GrassTip', + component: GrassTip, +} as ComponentMeta; + +const Template: ComponentStory = ({ + grassTipData, +}: GrassTipPropsType) => ; + +export const Default = Template.bind({}); +Default.args = { grassTipData: dummyData }; diff --git a/src/components/github-contest/Grass/GrassTip/GrassTip.tsx b/src/components/github-contest/Grass/GrassTip/GrassTip.tsx new file mode 100644 index 0000000..96682cd --- /dev/null +++ b/src/components/github-contest/Grass/GrassTip/GrassTip.tsx @@ -0,0 +1,37 @@ +import * as styles from './GrassTip.css'; + +export interface GrassTipDataType { + x: number; + y: number; + infoText: string; + isHover: boolean; +} + +export interface GrassTipPropsType { + grassTipData: GrassTipDataType; +} + +// TODO: style +const GrassTip = ({ grassTipData }: GrassTipPropsType) => { + const displayType = grassTipData.isHover ? 'block' : 'none'; + return ( +
+
{grassTipData.infoText}
+
+
+ ); +}; + +export default GrassTip; diff --git a/src/components/github-contest/Grass/GrassTip/index.ts b/src/components/github-contest/Grass/GrassTip/index.ts new file mode 100644 index 0000000..5372621 --- /dev/null +++ b/src/components/github-contest/Grass/GrassTip/index.ts @@ -0,0 +1,4 @@ +import GrassTip from './GrassTip'; + +export * from './GrassTip'; +export default GrassTip; diff --git a/src/components/github-contest/Grass/index.ts b/src/components/github-contest/Grass/index.ts new file mode 100644 index 0000000..de30256 --- /dev/null +++ b/src/components/github-contest/Grass/index.ts @@ -0,0 +1,4 @@ +import Grass from './Grass'; + +export * from './Grass'; +export default Grass; diff --git a/src/constants/date.ts b/src/constants/date.ts new file mode 100644 index 0000000..10339a1 --- /dev/null +++ b/src/constants/date.ts @@ -0,0 +1,43 @@ +interface IndexSignature { + [key: string]: string; +} + +export const DayInfo: IndexSignature = { + '0': 'Sun', + '1': 'Mon', + '2': 'Tue', + '3': 'Wed', + '4': 'Thu', + '5': 'Fri', + '6': 'Sat', +}; + +export const MonthInfo: IndexSignature = { + '0': 'JAN', + '1': 'FEB', + '2': 'MAR', + '3': 'APR', + '4': 'MAY', + '5': 'JUN', + '6': 'JUL', + '7': 'AUG', + '8': 'SEP', + '9': 'OCT', + '10': 'NOV', + '11': 'DEC', +}; + +export const MonthTxtTrans: IndexSignature = { + '0': 'January', + '1': 'Febuary', + '2': 'March', + '3': 'April', + '4': 'May', + '5': 'June', + '6': 'July', + '7': 'August', + '8': 'September', + '9': 'October', + '10': 'November', + '11': 'December', +}; diff --git a/src/constants/github.ts b/src/constants/github.ts new file mode 100644 index 0000000..315e99d --- /dev/null +++ b/src/constants/github.ts @@ -0,0 +1,2 @@ +// TODO: contributions를 가져올 수 있는 방법을 찾으면 업데이트 +export const CountType = 'commit'; diff --git a/src/constants/index.ts b/src/constants/index.ts new file mode 100644 index 0000000..1f624c3 --- /dev/null +++ b/src/constants/index.ts @@ -0,0 +1,2 @@ +export * from './date'; +export * from './github'; diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index da826ed..1b8fc24 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -1,4 +1,5 @@ import type { AppProps } from 'next/app'; +import '@/public/global.css'; export default function App({ Component, pageProps }: AppProps) { return ; diff --git a/tsconfig.json b/tsconfig.json index b14e1e7..689c4cc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,7 +21,8 @@ "@/logics/*": ["src/logics/*"], "@/constants/*": ["src/constants/*"], "@/assets/*": ["src/assets/*"], - "@/lib/*": ["lib/*"] + "@/lib/*": ["lib/*"], + "@/public/*": ["public/*"] } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],