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",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbo",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "19.0.0-rc-66855b96-20241106",
|
"@heroicons/react": "^2.2.0",
|
||||||
"react-dom": "19.0.0-rc-66855b96-20241106",
|
"@reduxjs/toolkit": "^2.3.0",
|
||||||
"next": "15.0.3"
|
"@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": {
|
"devDependencies": {
|
||||||
"typescript": "^5",
|
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^18",
|
"@types/react": "^18",
|
||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^18",
|
||||||
"postcss": "^8",
|
"autoprefixer": "^10.4.20",
|
||||||
"tailwindcss": "^3.4.1",
|
"css-loader": "^7.1.2",
|
||||||
"eslint": "^8",
|
"eslint": "^9.15.0",
|
||||||
"eslint-config-next": "15.0.3"
|
"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: {
|
colors: {
|
||||||
background: "var(--background)",
|
background: "var(--background)",
|
||||||
foreground: "var(--foreground)",
|
foreground: "var(--foreground)",
|
||||||
|
primary: "var(--primary)",
|
||||||
|
error: "var(--error)",
|
||||||
|
success: "var(--success)",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -22,6 +22,6 @@
|
|||||||
"@/*": ["./src/*"]
|
"@/*": ["./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"]
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user