This commit is contained in:
mindol1004
2024-12-23 10:03:23 +09:00
parent d80cba44d8
commit 877997c3f7
43 changed files with 3565 additions and 982 deletions

View File

@@ -1,3 +1,7 @@
{
"extends": ["next/core-web-vitals", "next/typescript"]
"extends": [
"airbnb-base",
"next/core-web-vitals",
"next/typescript"
]
}

9
.prettierrc Normal file
View File

@@ -0,0 +1,9 @@
{
"singleQuote": true,
"semi": true,
"useTabs": false,
"tabWidth": 2,
"trailingComma": "all",
"printWidth": 120,
"arrowParens": "always"
}

11
next.config.js Normal file
View File

@@ -0,0 +1,11 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
experimental: {
turbo: {
enable: true,
},
},
}
module.exports = nextConfig

View File

@@ -1,7 +0,0 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

2698
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,24 +3,36 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"dev": "next dev --turbo",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"react": "19.0.0-rc-66855b96-20241106",
"react-dom": "19.0.0-rc-66855b96-20241106",
"next": "15.0.3"
"@heroicons/react": "^2.2.0",
"@reduxjs/toolkit": "^2.3.0",
"@types/classnames": "^2.3.0",
"axios": "^1.7.8",
"classnames": "^2.5.1",
"drop-the-bit": "file:",
"next": "^14.2.18",
"next-redux-wrapper": "^8.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-redux": "^9.1.2",
"redux-persist": "^6.0.0"
},
"devDependencies": {
"typescript": "^5",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"eslint": "^8",
"eslint-config-next": "15.0.3"
"autoprefixer": "^10.4.20",
"css-loader": "^7.1.2",
"eslint": "^9.15.0",
"eslint-config-next": "15.0.3",
"postcss": "^8.4.49",
"style-loader": "^4.0.0",
"tailwindcss": "^3.4.15",
"typescript": "^5"
}
}

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Binary file not shown.

View File

@@ -1,21 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--background: #ffffff;
--foreground: #171717;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
color: var(--foreground);
background: var(--background);
font-family: Arial, Helvetica, sans-serif;
}

View File

@@ -1,35 +0,0 @@
import type { Metadata } from "next";
import localFont from "next/font/local";
import "./globals.css";
const geistSans = localFont({
src: "./fonts/GeistVF.woff",
variable: "--font-geist-sans",
weight: "100 900",
});
const geistMono = localFont({
src: "./fonts/GeistMonoVF.woff",
variable: "--font-geist-mono",
weight: "100 900",
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
</body>
</html>
);
}

View File

@@ -1,101 +0,0 @@
import Image from "next/image";
export default function Home() {
return (
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
<main className="flex flex-col gap-8 row-start-2 items-center sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={180}
height={38}
priority
/>
<ol className="list-inside list-decimal text-sm text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
<li className="mb-2">
Get started by editing{" "}
<code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-semibold">
src/app/page.tsx
</code>
.
</li>
<li>Save and see your changes instantly.</li>
</ol>
<div className="flex gap-4 items-center flex-col sm:flex-row">
<a
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={20}
height={20}
/>
Deploy now
</a>
<a
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:min-w-44"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Read our docs
</a>
</div>
</main>
<footer className="row-start-3 flex gap-6 flex-wrap items-center justify-center">
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/file.svg"
alt="File icon"
width={16}
height={16}
/>
Learn
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/window.svg"
alt="Window icon"
width={16}
height={16}
/>
Examples
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/globe.svg"
alt="Globe icon"
width={16}
height={16}
/>
Go to nextjs.org
</a>
</footer>
</div>
);
}

View File

