commit
This commit is contained in:
@@ -1,3 +1,7 @@
|
||||
{
|
||||
"extends": ["next/core-web-vitals", "next/typescript"]
|
||||
"extends": [
|
||||
"airbnb-base",
|
||||
"next/core-web-vitals",
|
||||
"next/typescript"
|
||||
]
|
||||
}
|
||||
|
||||
9
.prettierrc
Normal file
9
.prettierrc
Normal 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
11
next.config.js
Normal file
@@ -0,0 +1,11 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
experimental: {
|
||||
turbo: {
|
||||
enable: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
||||
@@ -1,7 +0,0 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
2698
package-lock.json
generated
2698
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
30
package.json
30
package.json
@@ -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
6
postcss.config.js
Normal 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.
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
101
src/app/page.tsx
101
src/app/page.tsx
@@ -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>
|
||||
);
|
||||
}
|
||||
144
src/components/common/Button/Button.module.css
Normal file
144
src/components/common/Button/Button.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
67
src/components/common/Button/index.tsx
Normal file
67
src/components/common/Button/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
src/components/common/Card/Card.module.css
Normal file
56
src/components/common/Card/Card.module.css
Normal 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);
|
||||
}
|
||||
65
src/components/common/Card/index.tsx
Normal file
65
src/components/common/Card/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
src/components/common/Footer/Footer.module.css
Normal file
37
src/components/common/Footer/Footer.module.css
Normal 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);
|
||||
}
|
||||
20
src/components/common/Footer/index.tsx
Normal file
20
src/components/common/Footer/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
src/components/common/Header/Header.module.css
Normal file
56
src/components/common/Header/Header.module.css
Normal 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);
|
||||
}
|
||||
28
src/components/common/Header/index.tsx
Normal file
28
src/components/common/Header/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
src/components/common/Input/Input.module.css
Normal file
76
src/components/common/Input/Input.module.css
Normal 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);
|
||||
}
|
||||
90
src/components/common/Input/index.tsx
Normal file
90
src/components/common/Input/index.tsx
Normal 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';
|
||||
39
src/components/common/LeftMenu/LeftMenu.module.css
Normal file
39
src/components/common/LeftMenu/LeftMenu.module.css
Normal 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;
|
||||
}
|
||||
41
src/components/common/LeftMenu/index.tsx
Normal file
41
src/components/common/LeftMenu/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
src/components/layouts/MainLayout/MainLayout.module.css
Normal file
17
src/components/layouts/MainLayout/MainLayout.module.css
Normal 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 */
|
||||
}
|
||||
24
src/components/layouts/MainLayout/index.tsx
Normal file
24
src/components/layouts/MainLayout/index.tsx
Normal 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
82
src/lib/axios.ts
Normal 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
17
src/pages/_app.tsx
Normal 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
9
src/pages/index.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { MainPage } from '@/pages/main/MainPage';
|
||||
|
||||
const HomePage = () => {
|
||||
return (
|
||||
<MainPage/>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomePage;
|
||||
129
src/pages/main/MainPage/MainPage.module.css
Normal file
129
src/pages/main/MainPage/MainPage.module.css
Normal 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);
|
||||
}
|
||||
160
src/pages/main/MainPage/index.tsx
Normal file
160
src/pages/main/MainPage/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
144
src/pages/user/SignUpModal/SignUpModal.module.css
Normal file
144
src/pages/user/SignUpModal/SignUpModal.module.css
Normal 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;
|
||||
}
|
||||
142
src/pages/user/SignUpModal/index.tsx
Normal file
142
src/pages/user/SignUpModal/index.tsx
Normal 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
22
src/redux/rootReducer.ts
Normal 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;
|
||||
28
src/redux/slices/tempSlice.ts
Normal file
28
src/redux/slices/tempSlice.ts
Normal 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
42
src/redux/store.ts
Normal 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
53
src/styles/globals.css
Normal 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
4
src/types/global.d.ts
vendored
Normal 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
4
src/types/scss.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
declare module '*.scss' {
|
||||
const content: { [className: string]: string };
|
||||
export default content;
|
||||
}
|
||||
22
tailwind.config.js
Normal file
22
tailwind.config.js
Normal 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: [],
|
||||
}
|
||||
|
||||
@@ -11,6 +11,9 @@ export default {
|
||||
colors: {
|
||||
background: "var(--background)",
|
||||
foreground: "var(--foreground)",
|
||||
primary: "var(--primary)",
|
||||
error: "var(--error)",
|
||||
success: "var(--success)",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user