Compare commits
11 Commits
1a0cc9376f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c8dcf9db7 | |||
|
|
4a8e761b3d | ||
| 0c8e0893c7 | |||
|
|
7df60fe004 | ||
| daab622638 | |||
|
|
ea7861b63a | ||
|
|
069f58075b | ||
|
|
edef4273c0 | ||
|
|
e3091494b1 | ||
|
|
3859099074 | ||
|
|
54c84dbc87 |
@@ -1 +1 @@
|
||||
VITE_API_URL=http://localhost:8080
|
||||
VITE_API_URL=http://localhost:8088
|
||||
|
||||
34
src/App.tsx
34
src/App.tsx
@@ -7,19 +7,41 @@ import { PageRouting } from './const/PageRouting';
|
||||
import LoginPage from './ui/page/account/login/LoginPage';
|
||||
import ResetPasswordPage from './ui/page/account/resetPassword/ResetPasswordPage';
|
||||
import { HomePage } from './ui/page/home/HomePage';
|
||||
import type { AuthData } from './data/AuthData';
|
||||
import { useEffect } from 'react';
|
||||
import { ScheduleMainPage } from './ui/page/schedule/ScheduleMainPage';
|
||||
|
||||
function App() {
|
||||
const { authData } = useAuthStore();
|
||||
const { authData, login } = useAuthStore();
|
||||
useEffect(() => {
|
||||
const autoLogin = localStorage.getItem('autoLogin') === 'true';
|
||||
if (autoLogin) {
|
||||
const stored = localStorage.getItem('auth-storage');
|
||||
if (stored) {
|
||||
const storedAuthData = JSON.parse(stored).state as AuthData;
|
||||
login(storedAuthData);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route element={<Layout />}>
|
||||
<Route element={<LoginPage />} path={PageRouting["LOGIN"].path} />
|
||||
<Route element={<SignUpPage />} path={PageRouting["SIGN_UP"].path} />
|
||||
<Route element={<ResetPasswordPage />} path={PageRouting["RESET_PASSWORD"].path} />
|
||||
{!authData ? <Route element={<Navigate to={PageRouting["LOGIN"].path} />} path="*" /> : null}
|
||||
<Route element={<HomePage />} path={PageRouting["HOME"].path} />
|
||||
{
|
||||
!authData
|
||||
? <>
|
||||
<Route element={<LoginPage />} path={PageRouting["LOGIN"].path} />
|
||||
<Route element={<SignUpPage />} path={PageRouting["SIGN_UP"].path} />
|
||||
<Route element={<ResetPasswordPage />} path={PageRouting["RESET_PASSWORD"].path} />
|
||||
<Route element={<Navigate to={PageRouting["LOGIN"].path} />} path="*" />
|
||||
</>
|
||||
: <>
|
||||
<Route element={<Navigate to={PageRouting["HOME"].path} />} path="*" />
|
||||
<Route element={<HomePage />} path={PageRouting["HOME"].path} />
|
||||
<Route element={<ScheduleMainPage />} path={PageRouting["SCHEDULES"].path} />
|
||||
</>
|
||||
}
|
||||
</Route>
|
||||
</Routes>
|
||||
</Router>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { format } from "date-fns"
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
@@ -36,8 +37,28 @@ function Calendar({
|
||||
)}
|
||||
captionLayout={captionLayout}
|
||||
formatters={{
|
||||
formatCaption: (month) => format(month, "yyyy년 MM월"),
|
||||
formatWeekdayName: (weekday) => {
|
||||
switch(weekday.getDay()) {
|
||||
case 0:
|
||||
return '일';
|
||||
case 1:
|
||||
return '월';
|
||||
case 2:
|
||||
return '화';
|
||||
case 3:
|
||||
return '수';
|
||||
case 4:
|
||||
return '목';
|
||||
case 5:
|
||||
return '금';
|
||||
case 6:
|
||||
return '토';
|
||||
}
|
||||
return '';
|
||||
},
|
||||
formatMonthDropdown: (date) =>
|
||||
date.toLocaleString("default", { month: "short" }),
|
||||
date.toLocaleString("", { month: "short" }),
|
||||
...formatters,
|
||||
}}
|
||||
classNames={{
|
||||
|
||||
@@ -89,9 +89,7 @@ function SidebarProvider({
|
||||
)
|
||||
|
||||
// Helper to toggle the sidebar.
|
||||
const toggleSidebar = React.useCallback(() => {
|
||||
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
|
||||
}, [isMobile, setOpen, setOpenMobile])
|
||||
const toggleSidebar = () => setOpen((open) => !open);
|
||||
|
||||
// Adds a keyboard shortcut to toggle the sidebar.
|
||||
React.useEffect(() => {
|
||||
@@ -157,13 +155,15 @@ function Sidebar({
|
||||
collapsible = "offcanvas",
|
||||
className,
|
||||
children,
|
||||
forceSheet = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
side?: "left" | "right"
|
||||
variant?: "sidebar" | "floating" | "inset"
|
||||
collapsible?: "offcanvas" | "icon" | "none"
|
||||
collapsible?: "offcanvas" | "icon" | "none",
|
||||
forceSheet?: boolean
|
||||
}) {
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
||||
const { isMobile, state, open, setOpen } = useSidebar()
|
||||
|
||||
if (collapsible === "none") {
|
||||
return (
|
||||
@@ -180,14 +180,14 @@ function Sidebar({
|
||||
)
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
if (isMobile || forceSheet) {
|
||||
return (
|
||||
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||
<Sheet open={open} onOpenChange={setOpen} {...props}>
|
||||
<SheetContent
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar"
|
||||
data-mobile="true"
|
||||
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
|
||||
className="bg-sidebar text-sidebar-foreground rounded-br-2xl rounded-tr-2xl w-(--sidebar-width) p-0 [&>button]:hidden"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
||||
|
||||
99
src/const/ColorPalette.ts
Normal file
99
src/const/ColorPalette.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
export type ColorPaletteType = {
|
||||
index: number;
|
||||
style: string;
|
||||
main: boolean;
|
||||
}
|
||||
|
||||
|
||||
export const ColorPalette: Record<any, ColorPaletteType> = {
|
||||
Black: {
|
||||
index: 0,
|
||||
style: '#000000',
|
||||
main: true
|
||||
},
|
||||
White: {
|
||||
index: 1,
|
||||
style: '#FFFFFF',
|
||||
main: true
|
||||
},
|
||||
PeachCream: {
|
||||
index: 2,
|
||||
style: '#FFDAB9',
|
||||
main: false
|
||||
},
|
||||
CoralPink: {
|
||||
index: 3,
|
||||
style: '#F08080',
|
||||
main: true
|
||||
},
|
||||
MintIcing: {
|
||||
index: 4,
|
||||
style: '#C1E1C1',
|
||||
main: false
|
||||
},
|
||||
Vanilla: {
|
||||
index: 5,
|
||||
style: '#FFFACD',
|
||||
main: true
|
||||
},
|
||||
Wheat: {
|
||||
index: 6,
|
||||
style: '#F5DEB3',
|
||||
main: false
|
||||
},
|
||||
AliceBlue: {
|
||||
index: 7,
|
||||
style: '#F0F8FF',
|
||||
main: true
|
||||
},
|
||||
Lavender: {
|
||||
index: 8,
|
||||
style: '#E6E6FA',
|
||||
main: false
|
||||
},
|
||||
LightAqua: {
|
||||
index: 9,
|
||||
style: '#A8E6CF',
|
||||
main: true
|
||||
},
|
||||
CloudWhite: {
|
||||
index: 10,
|
||||
style: '#F0F8FF',
|
||||
main: false
|
||||
},
|
||||
LightGray: {
|
||||
index: 11,
|
||||
style: '#D3D3D3',
|
||||
main: true
|
||||
},
|
||||
LightKhakki: {
|
||||
index: 12,
|
||||
style: '#F0F8E6',
|
||||
main: false
|
||||
},
|
||||
DustyRose: {
|
||||
index: 13,
|
||||
style: '#D8BFD8',
|
||||
main: true
|
||||
},
|
||||
CreamBeige: {
|
||||
index: 14,
|
||||
style: '#FAF0E6',
|
||||
main: true,
|
||||
},
|
||||
Oatmeal: {
|
||||
index: 15,
|
||||
style: '#FDF5E6',
|
||||
main: false
|
||||
},
|
||||
CharcoalLight: {
|
||||
index: 16,
|
||||
style: '#A9A9A9',
|
||||
main: true
|
||||
},
|
||||
Custom: {
|
||||
index: 17,
|
||||
style: 'transparent',
|
||||
main: false
|
||||
},
|
||||
}
|
||||
@@ -1,11 +1,20 @@
|
||||
import { Validator } from '@/util/Validator';
|
||||
import * as z from 'zod';
|
||||
|
||||
export const LoginSchema = z.object({
|
||||
id: z
|
||||
.string()
|
||||
.refine((val) => {
|
||||
if (val.includes('@')) {
|
||||
return Validator.isEmail(val);;
|
||||
}
|
||||
return true;
|
||||
}, {
|
||||
message: "이메일 형식이 올바르지 않습니다."
|
||||
})
|
||||
, password: z
|
||||
.string()
|
||||
.min(8, "비밀번호는 8-12 자리여야 합니다.")
|
||||
.max(12, "비밀번호는 8-12 자리여야 합니다.")
|
||||
.regex(/^[a-z](?=.*[0-9])(?=.*[!@#$]).*$/, "비밀번호는 영소문자로 시작하여 숫자, 특수문자(!@#$)를 한 개 이상 포함하여야 합니다.")
|
||||
.regex(/^(?=.*[0-9])(?=.*[!@#$%^])[a-zA-Z0-9!@#$%^]+$/, "비밀번호는 영소문자로 시작하여 숫자, 특수문자(!@#$)를 한 개 이상 포함하여야 합니다.")
|
||||
});
|
||||
@@ -6,12 +6,12 @@ export const ResetPasswordSchema = z.object({
|
||||
, code: z
|
||||
.string()
|
||||
.length(8)
|
||||
.regex(/^[a-z](?=.*[0-9])(?=.*[!@#$%^]).*$/, "영소문자로 시작하고 숫자와 특수문자(!@#$%^)를 포함해야 합니다.")
|
||||
.regex(/^(?=.*[0-9])(?=.*[!@#$%^])[a-zA-Z0-9!@#$%^]+$/, "영소문자로 시작하고 숫자와 특수문자(!@#$%^)를 포함해야 합니다.")
|
||||
, password: z
|
||||
.string()
|
||||
.min(8, "비밀번호는 8-12 자리여야 합니다.")
|
||||
.max(12, "비밀번호는 8-12 자리여야 합니다.")
|
||||
.regex(/^[a-z](?=.*[0-9])(?=.*[!@#$%^]).*$/, "영소문자로 시작하고 숫자와 특수문자(!@#$%^)를 포함해야 합니다.")
|
||||
.regex(/^(?=.*[0-9])(?=.*[!@#$%^])[a-zA-Z0-9!@#$%^]+$/, "영소문자로 시작하고 숫자와 특수문자(!@#$%^)를 포함해야 합니다.")
|
||||
, passwordConfirm: z
|
||||
.string()
|
||||
})
|
||||
|
||||
@@ -4,6 +4,11 @@ export const SignUpSchema = z.object({
|
||||
accountId: z
|
||||
.string()
|
||||
.min(5, "아이디는 5 자리 이상이어야 합니다.")
|
||||
.refine((val) => {
|
||||
return /^[a-zA-z-_.]*$/.test(val);
|
||||
}, {
|
||||
message: "영문, 숫자, '- _ .' 를 제외한 문자를 사용할 수 없습니다."
|
||||
})
|
||||
, email: z
|
||||
.string()
|
||||
.min(5, "이메일을 입력해주십시오.")
|
||||
@@ -11,7 +16,7 @@ export const SignUpSchema = z.object({
|
||||
.string()
|
||||
.min(8, "비밀번호는 8-12 자리여야 합니다.")
|
||||
.max(12, "비밀번호는 8-12 자리여야 합니다.")
|
||||
.regex(/^[a-z](?=.*[0-9])(?=.*[!@#$%^]).*$/, "영소문자로 시작하고 숫자와 특수문자(!@#$%^)를 포함해야 합니다.")
|
||||
.regex(/^(?=.*[0-9])(?=.*[!@#$%^])[a-zA-Z0-9!@#$%^]+$/, "영소문자로 시작하고 숫자와 특수문자(!@#$%^)를 포함해야 합니다.")
|
||||
, name: z
|
||||
.string()
|
||||
.min(1, "이름을 입력해주시십시오.")
|
||||
|
||||
67
src/hooks/use-palette.ts
Normal file
67
src/hooks/use-palette.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { ColorPalette, type ColorPaletteType } from "@/const/ColorPalette";
|
||||
|
||||
export function usePalette() {
|
||||
const ColorPaletteType = typeof ColorPalette;
|
||||
|
||||
const getPaletteNameList = () => {
|
||||
return Object.keys(ColorPalette);
|
||||
}
|
||||
|
||||
const getMainPaletteList = () => {
|
||||
const paletteKeys = Object.keys(ColorPalette);
|
||||
let paletteList: ColorPaletteType[] = [];
|
||||
paletteKeys.forEach((paletteKey) => {
|
||||
const key = paletteKey as keyof typeof ColorPalette;
|
||||
const palette: ColorPaletteType = ColorPalette[key];
|
||||
if (palette.main) {
|
||||
paletteList.push(palette);
|
||||
}
|
||||
});
|
||||
|
||||
paletteList = paletteList.sort((a, b) => a.index - b.index);
|
||||
|
||||
return paletteList;
|
||||
}
|
||||
|
||||
const getExtraPaletteList = () => {
|
||||
const paletteKeys = Object.keys(ColorPalette);
|
||||
let paletteList: ColorPaletteType[] = [];
|
||||
paletteKeys.forEach((paletteKey) => {
|
||||
const key = paletteKey as keyof typeof ColorPalette;
|
||||
const palette: ColorPaletteType = ColorPalette[key];
|
||||
if (!palette.main) {
|
||||
paletteList.push(palette);
|
||||
}
|
||||
});
|
||||
paletteList = paletteList.sort((a, b) => a.index - b.index);
|
||||
return paletteList;
|
||||
}
|
||||
|
||||
const getAllPaletteList = [...getMainPaletteList(), ...getExtraPaletteList()].sort((a, b) => a.index - b.index);
|
||||
|
||||
const getPaletteByKey = (key: keyof typeof ColorPalette) => {
|
||||
return ColorPalette[key];
|
||||
}
|
||||
|
||||
const getCustomColor = (style: string) => {
|
||||
return {
|
||||
style: `#${style}`,
|
||||
main: false
|
||||
} as ColorPaletteType;
|
||||
}
|
||||
|
||||
const getStyle = (palette: ColorPaletteType) => {
|
||||
return palette.style;
|
||||
}
|
||||
|
||||
return {
|
||||
ColorPaletteType,
|
||||
getPaletteNameList,
|
||||
getMainPaletteList,
|
||||
getExtraPaletteList,
|
||||
getAllPaletteList,
|
||||
getPaletteByKey,
|
||||
getCustomColor,
|
||||
getStyle
|
||||
}
|
||||
}
|
||||
27
src/hooks/use-viewport.ts
Normal file
27
src/hooks/use-viewport.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
const useViewport = () => {
|
||||
const [width, setWidth] = useState(
|
||||
typeof window !== 'undefined' ? window.innerWidth : 0
|
||||
);
|
||||
const [height, setHeight] = useState(
|
||||
typeof window !== 'undefined' ? window.innerHeight : 0
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setWidth(window.innerWidth);
|
||||
setHeight(window.innerHeight);
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
export default useViewport;
|
||||
@@ -120,7 +120,11 @@
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 1280px;
|
||||
min-height: 720px;
|
||||
max-height: 1080px;
|
||||
}
|
||||
|
||||
/* Chrome, Safari, Edge */
|
||||
@@ -130,7 +134,19 @@ input[type="number"]::-webkit-outer-spin-button {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.rdp-day {
|
||||
aspect-ratio: unset;
|
||||
}
|
||||
|
||||
/* Firefox */
|
||||
input[type="number"] {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
.rdp-week:not(:first-child) {
|
||||
@apply border-t;
|
||||
}
|
||||
|
||||
.rdp-day:not(:first-child) {
|
||||
@apply border-l;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import SideBar from "@/ui/component/SideBar";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { Outlet, useNavigate } from "react-router-dom";
|
||||
import { SidebarProvider } from "@/components/ui/sidebar";
|
||||
import Header from "@/ui/component/Header";
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
@@ -11,9 +11,22 @@ import {
|
||||
OctagonXIcon,
|
||||
TriangleAlertIcon,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function Layout() {
|
||||
const { authData } = useAuthStore();
|
||||
const [open, setOpen] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const pathname = location.pathname;
|
||||
|
||||
const goTo = (path: string) => {
|
||||
console.log(path);
|
||||
console.log(pathname);
|
||||
if (path === pathname) return;
|
||||
navigate(path);
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Toaster
|
||||
@@ -27,14 +40,16 @@ export default function Layout() {
|
||||
}}
|
||||
/>
|
||||
<SidebarProvider
|
||||
defaultOpen={false}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
id="root"
|
||||
>
|
||||
<SideBar />
|
||||
<SideBar goTo={goTo} />
|
||||
<div className="flex flex-col w-full h-full">
|
||||
{ authData ? <Header /> : null}
|
||||
{/* <Header /> */}
|
||||
<Outlet />
|
||||
<div className="w-full h-full p-2.5">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
</>
|
||||
|
||||
@@ -7,7 +7,8 @@ import type {
|
||||
InternalAxiosRequestConfig,
|
||||
} from "axios";
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import type { RefreshAccessTokenResponse } from '@/data/response/account/RefreshAccessTokenResponse';
|
||||
import { RefreshAccessTokenResponse } from '@/data/response/account/RefreshAccessTokenResponse';
|
||||
import type { AuthData } from '@/data/AuthData';
|
||||
|
||||
export class BaseNetwork {
|
||||
protected instance: AxiosInstance;
|
||||
@@ -98,30 +99,16 @@ export class BaseNetwork {
|
||||
this.isRefreshing = true;
|
||||
|
||||
try {
|
||||
const response = await this.get<RefreshAccessTokenResponse>(
|
||||
'/account/refresh-access-token',
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${refreshToken}`
|
||||
}
|
||||
}
|
||||
)
|
||||
await this.refreshToken();
|
||||
|
||||
const newAccessToken = response.data.accessToken;
|
||||
const newRefreshToken = response.data.refreshToken;
|
||||
|
||||
useAuthStore.getState().login({
|
||||
...authData,
|
||||
accessToken: newAccessToken,
|
||||
refreshToken: newRefreshToken
|
||||
});
|
||||
const newAccessToken = useAuthStore.getState().authData!.accessToken;
|
||||
|
||||
this.refreshQueue.forEach((cb) => cb(newAccessToken));
|
||||
this.refreshQueue = [];
|
||||
originalRequest.headers = {
|
||||
...originalRequest.headers,
|
||||
Authorization: `Bearer ${newAccessToken}`,
|
||||
};
|
||||
} as any;
|
||||
|
||||
return this.instance(originalRequest);
|
||||
} catch (err) {
|
||||
@@ -143,4 +130,39 @@ export class BaseNetwork {
|
||||
protected async post<T = any>(url: string, data?: any, config?: AxiosRequestConfig & { authPass?: boolean }) {
|
||||
return await this.instance.post<T>(url, data, config);
|
||||
}
|
||||
|
||||
public async refreshToken() {
|
||||
const storedAuth = localStorage.getItem('auth-storage');
|
||||
|
||||
if (!storedAuth) {
|
||||
localStorage.setItem('autoLogin', 'false');
|
||||
throw new Error;
|
||||
}
|
||||
|
||||
const authData: AuthData = JSON.parse(storedAuth).state;
|
||||
|
||||
if (!authData || !authData.refreshToken) {
|
||||
localStorage.setItem('autoLogin', 'false');
|
||||
throw new Error;
|
||||
}
|
||||
|
||||
const result = await this.get<RefreshAccessTokenResponse>(
|
||||
'/account/refresh-access-token',
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${authData.refreshToken}`
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (!result.data.success) throw new Error;
|
||||
|
||||
const newAccessToken = result.data.accessToken;
|
||||
const newRefreshToken = result.data.refreshToken;
|
||||
|
||||
useAuthStore.getState().login({
|
||||
accessToken: newAccessToken,
|
||||
refreshToken: newRefreshToken
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AuthData } from '@/data/AuthData';
|
||||
import { create } from 'zustand';
|
||||
import { createJSONStorage, persist } from 'zustand/middleware';
|
||||
|
||||
interface AuthStoreProps {
|
||||
authData: AuthData | undefined;
|
||||
@@ -7,17 +8,23 @@ interface AuthStoreProps {
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthStoreProps>((set) => ({
|
||||
authData: undefined,
|
||||
login: (data: AuthData) => {
|
||||
set({ authData: data });
|
||||
Object.entries(data)
|
||||
.forEach((entry) => {
|
||||
localStorage.setItem(entry[0], entry[1]);
|
||||
})
|
||||
},
|
||||
logout: () => {
|
||||
set({ authData: undefined });
|
||||
localStorage.clear();
|
||||
}
|
||||
}));
|
||||
const storage = sessionStorage;
|
||||
export const useAuthStore = create<AuthStoreProps>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
authData: undefined,
|
||||
login: (data: AuthData) => {
|
||||
set({ authData: data });
|
||||
},
|
||||
logout: () => {
|
||||
localStorage.setItem('autoLogin', 'false');
|
||||
localStorage.removeItem('auth-storage');
|
||||
set({ authData: undefined });
|
||||
}
|
||||
}),
|
||||
{
|
||||
name: 'auth-storage',
|
||||
storage: createJSONStorage(() => storage)
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -2,13 +2,53 @@ import { Label } from '@/components/ui/label';
|
||||
import { SidebarTrigger } from '@/components/ui/sidebar';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { LogOutIcon } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { PageRouting } from '@/const/PageRouting';
|
||||
|
||||
export default function Header() {
|
||||
const navigate = useNavigate();
|
||||
const { logout } = useAuthStore();
|
||||
|
||||
const handleClickLogoutButton = () => {
|
||||
logout();
|
||||
navigate(PageRouting["LOGIN"].path);
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="flex shrink-0 items-center gap-2 border-b px-4 w-full h-12">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
|
||||
<Label>{import.meta.env.BASE_URL}</Label>
|
||||
<header className="w-full flex shrink-0 flex-row justify-between items-center border-b px-4 h-12">
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
|
||||
<Label>{import.meta.env.BASE_URL}</Label>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
className={`
|
||||
group flex items-center justify-start
|
||||
pr-2 pl-2 border border-red-500 bg-white
|
||||
transition-all duration-150
|
||||
w-10 hover:w-25 hover:bg-red-500
|
||||
overflow-hidden rounded-md
|
||||
`}
|
||||
type="button"
|
||||
onClick={handleClickLogoutButton}
|
||||
>
|
||||
<LogOutIcon
|
||||
className="text-red-500 transition-colors duration-150 group-hover:text-white"
|
||||
/>
|
||||
<span className="
|
||||
text-red-500 group-hover:text-white
|
||||
opacity-0 scale-1
|
||||
transition-all duration-150
|
||||
group-hover:opacity-100 group-hover:scale-100
|
||||
">
|
||||
로그아웃
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -5,11 +5,20 @@ import {
|
||||
SidebarFooter,
|
||||
SidebarHeader
|
||||
} from '@/components/ui/sidebar';
|
||||
import { PageRouting } from '@/const/PageRouting';
|
||||
|
||||
interface SideBarProps {
|
||||
goTo: (path: string) => void;
|
||||
}
|
||||
export default function SideBar({ goTo } : SideBarProps) {
|
||||
|
||||
export default function SideBar() {
|
||||
return (
|
||||
<Sidebar>
|
||||
|
||||
<Sidebar forceSheet={true}>
|
||||
<SidebarHeader></SidebarHeader>
|
||||
<SidebarContent className="flex flex-col p-4 cursor-default">
|
||||
<div onClick={() => goTo(PageRouting["HOME"].path)}>Home</div>
|
||||
<div onClick={() => goTo(PageRouting["SCHEDULES"].path)}>Schedules</div>
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
190
src/ui/component/calendar/CustomCalendar.tsx
Normal file
190
src/ui/component/calendar/CustomCalendar.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
import { getDefaultClassNames } from "react-day-picker";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { isSameDay, getWeeksInMonth, getWeekOfMonth } from "date-fns";
|
||||
import { SchedulePopover } from "../popover/SchedulePopover";
|
||||
|
||||
interface CustomCalendarProps {
|
||||
data?: any;
|
||||
}
|
||||
|
||||
export const CustomCalendar = ({ data }: CustomCalendarProps) => {
|
||||
const [weekCount, setWeekCount] = useState(5);
|
||||
const [selectedDate, setSelectedDate] = useState<Date | undefined>(undefined);
|
||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||
const [popoverSide, setPopoverSide] = useState<'right' | 'left'>('right');
|
||||
const [popoverAlign, setPopoverAlign] = useState<'start' | 'end'>('end');
|
||||
const defaultClassNames = getDefaultClassNames();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const updateWeekCount = () => {
|
||||
if (containerRef === null) return;
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const weeks = containerRef.current.querySelectorAll('.rdp-week');
|
||||
|
||||
if (weeks?.length) setWeekCount(weeks.length);
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
updateWeekCount();
|
||||
}, []);
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
setPopoverOpen(open);
|
||||
if (!open) {
|
||||
setTimeout(() => {
|
||||
setSelectedDate(undefined);
|
||||
}, 150);
|
||||
}
|
||||
}
|
||||
|
||||
const handleDaySelect = (date: Date | undefined) => {
|
||||
if (!date) {
|
||||
setPopoverOpen(false);
|
||||
setTimeout(() => {
|
||||
setSelectedDate(undefined);
|
||||
}, 150);
|
||||
return;
|
||||
}
|
||||
if (date) {
|
||||
setSelectedDate(date);
|
||||
|
||||
const dayOfWeek = date.getDay();
|
||||
|
||||
if (0 <= dayOfWeek && dayOfWeek < 4) {
|
||||
setPopoverSide('right');
|
||||
} else {
|
||||
setPopoverSide('left');
|
||||
}
|
||||
|
||||
const options = { weekStartsOn: 0 as 0 };
|
||||
|
||||
const totalWeeks = getWeeksInMonth(date, options);
|
||||
|
||||
const currentWeekNumber = getWeekOfMonth(date, options);
|
||||
|
||||
const threshold = Math.ceil(totalWeeks / 2);
|
||||
|
||||
if (currentWeekNumber <= threshold) {
|
||||
setPopoverAlign('start');
|
||||
} else {
|
||||
setPopoverAlign('end');
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
setPopoverOpen(true);
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full h-full"
|
||||
ref={containerRef}
|
||||
>
|
||||
<Popover
|
||||
open={popoverOpen}
|
||||
onOpenChange={handleOpenChange}
|
||||
>
|
||||
<Calendar
|
||||
mode="single"
|
||||
className="h-full w-full border rounded-lg"
|
||||
selected={selectedDate}
|
||||
onSelect={handleDaySelect}
|
||||
onMonthChange={() => {
|
||||
// month 바뀐 직후 DOM 변화가 생기므로 다음 프레임에서 계산
|
||||
requestAnimationFrame(() => {
|
||||
updateWeekCount();
|
||||
});
|
||||
}}
|
||||
classNames={{
|
||||
months: cn(
|
||||
defaultClassNames.months,
|
||||
"w-full h-full relative"
|
||||
),
|
||||
nav: cn(
|
||||
defaultClassNames.nav,
|
||||
"flex w-full item-center gap-1 justify-around absolute top-0 inset-x-0"
|
||||
),
|
||||
month: cn(
|
||||
defaultClassNames.month,
|
||||
"h-full w-full flex flex-col"
|
||||
),
|
||||
month_grid: cn(
|
||||
defaultClassNames.month_grid,
|
||||
"w-full h-full flex-1"
|
||||
),
|
||||
weeks: cn(
|
||||
defaultClassNames.weeks,
|
||||
"w-full h-full"
|
||||
),
|
||||
weekdays: cn(
|
||||
defaultClassNames.weekdays,
|
||||
"w-full"
|
||||
),
|
||||
week: cn(
|
||||
defaultClassNames.week,
|
||||
`w-full`
|
||||
),
|
||||
day: cn(
|
||||
defaultClassNames.day,
|
||||
`w-[calc(100%/7)] rounded-none`
|
||||
),
|
||||
day_button: cn(
|
||||
defaultClassNames.day_button,
|
||||
"h-full w-full flex p-2 justify-start items-start",
|
||||
"hover:bg-transparent",
|
||||
"data-[selected-single=true]:bg-transparent data-[selected-single=true]:text-black"
|
||||
),
|
||||
selected: cn(
|
||||
defaultClassNames.selected,
|
||||
"h-full border-0 fill-transparent"
|
||||
),
|
||||
today: cn(
|
||||
defaultClassNames.today,
|
||||
"h-full"
|
||||
),
|
||||
|
||||
}}
|
||||
styles={{
|
||||
day: {
|
||||
height: `calc(100%/${weekCount})`
|
||||
},
|
||||
}}
|
||||
components={{
|
||||
Day: ({ day, ...props }) => {
|
||||
const date = day.date;
|
||||
const isSelected = selectedDate && isSameDay(selectedDate, date);
|
||||
return (
|
||||
<td {...props}>
|
||||
{ isSelected
|
||||
? <PopoverTrigger asChild>
|
||||
{props.children}
|
||||
</PopoverTrigger>
|
||||
: props.children
|
||||
}
|
||||
</td>
|
||||
)
|
||||
},
|
||||
DayButton: ({ day, ...props}) => (
|
||||
<button
|
||||
{...props}
|
||||
disabled={day.outside}
|
||||
>
|
||||
{props.children}
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<SchedulePopover
|
||||
date={selectedDate}
|
||||
popoverSide={popoverSide}
|
||||
popoverAlign={popoverAlign}
|
||||
/>
|
||||
</Popover>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
72
src/ui/component/popover/ColorPickPopover.tsx
Normal file
72
src/ui/component/popover/ColorPickPopover.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { PopoverContent } from "@/components/ui/popover"
|
||||
import type { ColorPaletteType } from "@/const/ColorPalette"
|
||||
import { usePalette } from "@/hooks/use-palette";
|
||||
import { useState } from "react";
|
||||
|
||||
interface ColorPickPopoverProps {
|
||||
setColor: (color: ColorPaletteType) => void;
|
||||
}
|
||||
|
||||
export const ColorPickPopover = ({ setColor }: ColorPickPopoverProps) => {
|
||||
const [seeMore, setSeeMore] = useState(false);
|
||||
const {
|
||||
getMainPaletteList,
|
||||
getExtraPaletteList,
|
||||
getCustomColor
|
||||
} = usePalette();
|
||||
const mainPaletteList = getMainPaletteList();
|
||||
const extraPaletteList = getExtraPaletteList();
|
||||
|
||||
const getSlicedList = (paletteList: ColorPaletteType[], length: number) => {
|
||||
const slicedList: ColorPaletteType[][] = [];
|
||||
let index = 0;
|
||||
while (index < paletteList.length) {
|
||||
slicedList.push(paletteList.slice(index, index + length));
|
||||
index += length;
|
||||
}
|
||||
return slicedList;
|
||||
}
|
||||
|
||||
return (
|
||||
<PopoverContent
|
||||
className="flex flex-col gap-1.5 w-fit"
|
||||
>
|
||||
{getSlicedList(mainPaletteList, 5).map((list) => (
|
||||
<div className="flex flex-row gap-2.5">
|
||||
{list.map((palette) => (
|
||||
<div
|
||||
className="rounded-full w-5 h-5 border border-gray-300"
|
||||
style={{ backgroundColor: `${palette.style}` }}
|
||||
onClick={() => setColor(palette)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
{
|
||||
!seeMore
|
||||
? <div className="w-full" onClick={() => setSeeMore(true)}>더 보기</div>
|
||||
: <>
|
||||
{getSlicedList(extraPaletteList, 5).map((list) => (
|
||||
<div className="flex flex-row gap-2.5">
|
||||
{list.map((palette) => (
|
||||
<div
|
||||
className="rounded-full w-5 h-5 border border-gray-300"
|
||||
style={{
|
||||
backgroundColor: `${palette.style !== 'transparent' && palette.style}`,
|
||||
background: `${palette.style === 'transparent' && 'linear-gradient(135deg, black 50%, white 50%)' }`
|
||||
}}
|
||||
onClick={() => setColor(palette)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
}
|
||||
{
|
||||
seeMore
|
||||
? <div className="w-full" onClick={() => setSeeMore(false)}>접기</div>
|
||||
: null
|
||||
}
|
||||
</PopoverContent>
|
||||
)
|
||||
}
|
||||
75
src/ui/component/popover/SchedulePopover.tsx
Normal file
75
src/ui/component/popover/SchedulePopover.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Sheet, SheetContent, SheetHeader } from '@/components/ui/sheet';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { usePalette } from '@/hooks/use-palette';
|
||||
import { type ColorPaletteType } from '@/const/ColorPalette';
|
||||
import { ColorPickPopover } from './ColorPickPopover';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
interface ScheduleSheetProps {
|
||||
date: Date | undefined;
|
||||
popoverSide: 'left' | 'right';
|
||||
popoverAlign: 'start' | 'end';
|
||||
}
|
||||
|
||||
export const SchedulePopover = ({ date, popoverSide, popoverAlign }: ScheduleSheetProps) => {
|
||||
const {
|
||||
ColorPaletteType,
|
||||
getPaletteNameList,
|
||||
getMainPaletteList,
|
||||
getAllPaletteList,
|
||||
getCustomColor,
|
||||
getPaletteByKey,
|
||||
getStyle
|
||||
} = usePalette();
|
||||
const defaultColor = getPaletteByKey('Black');
|
||||
const [scheduleColor, setScheduleColor] = useState(defaultColor);
|
||||
const [colorPopoverOpen, setColorPopoverOpen] = useState(false);
|
||||
const selectColor = (color: ColorPaletteType) => {
|
||||
setScheduleColor(color);
|
||||
setColorPopoverOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<PopoverContent
|
||||
className="rounded-xl xl:w-[calc(100vw/4)] xl:max-w-[480px] min-w-[320px]"
|
||||
align={popoverAlign} side={popoverSide}
|
||||
>
|
||||
<ScrollArea
|
||||
className={
|
||||
cn(
|
||||
"[&>div>div:last-child]:hidden min-h-[125px] h-[calc(100vh/2)] p-2.5 w-full flex flex-col",
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="w-full flex flex-row justify-center items-center gap-4">
|
||||
<Popover open={colorPopoverOpen} onOpenChange={setColorPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-full w-5 h-5 border-2 border-gray-300',
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: `${scheduleColor.style !== 'transparent' && scheduleColor.style}`,
|
||||
background: `${scheduleColor.style === 'transparent' && 'linear-gradient(135deg, black 50%, white 50%)' }`
|
||||
}}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<ColorPickPopover
|
||||
setColor={selectColor}
|
||||
/>
|
||||
</Popover>
|
||||
<Input
|
||||
placeholder="제목"
|
||||
className="font-bold border-t-0 border-r-0 border-l-0 p-0 border-b-2 rounded-none shadow-none border-indigo-300 focus-visible:ring-0 focus-visible:border-b-indigo-500"
|
||||
style={{
|
||||
fontSize: '20px'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</PopoverContent>
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { Field, FieldError, FieldLabel } from '@/components/ui/field';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useCallback, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { PageRouting } from '@/const/PageRouting';
|
||||
@@ -15,10 +15,15 @@ import { LoginRequest } from '@/data/request/account/LoginRequest';
|
||||
import { AccountNetwork } from '@/network/AccountNetwork';
|
||||
import { toast } from 'sonner';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
|
||||
export default function LoginPage() {
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [autoLogin, setAutoLogin] = useState<boolean>(localStorage.getItem('autoLogin') === 'true');
|
||||
const { login } = useAuthStore();
|
||||
const isMobile = useIsMobile();
|
||||
const navigate = useNavigate();
|
||||
const accountNetwork = new AccountNetwork();
|
||||
const loginForm = useForm<z.infer<typeof LoginSchema>>({
|
||||
@@ -30,6 +35,10 @@ export default function LoginPage() {
|
||||
});
|
||||
const { id, password } = { id: loginForm.watch('id'), password: loginForm.watch('password') };
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('autoLogin', `${autoLogin}`);
|
||||
}, [autoLogin]);
|
||||
|
||||
const moveToSignUpPage = useCallback(() => {
|
||||
navigate(PageRouting["SIGN_UP"].path);
|
||||
}, []);
|
||||
@@ -53,39 +62,33 @@ export default function LoginPage() {
|
||||
|
||||
const loginPromise = accountNetwork.login(data);
|
||||
|
||||
toast.promise<{ message?: string }>(
|
||||
() => new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
loginPromise.then((res) => {
|
||||
if (res.data.success) {
|
||||
resolve({message: ''});
|
||||
} else {
|
||||
reject(res.data.message);
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
reject ("서버 에러 발생");
|
||||
}
|
||||
}),
|
||||
toast.promise(
|
||||
loginPromise,
|
||||
{
|
||||
loading: "로그인 중입니다.",
|
||||
success: "로그인이 완료되었습니다.",
|
||||
error: (err) => `${err}`
|
||||
success: (res) => {
|
||||
setIsLoading(false);
|
||||
if (res.data.success) {
|
||||
const data = {
|
||||
accessToken: res.data.accessToken!,
|
||||
refreshToken: res.data.refreshToken!
|
||||
};
|
||||
login({...data});
|
||||
if (autoLogin) {
|
||||
localStorage.setItem('auth-storage', JSON.stringify({ state: data }));
|
||||
}
|
||||
moveToHomePage();
|
||||
return "로그인 성공";
|
||||
} else {
|
||||
throw new Error(res.data.message);
|
||||
}
|
||||
},
|
||||
error: (err: Error) => {
|
||||
setIsLoading(false);
|
||||
return err.message || "에러 발생"
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
loginPromise
|
||||
.then((res) => {
|
||||
if (res.data.success) {
|
||||
const data = {
|
||||
accessToken: res.data.accessToken!,
|
||||
refreshToken: res.data.refreshToken!
|
||||
}
|
||||
login({ ...data });
|
||||
moveToHomePage();
|
||||
}
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}
|
||||
|
||||
const TextSeparator = ({ text }: { text: string }) => {
|
||||
@@ -98,9 +101,16 @@ export default function LoginPage() {
|
||||
)
|
||||
}
|
||||
|
||||
const handleEnterKeyDown = async (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (!(e.key === 'Enter')) return;
|
||||
const result = await loginForm.trigger();
|
||||
if (!result) return;
|
||||
await reqLogin();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col justify-center items-center">
|
||||
<Card className="w-md pl-2 pr-2">
|
||||
<Card className={isMobile ? "w-full pl-2 pr-2" : "w-md pl-2 pr-2"}>
|
||||
<CardHeader>
|
||||
로그인
|
||||
</CardHeader>
|
||||
@@ -117,6 +127,8 @@ export default function LoginPage() {
|
||||
type="text"
|
||||
id="form-login-id"
|
||||
aria-invalid={fieldState.invalid}
|
||||
tabIndex={1}
|
||||
onKeyDown={handleEnterKeyDown}
|
||||
/>
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
</Field>
|
||||
@@ -134,6 +146,7 @@ export default function LoginPage() {
|
||||
className="p-0 bg-transparent hover:bg-transparent h-fit w-fit text-xs text-gray-400 hover:text-gray-500 cursor-pointer"
|
||||
onClick={moveToResetPasswordPage}
|
||||
type="button"
|
||||
tabIndex={3}
|
||||
>
|
||||
비밀번호를 잊으셨습니까?
|
||||
</Button>
|
||||
@@ -143,13 +156,28 @@ export default function LoginPage() {
|
||||
type="password"
|
||||
id="form-login-password"
|
||||
aria-invalid={fieldState.invalid}
|
||||
tabIndex={2}
|
||||
onKeyDown={handleEnterKeyDown}
|
||||
/>
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
</Field>
|
||||
)}
|
||||
>
|
||||
</Controller>
|
||||
<div className="flex flex-row gap-2 mt-2">
|
||||
<Checkbox
|
||||
className={[
|
||||
"data-[state=checked]:bg-indigo-500 data-[state=checked]:text-white"
|
||||
, "data-[state=checked]:outline-none data-[state=checked]:border-0"
|
||||
].join(' ')}
|
||||
id="auto-login"
|
||||
checked={autoLogin}
|
||||
onCheckedChange={(value) => setAutoLogin(value === true)}
|
||||
/>
|
||||
<Label htmlFor="auto-login">자동 로그인</Label>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</CardContent>
|
||||
<CardFooter
|
||||
className="w-full flex flex-col items-center gap-5"
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Field, FieldError, FieldLabel } from '@/components/ui/field';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useState, useCallback } from 'react';
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { PageRouting } from '@/const/PageRouting';
|
||||
@@ -122,7 +122,7 @@ export default function ResetPasswordPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleThirdStepButton = async () => {
|
||||
const handleClickThirdStepButton = async () => {
|
||||
if (isLoading) return;
|
||||
const passwordValid = await resetPasswordForm.trigger('password');
|
||||
if (!passwordValid) return;
|
||||
@@ -159,6 +159,19 @@ export default function ResetPasswordPage() {
|
||||
|
||||
}
|
||||
|
||||
const handleEnterKeyDown = async (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (currentStep === 1) {
|
||||
await handleClickFirstStepButton();
|
||||
return;
|
||||
}
|
||||
if (currentStep === 3) {
|
||||
await handleClickThirdStepButton();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Stepper
|
||||
value={currentStep}
|
||||
@@ -211,13 +224,7 @@ export default function ResetPasswordPage() {
|
||||
type="email"
|
||||
id="reset-password-email"
|
||||
aria-invalid={fieldState.invalid}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (email.length === 0) return;
|
||||
handleClickFirstStepButton();
|
||||
}
|
||||
}}
|
||||
onKeyDown={handleEnterKeyDown}
|
||||
/>
|
||||
<FieldError className="font-[12px]" errors={[fieldState.error]} />
|
||||
</>
|
||||
@@ -275,6 +282,7 @@ export default function ResetPasswordPage() {
|
||||
type={ showPassword ? "text" : "password" }
|
||||
id="reset-password-password"
|
||||
aria-invalid={fieldState.invalid}
|
||||
onKeyDown={handleEnterKeyDown}
|
||||
className="pr-10"
|
||||
/>
|
||||
<button
|
||||
@@ -306,6 +314,7 @@ export default function ResetPasswordPage() {
|
||||
id="reset-password-password-confirm"
|
||||
className="pr-10"
|
||||
aria-invalid={fieldState.invalid}
|
||||
onKeyDown={handleEnterKeyDown}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -380,7 +389,7 @@ export default function ResetPasswordPage() {
|
||||
(password.trim().length < 1)
|
||||
&& (passwordConfirm.trim().length < 1)
|
||||
}
|
||||
onClick={handleThirdStepButton}
|
||||
onClick={handleClickThirdStepButton}
|
||||
>
|
||||
비밀번호 변경
|
||||
</Button>
|
||||
|
||||
@@ -18,6 +18,8 @@ import { AccountNetwork } from '@/network/AccountNetwork';
|
||||
import { toast } from 'sonner';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { PageRouting } from '@/const/PageRouting';
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
|
||||
export default function SignUpPage() {
|
||||
const [emailVerificationModalOpen, setEmailVerificationModalOpen] = useState<boolean>(false);
|
||||
@@ -28,6 +30,7 @@ export default function SignUpPage() {
|
||||
|
||||
const accountNetwork = new AccountNetwork();
|
||||
const navigate = useNavigate();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const signUpForm = useForm<z.infer<typeof SignUpSchema>>({
|
||||
resolver: zodResolver(SignUpSchema),
|
||||
@@ -119,145 +122,150 @@ export default function SignUpPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col justify-center items-center">
|
||||
<Card className="w-md pl-2 pr-2">
|
||||
<CardHeader>회원가입</CardHeader>
|
||||
<CardContent>
|
||||
<form id="form-signup">
|
||||
<FieldGroup>
|
||||
<Controller
|
||||
name="accountId"
|
||||
control={signUpForm.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor="form-signup-account-id">아이디</FieldLabel>
|
||||
<div id="accountId-group" className="w-full flex flex-row justify-between gap-2.5">
|
||||
<div className={"w-full h-full flex flex-col justify-center items-center"}>
|
||||
|
||||
<Card className={isMobile ? "w-full pl-2 pr-2" : "w-md pl-2 pr-2"}>
|
||||
<CardHeader>회원가입</CardHeader>
|
||||
<ScrollArea className="h-72 [&>div>div:last-child]:hidden">
|
||||
<CardContent>
|
||||
<form id="form-signup">
|
||||
<FieldGroup>
|
||||
<Controller
|
||||
name="accountId"
|
||||
control={signUpForm.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor="form-signup-account-id">아이디</FieldLabel>
|
||||
<div id="accountId-group" className="w-full flex flex-row justify-between gap-2.5">
|
||||
<Input
|
||||
{...field}
|
||||
id="form-signup-account-id"
|
||||
aria-invalid={fieldState.invalid}
|
||||
onInput={handleOnChangeAccountId}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => handleDuplicationCheckButtonClick('accountId')}
|
||||
className="bg-indigo-500 hover:bg-indigo-400"
|
||||
>
|
||||
중복 확인
|
||||
</Button>
|
||||
</div>
|
||||
{ isCheckedAccountIdDuplication && <p className="text-green-500 text-sm font-normal">사용할 수 있는 아이디입니다</p> }
|
||||
<FieldError errors={[fieldState.error]}/>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="name"
|
||||
control={signUpForm.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor="form-signup-name">이름</FieldLabel>
|
||||
<Input
|
||||
{...field}
|
||||
id="form-signup-account-id"
|
||||
id="form-signup-name"
|
||||
aria-invalid={fieldState.invalid}
|
||||
onInput={handleOnChangeAccountId}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => handleDuplicationCheckButtonClick('accountId')}
|
||||
className="bg-indigo-500 hover:bg-indigo-400"
|
||||
>
|
||||
중복 확인
|
||||
</Button>
|
||||
</div>
|
||||
{ isCheckedAccountIdDuplication && <p className="text-green-500 text-sm font-normal">사용할 수 있는 아이디입니다</p> }
|
||||
<FieldError errors={[fieldState.error]}/>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="name"
|
||||
control={signUpForm.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor="form-signup-name">이름</FieldLabel>
|
||||
<Input
|
||||
{...field}
|
||||
id="form-signup-name"
|
||||
aria-invalid={fieldState.invalid}
|
||||
/>
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="nickname"
|
||||
control={signUpForm.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor="form-signup-nickname">닉네임</FieldLabel>
|
||||
<Input
|
||||
{...field}
|
||||
id="form-signup-nickname"
|
||||
aria-invalid={fieldState.invalid}
|
||||
/>
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="email"
|
||||
control={signUpForm.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor="form-signup-email">이메일</FieldLabel>
|
||||
<div id="email-group" className="w-full flex flex-row justify-between gap-2.5">
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="nickname"
|
||||
control={signUpForm.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor="form-signup-nickname">닉네임</FieldLabel>
|
||||
<Input
|
||||
{...field}
|
||||
id="form-signup-email"
|
||||
id="form-signup-nickname"
|
||||
aria-invalid={fieldState.invalid}
|
||||
placeholder="example@domain.com"
|
||||
type="email"
|
||||
onInput={handleOnChangeEmail}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => handleDuplicationCheckButtonClick('email')}
|
||||
className="bg-indigo-500 hover:bg-indigo-400"
|
||||
>
|
||||
중복 확인
|
||||
</Button>
|
||||
</div>
|
||||
{ isCheckedEmailDuplication && <p className="text-green-500 text-sm font-normal">사용할 수 있는 이메일입니다</p> }
|
||||
<FieldError errors={[fieldState.error]}/>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="password"
|
||||
control={signUpForm.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor="form-signup-password">비밀번호</FieldLabel>
|
||||
<Input
|
||||
{...field}
|
||||
id="form-signup-password"
|
||||
aria-invalid={fieldState.invalid}
|
||||
type="password"
|
||||
/>
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="passwordConfirm"
|
||||
control={signUpForm.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor="form-signup-password-confirm">비밀번호 확인</FieldLabel>
|
||||
<Input
|
||||
{...field}
|
||||
id="form-signup-password-confirm"
|
||||
aria-invalid={fieldState.invalid}
|
||||
type="password"
|
||||
/>
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<EmailVerificationModal
|
||||
trigger={
|
||||
<Button type="button" onClick={handleOnSignUpButtonClick} className="0">
|
||||
회원가입
|
||||
</Button>
|
||||
}
|
||||
email={duplicationCheckedEmail}
|
||||
open={emailVerificationModalOpen} // ✅ 부모 상태 연결
|
||||
setOpen={setEmailVerificationModalOpen} // ✅ 부모 상태 변경 함수 전달
|
||||
onVerifySuccess={signup} // ✅ 인증 성공 시 signup 호출
|
||||
/>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="email"
|
||||
control={signUpForm.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor="form-signup-email">이메일</FieldLabel>
|
||||
<div id="email-group" className="w-full flex flex-row justify-between gap-2.5">
|
||||
<Input
|
||||
{...field}
|
||||
id="form-signup-email"
|
||||
aria-invalid={fieldState.invalid}
|
||||
placeholder="example@domain.com"
|
||||
type="email"
|
||||
onInput={handleOnChangeEmail}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => handleDuplicationCheckButtonClick('email')}
|
||||
className="bg-indigo-500 hover:bg-indigo-400"
|
||||
>
|
||||
중복 확인
|
||||
</Button>
|
||||
</div>
|
||||
{ isCheckedEmailDuplication && <p className="text-green-500 text-sm font-normal">사용할 수 있는 이메일입니다</p> }
|
||||
<FieldError errors={[fieldState.error]}/>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="password"
|
||||
control={signUpForm.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor="form-signup-password">비밀번호</FieldLabel>
|
||||
<Input
|
||||
{...field}
|
||||
id="form-signup-password"
|
||||
aria-invalid={fieldState.invalid}
|
||||
type="password"
|
||||
/>
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="passwordConfirm"
|
||||
control={signUpForm.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor="form-signup-password-confirm">비밀번호 확인</FieldLabel>
|
||||
<Input
|
||||
{...field}
|
||||
id="form-signup-password-confirm"
|
||||
aria-invalid={fieldState.invalid}
|
||||
type="password"
|
||||
/>
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</CardContent>
|
||||
</ScrollArea>
|
||||
<CardFooter>
|
||||
<EmailVerificationModal
|
||||
trigger={
|
||||
<Button type="button" onClick={handleOnSignUpButtonClick} className="0">
|
||||
회원가입
|
||||
</Button>
|
||||
}
|
||||
email={duplicationCheckedEmail}
|
||||
open={emailVerificationModalOpen} // ✅ 부모 상태 연결
|
||||
setOpen={setEmailVerificationModalOpen} // ✅ 부모 상태 변경 함수 전달
|
||||
onVerifySuccess={signup} // ✅ 인증 성공 시 signup 호출
|
||||
/>
|
||||
</CardFooter>
|
||||
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export const HomePage = () => {
|
||||
return (
|
||||
<div>
|
||||
<div className="w-full h-full flex flex-column">
|
||||
HomePage
|
||||
</div>
|
||||
)
|
||||
|
||||
11
src/ui/page/schedule/ScheduleMainPage.tsx
Normal file
11
src/ui/page/schedule/ScheduleMainPage.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { CustomCalendar } from "@/ui/component/calendar/CustomCalendar";
|
||||
|
||||
export function ScheduleMainPage() {
|
||||
return (
|
||||
<div
|
||||
className="w-full h-full p-2"
|
||||
>
|
||||
<CustomCalendar />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ export class Validator {
|
||||
|
||||
static validatePasswordFormat = (password: string): boolean => {
|
||||
if (password.length < 8) return false;
|
||||
if (password.includes(' ')) return false;
|
||||
|
||||
const alphabets = 'abcdefghijklmnopqrstuvwxyz';
|
||||
const numbers = '0123456789';
|
||||
|
||||
@@ -5,6 +5,7 @@ import path from 'path'
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5185
|
||||
},
|
||||
plugins: [
|
||||
|
||||
Reference in New Issue
Block a user