@@ -0,0 +1,144 @@
.button {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 600;
border-radius: 0.5rem;
transition: all 0.2s;
cursor: pointer;
}
.button:focus {
outline: none;
box-shadow: 0 0 0 2px var(--background), 0 0 0 4px var(--primary);
}
.button.disabled {
opacity: 0.6;
cursor: not-allowed;
}
.button.fullWidth {
width: 100%;
}
.button.primary {
background-color: var(--primary);
color: white;
}
.button.primary:hover:not(.disabled) {
background-color: var(--primary-hover);
}
.button.secondary {
background-color: #f3f4f6;
color: #111827;
}
.button.secondary.dark-mode {
background-color: #374151;
color: #f9fafb;
}
.button.secondary:hover:not(.disabled) {
background-color: #e5e7eb;
}
.button.secondary.dark-mode:hover:not(.disabled) {
background-color: #4b5563;
}
.button.outline {
background-color: transparent;
border: 1px solid var(--border);
color: var(--foreground);
}
.button.outline:hover:not(.disabled) {
background-color: rgba(0, 0, 0, 0.05);
}
.button.outline.dark-mode:hover:not(.disabled) {
background-color: rgba(255, 255, 255, 0.05);
}
.button.ghost {
background-color: transparent;
color: var(--foreground);
}
.button.ghost:hover:not(.disabled) {
background-color: rgba(0, 0, 0, 0.05);
}
.button.ghost.dark-mode:hover:not(.disabled) {
background-color: rgba(255, 255, 255, 0.05);
}
.sm { height: 2rem; padding: 0.75rem; font-size: 0.875rem; gap: 0.375rem; }
.md { height: 2.5rem; padding: 1rem; font-size: 1rem; gap: 0.5rem; }
.lg { height: 3rem; padding: 1.5rem; font-size: 1.125rem; gap: 0.625rem; }
.loading {
cursor: wait;
}
.loading .content {
opacity: 0;
}
.spinner {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
display: flex;
align-items: center;
justify-content: center;
}
.spinnerIcon {
animation: spin 1s linear infinite;
width: 1.25em;
height: 1.25em;
}
.spinnerPath {
opacity: 0.3;
}
.spinnerPath:nth-child(1) {
opacity: 0.8;
stroke-dasharray: 89, 200;
stroke-dashoffset: -35;
animation: dash 1.5s ease-in-out infinite;
}
.leftIcon,
.rightIcon {
display: flex;
align-items: center;
font-size: 1.25em;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes dash {
0% {
stroke-dasharray: 1, 200;
stroke-dashoffset: 0;
}
50% {
stroke-dasharray: 89, 200;
stroke-dashoffset: -35;
}
100% {
stroke-dasharray: 89, 200;
stroke-dashoffset: -124;
}
}

View File

@@ -0,0 +1,67 @@
import React, { ButtonHTMLAttributes, ReactNode } from 'react';
import classNames from 'classnames';
import styles from './Button.module.css';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'outline' | 'ghost';
size?: 'sm' | 'md' | 'lg';
fullWidth?: boolean;
isLoading?: boolean;
leftIcon?: ReactNode;
rightIcon?: ReactNode;
children: ReactNode;
}
export function Button({
variant = 'primary',
size = 'md',
fullWidth = false,
isLoading = false,
leftIcon,
rightIcon,
className,
children,
disabled,
...props
}: Readonly<ButtonProps>) {
return (
<button
className={classNames(
styles.button,
styles[variant],
styles[size],
{
[styles.fullWidth]: fullWidth,
[styles.loading]: isLoading,
[styles.disabled]: disabled,
},
className
)}
disabled={disabled || isLoading}
{...props}
>
{isLoading && (
<span className={styles.spinner}>
<svg className={styles.spinnerIcon} viewBox="0 0 24 24">
<circle
className={styles.spinnerPath}
cx="12"
cy="12"
r="10"
fill="none"
stroke="currentColor"
strokeWidth="4"
/>
</svg>
</span>
)}
{!isLoading && leftIcon && (
<span className={styles.leftIcon}>{leftIcon}</span>
)}
<span className={styles.content}>{children}</span>
{!isLoading && rightIcon && (
<span className={styles.rightIcon}>{rightIcon}</span>
)}
</button>
);
}

View File

