diff --git a/apps/web/index.html b/apps/web/index.html index fd0e947..138fe63 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -1,8 +1,8 @@ - + - + \ No newline at end of file diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index b993ea1..04cc6d2 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,4 +1,5 @@ -import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'; +import { BrowserRouter, Route, Routes } from 'react-router-dom'; +import Home from '@/pages/Home'; import CommunityList from '@/pages/Community/CommunityList'; import CommunityFeed from './pages/Community/CommunityFeed'; import CommunityDetail from '@/pages/Community/CommunityDetail'; @@ -10,7 +11,7 @@ export default function App() { return ( - } /> + } /> } /> } /> } /> diff --git a/apps/web/src/hooks/useConstellation.ts b/apps/web/src/hooks/useConstellation.ts new file mode 100644 index 0000000..1560abf --- /dev/null +++ b/apps/web/src/hooks/useConstellation.ts @@ -0,0 +1,106 @@ +// src/hooks/useConstellation.ts +import { useEffect, useRef } from 'react'; + +export function useConstellation() { + const ref = useRef(null); + + useEffect(() => { + const canvas = ref.current; + if (!canvas) return; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + let raf = 0; + const DPR = Math.min(window.devicePixelRatio || 1, 2); + + const stars: { x: number; y: number; vx: number; vy: number; r: number }[] = + []; + + const gen = () => { + stars.length = 0; + const { clientWidth: w, clientHeight: h } = canvas; + const count = Math.floor((w * h) / 14000); // 화면 크기에 따른 밀도 + for (let i = 0; i < count; i++) { + stars.push({ + x: Math.random() * w, + y: Math.random() * h, + vx: (Math.random() - 0.5) * 0.15, + vy: (Math.random() - 0.5) * 0.15, + r: Math.random() * 1.6 + 0.6, + }); + } + }; + + const resize = () => { + const { clientWidth, clientHeight } = canvas; + canvas.width = Math.floor(clientWidth * DPR); + canvas.height = Math.floor(clientHeight * DPR); + ctx.setTransform(DPR, 0, 0, DPR, 0, 0); + gen(); + }; + + const draw = () => { + const { width, height } = canvas; + ctx.clearRect(0, 0, width, height); + const w = canvas.clientWidth; + const h = canvas.clientHeight; + + // 별 + ctx.save(); + for (const s of stars) { + ctx.globalAlpha = 0.9; + ctx.beginPath(); + ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2); + ctx.fillStyle = '#fff'; + ctx.fill(); + } + + // 연결선 + for (let i = 0; i < stars.length; i++) { + for (let j = i + 1; j < stars.length; j++) { + const dx = stars[i].x - stars[j].x; + const dy = stars[i].y - stars[j].y; + const dist = Math.hypot(dx, dy); + const max = 120; + if (dist < max) { + const a = 1 - dist / max; + ctx.globalAlpha = a * 0.5; + ctx.beginPath(); + ctx.moveTo(stars[i].x, stars[i].y); + ctx.lineTo(stars[j].x, stars[j].y); + ctx.lineWidth = 1; + ctx.strokeStyle = '#ffffff'; + ctx.stroke(); + } + } + } + ctx.restore(); + + // 이동 + for (const s of stars) { + s.x += s.vx; + s.y += s.vy; + if (s.x < -20) s.x = w + 20; + if (s.x > w + 20) s.x = -20; + if (s.y < -20) s.y = h + 20; + if (s.y > h + 20) s.y = -20; + } + + raf = requestAnimationFrame(draw); + }; + + const onResize = () => resize(); + + resize(); + draw(); + window.addEventListener('resize', onResize); + + return () => { + cancelAnimationFrame(raf); + window.removeEventListener('resize', onResize); + }; + }, []); + + return ref; +} diff --git a/apps/web/src/pages/Home/index.tsx b/apps/web/src/pages/Home/index.tsx new file mode 100644 index 0000000..b46a0f2 --- /dev/null +++ b/apps/web/src/pages/Home/index.tsx @@ -0,0 +1,192 @@ +import styled from '@emotion/styled'; +import { useConstellation } from '@/hooks/useConstellation'; + +const DL_URL = + 'https://github.com/prgrms-fullstack-devcourse/Runova_FE/releases/download/v1.0.0/runova.apk'; + +export default function Home() { + const canvasRef = useConstellation(); + + return ( + + + + + Runova + + 도시 위에 GPS 아트를 새기는 러닝 앱. 달려서 나만의 별자리 + 지도를 완성해 보세요. + + + + 다운로드 링크 + +
  • + + Android APK (v1.0.0) + + +
  • +
    +
    + + + + + 경로 안내 제공 +

    + 실시간 경로 트래킹과 경로 안내를 통해, 나만의 GPS 아트를 따라 + 달려보세요. +

    +
    + + + 경로 설계 +

    원하는 모양대로 경로를 그리고 다른 사람들과 공유해 보세요.

    +
    + + + 커뮤니티 +

    + 완주 인증 사진, 경로 공유, 팀메이트 구하기까지, 커뮤니티에서 함께 + 나눠요. +

    +
    +
    +
    +
    + ); +} + +const Wrap = styled.main` + position: relative; + min-height: 100dvh; + display: grid; + place-items: center; + gap: 18px; + padding: 24px 16px 40px; + background: #000; + color: #fff; + overflow: hidden; +`; + +const StarsCanvas = styled.canvas` + position: absolute; + inset: 0; + width: 100%; + height: 100%; +`; + +const Glow = styled.div` + position: absolute; + inset: -20%; + background: + radial-gradient( + 60% 50% at 50% 40%, + rgba(255, 255, 255, 0.08), + rgba(255, 255, 255, 0) 60% + ), + radial-gradient( + 40% 35% at 70% 70%, + rgba(255, 255, 255, 0.06), + rgba(255, 255, 255, 0) 65% + ); + pointer-events: none; +`; + +const Card = styled.section` + position: relative; + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + width: min(760px, 92vw); + border: 1px solid rgba(255, 255, 255, 0.14); + border-radius: 20px; + padding: 28px; + background: rgba(16, 16, 16, 0.72); + backdrop-filter: blur(6px); + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.45); +`; + +const Title = styled.h1` + font-size: 28px; + line-height: 1.2; + margin: 0 0 8px; +`; + +const Desc = styled.p` + margin: 0 10px 18px 0; + color: #d8d8d8; + font-size: 14px; + line-height: 1.6; +`; + +const Features = styled.div` + display: grid; + grid-template-columns: 1fr; + gap: 12px; + + @media (min-width: 720px) { + grid-template-columns: repeat(3, 1fr); + } +`; + +const Feature = styled.div` + padding: 14px; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 14px; + background: rgba(255, 255, 255, 0.04); +`; + +const H = styled.h3` + margin: 0 0 6px; + font-size: 16px; + line-height: 1.3; +`; + +const P = styled.p` + margin: 0; + color: #cfcfcf; + font-size: 13px; + line-height: 1.5; +`; + +const List = styled.ul` + display: grid; + gap: 12px; + margin: 12px 0 4px; + padding: 0; + list-style: none; +`; + +const A = styled.a` + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 14px 16px; + border: 1px solid rgba(255, 255, 255, 0.14); + border-radius: 14px; + text-decoration: none; + color: #fff; + transition: + transform 120ms ease, + background 120ms ease, + border-color 120ms ease; + + &:hover { + transform: translateY(-1px); + background: rgba(255, 255, 255, 0.06); + border-color: rgba(255, 255, 255, 0.28); + } +`; + +const FeatImg = styled.img` + width: 100%; + height: 380px; + object-fit: cover; + border-radius: 10px; + margin: 0 0 10px; + border: 1px solid rgba(255, 255, 255, 0.1); +`; diff --git a/apps/web/src/styles/global.tsx b/apps/web/src/styles/global.tsx index dd626f3..06fdd43 100644 --- a/apps/web/src/styles/global.tsx +++ b/apps/web/src/styles/global.tsx @@ -5,41 +5,70 @@ export default function GlobalStyle() { return ( );