Compare commits
33 Commits
49ca9b9ae3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e201f8ae0 | ||
| f8ff0c61f9 | |||
|
|
8b085107f6 | ||
|
|
60e9d2a631 | ||
|
|
4a3896a313 | ||
| 9173556204 | |||
| 64540e397e | |||
| fdbfd80462 | |||
|
|
8015eb45db | ||
|
|
78e3bdbda0 | ||
| b23b58e680 | |||
|
|
e86fb3bac2 | ||
| 0c13854257 | |||
|
|
a30c2bbb32 | ||
|
|
47d2eae519 | ||
|
|
6bbffbcb50 | ||
| c0941d0680 | |||
| 2c8dcf9db7 | |||
|
|
4a8e761b3d | ||
| 0c8e0893c7 | |||
|
|
7df60fe004 | ||
| daab622638 | |||
|
|
ea7861b63a | ||
|
|
069f58075b | ||
|
|
edef4273c0 | ||
|
|
e3091494b1 | ||
|
|
3859099074 | ||
|
|
54c84dbc87 | ||
| 1a0cc9376f | |||
| b730945d34 | |||
| 17e27fca70 | |||
|
|
af3fa26f3b | ||
|
|
eec883ac32 |
@@ -1 +1 @@
|
||||
VITE_API_URL=http://localhost:8080
|
||||
VITE_API_URL=/dev-api
|
||||
|
||||
@@ -1 +1 @@
|
||||
VITE_API_URL=https://api.scheduler.bkdhome.p-e.kr
|
||||
VITE_API_URL=/api
|
||||
3
.npmrc
Normal file
3
.npmrc
Normal file
@@ -0,0 +1,3 @@
|
||||
@baekyangdan:registry=https://gitea.ddoahh.kro.kr/api/packages/baekyangdan/npm/
|
||||
//gitea.ddoahh.kro.kr/api/packages/baekyangdan/npm/:_authToken=d39c7d88c52806df7522ce2b340b6577c5ec5082
|
||||
always-auth=true
|
||||
28
certs/localhost+2-key.pem
Normal file
28
certs/localhost+2-key.pem
Normal file
@@ -0,0 +1,28 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC1GqPn9O+FRM+a
|
||||
hQvEAjgjI7HXdRdeRJsX3K2BqNGYBR8Lat4fMskY4Es7WYNCDl9d/fFQd+K/gpjj
|
||||
sRnX0Bf3CYjVNlhTqdwoOFBPGUQ4i8fuSRM4rDvAq4enb+7RVWjE53MGgqQ0RdRQ
|
||||
Hyx95pEWTz0FzpNlzOzEGEdv8zBwbwgZBU73F4aCDNY0FLaaKepr1/NqdUA8xZYo
|
||||
sAbJvIkWJ/QS2F2/WwQZYQ3TLWtg4/2uDpGWbpQUwRnWZ7ma+Gcz/hJwQhoml4q4
|
||||
bHXrIAsCz/NFNYs7K5wCmIdTjLw2PcETJSEQMbGSk7CMIuiUd0ShVDfpBROIAlmF
|
||||
XAheZ/lNAgMBAAECggEAT1H7t/xva89XnjXnkVHnhHx9yABg28jwpOLim4d1RT/4
|
||||
+Oc1ojR8H4kdakEqXCQvYNt4deYMShTJIfDPgNaDqI9kfv3ucbZT1snTYtGOL7YJ
|
||||
OzSGVqwY/6ohIBTGZKkj2hoFJzTQ9pQfCXid5Aa4RS0vbPutU0kN6lU39LBu5s79
|
||||
8MGGu70Qcd63BRpyOxKbbWCbZ0S/7JRShng0GA8ILBvMCZdzZ7RZktpa+bA7Q6i7
|
||||
PM5zxxmxygmFQXOAizTB2KoeLEu/qb44kypK6aw9nMKEZebqzuF5bOFMlCR67Cfx
|
||||
QblFW0JQckef7DCc4ThPEnAXwEPSlsv2P//dbX1TgQKBgQDctIA/GZmzSXVtsUws
|
||||
aPPeAvKMWGFSGsq/9rcy2G7KBTHBQW5/763T6N7HKVpZwRaabiSxgrZekd6d9oTP
|
||||
XWpmnFQZgtLRtXLPXilCK8Udoi6HejGuXWvviFdfPUqhPh3tw6uCaFO7qT4pUiwO
|
||||
hTq4W5Cm+xy/q12m7cEDfoPeMwKBgQDSEOdQY5f5C6YrZvG2zrTZUvS8VW4NFhkY
|
||||
vSZBGqiACYGKq/7erMoAdtLrcYBnEMiLufn+tSInflw2I+ALEo4r2lcBAQncXuWj
|
||||
gnpM+DpTwRfZmGYs3jXA5eVRRzG0FBFLFyiBE62f0tvJjALB7V7vfpCswJGvPIyb
|
||||
p2P16jZKfwKBgQCn4VkoJlYGzZrYTKPvqAnQV5ed7+BfbufIq2dg8scbPmZBZX8j
|
||||
K/KinaFQB4Glgj2qTJv2tsH4H6chqxINFjbIRKOoIB4yzH2/hRWHMvomd2ZDQUyn
|
||||
IILo2mHznRC2pCRp5owAj1EaDzusfMfsZ6Vp9KSMj7inhzeesX0/Ji4yhwKBgHgh
|
||||
sJc5jYSgU9RIV/0qcyRBm7JEzN3xAEM0kLb0rt4iEZIjUGs5t3/SdEavLzZB096M
|
||||
adpu7exWCBfyJkNOxj1v7Qem92OuZXc/u/9eicSyDZij3fLU1TrOfnkf1N3eCBHA
|
||||
WaqPfWCELqsxRbZvsDYYVFZm/imP3/14GeNdoNSzAoGALFD3YdZ5bIETcD8hwXd7
|
||||
P9HXPQZag919fFe0dhPDauC5dgvgfLF2d88q0MPCqMK6ngTMVzrMh4X/u0XH1N3v
|
||||
LiQEBp4P0EX1z5DEj8eLajV0JgOyt1zzymZ3Jk7DDyfcYl+bVetB2R9Gd3MuV98t
|
||||
Sz72wO/jh1/w+SN07F9VS0w=
|
||||
-----END PRIVATE KEY-----
|
||||
26
certs/localhost+2.pem
Normal file
26
certs/localhost+2.pem
Normal file
@@ -0,0 +1,26 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIETjCCAragAwIBAgIQJ0x1ttig1msjzZOJWJwnITANBgkqhkiG9w0BAQsFADB7
|
||||
MR4wHAYDVQQKExVta2NlcnQgZGV2ZWxvcG1lbnQgQ0ExKDAmBgNVBAsMH+uwse2W
|
||||
peuLqFxiYWVreWFuZ2RhbkDrsLHtlqXri6gxLzAtBgNVBAMMJm1rY2VydCDrsLHt
|
||||
lqXri6hcYmFla3lhbmdkYW5A67Cx7Zal64uoMB4XDTI1MTIwNzEwMDIyNVoXDTI4
|
||||
MDMwNzEwMDIyNVowUzEnMCUGA1UEChMebWtjZXJ0IGRldmVsb3BtZW50IGNlcnRp
|
||||
ZmljYXRlMSgwJgYDVQQLDB/rsLHtlqXri6hcYmFla3lhbmdkYW5A67Cx7Zal64uo
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtRqj5/TvhUTPmoULxAI4
|
||||
IyOx13UXXkSbF9ytgajRmAUfC2reHzLJGOBLO1mDQg5fXf3xUHfiv4KY47EZ19AX
|
||||
9wmI1TZYU6ncKDhQTxlEOIvH7kkTOKw7wKuHp2/u0VVoxOdzBoKkNEXUUB8sfeaR
|
||||
Fk89Bc6TZczsxBhHb/MwcG8IGQVO9xeGggzWNBS2minqa9fzanVAPMWWKLAGybyJ
|
||||
Fif0Ethdv1sEGWEN0y1rYOP9rg6Rlm6UFMEZ1me5mvhnM/4ScEIaJpeKuGx16yAL
|
||||
As/zRTWLOyucApiHU4y8Nj3BEyUhEDGxkpOwjCLolHdEoVQ36QUTiAJZhVwIXmf5
|
||||
TQIDAQABo3YwdDAOBgNVHQ8BAf8EBAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUHAwEw
|
||||
HwYDVR0jBBgwFoAU9/lqZ9lo2f1oLg+JnBYubfpE4OEwLAYDVR0RBCUwI4IJbG9j
|
||||
YWxob3N0hwR/AAABhxAAAAAAAAAAAAAAAAAAAAABMA0GCSqGSIb3DQEBCwUAA4IB
|
||||
gQBNsZYSSGE6m3ve8bPGISSdlSU/pi1GVqOC4xxVD8JUeGNZeYAv8AJQ6w/496wK
|
||||
KTFL6PDOavHW37mWBEgz+fZe2AjOZK/hz/eOcKHTFhVRZo1snt5VuLk4PtAGmgn8
|
||||
xyyQUz/2wwlTqb0AgLrt1hLTnbSIWvBnFl3VdCnH0E2xsIPZUMFzcjVHTERJWvAS
|
||||
IanurnpeO/W3uNduu7UmGk03GDzTG8dXwVsSSE/HoXxscXSIP9qMvKOPeQvucd+X
|
||||
XkaYUkbjDhwoKqDB0rDmvbPkFcsAGuq8qpbPPavhoXgtdqO30lZfbTPWPq1qz99S
|
||||
nW9ihMfmwarw5s/LCXiQO70nMbcZMZ6UAqEhX4UUfGD0j5jHe3fPOyT4v0lLfmxp
|
||||
P4eoSiWoIfp/f9ZBn9zca5km9iGNT+n1Jrt6fOTIycPVu2gE7S4qXcHhVHWondle
|
||||
bMFLGbjZ75Qwc9HdnoY7Do8Vj+CPvSfhAAPehPf8D1GVlazmiuHql4fny1qbGw//
|
||||
YEQ=
|
||||
-----END CERTIFICATE-----
|
||||
@@ -18,5 +18,8 @@
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {}
|
||||
"registries": {
|
||||
"@reui": "https://reui.io/r/{name}.json",
|
||||
"@diceui": "https://diceui.com/r/{name}.json"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="/scheduler_favicon__1_.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>scheduler</title>
|
||||
</head>
|
||||
|
||||
1190
package-lock.json
generated
1190
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,8 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@baekyangdan/core-utils": "^1.0.23",
|
||||
"@diceui/mention": "^0.8.0",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
@@ -41,6 +43,8 @@
|
||||
"@tailwindcss/cli": "^4.1.16",
|
||||
"@tailwindcss/vite": "^4.1.16",
|
||||
"axios": "^1.13.2",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
@@ -56,8 +60,9 @@
|
||||
"react-resizable-panels": "^3.0.6",
|
||||
"react-router-dom": "^7.9.5",
|
||||
"recharts": "^2.15.4",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^4.1.12",
|
||||
"zustand": "^5.0.8"
|
||||
|
||||
673
public/scheduler_favicon__1_.svg
Normal file
673
public/scheduler_favicon__1_.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 113 KiB |
@@ -117,4 +117,4 @@
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
}
|
||||
29
src/App.tsx
29
src/App.tsx
@@ -1,11 +1,15 @@
|
||||
import './App.css';
|
||||
import SignUpPage from './ui/page/signup/SignUpPage';
|
||||
import 'reflect-metadata';
|
||||
import SignUpPage from './ui/page/account/signup/SignUpPage';
|
||||
import Layout from './layouts/Layout';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { useAuthStore } from './store/authStore';
|
||||
import { PageRouting } from './const/PageRouting';
|
||||
import LoginPage from './ui/page/login/LoginPage';
|
||||
import ResetPasswordPage from './ui/page/resetPassword/ResetPasswordPage';
|
||||
import LoginPage from './ui/page/account/login/LoginPage';
|
||||
import ResetPasswordPage from './ui/page/account/resetPassword/ResetPasswordPage';
|
||||
import { HomePage } from './ui/page/home/HomePage';
|
||||
import { ScheduleMainPage } from './ui/page/schedule/ScheduleMainPage';
|
||||
import { TempPage } from './ui/page/home/TempPage';
|
||||
|
||||
function App() {
|
||||
const { authData } = useAuthStore();
|
||||
@@ -13,11 +17,22 @@ function App() {
|
||||
return (
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route element={<TempPage />} path={"/"} /> {/* 자동로그인용 대기 화면 */}
|
||||
<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}
|
||||
{
|
||||
!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,6 +37,26 @@ 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" }),
|
||||
...formatters,
|
||||
@@ -193,7 +214,7 @@ function CalendarDayButton({
|
||||
ref={ref}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-day={day.date.toLocaleDateString()}
|
||||
data-day={day.date.toISOString()}
|
||||
data-selected-single={
|
||||
modifiers.selected &&
|
||||
!modifiers.range_start &&
|
||||
|
||||
@@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border border-indigo-200 py-6 shadow-sm shadow-indigo-200",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
91
src/components/ui/mention.tsx
Normal file
91
src/components/ui/mention.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import * as MentionPrimitive from "@diceui/mention";
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Mention({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MentionPrimitive.Root>) {
|
||||
return (
|
||||
<MentionPrimitive.Root
|
||||
data-slot="mention"
|
||||
className={cn(
|
||||
"**:data-tag:rounded **:data-tag:bg-blue-200 **:data-tag:py-px **:data-tag:text-blue-950 dark:**:data-tag:bg-blue-800 dark:**:data-tag:text-blue-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MentionLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MentionPrimitive.Label>) {
|
||||
return (
|
||||
<MentionPrimitive.Label
|
||||
data-slot="mention-label"
|
||||
className={cn("px-0.5 py-1.5 font-semibold text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MentionInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MentionPrimitive.Input>) {
|
||||
return (
|
||||
<MentionPrimitive.Input
|
||||
data-slot="mention-input"
|
||||
className={cn(
|
||||
"flex w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MentionContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MentionPrimitive.Content>) {
|
||||
return (
|
||||
<MentionPrimitive.Portal>
|
||||
<MentionPrimitive.Content
|
||||
data-slot="mention-content"
|
||||
className={cn(
|
||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</MentionPrimitive.Content>
|
||||
</MentionPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function MentionItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MentionPrimitive.Item>) {
|
||||
return (
|
||||
<MentionPrimitive.Item
|
||||
data-slot="mention-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden data-disabled:pointer-events-none data-highlighted:bg-accent data-highlighted:text-accent-foreground data-disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</MentionPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
export { Mention, MentionContent, MentionInput, MentionItem, MentionLabel };
|
||||
@@ -30,7 +30,7 @@ function PopoverContent({
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border border-indigo-200 p-4 shadow-md shadow-indigo-200 outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -13,7 +13,7 @@ function ScrollArea({
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
className={cn("relative", "[&>div>div:last-child]:hidden", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -14,7 +14,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
className="toaster group select-none"
|
||||
icons={{
|
||||
success: <CircleCheckIcon className="size-4" />,
|
||||
info: <InfoIcon className="size-4" />,
|
||||
|
||||
408
src/components/ui/stepper.tsx
Normal file
408
src/components/ui/stepper.tsx
Normal file
@@ -0,0 +1,408 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { createContext, useContext } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// Types
|
||||
type StepperOrientation = 'horizontal' | 'vertical';
|
||||
type StepState = 'active' | 'completed' | 'inactive' | 'loading';
|
||||
type StepIndicators = {
|
||||
active?: React.ReactNode;
|
||||
completed?: React.ReactNode;
|
||||
inactive?: React.ReactNode;
|
||||
loading?: React.ReactNode;
|
||||
};
|
||||
|
||||
interface StepperContextValue {
|
||||
activeStep: number;
|
||||
setActiveStep: (step: number) => void;
|
||||
stepsCount: number;
|
||||
orientation: StepperOrientation;
|
||||
registerTrigger: (node: HTMLButtonElement | null) => void;
|
||||
triggerNodes: HTMLButtonElement[];
|
||||
focusNext: (currentIdx: number) => void;
|
||||
focusPrev: (currentIdx: number) => void;
|
||||
focusFirst: () => void;
|
||||
focusLast: () => void;
|
||||
indicators: StepIndicators;
|
||||
}
|
||||
|
||||
interface StepItemContextValue {
|
||||
step: number;
|
||||
state: StepState;
|
||||
isDisabled: boolean;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const StepperContext = createContext<StepperContextValue | undefined>(undefined);
|
||||
const StepItemContext = createContext<StepItemContextValue | undefined>(undefined);
|
||||
|
||||
function useStepper() {
|
||||
const ctx = useContext(StepperContext);
|
||||
if (!ctx) throw new Error('useStepper must be used within a Stepper');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function useStepItem() {
|
||||
const ctx = useContext(StepItemContext);
|
||||
if (!ctx) throw new Error('useStepItem must be used within a StepperItem');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
interface StepperProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
defaultValue?: number;
|
||||
value?: number;
|
||||
onValueChange?: (value: number) => void;
|
||||
orientation?: StepperOrientation;
|
||||
indicators?: StepIndicators;
|
||||
}
|
||||
|
||||
function Stepper({
|
||||
defaultValue = 1,
|
||||
value,
|
||||
onValueChange,
|
||||
orientation = 'horizontal',
|
||||
className,
|
||||
children,
|
||||
indicators = {},
|
||||
...props
|
||||
}: StepperProps) {
|
||||
const [activeStep, setActiveStep] = React.useState(defaultValue);
|
||||
const [triggerNodes, setTriggerNodes] = React.useState<HTMLButtonElement[]>([]);
|
||||
|
||||
// Register/unregister triggers
|
||||
const registerTrigger = React.useCallback((node: HTMLButtonElement | null) => {
|
||||
setTriggerNodes((prev) => {
|
||||
if (node && !prev.includes(node)) {
|
||||
return [...prev, node];
|
||||
} else if (!node && prev.includes(node!)) {
|
||||
return prev.filter((n) => n !== node);
|
||||
} else {
|
||||
return prev;
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSetActiveStep = React.useCallback(
|
||||
(step: number) => {
|
||||
if (value === undefined) {
|
||||
setActiveStep(step);
|
||||
}
|
||||
onValueChange?.(step);
|
||||
},
|
||||
[value, onValueChange],
|
||||
);
|
||||
|
||||
const currentStep = value ?? activeStep;
|
||||
|
||||
// Keyboard navigation logic
|
||||
const focusTrigger = (idx: number) => {
|
||||
if (triggerNodes[idx]) triggerNodes[idx].focus();
|
||||
};
|
||||
const focusNext = (currentIdx: number) => focusTrigger((currentIdx + 1) % triggerNodes.length);
|
||||
const focusPrev = (currentIdx: number) => focusTrigger((currentIdx - 1 + triggerNodes.length) % triggerNodes.length);
|
||||
const focusFirst = () => focusTrigger(0);
|
||||
const focusLast = () => focusTrigger(triggerNodes.length - 1);
|
||||
|
||||
// Context value
|
||||
const contextValue = React.useMemo<StepperContextValue>(
|
||||
() => ({
|
||||
activeStep: currentStep,
|
||||
setActiveStep: handleSetActiveStep,
|
||||
stepsCount: React.Children.toArray(children).filter(
|
||||
(child): child is React.ReactElement =>
|
||||
React.isValidElement(child) && (child.type as { displayName?: string }).displayName === 'StepperItem',
|
||||
).length,
|
||||
orientation,
|
||||
registerTrigger,
|
||||
focusNext,
|
||||
focusPrev,
|
||||
focusFirst,
|
||||
focusLast,
|
||||
triggerNodes,
|
||||
indicators,
|
||||
}),
|
||||
[currentStep, handleSetActiveStep, children, orientation, registerTrigger, triggerNodes],
|
||||
);
|
||||
|
||||
return (
|
||||
<StepperContext.Provider value={contextValue}>
|
||||
<div
|
||||
role="tablist"
|
||||
aria-orientation={orientation}
|
||||
data-slot="stepper"
|
||||
className={cn('w-full', className)}
|
||||
data-orientation={orientation}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</StepperContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
interface StepperItemProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
step: number;
|
||||
completed?: boolean;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
function StepperItem({
|
||||
step,
|
||||
completed = false,
|
||||
disabled = false,
|
||||
loading = false,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: StepperItemProps) {
|
||||
const { activeStep } = useStepper();
|
||||
|
||||
const state: StepState = completed || step < activeStep ? 'completed' : activeStep === step ? 'active' : 'inactive';
|
||||
|
||||
const isLoading = loading && step === activeStep;
|
||||
|
||||
return (
|
||||
<StepItemContext.Provider value={{ step, state, isDisabled: disabled, isLoading }}>
|
||||
<div
|
||||
data-slot="stepper-item"
|
||||
className={cn(
|
||||
'group/step flex items-center justify-center group-data-[orientation=horizontal]/stepper-nav:flex-row group-data-[orientation=vertical]/stepper-nav:flex-col not-last:flex-1',
|
||||
className,
|
||||
)}
|
||||
data-state={state}
|
||||
{...(isLoading ? { 'data-loading': true } : {})}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</StepItemContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
interface StepperTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
function StepperTrigger({ asChild = false, className, children, tabIndex, ...props }: StepperTriggerProps) {
|
||||
const { state, isLoading } = useStepItem();
|
||||
const stepperCtx = useStepper();
|
||||
const { setActiveStep, activeStep, registerTrigger, triggerNodes, focusNext, focusPrev, focusFirst, focusLast } =
|
||||
stepperCtx;
|
||||
const { step, isDisabled } = useStepItem();
|
||||
const isSelected = activeStep === step;
|
||||
const id = `stepper-tab-${step}`;
|
||||
const panelId = `stepper-panel-${step}`;
|
||||
|
||||
// Register this trigger for keyboard navigation
|
||||
const btnRef = React.useRef<HTMLButtonElement>(null);
|
||||
React.useEffect(() => {
|
||||
if (btnRef.current) {
|
||||
registerTrigger(btnRef.current);
|
||||
}
|
||||
}, [btnRef.current]);
|
||||
|
||||
// Find our index among triggers for navigation
|
||||
const myIdx = React.useMemo(
|
||||
() => triggerNodes.findIndex((n: HTMLButtonElement) => n === btnRef.current),
|
||||
[triggerNodes, btnRef.current],
|
||||
);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
switch (e.key) {
|
||||
case 'ArrowRight':
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
if (myIdx !== -1 && focusNext) focusNext(myIdx);
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
if (myIdx !== -1 && focusPrev) focusPrev(myIdx);
|
||||
break;
|
||||
case 'Home':
|
||||
e.preventDefault();
|
||||
if (focusFirst) focusFirst();
|
||||
break;
|
||||
case 'End':
|
||||
e.preventDefault();
|
||||
if (focusLast) focusLast();
|
||||
break;
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
e.preventDefault();
|
||||
setActiveStep(step);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
if (asChild) {
|
||||
return (
|
||||
<span data-slot="stepper-trigger" data-state={state} className={className}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={btnRef}
|
||||
role="tab"
|
||||
id={id}
|
||||
aria-selected={isSelected}
|
||||
aria-controls={panelId}
|
||||
tabIndex={typeof tabIndex === 'number' ? tabIndex : isSelected ? 0 : -1}
|
||||
data-slot="stepper-trigger"
|
||||
data-state={state}
|
||||
data-loading={isLoading}
|
||||
className={cn(
|
||||
'cursor-pointer focus-visible:border-ring focus-visible:ring-ring/50 inline-flex items-center gap-3 rounded-full outline-none focus-visible:z-10 focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-60',
|
||||
className,
|
||||
)}
|
||||
onClick={() => setActiveStep(step)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={isDisabled}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function StepperIndicator({ children, className }: React.ComponentProps<'div'>) {
|
||||
const { state, isLoading } = useStepItem();
|
||||
const { indicators } = useStepper();
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="stepper-indicator"
|
||||
data-state={state}
|
||||
className={cn(
|
||||
'relative flex items-center overflow-hidden justify-center size-6 shrink-0 border-background rounded-full text-xs',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="absolute">
|
||||
{indicators &&
|
||||
((isLoading && indicators.loading) ||
|
||||
(state === 'completed' && indicators.completed) ||
|
||||
(state === 'active' && indicators.active) ||
|
||||
(state === 'inactive' && indicators.inactive))
|
||||
? (isLoading && indicators.loading) ||
|
||||
(state === 'completed' && indicators.completed) ||
|
||||
(state === 'active' && indicators.active) ||
|
||||
(state === 'inactive' && indicators.inactive)
|
||||
: children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StepperSeparator({ className }: React.ComponentProps<'div'>) {
|
||||
const { state } = useStepItem();
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="stepper-separator"
|
||||
data-state={state}
|
||||
className={cn(
|
||||
'm-0.5 rounded-full bg-muted group-data-[orientation=vertical]/stepper-nav:h-12 group-data-[orientation=vertical]/stepper-nav:w-0.5 group-data-[orientation=horizontal]/stepper-nav:h-0.5 group-data-[orientation=horizontal]/stepper-nav:flex-1',
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function StepperTitle({ children, className }: React.ComponentProps<'h3'>) {
|
||||
const { state } = useStepItem();
|
||||
|
||||
return (
|
||||
<h3 data-slot="stepper-title" data-state={state} className={cn('text-sm font-medium leading-none', className)}>
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
}
|
||||
|
||||
function StepperDescription({ children, className }: React.ComponentProps<'div'>) {
|
||||
const { state } = useStepItem();
|
||||
|
||||
return (
|
||||
<div data-slot="stepper-description" data-state={state} className={cn('text-sm text-muted-foreground', className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StepperNav({ children, className }: React.ComponentProps<'nav'>) {
|
||||
const { activeStep, orientation } = useStepper();
|
||||
|
||||
return (
|
||||
<nav
|
||||
data-slot="stepper-nav"
|
||||
data-state={activeStep}
|
||||
data-orientation={orientation}
|
||||
className={cn(
|
||||
'group/stepper-nav inline-flex data-[orientation=horizontal]:w-full data-[orientation=horizontal]:flex-row data-[orientation=vertical]:flex-col',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function StepperPanel({ children, className }: React.ComponentProps<'div'>) {
|
||||
const { activeStep } = useStepper();
|
||||
|
||||
return (
|
||||
<div data-slot="stepper-panel" data-state={activeStep} className={cn('w-full', className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface StepperContentProps extends React.ComponentProps<'div'> {
|
||||
value: number;
|
||||
forceMount?: boolean;
|
||||
}
|
||||
|
||||
function StepperContent({ value, forceMount, children, className }: StepperContentProps) {
|
||||
const { activeStep } = useStepper();
|
||||
const isActive = value === activeStep;
|
||||
|
||||
if (!forceMount && !isActive) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="stepper-content"
|
||||
data-state={activeStep}
|
||||
className={cn('w-full', className, !isActive && forceMount && 'hidden')}
|
||||
hidden={!isActive && forceMount}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
useStepper,
|
||||
useStepItem,
|
||||
Stepper,
|
||||
StepperItem,
|
||||
StepperTrigger,
|
||||
StepperIndicator,
|
||||
StepperSeparator,
|
||||
StepperTitle,
|
||||
StepperDescription,
|
||||
StepperPanel,
|
||||
StepperContent,
|
||||
StepperNav,
|
||||
type StepperProps,
|
||||
type StepperItemProps,
|
||||
type StepperTriggerProps,
|
||||
type StepperContentProps,
|
||||
};
|
||||
@@ -7,7 +7,7 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
72
src/const/ColorPalette.ts
Normal file
72
src/const/ColorPalette.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
export type ColorPaletteType = {
|
||||
index: number;
|
||||
style: string;
|
||||
}
|
||||
|
||||
|
||||
export const ColorPalette: Record<any, ColorPaletteType> = {
|
||||
SerenityBlue: {
|
||||
index: 0,
|
||||
style: '#92A8D1'
|
||||
},
|
||||
CoralPink: {
|
||||
index: 1,
|
||||
style: '#F08080'
|
||||
},
|
||||
MintIcing: {
|
||||
index: 2,
|
||||
style: '#C1E1C1'
|
||||
},
|
||||
Vanilla: {
|
||||
index: 3,
|
||||
style: '#FFFACD'
|
||||
},
|
||||
Wheat: {
|
||||
index: 4,
|
||||
style: '#F5DEB3'
|
||||
},
|
||||
Lavender: {
|
||||
index: 5,
|
||||
style: '#E6E6FA'
|
||||
},
|
||||
SageGreen: {
|
||||
index: 6,
|
||||
style: '#b2ac88'
|
||||
},
|
||||
LightGray: {
|
||||
index: 7,
|
||||
style: '#D3D3D3'
|
||||
},
|
||||
LightKhakki: {
|
||||
index: 8,
|
||||
style: '#F0F8E6'
|
||||
},
|
||||
DustyRose: {
|
||||
index: 9,
|
||||
style: '#D8BFD8'
|
||||
},
|
||||
CreamBeige: {
|
||||
index: 10,
|
||||
style: '#FAF0E6'
|
||||
},
|
||||
Oatmeal: {
|
||||
index: 11,
|
||||
style: '#FDF5E6'
|
||||
},
|
||||
CharcoalLight: {
|
||||
index: 12,
|
||||
style: '#A9A9A9'
|
||||
},
|
||||
PeachCream: {
|
||||
index: 13,
|
||||
style: '#FFDAB9'
|
||||
},
|
||||
LavenderBlue: {
|
||||
index: 14,
|
||||
style :'#CCCCFF'
|
||||
},
|
||||
SeaFoamGreen: {
|
||||
index: 15,
|
||||
style: '#93E9BE'
|
||||
}
|
||||
}
|
||||
9
src/const/schedule/ScheduleDay.ts
Normal file
9
src/const/schedule/ScheduleDay.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export const ScheduleDay: Record<number, string> = {
|
||||
1: '일',
|
||||
2: '월',
|
||||
3: '화',
|
||||
4: '수',
|
||||
5: '목',
|
||||
6: '금',
|
||||
7: '토'
|
||||
}
|
||||
1
src/const/schedule/SchedulePopoverMode.ts
Normal file
1
src/const/schedule/SchedulePopoverMode.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type SchedulePopoverMode = 'list' | 'create' | 'detail' | 'update';
|
||||
6
src/const/schedule/ScheduleStatus.ts
Normal file
6
src/const/schedule/ScheduleStatus.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export type ScheduleStatus = 'yet' | 'completed';
|
||||
|
||||
export const ScheduleStatusLabel: Record<ScheduleStatus, string> = {
|
||||
'yet': '미완료',
|
||||
'completed': '완료'
|
||||
};
|
||||
9
src/const/schedule/ScheduleType.ts
Normal file
9
src/const/schedule/ScheduleType.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export type ScheduleType = 'once' | 'daily' | 'weekly' | 'monthly' | 'annual';
|
||||
|
||||
export const ScheduleTypeLabel: Record<ScheduleType, string> = {
|
||||
'once': '반복없음',
|
||||
'daily': '매일',
|
||||
'weekly': '매주',
|
||||
'monthly': '매월',
|
||||
'annual': '매년'
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
export type AuthData = {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
20
src/data/form/account/login.schema.ts
Normal file
20
src/data/form/account/login.schema.ts
Normal file
@@ -0,0 +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(/^(?=.*[0-9])(?=.*[!@#$%^])[a-zA-Z0-9!@#$%^]+$/, "비밀번호는 영소문자로 시작하여 숫자, 특수문자(!@#$)를 한 개 이상 포함하여야 합니다.")
|
||||
});
|
||||
21
src/data/form/account/resetPassword.schema.ts
Normal file
21
src/data/form/account/resetPassword.schema.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import * as z from 'zod';
|
||||
|
||||
export const ResetPasswordSchema = z.object({
|
||||
email: z
|
||||
.email()
|
||||
, code: z
|
||||
.string()
|
||||
.length(8)
|
||||
.regex(/^(?=.*[0-9])(?=.*[!@#$%^])[a-zA-Z0-9!@#$%^]+$/, "영소문자로 시작하고 숫자와 특수문자(!@#$%^)를 포함해야 합니다.")
|
||||
, password: z
|
||||
.string()
|
||||
.min(8, "비밀번호는 8-12 자리여야 합니다.")
|
||||
.max(12, "비밀번호는 8-12 자리여야 합니다.")
|
||||
.regex(/^(?=.*[0-9])(?=.*[!@#$%^])[a-zA-Z0-9!@#$%^]+$/, "영소문자로 시작하고 숫자와 특수문자(!@#$%^)를 포함해야 합니다.")
|
||||
, passwordConfirm: z
|
||||
.string()
|
||||
})
|
||||
.refine((data) => data.password === data.passwordConfirm, {
|
||||
path: ["passwordConfirm"],
|
||||
error: "비밀번호가 일치하지 않습니다."
|
||||
});
|
||||
34
src/data/form/account/signup.schema.ts
Normal file
34
src/data/form/account/signup.schema.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import * as z from 'zod';
|
||||
|
||||
export const SignUpSchema = z.object({
|
||||
accountId: z
|
||||
.string()
|
||||
.min(5, "아이디는 6-14자여야 합니다.")
|
||||
.max(14, "아이디는 6-14자여야합니다.")
|
||||
.refine((val) => {
|
||||
return /^[a-zA-z-_.]*$/.test(val);
|
||||
}, {
|
||||
message: "영문, 숫자, '- _ .' 를 제외한 문자를 사용할 수 없습니다."
|
||||
})
|
||||
, email: z
|
||||
.string()
|
||||
.min(5, "이메일을 입력해주십시오.")
|
||||
, password: z
|
||||
.string()
|
||||
.min(8, "비밀번호는 8-12자여야 합니다.")
|
||||
.max(12, "비밀번호는 8-12자여야 합니다.")
|
||||
.regex(/^(?=.*[0-9])(?=.*[!@#$%^])[a-zA-Z0-9!@#$%^]+$/, "영소문자로 시작하고 숫자와 특수문자(!@#$%^)를 포함해야 합니다.")
|
||||
, name: z
|
||||
.string()
|
||||
.min(1, "이름을 입력해주시십시오.")
|
||||
.max(14, "너무 긴 이름은 사용하실 수 없습니다.")
|
||||
, nickname: z
|
||||
.string()
|
||||
.min(1, "닉네임을 입력해주십시오.")
|
||||
, passwordConfirm: z
|
||||
.string()
|
||||
})
|
||||
.refine((data) => data.password === data.passwordConfirm, {
|
||||
path: ["passwordConfirm"],
|
||||
error: "비밀번호가 일치하지 않습니다."
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
export { SignUpSchema } from './signup.schema';
|
||||
export { LoginSchema } from './login.schema';
|
||||
export { ResetPasswordSchema } from './resetPassword.schema';
|
||||
export { EmailVerificationSchema } from './emailVerification.schema';
|
||||
export { SignUpSchema } from './account/signup.schema';
|
||||
export { LoginSchema } from './account/login.schema';
|
||||
export { ResetPasswordSchema } from './account/resetPassword.schema';
|
||||
export { EmailVerificationSchema } from './account/emailVerification.schema';
|
||||
@@ -1,11 +0,0 @@
|
||||
import * as z from 'zod';
|
||||
|
||||
export const LoginSchema = z.object({
|
||||
id: z
|
||||
.string()
|
||||
, password: z
|
||||
.string()
|
||||
.min(8, "비밀번호는 8-12 자리여야 합니다.")
|
||||
.max(12, "비밀번호는 8-12 자리여야 합니다.")
|
||||
.regex(/^[a-z](?=.*[0-9])(?=.*[!@#$]).*$/, "비밀번호는 영소문자로 시작하여 숫자, 특수문자(!@#$)를 한 개 이상 포함하여야 합니다.")
|
||||
});
|
||||
@@ -1,6 +0,0 @@
|
||||
import * as z from 'zod';
|
||||
|
||||
export const ResetPasswordSchema = z.object({
|
||||
email: z
|
||||
.email()
|
||||
});
|
||||
26
src/data/form/schedule/createSchedule.schema.ts
Normal file
26
src/data/form/schedule/createSchedule.schema.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import * as z from 'zod';
|
||||
|
||||
export const CreateScheduleSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.nonempty()
|
||||
, startDate: z
|
||||
.date()
|
||||
, endDate: z
|
||||
.date()
|
||||
, status: z
|
||||
.string()
|
||||
, content: z
|
||||
.string()
|
||||
, type: z
|
||||
.string()
|
||||
, style: z
|
||||
.string()
|
||||
, startTime: z
|
||||
.string()
|
||||
, endTime: z
|
||||
.string()
|
||||
, dayList: z
|
||||
.string()
|
||||
.optional()
|
||||
});
|
||||
18
src/data/form/schedule/listSchedule.schema.ts
Normal file
18
src/data/form/schedule/listSchedule.schema.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import * as z from "zod";
|
||||
|
||||
export const ListScheduleSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.optional()
|
||||
, date: z
|
||||
.date()
|
||||
, status: z
|
||||
.string()
|
||||
.optional()
|
||||
, styleList: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
, typeList: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
});
|
||||
31
src/data/form/schedule/updateSchedule.schema.ts
Normal file
31
src/data/form/schedule/updateSchedule.schema.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import * as z from 'zod';
|
||||
|
||||
export const UpdateScheduleSchema = z.object({
|
||||
id: z
|
||||
.string()
|
||||
, name: z
|
||||
.string()
|
||||
.nonempty()
|
||||
, startDate: z
|
||||
.date()
|
||||
, endDate: z
|
||||
.date()
|
||||
, status: z
|
||||
.string()
|
||||
, content: z
|
||||
.string()
|
||||
, type: z
|
||||
.string()
|
||||
, style: z
|
||||
.string()
|
||||
, startTime: z
|
||||
.string()
|
||||
, endTime: z
|
||||
.string()
|
||||
, dayList: z
|
||||
.string()
|
||||
.optional()
|
||||
, participantList: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
import * as z from 'zod';
|
||||
|
||||
export const SignUpSchema = z.object({
|
||||
accountId: z
|
||||
.string()
|
||||
.min(5, "아이디는 5 자리 이상이어야 합니다.")
|
||||
, email: z
|
||||
.string()
|
||||
.min(5, "이메일을 입력해주십시오.")
|
||||
, password: z
|
||||
.string()
|
||||
.min(8, "비밀번호는 8-12 자리여야 합니다.")
|
||||
.max(12, "비밀번호는 8-12 자리여야 합니다.")
|
||||
.regex(/^[a-z](?=.*[0-9])(?=.*[!@#$]).*$/, "영문 소문자로 시작하고 숫자와 특수문자(!@#$)를 포함해야 합니다.")
|
||||
, name: z
|
||||
.string()
|
||||
.min(1, "이름을 입력해주시십시오.")
|
||||
, nickname: z
|
||||
.string()
|
||||
.min(1, "닉네임을 입력해주십시오.")
|
||||
, passwordConfirm: z
|
||||
.string()
|
||||
})
|
||||
.refine((data) => data.password === data.passwordConfirm, {
|
||||
path: ["passwordConfirm"],
|
||||
error: "비밀번호가 일치하지 않습니다."
|
||||
});
|
||||
4
src/data/request/account/ResetPasswordRequest.ts
Normal file
4
src/data/request/account/ResetPasswordRequest.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export class ResetPasswordRequest {
|
||||
email!: string;
|
||||
password!: string;
|
||||
}
|
||||
3
src/data/request/account/SendResetPasswordCodeRequest.ts
Normal file
3
src/data/request/account/SendResetPasswordCodeRequest.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export class SendResetPasswordCodeRequest {
|
||||
email!: string;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export class VerifyResetPasswordCodeRequest {
|
||||
email!: string;
|
||||
code!: string;
|
||||
}
|
||||
@@ -2,4 +2,12 @@ export * from './account/CheckDuplicationRequest';
|
||||
export * from './account/SendVerificationCodeRequest';
|
||||
export * from './account/VerifyCodeRequest';
|
||||
export * from './account/SignupRequest';
|
||||
export * from './account/LoginRequest';
|
||||
export * from './account/LoginRequest';
|
||||
export * from './account/SendResetPasswordCodeRequest';
|
||||
export * from './account/VerifyResetPasswordCodeRequest';
|
||||
export * from './account/ResetPasswordRequest';
|
||||
|
||||
export * from './schedule/CreateScheduleRequest';
|
||||
export * from './schedule/DeleteScheduleRequest';
|
||||
export * from './schedule/ScheduleListRequest';
|
||||
export * from './schedule/UpdateScheduleRequest';
|
||||
39
src/data/request/schedule/CreateScheduleRequest.ts
Normal file
39
src/data/request/schedule/CreateScheduleRequest.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { ScheduleStatus } from "@/const/schedule/ScheduleStatus";
|
||||
import type { ScheduleType } from "@/const/schedule/ScheduleType";
|
||||
|
||||
export class CreateScheduleRequest {
|
||||
name: string;
|
||||
content: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
status: ScheduleStatus;
|
||||
type: ScheduleType;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
style: string;
|
||||
// participantList: string[];
|
||||
|
||||
constructor (
|
||||
name: string,
|
||||
content: string,
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
status: ScheduleStatus,
|
||||
type: ScheduleType,
|
||||
startTime: string,
|
||||
endTime: string,
|
||||
style: string,
|
||||
// participantList: string[]
|
||||
) {
|
||||
this.name = name;
|
||||
this.content = content;
|
||||
this.startDate = startDate;
|
||||
this.endDate = endDate;
|
||||
this.status = status;
|
||||
this.type = type;
|
||||
this.startTime = startTime;
|
||||
this.endTime = endTime;
|
||||
this.style = style;
|
||||
// this.participantList = participantList;
|
||||
}
|
||||
}
|
||||
7
src/data/request/schedule/DeleteScheduleRequest.ts
Normal file
7
src/data/request/schedule/DeleteScheduleRequest.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export class DeleteScheduleRequest {
|
||||
id: string;
|
||||
|
||||
constructor(id: string) {
|
||||
this.id = id;
|
||||
}
|
||||
}
|
||||
12
src/data/request/schedule/ScheduleListRequest.ts
Normal file
12
src/data/request/schedule/ScheduleListRequest.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { ScheduleStatus } from "@/const/schedule/ScheduleStatus";
|
||||
import type { ScheduleType } from "@/const/schedule/ScheduleType";
|
||||
|
||||
export class ScheduleListRequest {
|
||||
date?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
typeList?: ScheduleType[];
|
||||
styleList?: string[];
|
||||
status?: ScheduleStatus;
|
||||
name?: string;
|
||||
}
|
||||
42
src/data/request/schedule/UpdateScheduleRequest.ts
Normal file
42
src/data/request/schedule/UpdateScheduleRequest.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { ScheduleStatus } from "@/const/schedule/ScheduleStatus";
|
||||
import type { ScheduleType } from "@/const/schedule/ScheduleType";
|
||||
|
||||
export class UpdateScheduleRequest {
|
||||
id: string;
|
||||
name: string;
|
||||
content: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
status: 'yet' | 'completed';
|
||||
type: 'once' | 'daily' | 'weekly' | 'aweekly' | 'monthly' | 'annual';
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
style: string;
|
||||
participantList: string[];
|
||||
|
||||
constructor (
|
||||
id: string,
|
||||
name: string,
|
||||
content: string,
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
status: ScheduleStatus,
|
||||
type: ScheduleType,
|
||||
startTime: string,
|
||||
endTime: string,
|
||||
style: string,
|
||||
participantList: string[]
|
||||
) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.content = content;
|
||||
this.startDate = startDate;
|
||||
this.endDate = endDate;
|
||||
this.status = status;
|
||||
this.type = type;
|
||||
this.startTime = startTime;
|
||||
this.endTime = endTime;
|
||||
this.style = style;
|
||||
this.participantList = participantList;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export class BaseResponse {
|
||||
success!: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import { BaseResponse } from "../BaseResponse";
|
||||
|
||||
export class LoginResponse extends BaseResponse {
|
||||
success!: boolean;
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
}
|
||||
@@ -2,5 +2,5 @@ import { BaseResponse } from "../BaseResponse";
|
||||
|
||||
export class RefreshAccessTokenResponse extends BaseResponse {
|
||||
accessToken!: string;
|
||||
refreshToken!: string;
|
||||
refreshToken?: string;
|
||||
}
|
||||
5
src/data/response/account/ResetPasswordResponse.ts
Normal file
5
src/data/response/account/ResetPasswordResponse.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { BaseResponse } from "../BaseResponse";
|
||||
|
||||
export class ResetPasswordResponse extends BaseResponse {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { BaseResponse } from "../BaseResponse";
|
||||
|
||||
export class SendResetPasswordCodeResponse extends BaseResponse {
|
||||
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BaseResponse } from "../BaseResponse";
|
||||
|
||||
export class SendVerificationCodeResponse extends BaseResponse {
|
||||
success!: boolean;
|
||||
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { BaseResponse } from "../BaseResponse";
|
||||
|
||||
export class SignupResponse extends BaseResponse {
|
||||
success!: boolean;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { BaseResponse } from "../BaseResponse";
|
||||
|
||||
export class VerifyResetPasswordCodeResponse extends BaseResponse {
|
||||
verified!: boolean;
|
||||
}
|
||||
@@ -2,4 +2,12 @@ export * from './account/CheckDuplicationResponse';
|
||||
export * from './account/SendVerificationCodeResponse';
|
||||
export * from './account/VerifyCodeResponse';
|
||||
export * from './account/SignupResponse';
|
||||
export * from './account/LoginResponse';
|
||||
export * from './account/LoginResponse';
|
||||
export * from './account/SendResetPasswordCodeResponse';
|
||||
export * from './account/VerifyResetPasswordCodeResponse';
|
||||
export * from './account/ResetPasswordResponse';
|
||||
|
||||
export * from './schedule/CreateScheduleResponse';
|
||||
export * from './schedule/UpdateScheduleResponse';
|
||||
export * from './schedule/ScheduleListResponse';
|
||||
export * from './schedule/ScheduleDetailResponse';
|
||||
3
src/data/response/schedule/CreateScheduleResponse.ts
Normal file
3
src/data/response/schedule/CreateScheduleResponse.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { BaseResponse } from "../BaseResponse";
|
||||
|
||||
export class CreateScheduleResponse extends BaseResponse {}
|
||||
24
src/data/response/schedule/ScheduleDetailResponse.ts
Normal file
24
src/data/response/schedule/ScheduleDetailResponse.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { ScheduleStatus } from "@/const/schedule/ScheduleStatus";
|
||||
import { BaseResponse } from "../BaseResponse";
|
||||
import type { ScheduleType } from "@/const/schedule/ScheduleType";
|
||||
|
||||
export class ScheduleDetailData {
|
||||
id!: string;
|
||||
name!: string;
|
||||
startDate!: string;
|
||||
endDate!: string;
|
||||
status!: ScheduleStatus;
|
||||
content?: string | null;
|
||||
type!: ScheduleType;
|
||||
createdAt!: string;
|
||||
owner!: string;
|
||||
style!: string;
|
||||
startTime!: string;
|
||||
endTime!: string;
|
||||
dayList?: string | null;
|
||||
participantList?: string[];
|
||||
}
|
||||
|
||||
export class ScheduleDetailResponse extends BaseResponse {
|
||||
data?: ScheduleDetailData;
|
||||
}
|
||||
17
src/data/response/schedule/ScheduleListResponse.ts
Normal file
17
src/data/response/schedule/ScheduleListResponse.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { ScheduleType } from "@/const/schedule/ScheduleType";
|
||||
import { BaseResponse } from "../BaseResponse";
|
||||
import type { ScheduleStatus } from "@/const/schedule/ScheduleStatus";
|
||||
|
||||
export class ScheduleListData {
|
||||
name!: string;
|
||||
id!: string;
|
||||
startDate!: string;
|
||||
endDate!: string;
|
||||
type!: string;
|
||||
style!: string;
|
||||
status!: string;
|
||||
}
|
||||
|
||||
export class ScheduleListResponse extends BaseResponse {
|
||||
data?: ScheduleListData[];
|
||||
}
|
||||
3
src/data/response/schedule/UpdateScheduleResponse.ts
Normal file
3
src/data/response/schedule/UpdateScheduleResponse.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { BaseResponse } from "../BaseResponse";
|
||||
|
||||
export class UpdateScheduleResponse extends BaseResponse {}
|
||||
57
src/hooks/use-palette.ts
Normal file
57
src/hooks/use-palette.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
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.slice(0, 10).forEach((paletteKey) => {
|
||||
const key = paletteKey as keyof typeof ColorPalette;
|
||||
const palette: ColorPaletteType = ColorPalette[key];
|
||||
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.slice(10).forEach((paletteKey) => {
|
||||
const key = paletteKey as keyof typeof ColorPalette;
|
||||
const palette: ColorPaletteType = ColorPalette[key];
|
||||
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 getStyle = (palette: ColorPaletteType) => {
|
||||
return palette.style;
|
||||
}
|
||||
|
||||
return {
|
||||
ColorPaletteType,
|
||||
getPaletteNameList,
|
||||
getMainPaletteList,
|
||||
getExtraPaletteList,
|
||||
getAllPaletteList,
|
||||
getPaletteByKey,
|
||||
getStyle
|
||||
}
|
||||
}
|
||||
13
src/hooks/use-record.ts
Normal file
13
src/hooks/use-record.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
export function useRecord(record: Record<any, any>) {
|
||||
const keys = useMemo(() => {
|
||||
return Object.keys(record);
|
||||
}, [record]);
|
||||
|
||||
const values = useMemo(() => {
|
||||
return Object.values(record);
|
||||
}, [record]);
|
||||
|
||||
return { keys, values };
|
||||
}
|
||||
57
src/hooks/use-time.ts
Normal file
57
src/hooks/use-time.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
export function useTime() {
|
||||
const getCurrentTimeString = (type: 'standard' | 'continental') => {
|
||||
const current = new Date();
|
||||
current.setSeconds(0, 0);
|
||||
const formatter = new Intl.DateTimeFormat('ko-KR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: type === 'standard' ? undefined : '2-digit',
|
||||
hour12: type === 'standard',
|
||||
hourCycle: type ==='standard' ? 'h12' : 'h23'
|
||||
});
|
||||
if (type === 'standard') {
|
||||
return formatter.format(current).replace(':', '시 ').replace(/(\d+)\s*$/, '$1분');
|
||||
}
|
||||
return formatter.format(current);
|
||||
};
|
||||
|
||||
const standardTimeToContinentalTime = (standardTime: string) => {
|
||||
const match = standardTime.match(/(오전|오후)\s*(\d+)\s*시\s*(\d+)\s*분/);
|
||||
|
||||
if (!match) return '';
|
||||
|
||||
const [_, ampm, hourString, minuteString] = match;
|
||||
|
||||
let hour = parseInt(hourString, 10);
|
||||
const minute = parseInt(minuteString, 10);
|
||||
|
||||
if (ampm === '오후' && hour !== 12) {
|
||||
hour += 12;
|
||||
} else if (ampm === '오전' && hour === 12) {
|
||||
hour = 0;
|
||||
}
|
||||
|
||||
return `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}:00`
|
||||
}
|
||||
|
||||
const continentalTimeToStandardTime = (continentalTime: string) => {
|
||||
const [hour, minute, _] = continentalTime.split(':');
|
||||
const date = new Date();
|
||||
date.setHours(parseInt(hour, 10), parseInt(minute, 10), 0, 0);
|
||||
|
||||
const formatter = new Intl.DateTimeFormat('ko-KR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
hourCycle: 'h12'
|
||||
});
|
||||
|
||||
return formatter.format(date).replace(':', '시 ').replace(/(\d+)\s*$/, '$1분');
|
||||
}
|
||||
|
||||
return {
|
||||
getCurrentTimeString,
|
||||
standardTimeToContinentalTime,
|
||||
continentalTimeToStandardTime
|
||||
}
|
||||
}
|
||||
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;
|
||||
@@ -1,3 +1,4 @@
|
||||
@import url("https://cdn.jsdelivr.net/gh/wanteddev/wanted-sans@v1.0.3/packages/wanted-sans/fonts/webfonts/variable/split/WantedSansVariable.min.css");
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@@ -115,12 +116,49 @@
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
font-family: "Wanted Sans Variable", "Wanted Sans", -apple-system,BlinkMacSystemFont, system-ui, "Segeo UI", "Apple SD Gothic Neo", "Noto Sans KR", "Malgun Gothic", "Apple Color Emoji", "Segeo UI Emoji", "Segeo UI Symbol", sans-serif;
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* Tailwind의 굵기 유틸리티를 @apply로 재정의 */
|
||||
/* 일반적인 가변 폰트의 wght 축 값을 사용 (400~700 사이) */
|
||||
.font-thin {
|
||||
font-variation-settings: 'wght' 100;
|
||||
}
|
||||
.font-extralight {
|
||||
font-variation-settings: 'wght' 200;
|
||||
}
|
||||
.font-light {
|
||||
font-variation-settings: 'wght' 300;
|
||||
}
|
||||
.font-normal, .font-regular { /* font-normal 및 font-regular 모두 400 */
|
||||
font-variation-settings: 'wght' 400;
|
||||
}
|
||||
.font-medium {
|
||||
font-variation-settings: 'wght' 500;
|
||||
}
|
||||
.font-semibold {
|
||||
font-variation-settings: 'wght' 600;
|
||||
}
|
||||
.font-bold {
|
||||
font-variation-settings: 'wght' 700;
|
||||
}
|
||||
.font-extrabold {
|
||||
font-variation-settings: 'wght' 800;
|
||||
}
|
||||
.font-black {
|
||||
font-variation-settings: 'wght' 900;
|
||||
}
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 1280px;
|
||||
min-height: 720px;
|
||||
max-height: 1080px;
|
||||
}
|
||||
|
||||
/* Chrome, Safari, Edge */
|
||||
@@ -133,4 +171,16 @@ input[type="number"]::-webkit-outer-spin-button {
|
||||
/* Firefox */
|
||||
input[type="number"] {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
.custom-rdp-day {
|
||||
aspect-ratio: unset!;
|
||||
}
|
||||
|
||||
.custom-rdp-week:not(:first-child) {
|
||||
@apply border-t! border-indigo-200!;
|
||||
}
|
||||
|
||||
.custom-rdp-day:not(:first-child) {
|
||||
@apply border-l! border-indigo-200!;
|
||||
}
|
||||
@@ -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,13 +11,27 @@ 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
|
||||
position="top-center"
|
||||
visibleToasts={1}
|
||||
icons={{
|
||||
success: <CircleCheckIcon className="size-4" fill="#15b815" color="white" />,
|
||||
error: <OctagonXIcon className="size-4" fill="#f14e4e" color="white" />,
|
||||
@@ -27,14 +41,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>
|
||||
</>
|
||||
|
||||
@@ -1,36 +1,24 @@
|
||||
import {
|
||||
CheckDuplicationRequest,
|
||||
SendVerificationCodeRequest,
|
||||
VerifyCodeRequest,
|
||||
SignupRequest,
|
||||
LoginRequest
|
||||
} from "@/data/request";
|
||||
import {
|
||||
CheckDuplicationResponse,
|
||||
SendVerificationCodeResponse,
|
||||
VerifyCodeResponse,
|
||||
SignupResponse,
|
||||
LoginResponse
|
||||
} from "@/data/response";
|
||||
import { BaseNetwork } from "./BaseNetwork";
|
||||
import { HttpApiUrl, SchedulerDTO as DTO } from "@baekyangdan/core-utils";
|
||||
|
||||
const AccountApi = HttpApiUrl.Account;
|
||||
export class AccountNetwork extends BaseNetwork {
|
||||
private baseUrl = "/account";
|
||||
private baseUrl = AccountApi.base;
|
||||
|
||||
async checkDuplication(data: CheckDuplicationRequest) {
|
||||
async checkDuplication(data: DTO.CheckDuplicationRequest) {
|
||||
const { type, value } = data;
|
||||
|
||||
return await this.get<CheckDuplicationResponse>(
|
||||
`${this.baseUrl}/check-duplication?type=${type}&value=${value}`
|
||||
return await this.get<DTO.CheckDuplicationResponse>(
|
||||
`${this.baseUrl}${AccountApi.checkDuplication}?type=${type}&value=${value}`
|
||||
, {
|
||||
authPass: true
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async sendVerificationCode(data: SendVerificationCodeRequest) {
|
||||
return await this.post<SendVerificationCodeResponse>(
|
||||
this.baseUrl + "/send-verification-code"
|
||||
async sendVerificationCode(data: DTO.SendEmailVerificationCodeRequest) {
|
||||
return await this.post<DTO.SendEmailVerificationCodeResponse>(
|
||||
`${this.baseUrl}${AccountApi.sendEmailVerificationCode}`
|
||||
, data
|
||||
, {
|
||||
authPass: true
|
||||
@@ -38,9 +26,9 @@ export class AccountNetwork extends BaseNetwork {
|
||||
);
|
||||
}
|
||||
|
||||
async verifyCode(data: VerifyCodeRequest) {
|
||||
return await this.post<VerifyCodeResponse>(
|
||||
this.baseUrl + "/verify-code"
|
||||
async verifyCode(data: DTO.VerifyEmailVerificationCodeRequest) {
|
||||
return await this.post<DTO.VerifyEmailVerificationCodeResponse>(
|
||||
`${this.baseUrl}${AccountApi.verifyEmailVerificationCode}`
|
||||
, data
|
||||
, {
|
||||
authPass: true
|
||||
@@ -48,9 +36,9 @@ export class AccountNetwork extends BaseNetwork {
|
||||
);
|
||||
}
|
||||
|
||||
async signup(data: SignupRequest) {
|
||||
return await this.post<SignupResponse>(
|
||||
this.baseUrl + "/signup"
|
||||
async signup(data: DTO.SignupRequest) {
|
||||
return await this.post<DTO.SignupResponse>(
|
||||
`${this.baseUrl}${AccountApi.signup}`
|
||||
, data
|
||||
, {
|
||||
authPass: true
|
||||
@@ -58,13 +46,34 @@ export class AccountNetwork extends BaseNetwork {
|
||||
);
|
||||
}
|
||||
|
||||
async login(data: LoginRequest) {
|
||||
return await this.post<LoginResponse>(
|
||||
this.baseUrl + "/login"
|
||||
async login(data: DTO.LoginRequest) {
|
||||
return await this.post<DTO.LoginResponse>(
|
||||
`${this.baseUrl}${AccountApi.login}`
|
||||
, data
|
||||
, {
|
||||
authPass: true
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async sendPasswordResetCode(data: DTO.SendPasswordResetCodeRequest) {
|
||||
return await this.post<DTO.SendPasswordResetCodeResponse>(
|
||||
`${this.baseUrl}${AccountApi.sendPasswordResetCode}`,
|
||||
data
|
||||
);
|
||||
}
|
||||
|
||||
async verifyPasswordResetCode(data: DTO.VerifyPasswordResetCodeRequest) {
|
||||
return await this.post<DTO.VerifyPasswordResetCodeResponse>(
|
||||
`${this.baseUrl}${AccountApi.verifyPasswordResetCode}`,
|
||||
data
|
||||
);
|
||||
}
|
||||
|
||||
async resetPassword(data: DTO.ResetPasswordRequest) {
|
||||
return await this.post<DTO.ResetPasswordResponse>(
|
||||
`${this.baseUrl}${AccountApi.resetPassword}`,
|
||||
data
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,16 @@
|
||||
import axios from 'axios';
|
||||
import type { AuthData } from '@/data/AuthData';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { SchedulerDTO as DTO, HttpApiUrl, UnauthorizedCode, UnauthorizedMessage } from '@baekyangdan/core-utils';
|
||||
import type {
|
||||
AxiosError,
|
||||
AxiosInstance,
|
||||
AxiosRequestConfig,
|
||||
AxiosError,
|
||||
AxiosResponse,
|
||||
InternalAxiosRequestConfig,
|
||||
} from "axios";
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import type { RefreshAccessTokenResponse } from '@/data/response/account/RefreshAccessTokenResponse';
|
||||
|
||||
import axios from 'axios';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validateOrReject } from 'class-validator';
|
||||
export class BaseNetwork {
|
||||
protected instance: AxiosInstance;
|
||||
|
||||
@@ -17,7 +19,7 @@ export class BaseNetwork {
|
||||
|
||||
constructor() {
|
||||
this.instance = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL || "http://localhost:3000",
|
||||
baseURL: import.meta.env.VITE_API_URL || "https://localhost:3000",
|
||||
timeout: 10_000,
|
||||
withCredentials: true,
|
||||
headers: {
|
||||
@@ -39,7 +41,24 @@ export class BaseNetwork {
|
||||
if (reqConfig.authPass) {
|
||||
return config;
|
||||
}
|
||||
const accessToken = localStorage.getItem("accessToken");
|
||||
|
||||
let accessToken = useAuthStore.getState().authData?.accessToken;
|
||||
|
||||
if (!accessToken) {
|
||||
const authStorage = localStorage.getItem('auth-storage');
|
||||
if (authStorage) {
|
||||
try {
|
||||
const parsedState = JSON.parse(authStorage).state;
|
||||
accessToken = parsedState.authData?.accessToken || null;
|
||||
|
||||
if (accessToken) {
|
||||
useAuthStore.getState().login({ accessToken });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to parse auth-storage for Access Token:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (accessToken) {
|
||||
config.headers.Authorization = `Bearer ${accessToken}`;
|
||||
}
|
||||
@@ -61,7 +80,7 @@ export class BaseNetwork {
|
||||
|
||||
if (
|
||||
status === 401
|
||||
&& errorCode === 'AccessTokenExpired'
|
||||
&& errorCode === UnauthorizedCode.ACCESS_TOKEN_EXPIRED
|
||||
&& !originalRequest._retry
|
||||
) {
|
||||
originalRequest._retry = true;
|
||||
@@ -74,15 +93,16 @@ export class BaseNetwork {
|
||||
}
|
||||
|
||||
private async handleRefreshToken(originalRequest: AxiosRequestConfig) {
|
||||
|
||||
const authData = useAuthStore.getState().authData;
|
||||
const refreshToken = authData?.refreshToken;
|
||||
|
||||
if (!authData || !refreshToken) {
|
||||
useAuthStore.getState().logout();
|
||||
return Promise.reject("no refresh token");
|
||||
const autoLogin = localStorage.getItem('autoLogin') === 'true';
|
||||
if (autoLogin) {
|
||||
const authData = useAuthStore.getState().authData;
|
||||
|
||||
if (!authData) {
|
||||
useAuthStore.getState().logout();
|
||||
return Promise.reject(UnauthorizedMessage.INVALID_TOKEN);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (this.isRefreshing) {
|
||||
return new Promise((resolve) => {
|
||||
this.refreshQueue.push((newToken: string) => {
|
||||
@@ -98,30 +118,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) {
|
||||
@@ -136,11 +142,89 @@ export class BaseNetwork {
|
||||
/**
|
||||
* 기본 CRUD 메서드
|
||||
*/
|
||||
protected async get<T = any>(url: string, config?: AxiosRequestConfig & { authPass?: boolean }) {
|
||||
return await this.instance.get<T>(url, config);
|
||||
protected async get<TResponse, TData extends Object = never>(
|
||||
url: string,
|
||||
config?: AxiosRequestConfig & { authPass?: boolean },
|
||||
dtoClass?: new () => TData
|
||||
) {
|
||||
const result = await this.instance.get<TResponse>(url, config);
|
||||
|
||||
if (dtoClass && (result.data as any)?.success === true) {
|
||||
const rawData = (result.data as any).data;
|
||||
|
||||
if (rawData) {
|
||||
const instance = plainToInstance(dtoClass, rawData);
|
||||
await validateOrReject(instance);
|
||||
(result.data as any).data = instance;
|
||||
}
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
|
||||
protected async post<T = any>(url: string, data?: any, config?: AxiosRequestConfig & { authPass?: boolean }) {
|
||||
return await this.instance.post<T>(url, data, config);
|
||||
protected async post<TResponse, TData extends Object = never>(
|
||||
url: string,
|
||||
data?: any,
|
||||
config?: AxiosRequestConfig & { authPass?: boolean },
|
||||
dtoClass?: new () => TData
|
||||
) {
|
||||
const result = await this.instance.post<TResponse>(url, data, config);
|
||||
|
||||
if (dtoClass && (result.data as any)?.success === true) {
|
||||
const rawData = (result.data as any).data;
|
||||
|
||||
if (rawData) {
|
||||
const instance = plainToInstance(dtoClass, rawData);
|
||||
|
||||
try {
|
||||
if (Array.isArray(instance)) {
|
||||
for (const item of instance) {
|
||||
await validateOrReject(item);
|
||||
}
|
||||
} else {
|
||||
await validateOrReject(instance);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
(result.data as any).data = instance;
|
||||
}
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
|
||||
public async refreshToken() {
|
||||
const autoLogin = localStorage.getItem('autoLogin') === 'true';
|
||||
if (autoLogin) {
|
||||
const storedAuth = localStorage.getItem('auth-storage');
|
||||
|
||||
if (!storedAuth) {
|
||||
localStorage.setItem('autoLogin', 'false');
|
||||
throw new Error;
|
||||
}
|
||||
|
||||
const authData: AuthData = JSON.parse(storedAuth).state;
|
||||
|
||||
if (!authData) {
|
||||
localStorage.setItem('autoLogin', 'false');
|
||||
throw new Error;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await this.get<DTO.RefreshAccessTokenResponse>(
|
||||
`${HttpApiUrl.Account.base}${HttpApiUrl.Account.refreshAccessToken}`,
|
||||
{
|
||||
withCredentials: true
|
||||
}
|
||||
);
|
||||
|
||||
if (!result.success || !result.data) throw new Error;
|
||||
|
||||
const newAccessToken = result.data.accessToken;
|
||||
|
||||
useAuthStore.getState().login({
|
||||
accessToken: newAccessToken
|
||||
});
|
||||
}
|
||||
}
|
||||
49
src/network/ScheduleNetwork.ts
Normal file
49
src/network/ScheduleNetwork.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { BaseNetwork } from "./BaseNetwork"
|
||||
import {
|
||||
UpdateScheduleRequest,
|
||||
DeleteScheduleRequest
|
||||
} from '@/data/request';
|
||||
import { HttpApiUrl } from "@baekyangdan/core-utils";
|
||||
import { SchedulerDTO } from "@baekyangdan/core-utils";
|
||||
const ScheduleApi = HttpApiUrl.Schedule;
|
||||
export class ScheduleNetwork extends BaseNetwork {
|
||||
private baseUrl = ScheduleApi.base;
|
||||
|
||||
async getList(data: SchedulerDTO.ScheduleListRequest) {
|
||||
return await this.post<SchedulerDTO.ScheduleListResponse, SchedulerDTO.ScheduleList>(
|
||||
this.baseUrl,
|
||||
data,
|
||||
undefined,
|
||||
SchedulerDTO.ScheduleList
|
||||
);
|
||||
}
|
||||
|
||||
async getDetail(id: string) {
|
||||
return await this.get<SchedulerDTO.ScheduleDetailResponse, SchedulerDTO.ScheduleDetail>(
|
||||
`${this.baseUrl}/${id}`,
|
||||
undefined,
|
||||
SchedulerDTO.ScheduleDetail
|
||||
);
|
||||
}
|
||||
|
||||
async create(data: SchedulerDTO.ScheduleCreateRequest) {
|
||||
return await this.post<SchedulerDTO.ScheduleCreateResponse>(
|
||||
`${this.baseUrl}${ScheduleApi.create}`,
|
||||
data
|
||||
);
|
||||
}
|
||||
|
||||
async update(data: UpdateScheduleRequest) {
|
||||
return await this.post(
|
||||
`${this.baseUrl}${ScheduleApi.update}`,
|
||||
data
|
||||
);
|
||||
}
|
||||
|
||||
async del(data: DeleteScheduleRequest) {
|
||||
return await this.post(
|
||||
`${this.baseUrl}${ScheduleApi.delete}`,
|
||||
data
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -1,14 +1,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 border-b-indigo-200 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>
|
||||
);
|
||||
}
|
||||
539
src/ui/component/calendar/CustomCalendar.tsx
Normal file
539
src/ui/component/calendar/CustomCalendar.tsx
Normal file
@@ -0,0 +1,539 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { Popover, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { isSameDay, getWeeksInMonth, getWeekOfMonth, eachDayOfInterval, parse, startOfMonth, startOfWeek, endOfWeek, endOfMonth, format, isSameMonth } from "date-fns";
|
||||
import { SchedulePopover } from "../schedule/SchedulePopover";
|
||||
import { ScheduleNetwork } from "@/network/ScheduleNetwork";
|
||||
import { ScheduleListData } from "@/data/response";
|
||||
import { CustomCalendarCN } from "./CustomCalendarCN";
|
||||
import { toast } from "sonner";
|
||||
import type { SchedulePopoverMode } from "@/const/schedule/SchedulePopoverMode";
|
||||
import { SchedulerDTO as DTO, Type } from '@baekyangdan/core-utils';
|
||||
|
||||
interface EventBarPosition extends DTO.ScheduleList {
|
||||
positionStyle: React.CSSProperties;
|
||||
trackIndex: number;
|
||||
isOverflow?: boolean;
|
||||
segmentId: string;
|
||||
}
|
||||
|
||||
const TRACK_HEIGHT = 20;
|
||||
const TOP_OFFSET_FROM_CELL = 35;
|
||||
const DATE_FORMAT_ARIA = 'EEEE, MMMM do, yyyy';
|
||||
const DATE_FORMAT_KEY = 'yyyyMMdd';
|
||||
|
||||
export const CustomCalendar = () => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [refetchTrigger, setRefetchTrigger] = useState(0);
|
||||
const [weekCount, setWeekCount] = useState(5);
|
||||
const [selectedDate, setSelectedDate] = useState<Date | undefined>(undefined);
|
||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||
const [popoverOpenCoolDown, setPopoverOpenCoolDown] = useState(false);
|
||||
const [popoverSide, setPopoverSide] = useState<'right' | 'left'>('right');
|
||||
const [popoverAlign, setPopoverAlign] = useState<'start' | 'end'>('end');
|
||||
const [popoverMode, setPopoverMode] = useState<SchedulePopoverMode>('list');
|
||||
const [popoverDetailId, setPopoverDetailId] = useState('');
|
||||
const [month, setMonth] = useState(new Date());
|
||||
const [windowSize, setWindowSize] = useState({ width: window.innerWidth, height: window.innerHeight });
|
||||
const [maxVisibleEvents, setMaxVisibleEvents] = useState(3);
|
||||
const [overflowTrackIndex, setOverflowTrackIndex] = useState(maxVisibleEvents + 1);
|
||||
const [scheduleList, setScheduleList] = useState<Array<DTO.ScheduleList>>([]);
|
||||
const [barPositions, setBarPositions] = useState<Array<EventBarPosition>>([]);
|
||||
const scheduleNetwork = new ScheduleNetwork();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const cellInfoMapRef = useRef<Map<string, { cell: HTMLElement, rect: DOMRect }>>(new Map());
|
||||
|
||||
const updateWeekCount = () => {
|
||||
if (containerRef === null) return;
|
||||
if (!containerRef.current) return;
|
||||
const weeks = containerRef.current.querySelectorAll('.rdp-week');
|
||||
|
||||
if (weeks?.length) setWeekCount(weeks.length);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
updateWeekCount();
|
||||
const handleResize = () => {
|
||||
setWindowSize({
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight
|
||||
});
|
||||
if (window.innerHeight >= 850) {
|
||||
setMaxVisibleEvents(3);
|
||||
setOverflowTrackIndex(4);
|
||||
} else {
|
||||
setMaxVisibleEvents(2);
|
||||
setOverflowTrackIndex(3);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 화면 상의 달이 바뀌면 req 하는 로직
|
||||
useLayoutEffect(() => {
|
||||
updateWeekCount();
|
||||
|
||||
const reqList = async () => {
|
||||
setIsLoading(true);
|
||||
const requestedMonth = month;
|
||||
|
||||
const monthStart = startOfMonth(month);
|
||||
const monthEnd = endOfMonth(month);
|
||||
|
||||
const startDate = startOfWeek(monthStart, { weekStartsOn: 0 });
|
||||
const endDate = endOfWeek(monthEnd, { weekStartsOn: 0 });
|
||||
|
||||
const data = {
|
||||
startDate: startDate,
|
||||
endDate: endDate
|
||||
} as DTO.ScheduleListRequest;
|
||||
|
||||
const result = await scheduleNetwork.getList(data);
|
||||
|
||||
if (result.success) {
|
||||
if (result.data) {
|
||||
if (isSameMonth(requestedMonth, month)) {
|
||||
// setCurrentDataMonth(month);
|
||||
setScheduleList(result.data);
|
||||
}
|
||||
// setScheduleList(result.data.data);
|
||||
}
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const reqListPromise = reqList;
|
||||
|
||||
toast.promise(
|
||||
reqListPromise,
|
||||
{
|
||||
loading: `${month.getFullYear()}년 ${month.getMonth() + 1}월 일정을 불러오는 중입니다`,
|
||||
success: `일정을 불러왔습니다.`,
|
||||
error: `일정을 불러오는 데에 실패하였습니다.\n잠시 후 다시 시도해주십시오.`,
|
||||
duration: 1000
|
||||
},
|
||||
)
|
||||
updateWeekCount();
|
||||
});
|
||||
}, [month, refetchTrigger]);
|
||||
|
||||
const refetchList = () => {
|
||||
setRefetchTrigger(prev => prev + 1);
|
||||
}
|
||||
|
||||
// 이벤트 bar 그리는 로직
|
||||
useLayoutEffect(() => {
|
||||
// if (!isSameMonth(month, currentDataMonth)) {
|
||||
// setBarPositions([]);
|
||||
// return;
|
||||
// }
|
||||
|
||||
if (!containerRef.current || scheduleList.length === 0) {
|
||||
// setBarPositions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// setBarPositions([]);
|
||||
const containerRect = containerRef.current.getBoundingClientRect();
|
||||
const dayCells = containerRef.current.querySelectorAll('.rdp-day button');
|
||||
|
||||
const cellInfoMap = new Map<string, { cell: HTMLElement, rect: DOMRect }>();
|
||||
|
||||
dayCells.forEach((cell) => {
|
||||
const dayButton = cell as HTMLButtonElement;
|
||||
let ariaLabel = dayButton.getAttribute('aria-label');
|
||||
if (ariaLabel) {
|
||||
try {
|
||||
if (ariaLabel.startsWith('Today, ')) {
|
||||
ariaLabel = ariaLabel.replace('Today, ', '');
|
||||
}
|
||||
if (ariaLabel.endsWith(', selected')) {
|
||||
ariaLabel = ariaLabel.replace(', selected', '');
|
||||
}
|
||||
const parsedDate = parse(ariaLabel, DATE_FORMAT_ARIA, new Date());
|
||||
|
||||
if (!isNaN(parsedDate.getTime())) {
|
||||
const dateKey = format(parsedDate, DATE_FORMAT_KEY);
|
||||
cellInfoMap.set(dateKey, {
|
||||
cell: dayButton,
|
||||
rect: dayButton.getBoundingClientRect()
|
||||
});
|
||||
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
});
|
||||
cellInfoMapRef.current = cellInfoMap;
|
||||
|
||||
const scheduleListWithTrack: (ScheduleListData & { trackIndex: number })[] = [];
|
||||
const occupiedTrackList = new Map<string, number[]>();
|
||||
const overflowCountMap = new Map<string, number>();
|
||||
|
||||
const sortedScheduleList = [...scheduleList].sort((a, b) =>
|
||||
new Date(b.endDate).getTime() - new Date(b.startDate).getTime() -
|
||||
(new Date(a.endDate).getTime() - new Date(a.startDate).getTime())
|
||||
);
|
||||
|
||||
sortedScheduleList.forEach(schedule => {
|
||||
const startDateObj = new Date(schedule.startDate);
|
||||
const endDateObj = new Date(schedule.endDate);
|
||||
|
||||
const eventDays = eachDayOfInterval({ start: startDateObj, end: endDateObj });
|
||||
|
||||
let assignedTrack = -1;
|
||||
|
||||
for (let track = 0; track < maxVisibleEvents; track++) {
|
||||
let isAvailable = true;
|
||||
for (const day of eventDays) {
|
||||
const dayKey = format(day, DATE_FORMAT_KEY);
|
||||
if (cellInfoMap.has(dayKey)) {
|
||||
const occupied = occupiedTrackList.get(dayKey) || [];
|
||||
|
||||
if (occupied.includes(track)) {
|
||||
isAvailable = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isAvailable) {
|
||||
assignedTrack = track;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (assignedTrack !== -1) {
|
||||
for (const day of eventDays) {
|
||||
const dayKey = format(day, DATE_FORMAT_KEY);
|
||||
if (cellInfoMap.has(dayKey)) {
|
||||
const occupied = occupiedTrackList.get(dayKey) || [];
|
||||
occupiedTrackList.set(dayKey, [...occupied, assignedTrack]);
|
||||
}
|
||||
}
|
||||
scheduleListWithTrack.push({
|
||||
...schedule,
|
||||
startDate: schedule.startDate.toISOString(),
|
||||
endDate: schedule.endDate.toISOString(),
|
||||
trackIndex: assignedTrack });
|
||||
} else {
|
||||
for (const day of eventDays) {
|
||||
const dayKey = format(day, DATE_FORMAT_KEY);
|
||||
if (cellInfoMap.has(dayKey)) {
|
||||
overflowCountMap.set(dayKey, (overflowCountMap.get(dayKey) || 0) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const regularPositions: EventBarPosition[] = [];
|
||||
|
||||
// 달력을 초과한 부분에 대한 처리
|
||||
const visibleDayKeys = Array.from(cellInfoMap.keys());
|
||||
if (!visibleDayKeys || visibleDayKeys.length === 0) return;
|
||||
|
||||
const calendarStart = parse(visibleDayKeys.at(0)!, DATE_FORMAT_KEY, new Date());
|
||||
const calendarEnd = parse(visibleDayKeys.at(-1)!, DATE_FORMAT_KEY, new Date());
|
||||
|
||||
scheduleListWithTrack.forEach(schedule => {
|
||||
const startDateObj = new Date(schedule.startDate);
|
||||
const endDateObj = new Date(schedule.endDate);
|
||||
|
||||
const renderStartDate = startDateObj > calendarStart ? startDateObj : calendarStart;
|
||||
const renderEndDate = endDateObj < calendarEnd ? endDateObj : calendarEnd;
|
||||
|
||||
// const renderStartKey = format(renderStartDate, DATE_FORMAT_KEY);
|
||||
// const renderEndKey = format(renderEndDate, DATE_FORMAT_KEY);
|
||||
|
||||
const allRenderDays = eachDayOfInterval({ start: renderStartDate, end: renderEndDate });
|
||||
|
||||
let segmentStartDay = renderStartDate;
|
||||
|
||||
allRenderDays.forEach((currentDay, index) => {
|
||||
const dayOfWeek = currentDay.getDay();
|
||||
const isLastDayOfEvent = index === allRenderDays.length - 1;
|
||||
|
||||
if (dayOfWeek === 6 && !isLastDayOfEvent) {
|
||||
const segmentEndDay = currentDay;
|
||||
|
||||
createAndPushPosition(
|
||||
regularPositions, schedule,
|
||||
segmentStartDay, segmentEndDay,
|
||||
cellInfoMap, containerRect
|
||||
);
|
||||
|
||||
segmentStartDay = new Date(currentDay);
|
||||
segmentStartDay.setDate(segmentStartDay.getDate() + 1);
|
||||
} else if (isLastDayOfEvent) {
|
||||
createAndPushPosition(
|
||||
regularPositions, schedule,
|
||||
segmentStartDay, currentDay,
|
||||
cellInfoMap, containerRect
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const overflowPositions: EventBarPosition[] = [];
|
||||
overflowCountMap.forEach((count, dayKey) => {
|
||||
const dayInfo = cellInfoMap.get(dayKey);
|
||||
|
||||
if (dayInfo) {
|
||||
const dayRect = dayInfo.rect;
|
||||
|
||||
const baseTop = dayRect.top - containerRect.top;
|
||||
const top = baseTop + TOP_OFFSET_FROM_CELL + ((overflowTrackIndex - 1) * (TRACK_HEIGHT + 5));
|
||||
const left = dayRect.left - containerRect.left + 5;
|
||||
const width = dayInfo.cell.clientWidth - 10;
|
||||
|
||||
overflowPositions.push({
|
||||
id: `overflow-${dayKey}`,
|
||||
name: `${count} more`,
|
||||
startDate: new Date(),
|
||||
endDate: new Date(),
|
||||
style: '#9CA3AF',
|
||||
trackIndex: overflowTrackIndex,
|
||||
isOverflow: true,
|
||||
segmentId: dayKey,
|
||||
positionStyle: {
|
||||
top: `${top}px`,
|
||||
left: `${left}px`,
|
||||
width: `${width}px`,
|
||||
height: `${TRACK_HEIGHT}px`
|
||||
}
|
||||
} as EventBarPosition)
|
||||
}
|
||||
});
|
||||
setBarPositions([...regularPositions, ...overflowPositions]);
|
||||
}, [scheduleList, month, windowSize]);
|
||||
|
||||
const createAndPushPosition = (
|
||||
positions: EventBarPosition[],
|
||||
schedule: ScheduleListData & { trackIndex: number },
|
||||
segmentStartDay: Date,
|
||||
segmentEndDay: Date,
|
||||
cellInfoMap: Map<string, { cell: HTMLElement, rect: DOMRect }>,
|
||||
containerRect: DOMRect
|
||||
) => {
|
||||
const renderStartKey = format(segmentStartDay, DATE_FORMAT_KEY);
|
||||
const renderEndKey = format(segmentEndDay, DATE_FORMAT_KEY);
|
||||
|
||||
const startInfo = cellInfoMap.get(renderStartKey);
|
||||
const endInfo = cellInfoMap.get(renderEndKey);
|
||||
|
||||
if (startInfo && endInfo) {
|
||||
const startRect = startInfo.rect;
|
||||
const endRect = endInfo.rect;
|
||||
|
||||
const baseTop = startRect.top - containerRect.top;
|
||||
const top = baseTop + TOP_OFFSET_FROM_CELL + (schedule.trackIndex * (TRACK_HEIGHT + 5));
|
||||
|
||||
const left = startRect.left - containerRect.left + 5;
|
||||
const width = endRect.right - startRect.left - 10;
|
||||
|
||||
positions.push({
|
||||
...schedule,
|
||||
type: schedule.type as Type.Type,
|
||||
status: schedule.status as Type.Status,
|
||||
startDate: new Date(schedule.startDate),
|
||||
endDate: new Date(schedule.endDate),
|
||||
trackIndex: schedule.trackIndex,
|
||||
id: schedule.id,
|
||||
segmentId: `${schedule.id}-${renderStartKey}`,
|
||||
positionStyle: {
|
||||
top: `${top}px`,
|
||||
left: `${left}px`,
|
||||
width: `${width}px`,
|
||||
height: `${TRACK_HEIGHT}px`
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleMonthChange = async (month: Date) => {
|
||||
if (isLoading) return;
|
||||
setMonth(month);
|
||||
}
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
setPopoverOpen(open);
|
||||
setPopoverDetailId('');
|
||||
if (!open) {
|
||||
setTimeout(() => {
|
||||
setSelectedDate(undefined);
|
||||
setPopoverMode('list');
|
||||
}, 150);
|
||||
}
|
||||
}
|
||||
|
||||
const handleDaySelect = (date: Date | undefined) => {
|
||||
if (popoverOpenCoolDown) return;
|
||||
if (!date) {
|
||||
setPopoverOpenCoolDown(true);
|
||||
setPopoverOpen(false);
|
||||
setPopoverDetailId('');
|
||||
setTimeout(() => {
|
||||
setPopoverOpenCoolDown(false);
|
||||
setSelectedDate(undefined);
|
||||
setPopoverMode('list');
|
||||
}, 150);
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
const findDateFromClick = (
|
||||
clientX: number,
|
||||
clientY: number
|
||||
): string | null => {
|
||||
if (!cellInfoMapRef.current) {
|
||||
return null;
|
||||
}
|
||||
const cellInfoMap = cellInfoMapRef.current;
|
||||
|
||||
for (const [dateKey, info] of cellInfoMap.entries()) {
|
||||
const rect = info.rect;
|
||||
|
||||
if (
|
||||
clientX >= rect.left
|
||||
&& clientX < rect.right
|
||||
&& clientY >= rect.top
|
||||
&& clientY < rect.bottom
|
||||
) {
|
||||
return dateKey;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleEventBarClick = (e: React.MouseEvent<HTMLDivElement>, bar: EventBarPosition) => {
|
||||
const clientX = e.clientX;
|
||||
const clientY = e.clientY;
|
||||
const eventId = bar.id;
|
||||
|
||||
const dateKey = findDateFromClick(clientX, clientY);
|
||||
|
||||
if (dateKey && !selectedDate) {
|
||||
const clickedDate = parse(dateKey, DATE_FORMAT_KEY, new Date());
|
||||
if (!(eventId.includes('overflow'))) {
|
||||
setPopoverMode('detail');
|
||||
setPopoverDetailId(eventId);
|
||||
}
|
||||
handleDaySelect(clickedDate);
|
||||
} else {
|
||||
handleDaySelect(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full h-full relative"
|
||||
ref={containerRef}
|
||||
>
|
||||
<Popover
|
||||
open={popoverOpen}
|
||||
onOpenChange={handleOpenChange}
|
||||
>
|
||||
<Calendar
|
||||
mode="single"
|
||||
className="h-full w-full border border-indigo-200 rounded-lg shadow-sm shadow-indigo-200"
|
||||
selected={selectedDate}
|
||||
onSelect={handleDaySelect}
|
||||
month={month}
|
||||
onMonthChange={handleMonthChange}
|
||||
classNames={CustomCalendarCN}
|
||||
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}
|
||||
>
|
||||
{props.children}
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
{
|
||||
barPositions.map(pos => (
|
||||
<div
|
||||
onClick={(e: React.MouseEvent<HTMLDivElement>) => handleEventBarClick(e, pos)}
|
||||
key={pos.segmentId}
|
||||
id={pos.segmentId}
|
||||
className={cn(
|
||||
`flex flex-row justify-start items-center absolute select-none`,
|
||||
"py-0.5 px-2 rounded-sm text-xs font-thin text-white overflow-hidden"
|
||||
)}
|
||||
style={{...pos.positionStyle, backgroundColor: pos.style}}
|
||||
>
|
||||
{pos.name}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
<SchedulePopover
|
||||
date={selectedDate}
|
||||
open={popoverOpen}
|
||||
mode={popoverMode}
|
||||
setMode={setPopoverMode}
|
||||
detailId={popoverDetailId}
|
||||
setDetailId={setPopoverDetailId}
|
||||
popoverSide={popoverSide}
|
||||
popoverAlign={popoverAlign}
|
||||
reqRefetchList={refetchList}
|
||||
/>
|
||||
|
||||
</Popover>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
55
src/ui/component/calendar/CustomCalendarCN.ts
Normal file
55
src/ui/component/calendar/CustomCalendarCN.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getDefaultClassNames } from "react-day-picker";
|
||||
|
||||
const defaultCN = getDefaultClassNames();
|
||||
|
||||
export const CustomCalendarCN = {
|
||||
months: cn(
|
||||
defaultCN.months,
|
||||
"w-full h-full relative"
|
||||
),
|
||||
nav: cn(
|
||||
defaultCN.nav,
|
||||
"flex w-full item-center gap-1 justify-center gap-30 absolute top-0 inset-x-0"
|
||||
),
|
||||
month: cn(
|
||||
defaultCN.month,
|
||||
"h-full w-full flex flex-col"
|
||||
),
|
||||
month_grid: cn(
|
||||
defaultCN.month_grid,
|
||||
"w-full h-full flex-1"
|
||||
),
|
||||
weeks: cn(
|
||||
defaultCN.weeks,
|
||||
"w-full h-full"
|
||||
),
|
||||
weekdays: cn(
|
||||
defaultCN.weekdays,
|
||||
"w-full"
|
||||
),
|
||||
week: cn(
|
||||
defaultCN.week,
|
||||
`w-full`,
|
||||
'custom-rdp-week'
|
||||
),
|
||||
day: cn(
|
||||
defaultCN.day,
|
||||
`w-[calc(100%/7)] rounded-none`,
|
||||
'custom-rdp-day'
|
||||
),
|
||||
day_button: cn(
|
||||
defaultCN.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(
|
||||
defaultCN.selected,
|
||||
"h-full border-0 fill-transparent"
|
||||
),
|
||||
today: cn(
|
||||
defaultCN.today,
|
||||
"h-full"
|
||||
),
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp";
|
||||
import { AccountNetwork } from "@/network/AccountNetwork";
|
||||
import { SendVerificationCodeRequest } from "@/data/request";
|
||||
import { SchedulerDTO as DTO } from '@baekyangdan/core-utils';
|
||||
import { toast } from "sonner";
|
||||
import { DialogHeader, Dialog, DialogTrigger, DialogContent, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
|
||||
@@ -43,9 +43,11 @@ export default function EmailVerificationModal({
|
||||
|
||||
const init = async () => {
|
||||
try {
|
||||
const data = new SendVerificationCodeRequest(email);
|
||||
const data = {
|
||||
email: email
|
||||
} as DTO.SendEmailVerificationCodeRequest;
|
||||
const result = await accountNetwork.sendVerificationCode(data);
|
||||
if (!result.data.success) {
|
||||
if (!result.success) {
|
||||
openErrorToast();
|
||||
} else {
|
||||
emailVerificationForm.setValue("email", email);
|
||||
@@ -67,19 +69,24 @@ export default function EmailVerificationModal({
|
||||
|
||||
const { email, code } = emailVerificationForm.getValues();
|
||||
|
||||
const verifyCodePromise = accountNetwork.verifyCode({ email, code });
|
||||
const data = {
|
||||
email: email,
|
||||
code: code
|
||||
} as DTO.VerifyEmailVerificationCodeRequest;
|
||||
|
||||
const verifyCodePromise = accountNetwork.verifyCode(data);
|
||||
|
||||
toast.promise(
|
||||
verifyCodePromise,
|
||||
{
|
||||
loading: "이메일 인증 확인 중입니다.",
|
||||
success: (res) => res.data.verified ? "이메일 인증이 완료되었습니다." : "잘못된 인증 코드입니다.",
|
||||
success: (res) => res.data?.verified ? "이메일 인증이 완료되었습니다." : "잘못된 인증 코드입니다.",
|
||||
error: "이메일 인증에 실패하였습니다.",
|
||||
}
|
||||
);
|
||||
|
||||
verifyCodePromise.then((res) => {
|
||||
if (res.data.verified) {
|
||||
if (res.data?.verified) {
|
||||
onVerifySuccess();
|
||||
}
|
||||
})
|
||||
|
||||
75
src/ui/component/schedule/SchedulePopover.tsx
Normal file
75
src/ui/component/schedule/SchedulePopover.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { PopoverContent } from '@/components/ui/popover';
|
||||
import { ScheduleCreateContent } from './content/ScheduleCreateContent';
|
||||
import { ScheduleListContent } from './content/ScheduleListContent';
|
||||
import { ScheduleDetailContent } from './content/ScheduleDetailContent';
|
||||
import type { SchedulePopoverMode } from '@/const/schedule/SchedulePopoverMode';
|
||||
import { ScheduleUpdateContent } from './content/ScheduleUpdateContent';
|
||||
import { memo } from 'react';
|
||||
|
||||
interface ScheduleSheetProps {
|
||||
date: Date | undefined;
|
||||
open: boolean;
|
||||
popoverSide: 'left' | 'right';
|
||||
popoverAlign: 'start' | 'end';
|
||||
mode: SchedulePopoverMode;
|
||||
setMode: (mode: SchedulePopoverMode) => void;
|
||||
detailId: string;
|
||||
setDetailId: (id: string) => void;
|
||||
reqRefetchList: () => void;
|
||||
}
|
||||
|
||||
export const SchedulePopover = memo(({ mode, ...props}: ScheduleSheetProps) => {
|
||||
|
||||
return (
|
||||
<PopoverContent
|
||||
className="p-0 rounded-xl xl:w-[calc(100vw/4)] xl:max-w-[480px] min-w-[384px] min-h-[125px] h-[calc(100vh/2.2)]"
|
||||
align={props.popoverAlign} side={props.popoverSide}
|
||||
>
|
||||
{<SchedulePopoverContent mode={mode} { ...props} />}
|
||||
</PopoverContent>
|
||||
)
|
||||
});
|
||||
|
||||
const SchedulePopoverContent = memo(({ mode, setMode, setDetailId, date, popoverAlign, popoverSide, open, reqRefetchList, detailId }: ScheduleSheetProps) => {
|
||||
switch(mode) {
|
||||
case 'list':
|
||||
return <ScheduleListContent
|
||||
setMode={setMode}
|
||||
setId={setDetailId}
|
||||
date={date}
|
||||
popoverAlign={popoverAlign}
|
||||
popoverSide={popoverSide}
|
||||
open={open}
|
||||
/>
|
||||
case 'create':
|
||||
return <ScheduleCreateContent
|
||||
setMode={setMode}
|
||||
date={date}
|
||||
popoverAlign={popoverAlign}
|
||||
popoverSide={popoverSide}
|
||||
open={open}
|
||||
refetchList={reqRefetchList}
|
||||
/>
|
||||
case 'detail':
|
||||
return <ScheduleDetailContent
|
||||
setMode={setMode}
|
||||
date={date}
|
||||
popoverAlign={popoverAlign}
|
||||
popoverSide={popoverSide}
|
||||
open={open}
|
||||
id={detailId}
|
||||
/>
|
||||
case 'update':
|
||||
return <ScheduleUpdateContent
|
||||
setMode={setMode}
|
||||
date={date}
|
||||
popoverAlign={popoverAlign}
|
||||
popoverSide={popoverSide}
|
||||
open={open}
|
||||
id={detailId}
|
||||
refetchList={reqRefetchList}
|
||||
/>
|
||||
}
|
||||
});
|
||||
|
||||
SchedulePopover.displayName = "SchedulePopover";
|
||||
26
src/ui/component/schedule/content/ContentProps.ts
Normal file
26
src/ui/component/schedule/content/ContentProps.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { SchedulePopoverMode } from "@/const/schedule/SchedulePopoverMode";
|
||||
|
||||
interface BaseProps {
|
||||
date: Date | undefined;
|
||||
open: boolean;
|
||||
popoverSide: 'left' | 'right';
|
||||
popoverAlign: 'start' | 'end';
|
||||
setMode: (mode: SchedulePopoverMode) => void;
|
||||
}
|
||||
|
||||
export interface ScheduleCreateContentProps extends BaseProps {
|
||||
refetchList: () => void;
|
||||
}
|
||||
|
||||
export interface ScheduleListContentProps extends BaseProps {
|
||||
setId: (id: string) => void;
|
||||
}
|
||||
|
||||
export interface ScheduleDetailContentProps extends BaseProps {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface ScheduleUpdateContentProps extends BaseProps {
|
||||
refetchList: () => void;
|
||||
id: string;
|
||||
}
|
||||
333
src/ui/component/schedule/content/ScheduleCreateContent.tsx
Normal file
333
src/ui/component/schedule/content/ScheduleCreateContent.tsx
Normal file
@@ -0,0 +1,333 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Field, FieldError } from '@/components/ui/field';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Popover, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
import type { ColorPaletteType } from '@/const/ColorPalette';
|
||||
import { Type } from '@baekyangdan/core-utils';
|
||||
import { CreateScheduleSchema } from '@/data/form/schedule/createSchedule.schema';
|
||||
import { usePalette } from '@/hooks/use-palette';
|
||||
import { useRecord } from '@/hooks/use-record';
|
||||
import { useTime } from '@/hooks/use-time';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ScheduleNetwork } from '@/network/ScheduleNetwork';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import * as z from 'zod';
|
||||
import { ColorPickPopover } from '../popover/ColorPickPopover';
|
||||
import { DatePickPopover } from '../popover/DatePickPopover';
|
||||
import { TimePickPopover } from '../popover/TimePickPopover';
|
||||
import { TypePickPopover } from '../popover/TypePickPopover';
|
||||
import type { ScheduleCreateContentProps } from './ContentProps';
|
||||
import { SchedulerDTO as DTO } from '@baekyangdan/core-utils';
|
||||
|
||||
export const ScheduleCreateContent = ({ date, setMode, popoverSide, popoverAlign, refetchList }: ScheduleCreateContentProps) => {
|
||||
const [colorPopoverOpen, setColorPopoverOpen] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { getPaletteByKey } = usePalette();
|
||||
const { getCurrentTimeString, standardTimeToContinentalTime } = useTime();
|
||||
const dayLabelList = useRecord(Type.Day).keys.map((key) => {
|
||||
return {
|
||||
day: key,
|
||||
label: Type.Day[key as keyof typeof Type.Day]
|
||||
} as { day: string, label: string };
|
||||
});
|
||||
const scheduleNetwork = new ScheduleNetwork();
|
||||
|
||||
const createScheduleForm = useForm<z.infer<typeof CreateScheduleSchema>>({
|
||||
resolver: zodResolver(CreateScheduleSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
startDate: date || new Date(),
|
||||
endDate: date || new Date(),
|
||||
content: "",
|
||||
startTime: getCurrentTimeString('standard'),
|
||||
endTime: getCurrentTimeString('standard'),
|
||||
type: "once",
|
||||
status: "yet",
|
||||
style: getPaletteByKey('SerenityBlue').style,
|
||||
dayList: ""
|
||||
}
|
||||
});
|
||||
|
||||
const {
|
||||
name,
|
||||
startDate,
|
||||
endDate,
|
||||
content,
|
||||
startTime,
|
||||
endTime,
|
||||
type,
|
||||
style,
|
||||
dayList,
|
||||
// participantList
|
||||
} = createScheduleForm.watch();
|
||||
|
||||
const reqCreate = async () => {
|
||||
if (isLoading) return;
|
||||
|
||||
const data = {
|
||||
name,
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
type: type as Type.Type,
|
||||
style: style,
|
||||
startTime: standardTimeToContinentalTime(startTime),
|
||||
endTime: standardTimeToContinentalTime(endTime),
|
||||
dayList,
|
||||
content
|
||||
} as DTO.ScheduleCreateRequest;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
const createPromise = scheduleNetwork.create(data);
|
||||
|
||||
toast.promise(createPromise, {
|
||||
loading: '일정 생성 중입니다',
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await createPromise;
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
|
||||
toast.success('일정이 생성되었습니다');
|
||||
|
||||
// ✅ 기존 동작 그대로
|
||||
setMode('list');
|
||||
refetchList();
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : '에러 발생';
|
||||
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const selectColor = (color: ColorPaletteType) => {
|
||||
createScheduleForm.setValue('style', color.style);
|
||||
setColorPopoverOpen(false);
|
||||
}
|
||||
|
||||
const selectType = (type: Type.Type) => {
|
||||
createScheduleForm.setValue('type', type);
|
||||
}
|
||||
|
||||
const selectDayList = (newValues: string[]) => {
|
||||
const sortedValues = newValues.sort();
|
||||
const newDayList = sortedValues.join('');
|
||||
createScheduleForm.setValue('dayList', newDayList);
|
||||
}
|
||||
|
||||
const selectDate = (type: 'startDate' | 'endDate', date: Date | undefined) => {
|
||||
if (!date) return;
|
||||
createScheduleForm.setValue(type, date);
|
||||
}
|
||||
|
||||
const selectTime = (type: 'startTime' | 'endTime', time: string) => {
|
||||
createScheduleForm.setValue(type, time);
|
||||
}
|
||||
|
||||
// const selectParticipant = (participantList: string[]) => {
|
||||
// createScheduleForm.setValue('participantList', participantList);
|
||||
// }
|
||||
|
||||
const OnceContent = () => {
|
||||
return (
|
||||
<div
|
||||
className="w-full h-10"
|
||||
>
|
||||
<DatePickPopover
|
||||
disabled={false}
|
||||
mode={'range'}
|
||||
popoverAlign={popoverAlign}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
setStartDate={(date: Date | undefined) => selectDate('startDate', date)}
|
||||
setEndDate={(date: Date | undefined) => selectDate('endDate', date)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const DailyContent = () => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const DefaultContent = () => {
|
||||
return (
|
||||
(
|
||||
<ToggleGroup
|
||||
type="multiple"
|
||||
className="w-full h-10 flex flex-row justify-center items-center"
|
||||
onValueChange={selectDayList}
|
||||
>
|
||||
{
|
||||
dayLabelList.map((day) => (
|
||||
<ToggleGroupItem
|
||||
value={`${day.day}`}
|
||||
className="border w-[calc(100%/7)] h-10 rounded-none not-last:border-r-0"
|
||||
>
|
||||
{day.label}
|
||||
</ToggleGroupItem>
|
||||
))
|
||||
}
|
||||
</ToggleGroup>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const renderContent = () => {
|
||||
switch (type) {
|
||||
case 'once':
|
||||
return <OnceContent />;
|
||||
case 'daily':
|
||||
return <DailyContent />;
|
||||
default:
|
||||
return <DefaultContent />;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full justify-between items-center">
|
||||
<div className="p-4 w-full h-[calc(100%-40px)] flex flex-col justify-start items-start">
|
||||
<div className="w-full flex flex-row justify-between items-center gap-4">
|
||||
<div
|
||||
onClick={() => setMode('list')}
|
||||
>
|
||||
<ArrowLeft className="stroke-indigo-100 hover:stroke-indigo-300 transition-all duration-150" />
|
||||
</div>
|
||||
<Controller
|
||||
name="name"
|
||||
control={createScheduleForm.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<Input
|
||||
{...field}
|
||||
id="form-create-schedule-name"
|
||||
placeholder="제목"
|
||||
maxLength={10}
|
||||
className="placeholder-indigo-200! text-indigo-400 font-bold border-t-0 border-r-0 border-l-0 p-0 border-b-2 rounded-none shadow-none border-indigo-100 focus-visible:ring-0 focus-visible:border-b-indigo-300"
|
||||
style={{
|
||||
fontSize: '20px'
|
||||
}}
|
||||
tabIndex={1}
|
||||
arai-invalid={fieldState.invalid}
|
||||
/>
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
<Popover open={colorPopoverOpen} onOpenChange={setColorPopoverOpen}>
|
||||
<div className="w-6 h-6 flex justify-center items-center">
|
||||
<PopoverTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-full w-5 h-5 border-2 border-gray-300 hover:border-indigo-300 transition-all duration-150',
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: `${style}`,
|
||||
}}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
<ColorPickPopover
|
||||
setColor={selectColor}
|
||||
/>
|
||||
</Popover>
|
||||
</div>
|
||||
<ScrollArea
|
||||
className={
|
||||
cn(
|
||||
"min-h-[125px] h-[calc(100vh/2.3-40px)]! w-full",
|
||||
"[&>div>div:last-child]:block"
|
||||
)
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="w-full h-full flex! flex-col! gap-4!"
|
||||
>
|
||||
<TypePickPopover
|
||||
type={type as Type.Type}
|
||||
setType={selectType}
|
||||
popoverSide={popoverSide}
|
||||
/>
|
||||
<div
|
||||
className="w-full h-10"
|
||||
>
|
||||
{renderContent()}
|
||||
</div>
|
||||
<div className="w-full h-10">
|
||||
<TimePickPopover
|
||||
mode='range'
|
||||
popoverAlign={popoverAlign}
|
||||
disabled={false}
|
||||
startTime={startTime}
|
||||
setStartTime={(time: string | undefined) => selectTime('startTime', time ?? '')}
|
||||
endTime={endTime}
|
||||
setEndTime={(time: string | undefined) => selectTime('endTime', time ?? '')}
|
||||
/>
|
||||
</div>
|
||||
<Controller
|
||||
name="content"
|
||||
control={createScheduleForm.control}
|
||||
render={({ field }) => (
|
||||
<Textarea
|
||||
{...field}
|
||||
rows={2}
|
||||
placeholder="일정 상세 사항"
|
||||
spellCheck={false}
|
||||
className="placeholder-indigo-200! text-indigo-400 focus-visible:placeholder-indigo-300! border-indigo-100 focus-visible:border-indigo-300 resize-none focus-visible:ring-0"
|
||||
style={{
|
||||
'scrollbarWidth': 'none'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{/* <ParticipantPopover
|
||||
participantList={participantList}
|
||||
setParticipantList={selectParticipant}
|
||||
/> */}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
<div
|
||||
className="w-full h-10 flex flex-row justify-start items-end p-0"
|
||||
>
|
||||
<Button
|
||||
className={cn(
|
||||
"h-full flex-5 rounded-none rounded-bl-md flex justify-center items-center",
|
||||
"text-indigo-300 bg-white",
|
||||
"hover:text-white hover:bg-indigo-300",
|
||||
"rounded-none rounded-bl-xl"
|
||||
)}
|
||||
type="button"
|
||||
onClick={reqCreate}
|
||||
>
|
||||
추가
|
||||
</Button>
|
||||
<Button
|
||||
className={cn(
|
||||
"h-full flex-5 flex justify-center items-center",
|
||||
"bg-white text-red-400",
|
||||
"hover:text-white hover:bg-red-400",
|
||||
"rounded-none rounded-br-xl"
|
||||
)}
|
||||
onClick={() => setMode('list')}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
}
|
||||
211
src/ui/component/schedule/content/ScheduleDetailContent.tsx
Normal file
211
src/ui/component/schedule/content/ScheduleDetailContent.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import { ScheduleNetwork } from '@/network/ScheduleNetwork';
|
||||
import type { ScheduleDetailContentProps } from './ContentProps';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { ArrowLeft, ChevronUp, LucideCalendarDays, LucideClock, LucidePen } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Converter, DateFormat, SchedulerDTO as DTO } from '@baekyangdan/core-utils'
|
||||
import { toast } from 'sonner';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
export const ScheduleDetailContent = ({ setMode, id }: ScheduleDetailContentProps) => {
|
||||
const scheduleNetwork = new ScheduleNetwork();
|
||||
const [data, setData] = useState<DTO.ScheduleDetail>();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [commentFold, setCommentFold] = useState(true);
|
||||
const converter = new Converter();
|
||||
useEffect(() => {
|
||||
if (!id || id === '' || id.trim().length === 0) {
|
||||
return;
|
||||
}
|
||||
reqDetail();
|
||||
}, [id]);
|
||||
|
||||
const reqDetail = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await scheduleNetwork.getDetail(id);
|
||||
if (result.success) {
|
||||
setData(result.data);
|
||||
} else {
|
||||
throw new Error;
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error('일정을 불러오는 데에 실패하였습니다.\n잠시 후 다시 시도해주십시오.');
|
||||
setMode('list');
|
||||
}
|
||||
setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
const moveToList = useCallback(() => {
|
||||
setMode('list');
|
||||
}, []);
|
||||
|
||||
const moveToUpdate = useCallback(() => {
|
||||
setMode('update');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
isLoading || !data
|
||||
? <div
|
||||
className="p-4 relative w-full h-full flex flex-col justify-start tiems-start"
|
||||
>
|
||||
<div
|
||||
className="relative w-full h-10 flex flex-row justify-center items-center"
|
||||
>
|
||||
<div
|
||||
className="absolute top-1.5 left-0.5"
|
||||
>
|
||||
<ArrowLeft
|
||||
className="stroke-indigo-200 hover:stroke-indigo-400 transition-all duration-300"
|
||||
onClick={moveToList}
|
||||
/>
|
||||
</div>
|
||||
<Skeleton className="h-full w-3/5 bg-indigo-200" />
|
||||
</div>
|
||||
<div
|
||||
className="mt-2 w-full h-[calc(100%-86px)] flex flex-col gap-2"
|
||||
>
|
||||
<div
|
||||
className="w-full h-5 flex flex-col items-end"
|
||||
>
|
||||
<Skeleton className="w-3/5 h-full bg-indigo-200 rounded-sm" />
|
||||
</div>
|
||||
<Skeleton className="w-full h-30 bg-indigo-200" />
|
||||
<div
|
||||
className="w-full h-12 flex flex-row gap-3.5"
|
||||
>
|
||||
<Skeleton className="flex-1 h-full bg-indigo-200" />
|
||||
<Skeleton className="flex-1 h-full bg-indigo-200" />
|
||||
</div>
|
||||
<Skeleton className="w-4/5 h-10 bg-indigo-200" />
|
||||
</div>
|
||||
</div>
|
||||
: <div
|
||||
className="p-4 relative w-full h-full flex flex-col justify-start items-start"
|
||||
>
|
||||
<div className="relative w-full h-10 flex flex-row justify-center items-center border-b border-b-indigo-300">
|
||||
<div
|
||||
className="absolute top-1.5 left-0.5"
|
||||
>
|
||||
<ArrowLeft
|
||||
className="stroke-indigo-200 hover:stroke-indigo-400 transition-all duration-300"
|
||||
onClick={moveToList}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-indigo-400 text-lg font-bold select-none">{data?.name}</span>
|
||||
<div
|
||||
className="absolute top-3.5 right-0.5"
|
||||
>
|
||||
<LucidePen
|
||||
size={16}
|
||||
className="stroke-indigo-200 hover:stroke-indigo-400 transition-all duration-300"
|
||||
onClick={moveToUpdate}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"mt-2 w-full flex flex-col transition-all duration-300",
|
||||
"h-[calc(100%-86px)]"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="w-full flex flex-col items-end text-xs text-indigo-300"
|
||||
>
|
||||
<span className="flex flex-row justify-start items-center text-sm font-regular gap-1"><LucideClock size={12}/>생성일 : {data?.createdAt}</span>
|
||||
</div>
|
||||
<ScrollArea
|
||||
className="h-25 w-full"
|
||||
>
|
||||
<span
|
||||
className="whitespace-pre-wrap text-indigo-500 select-none"
|
||||
>
|
||||
{data?.content && data?.content}
|
||||
</span>
|
||||
</ScrollArea>
|
||||
<div className="w-full h-12 flex flex-row justify-between items-center">
|
||||
<div className="flex-1 flex flex-col">
|
||||
<div className="flex flex-row gap-1 items-center">
|
||||
<LucideCalendarDays size={14} className="stroke-indigo-300" />
|
||||
<span className="font-normal text-[12px] text-indigo-300">시작일</span>
|
||||
</div>
|
||||
<span className="text-sm font-normal text-indigo-400">{converter.dateToFormattedString(data.startDate, DateFormat.KOREAN)}</span>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col">
|
||||
<div className="flex flex-row gap-1 items-center">
|
||||
<LucideCalendarDays size={14} className="stroke-indigo-300" />
|
||||
<span className="font-normal text-[12px] text-indigo-300">종료일</span>
|
||||
</div>
|
||||
<span className="text-sm font-normal text-indigo-400">{converter.dateToFormattedString(data.endDate, DateFormat.KOREAN)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full h-12 flex flex-row justify-between items-center">
|
||||
<div className="flex-1 flex flex-col">
|
||||
<div className="flex flex-row gap-1 items-center">
|
||||
<LucideClock size={14} className="stroke-indigo-300" />
|
||||
<span className="font-normal text-[12px] text-indigo-300">시작 시간</span>
|
||||
</div>
|
||||
<span className="text-sm font-normal text-indigo-400">{data.startTime}</span>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col">
|
||||
<div className="flex flex-row gap-1 items-center">
|
||||
<LucideClock size={14} className="stroke-indigo-300" />
|
||||
<span className="font-normal text-[12px] text-indigo-300">종료 시간</span>
|
||||
</div>
|
||||
<span className="text-sm font-normal text-indigo-400">{data.endTime}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute px-1 left-2 bottom-4 w-[calc(100%-16px)] flex flex-col transition-all overflow-hidden duration-300 bg-white",
|
||||
"border-t border-l border-r border-b border-indigo-100 rounded-t-lg",
|
||||
commentFold? "h-10" : "h-[calc(100%-44px)]"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"w-full h-10 px-2 flex flex-row justify-between items-center",
|
||||
"border-b border-b-indigo-200"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className="text-indigo-400 text-sm"
|
||||
>
|
||||
댓글 <span className="text-indigo-300 text-xs">[12]</span>
|
||||
</span>
|
||||
|
||||
<div
|
||||
className="h-10 text-indigo-400 flex flex-row justify-center items-center select-none"
|
||||
onClick={() => setCommentFold(prev => !prev)}
|
||||
>
|
||||
<ChevronUp
|
||||
className={cn(
|
||||
"transition-all duration-300 stroke-indigo-400 mr-1",
|
||||
commentFold ? "" : "rotate-180"
|
||||
)}
|
||||
size={14}
|
||||
/>
|
||||
<span className="text-indigo-300 text-xs">{ commentFold ? "열기" : "닫기" }</span>
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea
|
||||
className="flex-1"
|
||||
>
|
||||
<div>
|
||||
a
|
||||
<br/>
|
||||
b
|
||||
<br/>
|
||||
c
|
||||
<br/>
|
||||
d
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
117
src/ui/component/schedule/content/ScheduleListContent.tsx
Normal file
117
src/ui/component/schedule/content/ScheduleListContent.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { ListScheduleSchema } from "@/data/form/schedule/listSchedule.schema";
|
||||
import { format } from "date-fns";
|
||||
import { PenSquare } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
import type { ScheduleListContentProps } from "./ContentProps";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ScheduleNetwork } from "@/network/ScheduleNetwork";
|
||||
import { ScheduleListTile } from "../tile/ScheduleListTile";
|
||||
import { SchedulerDTO as DTO, Type } from "@baekyangdan/core-utils";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const ScheduleListContent = ({ date, setMode, open, setId }: ScheduleListContentProps) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [scheduleList, setScheduleList] = useState<Array<DTO.ScheduleList>>([]);
|
||||
const scheduleNetwork = new ScheduleNetwork();
|
||||
|
||||
const listScheduleForm = useForm<z.infer<typeof ListScheduleSchema>>({
|
||||
resolver: zodResolver(ListScheduleSchema),
|
||||
defaultValues: {
|
||||
name: undefined,
|
||||
date: date,
|
||||
status: undefined,
|
||||
typeList: undefined,
|
||||
styleList: undefined
|
||||
}
|
||||
});
|
||||
|
||||
const {
|
||||
name,
|
||||
date: searchDate,
|
||||
status,
|
||||
typeList,
|
||||
styleList
|
||||
} = listScheduleForm.watch();
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || !open) {
|
||||
return;
|
||||
}
|
||||
reqList();
|
||||
|
||||
return (() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const reqList = async () => {
|
||||
const data = {
|
||||
name,
|
||||
date: searchDate,
|
||||
status: status as Type.Status,
|
||||
typeList: typeList as Array<Type.Type>,
|
||||
styleList: styleList
|
||||
} as DTO.ScheduleListRequest;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await scheduleNetwork.getList(data);
|
||||
|
||||
if (!result.success) throw new Error(result.error);
|
||||
|
||||
setScheduleList(result.data);
|
||||
setIsLoading(false);
|
||||
} catch (e) {
|
||||
setIsLoading(false);
|
||||
toast.error('일정을 불러오는 데에 실패하였습니다.\n잠시 후 다시 시도해주십시오.');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const moveToDetail = (id: string) => {
|
||||
setId(id);
|
||||
setMode('detail');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 w-full h-full flex flex-col gap-4">
|
||||
<div className="relative w-full h-10 border-b border-b-indigo-300 flex flex-row items-center justify-center">
|
||||
<span className="text-indigo-400">{date && format(date, "yyyy년 MM월 dd일")}</span>
|
||||
<div className="absolute top-3 right-0.5 group">
|
||||
<PenSquare
|
||||
className="transition-all duration-150 group-hover:stroke-indigo-600 stroke-indigo-400"
|
||||
size={18}
|
||||
onClick={() => setTimeout(() => {setMode('create')}, 150)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full h-[calc(100%-40px)]">
|
||||
{
|
||||
isLoading
|
||||
? <div className="w-full h-full flex flex-col justify-start items-start gap-3">
|
||||
{[1,2,3,4].map((_) => (
|
||||
<Skeleton className="w-full h-10 bg-indigo-200"/>
|
||||
))}
|
||||
</div>
|
||||
: <ScrollArea
|
||||
className="w-full h-full"
|
||||
>
|
||||
<div className="w-full h-full flex flex-col justify-start items-start gap-3">
|
||||
{ scheduleList.map(schedule => (
|
||||
<ScheduleListTile
|
||||
onClick={moveToDetail}
|
||||
data={schedule}
|
||||
setMode={setMode}
|
||||
/>
|
||||
)) }
|
||||
</div>
|
||||
</ScrollArea>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
365
src/ui/component/schedule/content/ScheduleUpdateContent.tsx
Normal file
365
src/ui/component/schedule/content/ScheduleUpdateContent.tsx
Normal file
@@ -0,0 +1,365 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Field, FieldError } from '@/components/ui/field';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Popover, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
import type { ColorPaletteType } from '@/const/ColorPalette';
|
||||
import { Type } from '@baekyangdan/core-utils';
|
||||
import { usePalette } from '@/hooks/use-palette';
|
||||
import { useRecord } from '@/hooks/use-record';
|
||||
import { useTime } from '@/hooks/use-time';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ScheduleNetwork } from '@/network/ScheduleNetwork';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import * as z from 'zod';
|
||||
import { ColorPickPopover } from '../popover/ColorPickPopover';
|
||||
import { DatePickPopover } from '../popover/DatePickPopover';
|
||||
import { TimePickPopover } from '../popover/TimePickPopover';
|
||||
import { TypePickPopover } from '../popover/TypePickPopover';
|
||||
import type { ScheduleUpdateContentProps } from './ContentProps';
|
||||
import { SchedulerDTO as DTO } from '@baekyangdan/core-utils';
|
||||
import { UpdateScheduleSchema } from '@/data/form/schedule/updateSchedule.schema';
|
||||
import { ParticipantPopover } from '../popover/ParticipantPopover';
|
||||
|
||||
export const ScheduleUpdateContent = ({ date, setMode, popoverSide, popoverAlign, refetchList, id }: ScheduleUpdateContentProps) => {
|
||||
const [colorPopoverOpen, setColorPopoverOpen] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { getPaletteByKey } = usePalette();
|
||||
const { getCurrentTimeString, standardTimeToContinentalTime } = useTime();
|
||||
const dayLabelList = useRecord(Type.Day).keys.map((key) => {
|
||||
return {
|
||||
day: key,
|
||||
label: Type.Day[key as keyof typeof Type.Day]
|
||||
} as { day: string, label: string };
|
||||
});
|
||||
const scheduleNetwork = new ScheduleNetwork();
|
||||
|
||||
const updateScheduleForm = useForm<z.infer<typeof UpdateScheduleSchema>>({
|
||||
resolver: zodResolver(UpdateScheduleSchema),
|
||||
defaultValues: {
|
||||
id: id,
|
||||
name: "",
|
||||
startDate: date || new Date(),
|
||||
endDate: date || new Date(),
|
||||
content: "",
|
||||
startTime: getCurrentTimeString('standard'),
|
||||
endTime: getCurrentTimeString('standard'),
|
||||
type: "once",
|
||||
status: "yet",
|
||||
style: getPaletteByKey('SerenityBlue').style,
|
||||
dayList: "",
|
||||
participantList: []
|
||||
}
|
||||
});
|
||||
|
||||
const {
|
||||
name,
|
||||
startDate,
|
||||
endDate,
|
||||
content,
|
||||
startTime,
|
||||
endTime,
|
||||
type,
|
||||
style,
|
||||
dayList,
|
||||
participantList
|
||||
} = updateScheduleForm.watch();
|
||||
|
||||
useEffect(() => {
|
||||
init();
|
||||
}, [id]);
|
||||
|
||||
const init = async () => {
|
||||
if (isLoading) return;
|
||||
if (!id || id.trim().length === 0) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const initData = await scheduleNetwork.getDetail(id);
|
||||
if (!initData.success) throw new Error(initData.error);
|
||||
const data = initData.data;
|
||||
updateScheduleForm.reset({
|
||||
...data,
|
||||
content: data.content ? data.content : '',
|
||||
dayList: data.dayList ? data.dayList : '',
|
||||
participantList: data.participantList ? data.participantList : []
|
||||
});
|
||||
setIsLoading(false);
|
||||
} catch (e) {
|
||||
setIsLoading(false);
|
||||
console.error((e as Error).message);
|
||||
toast.error('일정을 불러오는 데에 실패하였습니다.\n잠시 후 다시 시도해주십시오.');
|
||||
setMode('detail');
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
const reqUpate = async () => {
|
||||
if (isLoading) return;
|
||||
|
||||
const data = {
|
||||
name,
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
type: type as Type.Type,
|
||||
style: style,
|
||||
startTime: standardTimeToContinentalTime(startTime),
|
||||
endTime: standardTimeToContinentalTime(endTime),
|
||||
dayList,
|
||||
content
|
||||
} as DTO.ScheduleCreateRequest;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
const createPromise = scheduleNetwork.create(data);
|
||||
|
||||
toast.promise(createPromise, {
|
||||
loading: '일정 생성 중입니다',
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await createPromise;
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
|
||||
toast.success('일정이 생성되었습니다');
|
||||
|
||||
// ✅ 기존 동작 그대로
|
||||
setMode('list');
|
||||
refetchList();
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : '에러 발생';
|
||||
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const selectColor = (color: ColorPaletteType) => {
|
||||
updateScheduleForm.setValue('style', color.style);
|
||||
setColorPopoverOpen(false);
|
||||
}
|
||||
|
||||
const selectType = (type: Type.Type) => {
|
||||
updateScheduleForm.setValue('type', type);
|
||||
}
|
||||
|
||||
const selectDayList = (newValues: string[]) => {
|
||||
const sortedValues = newValues.sort();
|
||||
const newDayList = sortedValues.join('');
|
||||
updateScheduleForm.setValue('dayList', newDayList);
|
||||
}
|
||||
|
||||
const selectDate = (type: 'startDate' | 'endDate', date: Date | undefined) => {
|
||||
if (!date) return;
|
||||
updateScheduleForm.setValue(type, date);
|
||||
}
|
||||
|
||||
const selectTime = (type: 'startTime' | 'endTime', time: string) => {
|
||||
updateScheduleForm.setValue(type, time);
|
||||
}
|
||||
|
||||
const selectParticipant = (participantList: string[]) => {
|
||||
updateScheduleForm.setValue('participantList', participantList);
|
||||
}
|
||||
|
||||
const OnceContent = () => {
|
||||
return (
|
||||
<div
|
||||
className="w-full h-10"
|
||||
>
|
||||
<DatePickPopover
|
||||
disabled={false}
|
||||
mode={'range'}
|
||||
popoverAlign={popoverAlign}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
setStartDate={(date: Date | undefined) => selectDate('startDate', date)}
|
||||
setEndDate={(date: Date | undefined) => selectDate('endDate', date)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const DailyContent = () => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const DefaultContent = () => {
|
||||
return (
|
||||
(
|
||||
<ToggleGroup
|
||||
type="multiple"
|
||||
className="w-full h-10 flex flex-row justify-center items-center"
|
||||
onValueChange={selectDayList}
|
||||
>
|
||||
{
|
||||
dayLabelList.map((day) => (
|
||||
<ToggleGroupItem
|
||||
value={`${day.day}`}
|
||||
className="border w-[calc(100%/7)] h-10 rounded-none not-last:border-r-0"
|
||||
>
|
||||
{day.label}
|
||||
</ToggleGroupItem>
|
||||
))
|
||||
}
|
||||
</ToggleGroup>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const renderContent = () => {
|
||||
switch (type) {
|
||||
case 'once':
|
||||
return <OnceContent />;
|
||||
case 'daily':
|
||||
return <DailyContent />;
|
||||
default:
|
||||
return <DefaultContent />;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col justify-between items-center">
|
||||
<div className="px-4 pt-4 w-full h-[calc(100%-40px)] flex flex-col justify-start items-start gap-4">
|
||||
<div className="w-full flex flex-row justify-between items-center gap-4">
|
||||
<div
|
||||
onClick={() => setMode('list')}
|
||||
>
|
||||
<ArrowLeft className="stroke-indigo-100 hover:stroke-indigo-300 transition-all duration-150" />
|
||||
</div>
|
||||
<Controller
|
||||
name="name"
|
||||
control={updateScheduleForm.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<Input
|
||||
{...field}
|
||||
id="form-create-schedule-name"
|
||||
placeholder="제목"
|
||||
maxLength={10}
|
||||
className="placeholder-indigo-200! text-indigo-400 font-bold border-t-0 border-r-0 border-l-0 p-0 border-b-2 rounded-none shadow-none border-indigo-100 focus-visible:ring-0 focus-visible:border-b-indigo-300"
|
||||
style={{
|
||||
fontSize: '20px'
|
||||
}}
|
||||
tabIndex={1}
|
||||
arai-invalid={fieldState.invalid}
|
||||
/>
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
<Popover open={colorPopoverOpen} onOpenChange={setColorPopoverOpen}>
|
||||
<div className="w-6 h-6 flex justify-center items-center">
|
||||
<PopoverTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-full w-5 h-5 border-2 border-gray-300 hover:border-indigo-300 transition-all duration-150',
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: `${style}`,
|
||||
}}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
<ColorPickPopover
|
||||
setColor={selectColor}
|
||||
/>
|
||||
</Popover>
|
||||
</div>
|
||||
<ScrollArea
|
||||
className={
|
||||
cn(
|
||||
"min-h-[120px] h-[calc(78%)] w-full",
|
||||
"[&>div>div:last-child]:block *:data-radix-scroll-area-thumb:bg-indigo-200"
|
||||
)
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="w-full h-full flex! flex-col! gap-4!"
|
||||
>
|
||||
<TypePickPopover
|
||||
type={type as Type.Type}
|
||||
setType={selectType}
|
||||
popoverSide={popoverSide}
|
||||
/>
|
||||
<div
|
||||
className="w-full h-10"
|
||||
>
|
||||
{renderContent()}
|
||||
</div>
|
||||
<div className="w-full h-10">
|
||||
<TimePickPopover
|
||||
mode='range'
|
||||
popoverAlign={popoverAlign}
|
||||
disabled={false}
|
||||
startTime={startTime}
|
||||
setStartTime={(time: string | undefined) => selectTime('startTime', time ?? '')}
|
||||
endTime={endTime}
|
||||
setEndTime={(time: string | undefined) => selectTime('endTime', time ?? '')}
|
||||
/>
|
||||
</div>
|
||||
<Controller
|
||||
name="content"
|
||||
control={updateScheduleForm.control}
|
||||
render={({ field }) => (
|
||||
<Textarea
|
||||
{...field}
|
||||
rows={2}
|
||||
placeholder="일정 상세 사항"
|
||||
spellCheck={false}
|
||||
className="placeholder-indigo-200! text-indigo-400 focus-visible:placeholder-indigo-300! border-indigo-100 focus-visible:border-indigo-300 resize-none focus-visible:ring-0"
|
||||
style={{
|
||||
'scrollbarWidth': 'none'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<ParticipantPopover
|
||||
participantList={participantList || []}
|
||||
setParticipantList={selectParticipant}
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
<div
|
||||
className="w-full h-10 flex flex-row justify-start items-start"
|
||||
>
|
||||
<Button
|
||||
className={cn(
|
||||
"h-full flex-5 flex justify-center items-center",
|
||||
"text-indigo-300 bg-white",
|
||||
"hover:text-white hover:bg-indigo-300",
|
||||
"rounded-none rounded-bl-xl"
|
||||
)}
|
||||
type="button"
|
||||
onClick={reqUpate}
|
||||
>
|
||||
추가
|
||||
</Button>
|
||||
<Button
|
||||
className={cn(
|
||||
"h-full flex-5 flex justify-center items-center",
|
||||
"bg-white text-red-400",
|
||||
"hover:text-white hover:bg-red-400",
|
||||
"rounded-none rounded-br-xl"
|
||||
)}
|
||||
onClick={() => setMode('list')}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
}
|
||||
90
src/ui/component/schedule/popover/ColorPickPopover.tsx
Normal file
90
src/ui/component/schedule/popover/ColorPickPopover.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { PopoverContent } from "@/components/ui/popover"
|
||||
import type { ColorPaletteType } from "@/const/ColorPalette"
|
||||
import { usePalette } from "@/hooks/use-palette";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Triangle } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
interface ColorPickPopoverProps {
|
||||
setColor: (color: ColorPaletteType) => void;
|
||||
}
|
||||
|
||||
export const ColorPickPopover = ({ setColor }: ColorPickPopoverProps) => {
|
||||
const [seeMore, setSeeMore] = useState(false);
|
||||
const {
|
||||
getMainPaletteList,
|
||||
getExtraPaletteList
|
||||
} = 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={cn(
|
||||
"flex flex-col gap-1.5 w-fit relative",
|
||||
seeMore ? "h-40" : "h-26"
|
||||
)}
|
||||
>
|
||||
{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 && (
|
||||
<>
|
||||
{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}`
|
||||
}}
|
||||
onClick={() => setColor(palette)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute h-8 bottom-0 left-0 w-full flex flex-row justify-center items-center gap-4 group bg-white hover:bg-indigo-300 transition-all duration-150",
|
||||
"rounded-b-md"
|
||||
)}
|
||||
onClick={() => setSeeMore(prev => !prev)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"text-indigo-300 select-none group-hover:text-white text-sm transition-all duration-150"
|
||||
)}
|
||||
>
|
||||
{ seeMore ? " 접기 " : "더 보기"}
|
||||
</span>
|
||||
<div
|
||||
className={cn(
|
||||
"w-0 h-0 border-l-6 border-l-transparent border-r-6 border-r-transparent border-b-10 border-b-indigo-300",
|
||||
"group-hover:border-b-white trnasition-all duration-150",
|
||||
!seeMore && "rotate-180"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
)
|
||||
}
|
||||
141
src/ui/component/schedule/popover/DatePickPopover.tsx
Normal file
141
src/ui/component/schedule/popover/DatePickPopover.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { format } from "date-fns";
|
||||
import { useState } from "react";
|
||||
|
||||
interface BaseProps {
|
||||
disabled: boolean;
|
||||
popoverAlign: 'start' | 'end';
|
||||
}
|
||||
|
||||
interface SingleModeProps extends BaseProps {
|
||||
mode: 'single';
|
||||
date: Date | undefined;
|
||||
setDate: (date: Date | undefined) => void;
|
||||
}
|
||||
|
||||
interface RangeModeProps extends BaseProps {
|
||||
mode: 'range';
|
||||
startDate: Date | undefined;
|
||||
endDate: Date | undefined;
|
||||
setStartDate: (date: Date | undefined) => void;
|
||||
setEndDate: (date: Date | undefined) => void;
|
||||
}
|
||||
|
||||
type DaetPickPopoverProps = SingleModeProps | RangeModeProps;
|
||||
|
||||
export const DatePickPopover = ({ ...props } : DaetPickPopoverProps) => {
|
||||
const { mode, popoverAlign, disabled } = props;
|
||||
|
||||
if (mode === 'single') {
|
||||
const { date, setDate } = props;
|
||||
const [open, setOpen] = useState(false);
|
||||
const onDaySelected = (open: boolean) => {
|
||||
setOpen(open);
|
||||
}
|
||||
return(
|
||||
<div>{date?.toString()}</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
const { startDate, setStartDate, endDate, setEndDate } = props;
|
||||
const [startOpen, setStartOpen] = useState(false);
|
||||
const [endOpen, setEndOpen] = useState(false);
|
||||
|
||||
const onStartDaySelected = (date: Date | undefined) => {
|
||||
setStartDate(date);
|
||||
setStartOpen(false);
|
||||
}
|
||||
|
||||
const onEndDaySelected = (date: Date | undefined) => {
|
||||
setEndDate(date);
|
||||
setEndOpen(false);
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full h-full flex flex-row justify-between gap-3 items-center"
|
||||
>
|
||||
<Popover
|
||||
open={startOpen}
|
||||
onOpenChange={setStartOpen}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
className={cn(
|
||||
"flex-9 h-full",
|
||||
!startOpen
|
||||
? "bg-white text-indigo-300 hover:bg-indigo-300 hover:text-white"
|
||||
: "bg-indigo-300 text-white hover:bg-indigo-300"
|
||||
)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{
|
||||
!startDate
|
||||
? "시작일 선택"
|
||||
: format(startDate, "yyyy년 MM월 dd일")
|
||||
}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-fit h-fit"
|
||||
align={'start'}
|
||||
side={popoverAlign === 'start' ? 'bottom' : 'top'}
|
||||
>
|
||||
<Calendar
|
||||
mode={'single'}
|
||||
onSelect={onStartDaySelected}
|
||||
disabled={
|
||||
endDate
|
||||
? { after: endDate }
|
||||
: undefined
|
||||
}
|
||||
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<div className="flex-2 h-px bg-indigo-300" />
|
||||
<Popover
|
||||
open={endOpen}
|
||||
onOpenChange={setEndOpen}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
className={cn(
|
||||
"flex-9 h-full",
|
||||
!endOpen
|
||||
? "bg-white text-indigo-300 hover:bg-indigo-300 hover:text-white"
|
||||
: "bg-indigo-300 text-white hover:bg-indigo-300"
|
||||
)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{
|
||||
!endDate
|
||||
? "종료일 선택"
|
||||
: format(endDate, "yyyy년 MM월 dd일")
|
||||
}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-fit h-fit"
|
||||
align={'end'}
|
||||
side={popoverAlign === 'start' ? 'bottom' : 'top'}
|
||||
>
|
||||
<Calendar
|
||||
mode='single'
|
||||
onSelect={onEndDaySelected}
|
||||
disabled={
|
||||
startDate
|
||||
? { before: startDate }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
11
src/ui/component/schedule/popover/FilterPopover.tsx
Normal file
11
src/ui/component/schedule/popover/FilterPopover.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Popover, PopoverTrigger } from "@/components/ui/popover"
|
||||
|
||||
export const FilterPopover = () => {
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
|
||||
</PopoverTrigger>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
167
src/ui/component/schedule/popover/ParticipantPopover.tsx
Normal file
167
src/ui/component/schedule/popover/ParticipantPopover.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CircleSmall, PlusIcon, SearchIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const dummyUser = [
|
||||
{
|
||||
accountId: "dummy1",
|
||||
name: "더미1"
|
||||
},
|
||||
{
|
||||
accountId: "test2",
|
||||
name: "테스트2"
|
||||
},
|
||||
{
|
||||
accountId: "dummy3",
|
||||
name: "테스트3"
|
||||
},
|
||||
{
|
||||
accountId: "test4",
|
||||
name: "더미4"
|
||||
}
|
||||
]
|
||||
|
||||
interface ParticipantPopoverProps {
|
||||
participantList: string[];
|
||||
setParticipantList: (list: string[]) => void;
|
||||
}
|
||||
|
||||
export const ParticipantPopover = ({ participantList, setParticipantList }: ParticipantPopoverProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [filterString, setFilterString] = useState('');
|
||||
const [userList, setUserList] = useState(dummyUser);
|
||||
const [tempList, setTempList] = useState(participantList);
|
||||
const updateTempList = (accountId: string) => {
|
||||
if (tempList.includes(accountId)) {
|
||||
setTempList(tempList.filter((id) => id !== accountId));
|
||||
} else {
|
||||
setTempList([...tempList, accountId]);
|
||||
}
|
||||
}
|
||||
const applyList = () => {
|
||||
setParticipantList(tempList);
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
const cancelList = () => {
|
||||
setOpen(false);
|
||||
setTimeout(() => {
|
||||
setTempList(participantList);
|
||||
}, 150);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full h-10 flex flex-row justify-start items-center">
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
className={cn(
|
||||
!open
|
||||
? "bg-white text-indigo-300 hover:bg-indigo-300 hover:text-white"
|
||||
: "bg-indigo-300 text-white hover:bg-indigo-300!"
|
||||
|
||||
)}
|
||||
type="button"
|
||||
>
|
||||
<PlusIcon
|
||||
/>
|
||||
<span>
|
||||
참여자 추가
|
||||
</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
side="right"
|
||||
className="py-0 px-0 w-60 h-80 flex flex-col justify-start items-start"
|
||||
>
|
||||
<div
|
||||
className="flex flex-row items-center px-3 h-10 w-full border-b border-b-indigo-100"
|
||||
>
|
||||
<SearchIcon size={16} className="stroke-indigo-300" />
|
||||
<Input
|
||||
placeholder="검색"
|
||||
value={filterString}
|
||||
onChange={(value) => setFilterString(value.target.value)}
|
||||
className="shadow-none placeholder-indigo-300! focus-visible:placeholder-indigo-400! flex-1 focus-visible:ring-0 focus-visible:border-0 border-0"
|
||||
/>
|
||||
</div>
|
||||
<ScrollArea
|
||||
className="w-full h-60 flex flex-col"
|
||||
>
|
||||
{
|
||||
userList.filter((user) => user.accountId.includes(filterString)).map((user, idx) => (
|
||||
<div
|
||||
className={cn(
|
||||
"w-full h-10 flex flex-row items-center",
|
||||
"hover:bg-indigo-50",
|
||||
(idx !== dummyUser.length - 1)
|
||||
? "border-b border-b-indigo-100"
|
||||
: null
|
||||
)}
|
||||
onClick={() => updateTempList(user.accountId)}
|
||||
>
|
||||
<div
|
||||
className="flex-11 flex flex-col"
|
||||
>
|
||||
<div className="pl-2 flex-1 text-sm align-middle">
|
||||
{user.name}
|
||||
</div>
|
||||
<div className="pl-2 flex-1 text-[10px] text-gray-400 align-middle">
|
||||
@{user.accountId}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="flex-1 flex items-center justify-center pr-2"
|
||||
>
|
||||
<div className={cn(
|
||||
"w-5 h-5 rounded-full bg-gray-100 flex justify-center items-center",
|
||||
!tempList.includes(user.accountId)
|
||||
? "border-gray-200 border"
|
||||
: "border-indigo-300 border-2"
|
||||
)}>
|
||||
{ tempList.includes(user.accountId) && <div className="w-2.5 h-2.5 rounded-full bg-indigo-300" />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</ScrollArea>
|
||||
<div
|
||||
className="border-t border-t-indigo-100 w-full h-10 flex flex-row cursor-default"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex-5 h-full flex flex-row justify-center items-center transition-all duration-150",
|
||||
"bg-white text-indigo-300",
|
||||
"hover:bg-indigo-300 hover:text-white",
|
||||
"rounded-none rounded-bl-md"
|
||||
)}
|
||||
onClick={applyList}
|
||||
>
|
||||
확인
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex-5 h-full flex flex-row justify-center items-center transition-all duration-150",
|
||||
"bg-white text-red-400",
|
||||
"hover:bg-red-400 hover:text-white",
|
||||
"rounded-none rounded-br-md"
|
||||
)}
|
||||
onClick={cancelList}
|
||||
>
|
||||
취소
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
415
src/ui/component/schedule/popover/TimePickPopover.tsx
Normal file
415
src/ui/component/schedule/popover/TimePickPopover.tsx
Normal file
@@ -0,0 +1,415 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useState } from "react";
|
||||
|
||||
interface BaseProps {
|
||||
disabled: boolean;
|
||||
popoverAlign: 'start' | 'end';
|
||||
}
|
||||
|
||||
interface SingleModeProps extends BaseProps {
|
||||
mode: 'single';
|
||||
time: string | undefined;
|
||||
setTime: (time: string | undefined) => void;
|
||||
}
|
||||
|
||||
interface RangeModeProps extends BaseProps {
|
||||
mode: 'range';
|
||||
startTime: string | undefined;
|
||||
setStartTime: (time: string | undefined) => void;
|
||||
endTime: string | undefined;
|
||||
setEndTime: (time: string | undefined) => void;
|
||||
}
|
||||
|
||||
type TimePickPopoverProps = SingleModeProps | RangeModeProps;
|
||||
|
||||
export const TimePickPopover = ({ ...props }: TimePickPopoverProps) => {
|
||||
const { mode, disabled, popoverAlign } = props;
|
||||
|
||||
if (mode === 'single') {
|
||||
return (
|
||||
<div>single</div>
|
||||
)
|
||||
}
|
||||
|
||||
const [startOpen, setStartOpen] = useState(false);
|
||||
const [endOpen, setEndOpen] = useState(false);
|
||||
const { startTime, setStartTime, endTime, setEndTime } = props;
|
||||
|
||||
const onStartOpenChange = (open: boolean) => {
|
||||
if (open) {
|
||||
setStartOpen(true);
|
||||
if (startTime) {
|
||||
const startTimeArray = startTime.split(' ');
|
||||
setTimeout(() => {
|
||||
const startAmPmList = document.querySelectorAll('#startAmPm button');
|
||||
const targetAmPm = Array.from(startAmPmList).find(button => {
|
||||
return button.textContent.trim() === startTimeArray[0];
|
||||
});
|
||||
const startHourList = document.querySelectorAll('#startHour button');
|
||||
const targetHour = Array.from(startHourList).find(button => {
|
||||
return button.textContent.trim() === startTimeArray[1].slice(0, 2);
|
||||
});
|
||||
const startMinuteList = document.querySelectorAll('#startMinute button');
|
||||
const targetMinute = Array.from(startMinuteList).find(button => {
|
||||
return button.textContent.trim() === startTimeArray[2].slice(0, 2);
|
||||
});
|
||||
[targetAmPm, targetHour, targetMinute].forEach(element => {
|
||||
(element as HTMLButtonElement).click();
|
||||
});
|
||||
}, 150);
|
||||
|
||||
}
|
||||
} else {
|
||||
setStartOpen(false);
|
||||
}
|
||||
}
|
||||
const applyStartTime = () => {
|
||||
const startOnStateElements = document.querySelectorAll('[id^="start"] [data-state="on"]');
|
||||
if (startOnStateElements.length < 3) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
startAmPm,
|
||||
startHour,
|
||||
startMinute
|
||||
} = {
|
||||
startAmPm: document.querySelector('#startAmPm [data-state="on"]')?.textContent.trim(),
|
||||
startHour: document.querySelector('#startHour [data-state="on"]')?.textContent.trim(),
|
||||
startMinute: document.querySelector('#startMinute [data-state="on"]')?.textContent.trim()
|
||||
};
|
||||
const startTime = `${startAmPm} ${startHour}시 ${startMinute}분`;
|
||||
setStartTime(startTime);
|
||||
setStartOpen(false);
|
||||
}
|
||||
const cancelStartTime = () => {
|
||||
setStartOpen(false);
|
||||
setTimeout(() => {
|
||||
const startOnElements = document.querySelectorAll('[id^="start"] [data-state="on"]');
|
||||
startOnElements.forEach(element => {
|
||||
(element as HTMLButtonElement).click();
|
||||
});
|
||||
}, 150);
|
||||
}
|
||||
|
||||
const onEndOpenChange = (open: boolean) => {
|
||||
if (open) {
|
||||
setEndOpen(true);
|
||||
if (endTime) {
|
||||
const endTimeArray = endTime.split(' ');
|
||||
setTimeout(() => {
|
||||
const endAmPmList = document.querySelectorAll('#endAmPm button');
|
||||
const targetAmPm = Array.from(endAmPmList).find(button => {
|
||||
return button.textContent.trim() === endTimeArray[0];
|
||||
});
|
||||
const endHourList = document.querySelectorAll('#endHour button');
|
||||
const targetHour = Array.from(endHourList).find(button => {
|
||||
return button.textContent.trim() === endTimeArray[1].slice(0, 2);
|
||||
});
|
||||
const endMinuteList = document.querySelectorAll('#endMinute button');
|
||||
const targetMinute = Array.from(endMinuteList).find(button => {
|
||||
return button.textContent.trim() === endTimeArray[2].slice(0, 2);
|
||||
});
|
||||
[targetAmPm, targetHour, targetMinute].forEach(element => {
|
||||
(element as HTMLButtonElement).click();
|
||||
});
|
||||
}, 150);
|
||||
|
||||
}
|
||||
} else {
|
||||
setEndOpen(false);
|
||||
}
|
||||
}
|
||||
const applyEndTime = () => {
|
||||
const endOnStateElements = document.querySelectorAll('[id^="end"] [data-state="on"]');
|
||||
if (endOnStateElements.length < 3) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
endAmPm,
|
||||
endHour,
|
||||
endMinute
|
||||
} = {
|
||||
endAmPm: document.querySelector('#endAmPm [data-state="on"]')?.textContent.trim(),
|
||||
endHour: document.querySelector('#endHour [data-state="on"]')?.textContent.trim(),
|
||||
endMinute: document.querySelector('#endMinute [data-state="on"]')?.textContent.trim()
|
||||
};
|
||||
const endTime = `${endAmPm} ${endHour}시 ${endMinute}분`;
|
||||
setEndTime(endTime);
|
||||
setEndOpen(false);
|
||||
}
|
||||
const cancelEndTime = () => {
|
||||
setEndOpen(false);
|
||||
setTimeout(() => {
|
||||
const endOnElements = document.querySelectorAll('[id^="end"] [data-state="on"]');
|
||||
endOnElements.forEach(element => {
|
||||
(element as HTMLButtonElement).click();
|
||||
});
|
||||
}, 150);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full h-full flex flex-row justify-between gap-3 items-center"
|
||||
>
|
||||
<Popover
|
||||
open={startOpen}
|
||||
onOpenChange={onStartOpenChange}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
className={cn(
|
||||
"flex-9 h-full",
|
||||
!startOpen
|
||||
? "bg-white text-indigo-300 hover:bg-indigo-300 hover:text-white"
|
||||
: "bg-indigo-300 text-white hover:bg-indigo-300"
|
||||
)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{
|
||||
!startTime
|
||||
? '시작 시간 설정'
|
||||
: startTime
|
||||
}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-fit h-42 flex flex-row p-0"
|
||||
align={'start'}
|
||||
side={popoverAlign === 'start' ? 'bottom' : 'top'}
|
||||
>
|
||||
<ToggleGroup
|
||||
id="startAmPm"
|
||||
type="single"
|
||||
className="w-15 flex flex-col justify-start items-center"
|
||||
>
|
||||
<ToggleGroupItem
|
||||
value={"오전"}
|
||||
className={cn(
|
||||
"w-full h-7 rounded-none! rounded-tl-md!",
|
||||
"text-indigo-300! bg-white! hover:bg-indigo-300! hover:text-white!",
|
||||
"data-[state=on]:bg-indigo-300! data-[state=on]:text-white!"
|
||||
)}
|
||||
>
|
||||
오전
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value={"오후"}
|
||||
className={cn(
|
||||
"w-full h-7 rounded-none!",
|
||||
"text-indigo-300! bg-white! hover:bg-indigo-300! hover:text-white!",
|
||||
"data-[state=on]:bg-indigo-300! data-[state=on]:text-white!"
|
||||
)}
|
||||
>
|
||||
오후
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<ScrollArea
|
||||
className="w-15 h-full"
|
||||
>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
id="startHour"
|
||||
className="w-15 border-r border-r-indigo-300 rounded-none border-l border-l-indigo-300 flex flex-col justify-start items-center"
|
||||
>
|
||||
{
|
||||
[1,2,3,4,5,6,7,8,9,10,11,12].map((time) => {
|
||||
return <ToggleGroupItem
|
||||
className={cn(
|
||||
"w-full h-7 rounded-none!",
|
||||
"bg-white! text-indigo-300! hover:bg-indigo-300! hover:text-white!",
|
||||
"data-[state=on]:bg-indigo-300! data-[state=on]:text-white!"
|
||||
)}
|
||||
value={time.toString().padStart(2, '0')}
|
||||
key={`startHour${time.toString().padStart(2, '0')}`}
|
||||
>
|
||||
{time.toString().padStart(2, '0')}
|
||||
</ToggleGroupItem>
|
||||
})
|
||||
}
|
||||
</ToggleGroup>
|
||||
</ScrollArea>
|
||||
<ScrollArea
|
||||
className="w-15 h-full"
|
||||
>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
id="startMinute"
|
||||
className="w-full h-full rounded-none border-r border-r-indigo-300 flex flex-col justify-start items-center"
|
||||
>
|
||||
{
|
||||
Array.from({ length: 60 }).map((_, idx) => (
|
||||
<ToggleGroupItem
|
||||
value={idx.toString().padStart(2, '0')}
|
||||
className={cn(
|
||||
"w-full h-7 rounded-none!",
|
||||
"bg-white! text-indigo-300! hover:bg-indigo-300! hover:text-white!",
|
||||
"data-[state=on]:bg-indigo-300! data-[state=on]:text-white!"
|
||||
)}
|
||||
key={`startMinute${idx.toString().padStart(2, '0')}`}
|
||||
>
|
||||
{idx.toString().padStart(2, '0')}
|
||||
</ToggleGroupItem>
|
||||
))
|
||||
}
|
||||
</ToggleGroup>
|
||||
</ScrollArea>
|
||||
<div className="w-15 h-full flex flex-col">
|
||||
<div
|
||||
className={cn(
|
||||
"cursor-default text-sm flex justify-center items-center w-full h-7 rounded-none rounded-tr-md!",
|
||||
"text-indigo-300 bg-white tarnsition-all duration-150",
|
||||
"hover:text-white hover:bg-indigo-300"
|
||||
)}
|
||||
onClick={applyStartTime}
|
||||
>
|
||||
확인
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"cursor-default text-sm flex justify-center items-center w-full h-7 rounded-none",
|
||||
"text-red-400 bg- transition-all duration-150",
|
||||
"hover:text-white hover:bg-red-400"
|
||||
)}
|
||||
onClick={cancelStartTime}
|
||||
>
|
||||
취소
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<div className="flex-2 h-px bg-indigo-300" />
|
||||
<Popover
|
||||
open={endOpen}
|
||||
onOpenChange={onEndOpenChange}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
className={cn(
|
||||
"flex-9 h-full",
|
||||
!endOpen
|
||||
? "bg-white text-indigo-300 hover:bg-indigo-300 hover:text-white"
|
||||
: "bg-indigo-300 text-white hover:bg-indigo-300"
|
||||
)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{
|
||||
!endTime
|
||||
? '종료 시간 설정'
|
||||
: `${endTime}`
|
||||
}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="200 w-fit h-42 flex flex-row p-0"
|
||||
align={'end'}
|
||||
side={popoverAlign === 'start' ? 'bottom' : 'top'}
|
||||
>
|
||||
<ToggleGroup
|
||||
id="endAmPm"
|
||||
type="single"
|
||||
className="w-15 flex flex-col justify-start items-center"
|
||||
>
|
||||
<ToggleGroupItem
|
||||
value={"오전"}
|
||||
className={cn(
|
||||
"w-full h-7 rounded-none! rounded-tl-md!",
|
||||
"text-indigo-300! bg-white! hover:bg-indigo-300! hover:text-white!",
|
||||
"data-[state=on]:bg-indigo-300! data-[state=on]:text-white!"
|
||||
)}
|
||||
>
|
||||
오전
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value={"오후"}
|
||||
className={cn(
|
||||
"w-full h-7 rounded-none!",
|
||||
"text-indigo-300! bg-white! hover:bg-indigo-300! hover:text-white!",
|
||||
"data-[state=on]:bg-indigo-300! data-[state=on]:text-white!"
|
||||
)}
|
||||
>
|
||||
오후
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<ScrollArea
|
||||
className="w-15 h-full"
|
||||
>
|
||||
<ToggleGroup
|
||||
id="endHour"
|
||||
type="single"
|
||||
className="w-15 border-r border-r-indigo-300 rounded-none border-l border-l-indigo-300 flex flex-col justify-start items-center"
|
||||
>
|
||||
{
|
||||
[1,2,3,4,5,6,7,8,9,10,11,12].map((time) => (
|
||||
<ToggleGroupItem
|
||||
className={cn(
|
||||
"w-full h-7 rounded-none!",
|
||||
"bg-white! text-indigo-300! hover:bg-indigo-300! hover:text-white!",
|
||||
"data-[state=on]:bg-indigo-300! data-[state=on]:text-white!"
|
||||
)}
|
||||
key={`endHour${time.toString().padStart(2, '0')}`}
|
||||
value={time.toString().padStart(2, '0')}
|
||||
>
|
||||
{time.toString().padStart(2, '0')}
|
||||
</ToggleGroupItem>
|
||||
))
|
||||
}
|
||||
</ToggleGroup>
|
||||
</ScrollArea>
|
||||
<ScrollArea
|
||||
className="w-15 h-full"
|
||||
>
|
||||
<ToggleGroup
|
||||
id="endMinute"
|
||||
type="single"
|
||||
className="w-full h-full rounded-none border-r border-r-indigo-300 flex flex-col justify-start items-center"
|
||||
>
|
||||
{
|
||||
Array.from({ length: 60 }).map((_, idx) => (
|
||||
<ToggleGroupItem
|
||||
value={idx.toString().padStart(2, '0')}
|
||||
key={`endMinute${idx.toString().padStart(2, '0')}`}
|
||||
className={cn(
|
||||
"w-full h-7 rounded-none!",
|
||||
"bg-white! text-indigo-300! hover:bg-indigo-300! hover:text-white!",
|
||||
"data-[state=on]:bg-indigo-300! data-[state=on]:text-white!"
|
||||
)}
|
||||
>
|
||||
{idx.toString().padStart(2, '0')}
|
||||
</ToggleGroupItem>
|
||||
))
|
||||
}
|
||||
</ToggleGroup>
|
||||
</ScrollArea>
|
||||
<div className="w-15 h-full flex flex-col">
|
||||
<div
|
||||
className={cn(
|
||||
"cursor-default text-sm flex justify-center items-center w-full h-7 rounded-none rounded-tr-md!",
|
||||
"text-indigo-300 bg-white transition-all duration-150",
|
||||
"hover:text-white hover:bg-indigo-300"
|
||||
)}
|
||||
onClick={applyEndTime}
|
||||
>
|
||||
확인
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"cursor-default text-sm flex justify-center items-center w-full h-7 rounded-none",
|
||||
"text-red-400 bg-white transition-all duration-150",
|
||||
"hover:text-white hover:bg-red-400"
|
||||
)}
|
||||
onClick={cancelEndTime}
|
||||
>
|
||||
취소
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
68
src/ui/component/schedule/popover/TypePickPopover.tsx
Normal file
68
src/ui/component/schedule/popover/TypePickPopover.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||
import { ScheduleTypeLabel, type ScheduleType } from "@/const/schedule/ScheduleType";
|
||||
import { useRecord } from "@/hooks/use-record";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useState } from "react";
|
||||
|
||||
interface TypePickPopoverProps {
|
||||
popoverSide : 'left' | 'right';
|
||||
type: ScheduleType;
|
||||
setType: (type: ScheduleType) => void;
|
||||
}
|
||||
|
||||
export const TypePickPopover = ({ popoverSide, type, setType }: TypePickPopoverProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const typeLabelList = useRecord(ScheduleTypeLabel).keys.map((key) => {
|
||||
return {
|
||||
type: key,
|
||||
label: ScheduleTypeLabel[key as keyof typeof ScheduleTypeLabel]
|
||||
} as { type: ScheduleType, label: string};
|
||||
});
|
||||
|
||||
const selectType = (type: ScheduleType) => {
|
||||
setType(type);
|
||||
setOpen(false);
|
||||
}
|
||||
return (
|
||||
<div className="w-full h-10">
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
className={cn(
|
||||
"w-full h-10 rounded-md",
|
||||
!open
|
||||
? "bg-white text-indigo-300 hover:bg-indigo-300 hover:text-white"
|
||||
: "bg-indigo-300 text-white hover:bg-indigo-300!"
|
||||
)}
|
||||
type="button"
|
||||
>
|
||||
{ScheduleTypeLabel[type as keyof typeof ScheduleTypeLabel]}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side={popoverSide} align={'start'} className="p-0 w-fit h-fit">
|
||||
<div className="w-20 h-62.5 flex flex-col rounded-md">
|
||||
{typeLabelList.map((label, idx) => (
|
||||
<div
|
||||
className={cn(
|
||||
"cursor-default flex-1 h-full flex justify-center items-center transition-all duration-150",
|
||||
type === label.type
|
||||
? "bg-indigo-300 text-white hover:bg-indigo-300!"
|
||||
: "bg-white text-indigo-300 hover:bg-indigo-300 hover:text-white",
|
||||
(idx === 0 && "rounded-t-md"),
|
||||
(idx === typeLabelList.length - 1 && "rounded-b-md")
|
||||
)}
|
||||
onClick={() => selectType(label.type)}
|
||||
>
|
||||
{label.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
45
src/ui/component/schedule/tile/ScheduleListTile.tsx
Normal file
45
src/ui/component/schedule/tile/ScheduleListTile.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { SchedulePopoverMode } from "@/const/schedule/SchedulePopoverMode";
|
||||
import { Converter } from "@/util/Converter";
|
||||
import { SchedulerDTO as DTO } from "@baekyangdan/core-utils";
|
||||
interface ScheduleListTileProps {
|
||||
setMode: (mode: SchedulePopoverMode) => void;
|
||||
data: DTO.ScheduleList;
|
||||
onClick: (id: string) => void;
|
||||
}
|
||||
export const ScheduleListTile = ({ data, onClick }: ScheduleListTileProps) => {
|
||||
const formatter = Converter.isoStringToFormattedString;
|
||||
|
||||
const handleOnClickTile = (id: string) => {
|
||||
onClick(id);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full select-none h-15 rounded-sm border shadow-sm shadow-indigo-200 flex flex-row items-center cursor-default group"
|
||||
style={{
|
||||
borderColor: data.style
|
||||
}}
|
||||
onClick={() => handleOnClickTile(data.id)}
|
||||
>
|
||||
<div className={`w-6 h-full rounded-l-xs group-hover:w-10 transition-all duration-150`} style={{backgroundColor: `${data.style}CC`}} />
|
||||
<div
|
||||
className="w-[calc(100%-24px)] border-l px-2 h-full flex flex-col justify-center items-start"
|
||||
style={{
|
||||
borderLeftColor: data.style
|
||||
}}
|
||||
>
|
||||
<div className="flex-6 h-full flex flex-row justify-end items-start">
|
||||
<span
|
||||
className="text-lg font-semibold text-indigo-300"
|
||||
>
|
||||
{data.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-4 w-full flex flex-row text-xs font-light items-center text-indigo-200">
|
||||
{formatter(data.startDate.toISOString())} - {formatter(data.endDate.toISOString())}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,21 +4,26 @@ 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';
|
||||
import * as z from 'zod';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Validator } from '@/util/Validator';
|
||||
import { LoginRequest } from '@/data/request/account/LoginRequest';
|
||||
import { SchedulerDTO as DTO } from '@baekyangdan/core-utils';
|
||||
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);
|
||||
}, []);
|
||||
@@ -47,45 +56,42 @@ export default function LoginPage() {
|
||||
if (isLoading) return;
|
||||
const type = Validator.isEmail(id) ? 'email' : 'accountId';
|
||||
|
||||
const data: LoginRequest = new LoginRequest(type, id, password);
|
||||
const data = {
|
||||
type,
|
||||
id,
|
||||
password
|
||||
} as DTO.LoginRequest;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
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.success) {
|
||||
const data = {
|
||||
accessToken: res.data.accessToken!
|
||||
};
|
||||
login({...data});
|
||||
if (autoLogin) {
|
||||
localStorage.setItem('auth-storage', JSON.stringify({ state: data }));
|
||||
}
|
||||
moveToHomePage();
|
||||
return "로그인 성공";
|
||||
} else {
|
||||
throw new Error(res.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 +104,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 +130,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 +149,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 +159,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"
|
||||
421
src/ui/page/account/resetPassword/ResetPasswordPage.tsx
Normal file
421
src/ui/page/account/resetPassword/ResetPasswordPage.tsx
Normal file
@@ -0,0 +1,421 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardFooter, CardHeader } from '@/components/ui/card';
|
||||
import { Field, FieldError, FieldLabel } from '@/components/ui/field';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/input-otp';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Stepper, StepperContent, StepperIndicator, StepperItem, StepperNav, StepperPanel, StepperSeparator, StepperTrigger } from '@/components/ui/stepper';
|
||||
import { PageRouting } from '@/const/PageRouting';
|
||||
import { ResetPasswordSchema } from '@/data/form';
|
||||
import { AccountNetwork } from '@/network/AccountNetwork';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { CircleCheckBigIcon, Eye, EyeOff, LoaderCircleIcon } from 'lucide-react';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import * as z from 'zod';
|
||||
import { SchedulerDTO as DTO } from '@baekyangdan/core-utils';
|
||||
|
||||
const steps = [1, 2, 3, 4];
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showPasswordConfirm, setShowPasswordConfirm] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const accountNetwork = new AccountNetwork();
|
||||
|
||||
const resetPasswordForm = useForm<z.infer<typeof ResetPasswordSchema>>({
|
||||
resolver: zodResolver(ResetPasswordSchema),
|
||||
defaultValues: {
|
||||
email: "",
|
||||
code: "",
|
||||
password: "",
|
||||
passwordConfirm: ""
|
||||
}
|
||||
});
|
||||
|
||||
const { email, code, password, passwordConfirm } = resetPasswordForm.watch();
|
||||
|
||||
const moveToLoginPage = useCallback(() => {
|
||||
navigate(PageRouting["LOGIN"].path);
|
||||
}, []);
|
||||
|
||||
const handleClickFirstStepButton = async () => {
|
||||
if (isLoading) return;
|
||||
if (!email || email.trim().length === 0) {
|
||||
resetPasswordForm.setError('email', {
|
||||
type: 'manual',
|
||||
message: '이메일을 입력해주십시오'
|
||||
});
|
||||
return;
|
||||
}
|
||||
const isEmailValid = await resetPasswordForm.trigger('email');
|
||||
if (!isEmailValid) {
|
||||
resetPasswordForm.setError('email', {
|
||||
type: 'validate',
|
||||
message: '이메일 형식이 올바르지 않습니다'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const data = {
|
||||
email
|
||||
} as DTO.SendPasswordResetCodeRequest;
|
||||
|
||||
const response = await accountNetwork.sendPasswordResetCode(data);
|
||||
|
||||
if (!response.success) {
|
||||
resetPasswordForm.setError('email', {
|
||||
message: '서버 오류로 코드 발송에 실패하였습니다. 잠시 후 다시 시도해주십시오.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setCurrentStep(current => current + 1);
|
||||
|
||||
} catch (err) {
|
||||
resetPasswordForm.setError('email', {
|
||||
message: '서버 오류로 코드 발송에 실패하였습니다. 잠시 후 다시 시도해주십시오.'
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const handleSecondStepOTPCompleted = async () => {
|
||||
if (isLoading) return;
|
||||
const codeValid = await resetPasswordForm.trigger('code');
|
||||
if (!codeValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = {
|
||||
email,
|
||||
code
|
||||
} as DTO.VerifyPasswordResetCodeRequest;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const response = await accountNetwork.verifyPasswordResetCode(data);
|
||||
|
||||
if (!response.success || !response.data.verified) {
|
||||
resetPasswordForm.setError('code', {
|
||||
type: 'value',
|
||||
message: response.error
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setCurrentStep(current => current + 1);
|
||||
} catch (err) {
|
||||
resetPasswordForm.setError('code', {
|
||||
type: 'value',
|
||||
message: '서버 오류로 코드 인증에 실패하였습니다. 잠시 후 다시 시도해주십시오.'
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const handleClickThirdStepButton = async () => {
|
||||
if (isLoading) return;
|
||||
const passwordValid = await resetPasswordForm.trigger('password');
|
||||
if (!passwordValid) return;
|
||||
|
||||
const passwordConfirmValid = await resetPasswordForm.trigger('passwordConfirm');
|
||||
if (!passwordConfirmValid) return;
|
||||
|
||||
const data = {
|
||||
email,
|
||||
password
|
||||
} as DTO.ResetPasswordRequest;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const response = await accountNetwork.resetPassword(data);
|
||||
|
||||
if (!response.success) {
|
||||
resetPasswordForm.setError('password', {
|
||||
message: '서버 오류로 비밀번호 변경에 실패하였습니다. 잠시 후 다시 시도해주십시오.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setCurrentStep(current => current + 1);
|
||||
} catch (err) {
|
||||
resetPasswordForm.setError('password', {
|
||||
message: '서버 오류로 비밀번호 변경에 실패하였습니다. 잠시 후 다시 시도해주십시오.'
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
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}
|
||||
onValueChange={setCurrentStep}
|
||||
className="w-full h-full flex flex-col justify-center items-center"
|
||||
indicators={{
|
||||
loading: <LoaderCircleIcon className="size-3.5 animate-spin" />
|
||||
}}
|
||||
>
|
||||
<Card className="w-md pl-2 pr-2">
|
||||
<CardHeader>
|
||||
비밀번호 초기화
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<StepperNav className="select-none">
|
||||
{steps.map((step) => (
|
||||
<StepperItem key={step} step={step} loading={isLoading}>
|
||||
<StepperTrigger asChild>
|
||||
<StepperIndicator
|
||||
className={[
|
||||
`transition-all duration-300`,
|
||||
`bg-accent text-accent-foreground rounded-full text-xs data-[state=completed]:bg-indigo-500 data-[state=completed]:text-primary-foreground data-[state=active]:bg-indigo-300 data-[state=active]:text-primary-foreground`,
|
||||
].join(' ')}
|
||||
>
|
||||
{step}
|
||||
</StepperIndicator>
|
||||
</StepperTrigger>
|
||||
{
|
||||
steps.length > step
|
||||
&& <StepperSeparator
|
||||
className="transition-all duration-300 group-data-[state=completed]/step:bg-indigo-500"
|
||||
/>
|
||||
}
|
||||
</StepperItem>
|
||||
))}
|
||||
</StepperNav>
|
||||
</CardContent>
|
||||
<CardContent>
|
||||
<StepperPanel>
|
||||
<StepperContent value={1} key={1}>
|
||||
<Field data-invalid={resetPasswordForm.formState.errors.email?.message ? true : false}>
|
||||
<FieldLabel htmlFor="reseet-password-email">이메일</FieldLabel>
|
||||
<Controller
|
||||
name="email"
|
||||
control={resetPasswordForm.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<>
|
||||
<Input
|
||||
{...field}
|
||||
type="email"
|
||||
id="reset-password-email"
|
||||
aria-invalid={fieldState.invalid}
|
||||
onKeyDown={handleEnterKeyDown}
|
||||
/>
|
||||
<FieldError className="font-[12px]" errors={[fieldState.error]} />
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
</StepperContent>
|
||||
<StepperContent value={2} key={2}>
|
||||
<Controller
|
||||
name="code"
|
||||
control={resetPasswordForm.control}
|
||||
render={
|
||||
({ field, fieldState }) => (
|
||||
<div className="w-full flex flex-col justify-center gap-5">
|
||||
<FieldLabel htmlFor="reset-password-code">코드 입력</FieldLabel>
|
||||
<div className="flex flex-row justify-center items-center">
|
||||
<InputOTP
|
||||
maxLength={8}
|
||||
inputMode="text"
|
||||
id="reset-password-code"
|
||||
onComplete={handleSecondStepOTPCompleted}
|
||||
value={field.value}
|
||||
onChange={(value) => field.onChange(value)}
|
||||
onBlur={field.onBlur}
|
||||
required
|
||||
>
|
||||
<InputOTPGroup
|
||||
className="gap-2.5 *:data-[slot=input-otp-slot]:rounded-md *:data-[slot=input-otp-slot]:border"
|
||||
>
|
||||
{
|
||||
[0, 1, 2, 3, 4, 5, 6, 7].map((idx) => (
|
||||
<InputOTPSlot index={idx} />
|
||||
))
|
||||
}
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</div>
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</StepperContent>
|
||||
<StepperContent value={3} key={3}>
|
||||
<div className="w-full flex flex-col gap-5 items-start">
|
||||
<FieldLabel htmlFor="reset-password-password">새 비밀번호</FieldLabel>
|
||||
<Controller
|
||||
name="password"
|
||||
control={resetPasswordForm.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<>
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
{...field}
|
||||
type={showPassword ? "text" : "password"}
|
||||
id="reset-password-password"
|
||||
aria-invalid={fieldState.invalid}
|
||||
onKeyDown={handleEnterKeyDown}
|
||||
className="pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-1/2 right-2 -translate-y-1/2"
|
||||
onClick={() => setShowPassword(prev => !prev)}
|
||||
>
|
||||
{
|
||||
showPassword
|
||||
? <EyeOff size={14} />
|
||||
: <Eye size={14} />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
<FieldError className="text-[12px]" errors={[fieldState.error]} />
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<FieldLabel htmlFor="reset-password-password-confirm">새 비밀번호 확인</FieldLabel>
|
||||
<Controller
|
||||
name="passwordConfirm"
|
||||
control={resetPasswordForm.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<>
|
||||
<div className="w-full relative">
|
||||
<Input
|
||||
{...field}
|
||||
type={showPasswordConfirm ? "text" : "password"}
|
||||
id="reset-password-password-confirm"
|
||||
className="pr-10"
|
||||
aria-invalid={fieldState.invalid}
|
||||
onKeyDown={handleEnterKeyDown}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-1/2 right-2 -translate-y-1/2"
|
||||
onClick={() => setShowPasswordConfirm(prev => !prev)}
|
||||
>
|
||||
{
|
||||
showPasswordConfirm
|
||||
? <EyeOff size={14} />
|
||||
: <Eye size={14} />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
<FieldError className="text-[12px]" errors={[fieldState.error]} />
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</StepperContent>
|
||||
<StepperContent value={4} key={4}>
|
||||
<div className="w-full flex flex-col justify-center items-center gap-5">
|
||||
<CircleCheckBigIcon size={50} className="text-indigo-500" />
|
||||
<Label className="font-extrabold text-xl">비밀번호 변경 완료</Label>
|
||||
</div>
|
||||
</StepperContent>
|
||||
</StepperPanel>
|
||||
</CardContent>
|
||||
<CardFooter
|
||||
className="w-full"
|
||||
>
|
||||
<StepperPanel className="w-full">
|
||||
<StepperContent value={1}>
|
||||
<div className="w-full flex flex-row items-center gap-5">
|
||||
<Button
|
||||
className="flex-8 bg-indigo-500 hover:bg-indigo-400"
|
||||
type="button"
|
||||
form="form-reset-password"
|
||||
disabled={
|
||||
(email.trim().length < 1)
|
||||
}
|
||||
onClick={handleClickFirstStepButton}
|
||||
>
|
||||
인증 번호 발송
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="flex-1 bg-stone-300 text-white hover:bg-stone-400"
|
||||
onClick={moveToLoginPage}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
</div>
|
||||
</StepperContent>
|
||||
<StepperContent value={2} key={2}>
|
||||
<div className="w-full flex flex-row justify-end items-center gap-5">
|
||||
<Button
|
||||
type="button"
|
||||
className="bg-stone-300 text-white hover:bg-stone-400"
|
||||
onClick={moveToLoginPage}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
</div>
|
||||
</StepperContent>
|
||||
<StepperContent value={3} key={3}>
|
||||
<div className="w-full flex flex-row align-center gap-5">
|
||||
<Button
|
||||
className="flex-8 bg-indigo-500 hover:bg-indigo-400"
|
||||
type="button"
|
||||
form="form-reset-password"
|
||||
disabled={
|
||||
(password.trim().length < 1)
|
||||
&& (passwordConfirm.trim().length < 1)
|
||||
}
|
||||
onClick={handleClickThirdStepButton}
|
||||
>
|
||||
비밀번호 변경
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="flex-1 bg-stone-300 text-white hover:bg-stone-400"
|
||||
onClick={moveToLoginPage}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
</div>
|
||||
</StepperContent>
|
||||
<StepperContent value={4} key={4}>
|
||||
<div className="w-full flex flex-row justify-end items-center gap-5">
|
||||
<Button
|
||||
type="button"
|
||||
className="bg-stone-500 text-white hover:bg-stone-400"
|
||||
onClick={moveToLoginPage}
|
||||
>
|
||||
로그인 화면으로 이동
|
||||
</Button>
|
||||
</div>
|
||||
</StepperContent>
|
||||
</StepperPanel>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</Stepper>
|
||||
)
|
||||
}
|
||||
313
src/ui/page/account/signup/SignUpPage.tsx
Normal file
313
src/ui/page/account/signup/SignUpPage.tsx
Normal file
@@ -0,0 +1,313 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardFooter
|
||||
} from '@/components/ui/card';
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from '@/components/ui/field';
|
||||
import { SignUpSchema } from '@/data/form';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import * as z from 'zod';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import EmailVerificationModal from '@/ui/component/modal/EmailVerificationModal';
|
||||
import { CheckDuplicationRequest, SignupRequest } from '@/data/request';
|
||||
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';
|
||||
import { SchedulerDTO as DTO } from '@baekyangdan/core-utils';
|
||||
|
||||
export default function SignUpPage() {
|
||||
const [emailVerificationModalOpen, setEmailVerificationModalOpen] = useState<boolean>(false);
|
||||
const [isCheckedEmailDuplication, setIsCheckedEmailDuplication] = useState<boolean>(false);
|
||||
const [isCheckedAccountIdDuplication, setIsCheckedAccountIdDuplication] = useState<boolean>(false);
|
||||
const [duplicationCheckedEmail, setDuplicationCheckedEmail] = useState<string>("");
|
||||
const [duplicationCheckedAccountId, setDuplicationCheckedAccountId] = useState<string>("");
|
||||
|
||||
const accountNetwork = new AccountNetwork();
|
||||
const navigate = useNavigate();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const signUpForm = useForm<z.infer<typeof SignUpSchema>>({
|
||||
resolver: zodResolver(SignUpSchema),
|
||||
defaultValues: {
|
||||
accountId: "",
|
||||
email: "",
|
||||
password: "",
|
||||
passwordConfirm: "",
|
||||
name: "",
|
||||
nickname: "",
|
||||
}
|
||||
});
|
||||
|
||||
const goToLogin = useCallback(() => {
|
||||
navigate(PageRouting["LOGIN"].path);
|
||||
}, [navigate]);
|
||||
|
||||
const checkDuplication = async (type: 'email' | 'accountId', value: string) => {
|
||||
const data = {
|
||||
type,
|
||||
value
|
||||
} as DTO.CheckDuplicationRequest;
|
||||
return await accountNetwork.checkDuplication(data);
|
||||
}
|
||||
|
||||
const signup = async () => {
|
||||
const { email, accountId, name, nickname, password } = signUpForm.getValues();
|
||||
const data = {
|
||||
email,
|
||||
accountId,
|
||||
name,
|
||||
nickname,
|
||||
password
|
||||
} as DTO.SignupRequest;
|
||||
|
||||
const signupPromise = accountNetwork.signup(data);
|
||||
|
||||
toast.promise(
|
||||
signupPromise,
|
||||
{
|
||||
loading: "회원가입 진행 중입니다.",
|
||||
success: (res) => {
|
||||
if (!res.success) return "회원가입에 실패하였습니다.\n잠시 후 다시 시도해주십시오.";
|
||||
|
||||
return <SuccessToast onClose={goToLogin} />
|
||||
},
|
||||
error: "회원가입에 실패하였습니다.\n잠시 후 다시 시도해주십시오.",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const handleOnChangeAccountId = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setIsCheckedAccountIdDuplication(
|
||||
e.currentTarget.value === duplicationCheckedAccountId
|
||||
);
|
||||
}
|
||||
|
||||
const handleOnChangeEmail = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setIsCheckedEmailDuplication(
|
||||
e.currentTarget.value === duplicationCheckedEmail
|
||||
);
|
||||
}
|
||||
|
||||
const handleDuplicationCheckButtonClick = async (type: 'email' | 'accountId') => {
|
||||
const value = signUpForm.getValues(type);
|
||||
const duplicatedMessage = type === 'email' ? '사용할 수 없는 이메일입니다.' : '사용할 수 없는 아이디입니다.';
|
||||
|
||||
if (!value) return;
|
||||
|
||||
try {
|
||||
|
||||
const result = await checkDuplication(type, value);
|
||||
let isDuplicated = false;
|
||||
|
||||
if (result.success) {
|
||||
isDuplicated = result.data.isDuplicated;
|
||||
} else {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
|
||||
if (isDuplicated) {
|
||||
signUpForm.setError(type, { message: duplicatedMessage });
|
||||
} else {
|
||||
signUpForm.clearErrors(type);
|
||||
if (type === 'email') {
|
||||
setIsCheckedEmailDuplication(true);
|
||||
setDuplicationCheckedEmail(value);
|
||||
} else {
|
||||
setIsCheckedAccountIdDuplication(true);
|
||||
setDuplicationCheckedAccountId(value);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (type === 'email') {
|
||||
setIsCheckedEmailDuplication(false);
|
||||
setDuplicationCheckedEmail('');
|
||||
} else {
|
||||
setIsCheckedAccountIdDuplication(false);
|
||||
setDuplicationCheckedAccountId('');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleOnSignUpButtonClick = () => {
|
||||
if (!isCheckedAccountIdDuplication) {
|
||||
signUpForm.setError("accountId", { message: "아이디 중복 확인이 필요합니다."});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isCheckedEmailDuplication) {
|
||||
signUpForm.setError("email", { message: "이메일 중복 확인이 필요합니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
setEmailVerificationModalOpen(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<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-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">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
function SuccessToast({ onClose }: { onClose: () => void }) {
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => onClose(), 3000); // 3초 후 이동
|
||||
return () => clearTimeout(timer);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-row justify-between items-center">
|
||||
회원가입 성공!
|
||||
<button onClick={onClose}>로그인 페이지로 이동</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
7
src/ui/page/home/HomePage.tsx
Normal file
7
src/ui/page/home/HomePage.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
export const HomePage = () => {
|
||||
return (
|
||||
<div className="w-full h-full flex flex-column">
|
||||
HomePage
|
||||
</div>
|
||||
)
|
||||
};
|
||||
27
src/ui/page/home/TempPage.tsx
Normal file
27
src/ui/page/home/TempPage.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { PageRouting } from "@/const/PageRouting";
|
||||
import { BaseNetwork } from "@/network/BaseNetwork";
|
||||
import { useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export const TempPage = () => {
|
||||
const baseNetwork = new BaseNetwork();
|
||||
const navigate = useNavigate();
|
||||
useEffect(() => {
|
||||
const autoLogin = localStorage.getItem('autoLogin') === 'true';
|
||||
if (autoLogin) {
|
||||
try {
|
||||
(async () => {
|
||||
await baseNetwork.refreshToken();
|
||||
navigate(PageRouting["HOME"].path);
|
||||
})();
|
||||
} catch (err) {
|
||||
localStorage.setItem('autoLogin', 'false');
|
||||
navigate(PageRouting["LOGIN"].path);
|
||||
}
|
||||
} else {
|
||||
navigate(PageRouting["LOGIN"].path);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (<div />);
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import { Card, CardContent, CardHeader, CardFooter } from '@/components/ui/card';
|
||||
import { ResetPasswordSchema } from '@/data/form';
|
||||
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 { Controller, useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { PageRouting } from '@/const/PageRouting';
|
||||
import * as z from 'zod';
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const loginForm = useForm<z.infer<typeof ResetPasswordSchema>>({
|
||||
resolver: zodResolver(ResetPasswordSchema),
|
||||
defaultValues: {
|
||||
email: ""
|
||||
}
|
||||
});
|
||||
|
||||
const moveToLoginPage = useCallback(() => {
|
||||
navigate(PageRouting["LOGIN"].path);
|
||||
}, []);
|
||||
|
||||
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-reset-password" className="w-full flex flex-col gap-2.5">
|
||||
<Controller
|
||||
name="email"
|
||||
control={loginForm.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor="form-reset-password-email">이메일</FieldLabel>
|
||||
<Input
|
||||
{...field}
|
||||
type="email"
|
||||
id="form-reset-password-email"
|
||||
aria-invalid={fieldState.invalid}
|
||||
/>
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
</Field>
|
||||
)}
|
||||
>
|
||||
</Controller>
|
||||
</form>
|
||||
</CardContent>
|
||||
<CardFooter
|
||||
className="w-full flex flex-row items-center gap-5"
|
||||
>
|
||||
<Button
|
||||
className="flex-8 bg-indigo-500 hover:bg-indigo-400"
|
||||
type="submit"
|
||||
form="form-reset-password"
|
||||
disabled={
|
||||
(loginForm.getValues("email").trim.length < 1)
|
||||
}>
|
||||
인증 번호 발송
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1 bg-stone-300 text-white hover:bg-stone-400"
|
||||
onClick={moveToLoginPage}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -1,277 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardFooter
|
||||
} from '@/components/ui/card';
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from '@/components/ui/field';
|
||||
import { SignUpSchema } from '@/data/form';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import * as z from 'zod';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import EmailVerificationModal from '@/ui/component/modal/EmailVerificationModal';
|
||||
import { CheckDuplicationRequest, SignupRequest } from '@/data/request';
|
||||
import { AccountNetwork } from '@/network/AccountNetwork';
|
||||
import { toast } from 'sonner';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { PageRouting } from '@/const/PageRouting';
|
||||
|
||||
export default function SignUpPage() {
|
||||
const [emailVerificationModalOpen, setEmailVerificationModalOpen] = useState<boolean>(false);
|
||||
const [isCheckedEmailDuplication, setIsCheckedEmailDuplication] = useState<boolean>(false);
|
||||
const [isCheckedAccountIdDuplication, setIsCheckedAccountIdDuplication] = useState<boolean>(false);
|
||||
const [duplicationCheckedEmail, setDuplicationCheckedEmail] = useState<string>("");
|
||||
const [duplicationCheckedAccountId, setDuplicationCheckedAccountId] = useState<string>("");
|
||||
|
||||
const accountNetwork = new AccountNetwork();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const signUpForm = useForm<z.infer<typeof SignUpSchema>>({
|
||||
resolver: zodResolver(SignUpSchema),
|
||||
defaultValues: {
|
||||
accountId: "",
|
||||
email: "",
|
||||
password: "",
|
||||
passwordConfirm: "",
|
||||
name: "",
|
||||
nickname: "",
|
||||
}
|
||||
});
|
||||
|
||||
const goToLogin = useCallback(() => {
|
||||
navigate(PageRouting["LOGIN"].path);
|
||||
}, [navigate]);
|
||||
|
||||
const checkDuplication = async (type: 'email' | 'accountId', value: string) => {
|
||||
const data: CheckDuplicationRequest = new CheckDuplicationRequest(type, value);
|
||||
return await accountNetwork.checkDuplication(data);
|
||||
}
|
||||
|
||||
const signup = async () => {
|
||||
const { email, accountId, name, nickname, password } = signUpForm.getValues();
|
||||
const data: SignupRequest = new SignupRequest(accountId, email, name, nickname, password);
|
||||
|
||||
const signupPromise = accountNetwork.signup(data);
|
||||
|
||||
toast.promise(
|
||||
signupPromise,
|
||||
{
|
||||
loading: "회원가입 진행 중입니다.",
|
||||
success: (res) => {
|
||||
if (!res.data.success) return "회원가입에 실패하였습니다.\n잠시 후 다시 시도해주십시오.";
|
||||
|
||||
return <SuccessToast onClose={goToLogin} />
|
||||
},
|
||||
error: "회원가입에 실패하였습니다.\n잠시 후 다시 시도해주십시오.",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const handleOnChangeAccountId = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setIsCheckedAccountIdDuplication(
|
||||
e.currentTarget.value === duplicationCheckedAccountId
|
||||
);
|
||||
}
|
||||
|
||||
const handleOnChangeEmail = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setIsCheckedEmailDuplication(
|
||||
e.currentTarget.value === duplicationCheckedEmail
|
||||
);
|
||||
}
|
||||
|
||||
const handleDuplicationCheckButtonClick = async (type: 'email' | 'accountId') => {
|
||||
const value = signUpForm.getValues(type);
|
||||
const duplicatedMessage = type === 'email' ? '사용할 수 없는 이메일입니다.' : '사용할 수 없는 아이디입니다.';
|
||||
|
||||
if (!value) return;
|
||||
|
||||
const isDuplicated = (await checkDuplication(type, value)).data.isDuplicated;
|
||||
|
||||
if (isDuplicated) {
|
||||
signUpForm.setError(type, { message: duplicatedMessage });
|
||||
} else {
|
||||
signUpForm.clearErrors(type);
|
||||
if (type === 'email') {
|
||||
setIsCheckedEmailDuplication(true);
|
||||
setDuplicationCheckedEmail(value);
|
||||
} else {
|
||||
setIsCheckedAccountIdDuplication(true);
|
||||
setDuplicationCheckedAccountId(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleOnSignUpButtonClick = () => {
|
||||
if (!isCheckedAccountIdDuplication) {
|
||||
signUpForm.setError("accountId", { message: "아이디 중복 확인이 필요합니다."});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isCheckedEmailDuplication) {
|
||||
signUpForm.setError("email", { message: "이메일 중복 확인이 필요합니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
setEmailVerificationModalOpen(true);
|
||||
}
|
||||
|
||||
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">
|
||||
<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-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">
|
||||
<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>
|
||||
<CardFooter>
|
||||
<EmailVerificationModal
|
||||
trigger={
|
||||
<Button type="button" onClick={handleOnSignUpButtonClick} className="0">
|
||||
회원가입
|
||||
</Button>
|
||||
}
|
||||
email={duplicationCheckedEmail}
|
||||
open={emailVerificationModalOpen} // ✅ 부모 상태 연결
|
||||
setOpen={setEmailVerificationModalOpen} // ✅ 부모 상태 변경 함수 전달
|
||||
onVerifySuccess={signup} // ✅ 인증 성공 시 signup 호출
|
||||
/>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SuccessToast({ onClose }: { onClose: () => void }) {
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => onClose(), 3000); // 3초 후 이동
|
||||
return () => clearTimeout(timer);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-row justify-between items-center">
|
||||
회원가입 성공!
|
||||
<button onClick={onClose}>로그인 페이지로 이동</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
src/util/Converter.ts
Normal file
16
src/util/Converter.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { format } from "date-fns";
|
||||
|
||||
export class Converter {
|
||||
static dateToUTC9(date: Date) {
|
||||
const utc9Date = new Date(date);
|
||||
utc9Date.setHours(9);
|
||||
|
||||
return utc9Date.toISOString();
|
||||
}
|
||||
|
||||
static isoStringToFormattedString(isoString: string) {
|
||||
const isoDate = new Date(isoString);
|
||||
const dateFormatter = "yyyy년 MM월 dd일";
|
||||
return format(isoDate, dateFormatter);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,23 @@
|
||||
export class Validator {
|
||||
static isEmail = (value: any) => {
|
||||
if (typeof value !== 'string') return false;
|
||||
const email = value.trim();
|
||||
return /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/.test(email);
|
||||
};
|
||||
if (typeof value !== 'string') return false;
|
||||
const email = value.trim();
|
||||
return /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/.test(email);
|
||||
};
|
||||
|
||||
static validatePasswordFormat = (password: string): boolean => {
|
||||
if (password.length < 8) return false;
|
||||
if (password.includes(' ')) return false;
|
||||
|
||||
const alphabets = 'abcdefghijklmnopqrstuvwxyz';
|
||||
const numbers = '0123456789';
|
||||
const specials = '!@#$%^';
|
||||
|
||||
if (!alphabets.includes(password[0])) return false;
|
||||
|
||||
const hasNumber = [...numbers].some((char) => password.includes(char));
|
||||
const hasSpecial = [...specials].some((char) => password.includes(char));
|
||||
|
||||
return hasNumber && hasSpecial;
|
||||
}
|
||||
}
|
||||
23
tailwind.config.js
Normal file
23
tailwind.config.js
Normal file
@@ -0,0 +1,23 @@
|
||||
export default {
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: [
|
||||
"Wanted Sans Variable",
|
||||
"Wanted Sans",
|
||||
"-apple-system",
|
||||
"BlinkMacSystemFont",
|
||||
"system-ui",
|
||||
"Segoe UI",
|
||||
"Apple SD Gothic Neo",
|
||||
"Noto Sans KR",
|
||||
"Malgun Gothic",
|
||||
"Apple Color Emoji",
|
||||
"Segoe UI Emoji",
|
||||
"Segoe UI Symbol",
|
||||
"sans-serif"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,37 @@ import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import path from 'path'
|
||||
import * as fs from 'fs'
|
||||
|
||||
const certPath = path.resolve(__dirname, 'certs');
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
server: {
|
||||
port: 5185
|
||||
// host: '0.0.0.0',
|
||||
port: 5185,
|
||||
https: {
|
||||
key: fs.readFileSync(path.join(certPath, 'localhost+2-key.pem')),
|
||||
cert: fs.readFileSync(path.join(certPath, 'localhost+2.pem'))
|
||||
},
|
||||
proxy: {
|
||||
'/local-api': {
|
||||
target: 'https://localhost:3000',
|
||||
secure: false,
|
||||
ws: true,
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/local-api/, ''),
|
||||
},
|
||||
'/dev-api': {
|
||||
target: 'https://localhost:8088',
|
||||
secure: false,
|
||||
ws: true,
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/dev-api/, ''),
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss()
|
||||
|
||||
Reference in New Issue
Block a user