@@ -0,0 +1,56 @@
.card {
border-radius: 0.75rem;
background-color: var(--card-bg);
transition: all 0.2s;
}
.default {
border: 1px solid var(--border);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.default.dark-mode {
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
.outline {
border: 1px solid var(--border);
}
.ghost {
background-color: transparent;
}
.padding {
&-none { padding: 0; }
&-sm { padding: 0.75rem; }
&-md { padding: 1rem; }
&-lg { padding: 1.5rem; }
}
.hoverable:hover {
transform: translateY(-2px);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
.hoverable.dark-mode:hover {
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3),
0 2px 4px -1px rgba(0, 0, 0, 0.18);
}
.header {
padding: 1rem;
border-bottom: 1px solid var(--border);
font-weight: 600;
}
.body {
padding: 1rem;
}
.footer {
padding: 1rem;
border-top: 1px solid var(--border);
background-color: var(--card-footer-bg);
}

View File

@@ -0,0 +1,65 @@
import React, { HTMLAttributes, ReactNode } from 'react';
import classNames from 'classnames';
import styles from './Card.module.css';
interface CardProps extends HTMLAttributes<HTMLDivElement> {
variant?: 'default' | 'outline' | 'ghost';
padding?: 'none' | 'sm' | 'md' | 'lg';
children: ReactNode;
hoverable?: boolean;
}
export function Card({
variant = 'default',
padding = 'md',
hoverable = false,
className,
children,
...props
}: Readonly<CardProps>) {
return (
<div
className={classNames(
styles.card,
styles[variant],
styles[`padding-${padding}`],
{
[styles.hoverable]: hoverable,
},
className
)}
{...props}
>
{children}
</div>
);
}
// 서브 컴포넌트들
interface CardSubComponentProps extends HTMLAttributes<HTMLDivElement> {
children: ReactNode;
}
export function CardHeader({ className, children, ...props }: Readonly<CardSubComponentProps>) {
return (
<div className={classNames(styles.header, className)} {...props}>
{children}
</div>
);
}
export function CardBody({ className, children, ...props }: Readonly<CardSubComponentProps>) {
return (
<div className={classNames(styles.body, className)} {...props}>
{children}
</div>
);
}
export function CardFooter({ className, children, ...props }: Readonly<CardSubComponentProps>) {
return (
<div className={classNames(styles.footer, className)} {...props}>
{children}
</div>
);
}

View File

@@ -0,0 +1,37 @@
.footer {
border-top: 1px solid var(--border);
background-color: var(--background);
padding: 1.5rem 0;
}
.container {
max-width: 1280px; /* lg breakpoint */
margin: 0 auto;
padding: 0 1rem;
}
.content {
display: flex;
justify-content: space-between;
align-items: center;
}
.copyright {
color: #6b7280;
font-size: 0.875rem;
}
.links {
display: flex;
gap: 1.5rem;
}
.link {
color: #6b7280;
text-decoration: none;
font-size: 0.875rem;
}
.link:hover {
color: var(--foreground);
}

View File

@@ -0,0 +1,20 @@
import React from 'react';
import styles from './Footer.module.css';
export function Footer() {
return (
<footer className={styles.footer}>
<div className={styles.container}>
<div className={styles.content}>
<p className={styles.copyright}>
© {new Date().getFullYear()} Your Company. All rights reserved.
</p>
<div className={styles.links}>
<a href="/terms" className={styles.link}></a>
<a href="/privacy" className={styles.link}></a>
</div>
</div>
</div>
</footer>
);
}

View File

@@ -0,0 +1,56 @@
.header {
height: 64px;
border-bottom: 1px solid var(--border);
background-color: var(--background);
}
.container {
max-width: 1280px; /* lg breakpoint */
height: 100%;
margin: 0 auto;
padding: 0 1rem;
display: flex;
align-items: center;
justify-content: space-between;
}
.logo {
font-size: 1.25rem;
font-weight: 600;
color: var(--foreground);
text-decoration: none;
}
.nav {
display: flex;
gap: 1.5rem;
}
.navItem {
color: var(--foreground);
text-decoration: none;
font-weight: 500;
}
.navItem:hover {
color: var(--primary);
}
.actions {
display: flex;
align-items: center;
gap: 1rem;
}
.button {
padding: 0.5rem 1rem;
border-radius: 0.5rem;
border: 1px solid var(--border);
background: transparent;
color: var(--foreground);
cursor: pointer;
}
.button:hover {
background-color: var(--input-bg);
}

View File

@@ -0,0 +1,28 @@
import React from 'react';
import Link from 'next/link';
import styles from './Header.module.css';
export function Header() {
return (
<header className={styles.header}>
<div className={styles.container}>
<Link href="/" className={styles.logo}>
</Link>
<nav className={styles.nav}>
<Link href="/dashboard" className={styles.navItem}>
</Link>
<Link href="/profile" className={styles.navItem}>
</Link>
</nav>
<div className={styles.actions}>
<button className={styles.button}></button>
</div>
</div>
</header>
);
}

View File

@@ -0,0 +1,76 @@
.container {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.container.fullWidth {
width: 100%;
}
.label {
font-size: 0.875rem;
font-weight: 500;
color: var(--foreground);
}
.inputWrapper {
position: relative;
display: flex;
align-items: center;
}
.input {
width: 100%;
height: 2.5rem;
padding: 0 0.75rem;
font-size: 0.875rem;
border-radius: 0.5rem;
border: 1px solid var(--border);
background-color: var(--input-bg);
color: var(--foreground);
transition: all 0.2s;
}
.input.error {
border-color: var(--error);
}
.input.disabled {
opacity: 0.6;
cursor: not-allowed;
background-color: #f3f4f6;
}
.input.disabled.dark-mode {
background-color: #374151;
}
.input::placeholder {
color: #9ca3af;
}
.leftElement,
.rightElement {
position: absolute;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
color: #6b7280;
pointer-events: none;
}
.leftElement { left: 0; }
.rightElement { right: 0; }
.message {
font-size: 0.75rem;
color: #6b7280;
}
.message.errorMessage {
color: var(--error);
}

View File

@@ -0,0 +1,90 @@
import React, { InputHTMLAttributes, forwardRef } from 'react';
import classNames from 'classnames';
import styles from './Input.module.css';
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
helperText?: string;
fullWidth?: boolean;
leftElement?: React.ReactNode;
rightElement?: React.ReactNode;
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({
label,
error,
helperText,
fullWidth = true,
leftElement,
rightElement,
className,
disabled,
...props
}, ref) => {
const inputClasses = classNames(
styles.input,
{
[styles.error]: Boolean(error),
[styles.disabled]: disabled,
[styles.hasLeftElement]: Boolean(leftElement),
[styles.hasRightElement]: Boolean(rightElement),
},
className
);
const containerClasses = classNames(
styles.container,
{
[styles.fullWidth]: fullWidth,
}
);
const messageClasses = classNames(
styles.message,
{
[styles.errorMessage]: Boolean(error),
}
);
return (
<div className={containerClasses}>
{label && (
<label className={styles.label}>
{label}
</label>
)}
<div className={styles.inputWrapper}>
{leftElement && (
<div className={styles.leftElement}>
{leftElement}
</div>
)}
<input
ref={ref}
className={inputClasses}
disabled={disabled}
{...props}
/>
{rightElement && (
<div className={styles.rightElement}>
{rightElement}
</div>
)}
</div>
{(error || helperText) && (
<span className={messageClasses}>
{error ?? helperText}
</span>
)}
</div>
);
}
);
Input.displayName = 'Input';

View File

@@ -0,0 +1,39 @@
.menu {
width: 240px;
height: 100%;
border-right: 1px solid var(--border);
background-color: var(--background);
padding: 1rem;
}
.menuItems {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.menuItem {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
color: var(--foreground);
text-decoration: none;
transition: all 0.2s;
}
.menuItem:hover {
background-color: var(--input-bg);
}
.menuItem.active {
background-color: var(--primary);
color: white;
}
.icon {
display: flex;
align-items: center;
font-size: 1.25rem;
}

View File

@@ -0,0 +1,41 @@
import React from 'react';
import Link from 'next/link';
import { useRouter } from 'next/router';
import classNames from 'classnames';
import styles from './LeftMenu.module.css';
interface MenuItem {
label: string;
href: string;
icon?: React.ReactNode;
}
const menuItems: MenuItem[] = [
{ label: '대시보드', href: '/dashboard' },
{ label: '프로필', href: '/profile' },
{ label: '설정', href: '/settings' },
];
export function LeftMenu() {
const router = useRouter();
return (
<nav className={styles.menu}>
<div className={styles.menuItems}>
{menuItems.map((item) => (
<Link
key={item.href}
href={item.href}
className={classNames(
styles.menuItem,
router.pathname === item.href && styles.active
)}
>
{item.icon && <span className={styles.icon}>{item.icon}</span>}
<span>{item.label}</span>
</Link>
))}
</div>
</nav>
);
}

View File

@@ -0,0 +1,17 @@
.layout {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.container {
flex: 1;
display: flex;
}
.main {
flex: 1;
padding: 2rem;
background-color: var(--background);
min-height: calc(100vh - 64px - 80px); /* header height + footer height */
}

View File

@@ -0,0 +1,24 @@
import React, { ReactNode } from 'react';
import { Header } from '@/components/common/Header';
import { LeftMenu } from '@/components/common/LeftMenu';
import { Footer } from '@/components/common/Footer';
import styles from './MainLayout.module.css';
interface MainLayoutProps {
children: ReactNode;
}
export function MainLayout({ children }: Readonly<MainLayoutProps>) {
return (
<div className={styles.layout}>
<Header />
<div className={styles.container}>
<LeftMenu />
<main className={styles.main}>
{children}
</main>
</div>
<Footer />
</div>
);
}

82
src/lib/axios.ts Normal file
View File

@@ -0,0 +1,82 @@
import axios from 'axios';
const baseURL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000/api';
export const axiosInstance = axios.create({
baseURL,
timeout: 5000,
headers: {
'Content-Type': 'application/json',
},
});
// 요청 인터셉터
axiosInstance.interceptors.request.use(
(config) => {
// 토큰이 필요한 경우 여기서 처리
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
// Promise 거부 이유를 Error 객체로 변환
return Promise.reject(error instanceof Error ? error : new Error('Request error'));
}
);
// 응답 인터셉터
axiosInstance.interceptors.response.use(
(response) => {
return response;
},
async (error) => {
const originalRequest = error.config;
// 401 에러 처리 (토큰 만료 등)
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
// 리프레시 토큰으로 새로운 액세스 토큰 발급
const refreshToken = localStorage.getItem('refreshToken');
if (refreshToken) {
const response = await axios.post(`${baseURL}/auth/refresh`, {
refreshToken,
});
const newToken = response.data.token;
localStorage.setItem('token', newToken);
originalRequest.headers.Authorization = `Bearer ${newToken}`;
return axiosInstance(originalRequest);
}
throw new Error('Refresh token not found');
} catch (refreshError) {
// 리프레시 토큰도 만료된 경우 로그아웃 처리
localStorage.removeItem('token');
localStorage.removeItem('refreshToken');
window.location.href = '/login';
return Promise.reject(refreshError instanceof Error ? refreshError : new Error('Token refresh failed'));
}
}
return Promise.reject(error instanceof Error ? error : new Error('Request failed'));
}
);
// API 요청 함수들
export const api = {
get: <T>(url: string, config = {}) =>
axiosInstance.get<T>(url, config).then((response) => response.data),
post: <T>(url: string, data = {}, config = {}) =>
axiosInstance.post<T>(url, data, config).then((response) => response.data),
put: <T>(url: string, data = {}, config = {}) =>
axiosInstance.put<T>(url, data, config).then((response) => response.data),
delete: <T>(url: string, config = {}) =>
axiosInstance.delete<T>(url, config).then((response) => response.data),
};

17
src/pages/_app.tsx Normal file
View File

@@ -0,0 +1,17 @@
import type { AppProps } from 'next/app'
import { Provider } from 'react-redux'
import { PersistGate } from 'redux-persist/integration/react'
import { store, persistor } from '@/redux/store'
import '@/styles/globals.css'
function DropTheBitApp({ Component, pageProps }: AppProps) {
return (
<Provider store={store}>
<PersistGate loading={null} persistor={persistor}>
<Component {...pageProps} />
</PersistGate>
</Provider>
)
}
export default DropTheBitApp

9
src/pages/index.tsx Normal file
View File

@@ -0,0 +1,9 @@
import { MainPage } from '@/pages/main/MainPage';
const HomePage = () => {
return (
<MainPage/>
);
};
export default HomePage;

View File

@@ -0,0 +1,129 @@
.container {
min-height: 100vh;
background-color: var(--background);
}
.nav {
position: sticky;
top: 0;
z-index: 50;
background-color: #1f2937;
border-bottom: 1px solid var(--border);
}
.navContent {
max-width: 1280px; /* lg breakpoint */
margin: 0 auto;
padding: 0 1rem;
}
.navWrapper {
display: flex;
align-items: center;
justify-content: space-between;
height: 4rem;
}
.logo {
display: flex;
align-items: center;
gap: 0.5rem;
text-decoration: none;
}
.actions {
display: flex;
align-items: center;
gap: 1.5rem;
}
.main {
max-width: 1280px; /* lg breakpoint */
margin: 0 auto;
padding: 2rem 1rem;
}
.section {
margin-bottom: 3rem;
}
.coinGrid {
display: grid;
grid-template-columns: repeat(1, 1fr);
gap: 1.5rem;
}
.coinCard {
padding: 1.5rem;
background-color: var(--card-bg);
border: 1px solid var(--border);
border-radius: 0.75rem;
transition: all 0.2s;
}
.coinCard:hover {
border-color: rgba(255, 255, 255, 0.1);
}
.newsList {
display: flex;
flex-direction: column;
gap: 1rem;
}
.newsCard {
padding: 1.5rem;
background-color: var(--card-bg);
border: 1px solid var(--border);
border-radius: 0.75rem;
cursor: pointer;
transition: all 0.2s;
}
.newsCard:hover {
border-color: rgba(255, 255, 255, 0.1);
}
.footer {
padding: 3rem 0;
background-color: #1f2937;
border-top: 1px solid var(--border);
}
.footerContent {
max-width: 1280px; /* lg breakpoint */
margin: 0 auto;
padding: 0 1rem;
}
.footerGrid {
display: grid;
grid-template-columns: repeat(1, 1fr);
gap: 2rem;
}
.footerTitle {
font-size: 1.125rem;
font-weight: 700;
color: var(--foreground);
margin-bottom: 1rem;
}
.footerText {
color: var(--foreground);
}
.footerLinks {
display: flex;
flex-direction: column;
gap: 0.5rem;
color: var(--foreground);
}
.footerCopyright {
margin-top: 2rem;
padding-top: 2rem;
border-top: 1px solid var(--border);
text-align: center;
color: var(--foreground);
}

View File

@@ -0,0 +1,160 @@
import React, { useState } from 'react';
import Link from 'next/link';
import {
ArrowTrendingUpIcon,
ArrowTrendingDownIcon,
ChartBarIcon,
NewspaperIcon,
BellIcon,
ArrowRightStartOnRectangleIcon,
UserPlusIcon
} from '@heroicons/react/24/outline';
import { SignUpModal } from '@/pages/user/SignUpModal';
import styles from './MainPage.module.css';
export const MainPage = () => {
const [isSignUpOpen, setIsSignUpOpen] = useState(false);
return (
<div className={styles.container}>
<nav className={styles.nav}>
<div className={styles.navContent}>
<div className={styles.navWrapper}>
<Link href="/" className={styles.logo}>
<ChartBarIcon className={styles.logoIcon} />
<span className={styles.logoText}>Drop the Bit</span>
</Link>
<div className={styles.actions}>
<button className="p-2 rounded-full hover:bg-gray-700">
<BellIcon className="w-6 h-6 text-gray-300" />
</button>
<button className="flex items-center px-4 py-2 space-x-2 text-white transition-all bg-blue-500 rounded-lg hover:bg-blue-600">
<ArrowRightStartOnRectangleIcon className="w-5 h-5" />
<span>Sign In</span>
</button>
<button
onClick={() => setIsSignUpOpen(true)}
className="flex items-center px-4 py-2 space-x-2 text-gray-300 transition-all border border-gray-600 rounded-lg hover:bg-gray-700"
>
<UserPlusIcon className="w-5 h-5" />
<span>Sign Up</span>
</button>
</div>
</div>
</div>
</nav>
<main className={styles.main}>
{/* 실시간 코인 가격 섹션 */}
<section className={styles.section}>
<div className={styles.sectionHeader}>
<ChartBarIcon className={styles.sectionIcon} />
<h2 className={styles.sectionTitle}> </h2>
</div>
<div className={styles.coinGrid}>
{[
{ name: 'Bitcoin', price: '58,432.00', change: '+2.5%', up: true },
{ name: 'Ethereum', price: '3,286.15', change: '-1.2%', up: false },
{ name: 'Ripple', price: '1.15', change: '+3.8%', up: true },
].map((coin) => (
<div key={coin.name} className={styles.coinCard}>
<div className="flex items-start justify-between">
<div>
<h3 className="mb-1 text-lg font-bold text-white">{coin.name}</h3>
<p className="text-2xl font-semibold text-gray-200">${coin.price}</p>
</div>
{coin.up ? (
<ArrowTrendingUpIcon className="w-6 h-6 text-green-400" />
) : (
<ArrowTrendingDownIcon className="w-6 h-6 text-red-400" />
)}
</div>
<p className={`text-sm mt-2 ${coin.up ? 'text-green-400' : 'text-red-400'}`}>
{coin.change}
</p>
</div>
))}
</div>
</section>
{/* 최신 뉴스 섹션 */}
<section className={styles.section}>
<div className={styles.sectionHeader}>
<NewspaperIcon className={styles.sectionIcon} />
<h2 className={styles.sectionTitle}> </h2>
</div>
<div className={styles.newsList}>
{[
{
title: '비트코인, 새로운 고점 달성',
content: '비트코인이 새로운 역사적 고점을 기록했습니다. 전문가들은...',
date: '2024.03.21',
tag: '시장 동향'
},
{
title: '이더리움 2.0 업그레이드 임박',
content: '이더리움 재단이 다음 달 주요 업그레이드를 발표했습니다...',
date: '2024.03.20',
tag: '기술'
},
{
title: '글로벌 거래소 신규 상장 소식',
content: '주요 글로벌 거래소들이 새로운 알트코인 상장을 예고했습니다...',
date: '2024.03.19',
tag: '거래소'
}
].map((news) => (
<div key={news.title} className={styles.newsCard}>
<div className="flex items-start justify-between mb-3">
<h3 className="text-lg font-bold text-white">{news.title}</h3>
<span className="px-3 py-1 text-sm text-blue-300 rounded-full bg-blue-900/50">
{news.tag}
</span>
</div>
<p className="mb-3 text-gray-400">{news.content}</p>
<div className="text-sm text-gray-500">{news.date}</div>
</div>
))}
</div>
</section>
</main>
<footer className={styles.footer}>
<div className={styles.footerContent}>
<div className={styles.footerGrid}>
<div>
<h3 className={styles.footerTitle}>Drop the Bit</h3>
<p className={styles.footerText}>
.
</p>
</div>
<div>
<h3 className={styles.footerTitle}> </h3>
<ul className={styles.footerLinks}>
<li> </li>
<li> </li>
<li></li>
<li></li>
</ul>
</div>
<div>
<h3 className={styles.footerTitle}></h3>
<p className={styles.footerText}>
support@dropthebit.com<br />
</p>
</div>
</div>
<div className={styles.footerCopyright}>
<p>© 2024 Drop the Bit. All rights reserved.</p>
</div>
</div>
</footer>
<SignUpModal
isOpen={isSignUpOpen}
onClose={() => setIsSignUpOpen(false)}
/>
</div>
);
};

View File

@@ -0,0 +1,144 @@
.dialog {
position: fixed;
margin: auto;
padding: 0;
border: none;
border-radius: 1rem;
background: transparent;
}
.dialog::backdrop {
background-color: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
}
.dialog[open] {
display: flex;
align-items: center;
justify-content: center;
}
.modal {
width: 100%;
max-width: 28rem;
padding: 1.5rem;
border-radius: 1rem;
background-color: var(--background);
border: 1px solid var(--border);
box-shadow:
0 20px 25px -5px rgba(0, 0, 0, 0.1),
0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
.modal.dark-mode {
background-color: #1a1b1e;
box-shadow:
0 20px 25px -5px rgba(0, 0, 0, 0.3),
0 10px 10px -5px rgba(0, 0, 0, 0.2);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.5rem;
}
.title {
font-size: 1.5rem;
font-weight: 700;
color: var(--foreground);
}
.closeButton {
padding: 0.5rem;
border-radius: 9999px;
color: var(--foreground);
opacity: 0.6;
transition: all 0.2s;
}
.closeButton:hover {
opacity: 1;
background-color: var(--input-bg);
}
.form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.inputGroup {
position: relative;
}
.input {
width: 100%;
padding: 0.75rem 1rem 0.75rem 2.5rem;
border-radius: 0.5rem;
border: 1px solid var(--border);
background-color: var(--input-bg);
color: var(--foreground);
transition: all 0.2s;
}
.input.error {
border-color: var(--error);
}
.input.disabled {
opacity: 0.6;
cursor: not-allowed;
background-color: #f3f4f6;
}
.input.disabled.dark-mode {
background-color: #374151;
}
.input::placeholder {
color: #9ca3af;
}
.icon {
position: absolute;
left: 0.75rem;
top: 50%;
transform: translateY(-50%);
width: 1.25rem;
height: 1.25rem;
color: var(--foreground);
opacity: 0.5;
}
.submitButton {
margin-top: 0.5rem;
padding: 0.75rem;
border-radius: 0.5rem;
background-color: var(--primary);
color: white;
font-weight: 600;
transition: all 0.2s;
}
.submitButton:hover {
background-color: var(--primary-hover);
}
.footer {
margin-top: 1rem;
text-align: center;
font-size: 0.875rem;
color: var(--foreground);
opacity: 0.8;
}
.footer button {
color: var(--primary);
font-weight: 500;
}
.footer button:hover {
text-decoration: underline;
}

View File

@@ -0,0 +1,142 @@
import React, { useState, useEffect, useRef } from 'react';
import {
XMarkIcon,
UserIcon,
EnvelopeIcon,
LockClosedIcon,
PhoneIcon
} from '@heroicons/react/24/outline';
import { Button } from '@/components/common/Button';
import { Input } from '@/components/common/Input';
import styles from './SignUpModal.module.css';
interface SignUpModalProps {
isOpen: boolean;
onClose: () => void;
}
export function SignUpModal({ isOpen, onClose }: Readonly<SignUpModalProps>) {
const dialogRef = useRef<HTMLDialogElement>(null);
const [formData, setFormData] = useState({
name: '',
email: '',
password: '',
phone: ''
});
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (isOpen) {
dialog.showModal();
} else {
dialog.close();
}
}, [isOpen]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// 회원가입 로직 구현
console.log(formData);
};
// ESC 키는 dialog 요소에서 자동으로 처리됨
const handleClose = () => {
if (dialogRef.current?.returnValue !== 'cancel') {
onClose();
}
};
return (
<dialog
ref={dialogRef}
className={styles.dialog}
onClose={handleClose}
>
<div className={styles.modal}>
<div className={styles.header}>
<h2 id="signup-title" className={styles.title}>Sign Up</h2>
<button
onClick={onClose}
className={styles.closeButton}
aria-label="Close sign up modal"
type="button"
>
<XMarkIcon className="w-6 h-6" aria-hidden="true" />
</button>
</div>
<form onSubmit={handleSubmit} className={styles.form}>
<div className={styles.inputGroup}>
<UserIcon className={styles.icon} aria-hidden="true" />
<Input
type="text"
placeholder="Full Name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
aria-label="Full Name"
required
/>
</div>
<div className={styles.inputGroup}>
<EnvelopeIcon className={styles.icon} aria-hidden="true" />
<Input
type="email"
placeholder="Email Address"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
aria-label="Email Address"
required
/>
</div>
<div className={styles.inputGroup}>
<LockClosedIcon className={styles.icon} aria-hidden="true" />
<Input
type="password"
placeholder="Password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
aria-label="Password"
required
/>
</div>
<div className={styles.inputGroup}>
<PhoneIcon className={styles.icon} aria-hidden="true" />
<Input
type="tel"
placeholder="Phone Number"
value={formData.phone}
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
aria-label="Phone Number"
required
/>
</div>
<Button
type="submit"
variant="primary"
size="lg"
fullWidth
>
Create Account
</Button>
</form>
<div className={styles.footer}>
Already have an account?{' '}
<Button
type="button"
variant="ghost"
onClick={onClose}
>
Sign In
</Button>
</div>
</div>
</dialog>
);
}

22
src/redux/rootReducer.ts Normal file
View File

@@ -0,0 +1,22 @@
import { combineReducers, createSlice } from '@reduxjs/toolkit';
// 임시 초기 상태와 리듀서를 생성
const initialState = {
value: 0
};
const tempSlice = createSlice({
name: 'temp',
initialState,
reducers: {
increment: (state) => {
state.value += 1;
},
},
});
const rootReducer = combineReducers({
temp: tempSlice.reducer,
});
export default rootReducer;

View File

@@ -0,0 +1,28 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
interface TempState {
value: number
}
const initialState: TempState = {
value: 0
}
const tempSlice = createSlice({
name: 'temp',
initialState,
reducers: {
setValue: (state, action: PayloadAction<number>) => {
state.value = action.payload
},
increment: (state) => {
state.value += 1
},
decrement: (state) => {
state.value -= 1
}
}
})
export const { setValue, increment, decrement } = tempSlice.actions
export default tempSlice.reducer

42
src/redux/store.ts Normal file
View File

@@ -0,0 +1,42 @@
// Redux Toolkit을 사용하여 스토어를 구성하는 파일입니다.
// 필요한 라이브러리와 모듈을 임포트합니다.
import { configureStore, combineReducers } from '@reduxjs/toolkit'; // Redux 스토어를 구성하는 함수
import { persistStore, persistReducer } from 'redux-persist'; // 상태를 지속화하기 위한 함수들
import storage from 'redux-persist/lib/storage'; // 로컬 스토리지에 상태를 저장하기 위한 모듈
import tempReducer from './slices/tempSlice'; // 예시 리듀서
// combineReducers를 사용하여 rootReducer 생성
const rootReducer = combineReducers({
temp: tempReducer,
// 다른 리듀서들...
})
// 상태 지속화를 위한 설정 객체입니다.
const persistConfig = {
key: 'root', // 저장할 상태의 키
storage, // 사용할 저장소 (여기서는 로컬 스토리지)
whitelist: ['temp'], // 유지할 리듀서 상태 지정
};
// 루트 리듀서를 지속화된 리듀서로 변환합니다.
const persistedReducer = persistReducer(persistConfig, rootReducer);
// Redux 스토어를 구성합니다.
export const store = configureStore({
reducer: persistedReducer, // 지속화된 리듀서를 사용
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: {
ignoredActions: ['persist/PERSIST', 'persist/REHYDRATE'], // 특정 액션에 대해 직렬화 체크를 무시합니다.
},
}),
});
// 스토어의 상태를 지속화하기 위한 persistor 객체를 생성합니다.
export const persistor = persistStore(store);
// RootState와 AppDispatch 타입을 정의합니다.
// RootState는 스토어의 전체 상태 타입을 나타냅니다.
export type RootState = ReturnType<typeof store.getState>;
// AppDispatch는 스토어의 dispatch 함수 타입을 나타냅니다.
export type AppDispatch = typeof store.dispatch;

53
src/styles/globals.css Normal file
View File

@@ -0,0 +1,53 @@
/* Breakpoints */
@media (min-width: 768px) {
/* md styles */
}
@media (min-width: 1280px) {
/* lg styles */
}
/* Variables */
:root {
--primary: #60a5fa;
--primary-hover: #3b82f6;
--background: #ffffff;
--foreground: #111827;
--border: #e5e7eb;
--error: #ff3b30;
--card-bg: #ffffff;
--card-footer-bg: #ffffff;
--input-bg: #ffffff;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #111827;
--foreground: #ffffff;
--border: #374151;
--card-bg: #1f2937;
--card-footer-bg: #1f2937;
--input-bg: #1f2937;
}
}
/* Global styles */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
background-color: var(--background);
color: var(--foreground);
}
button {
border: none;
background: none;
cursor: pointer;
font: inherit;
}

4
src/types/global.d.ts vendored Normal file
View File

@@ -0,0 +1,4 @@
declare module '*.module.scss' {
const classes: { readonly [key: string]: string };
export default classes;
}

4
src/types/scss.d.ts vendored Normal file
View File

@@ -0,0 +1,4 @@
declare module '*.scss' {
const content: { [className: string]: string };
export default content;
}

22
tailwind.config.js Normal file
View File

@@ -0,0 +1,22 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./src/pages/**/*.{js,ts,jsx,tsx}",
"./src/components/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
background: 'var(--background)',
foreground: 'var(--foreground)',
primary: 'var(--primary)',
'primary-hover': 'var(--primary-hover)',
error: 'var(--error)',
success: 'var(--success)',
border: 'var(--border)',
},
},
},
plugins: [],
}

View File

@@ -11,6 +11,9 @@ export default {
colors: {
background: "var(--background)",
foreground: "var(--foreground)",
primary: "var(--primary)",
error: "var(--error)",
success: "var(--success)",
},
},
},

View File

@@ -22,6 +22,6 @@
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "config/next.config.js"],
"exclude": ["node_modules"]
}