Compare commits

...

33 Commits

Author SHA1 Message Date
geonhee-min
5e201f8ae0 issue #
All checks were successful
Test CI / build (push) Successful in 38s
- 도메인 bkdhome.p-e.kr -> ddoahh.kro.kr
2025-12-19 10:35:29 +09:00
f8ff0c61f9 issue #
All checks were successful
Test CI / build (push) Successful in 21s
- 이메일 검증 소스 수정
2025-12-17 23:48:44 +09:00
geonhee-min
8b085107f6 issue #60
All checks were successful
Test CI / build (push) Successful in 27s
- 일정 상세 조회 화면 및 기능 1차 구현 완료
2025-12-17 17:02:00 +09:00
geonhee-min
60e9d2a631 issue #
All checks were successful
Test CI / build (push) Successful in 28s
- DTO 패키지 레지스트리 전환 중
2025-12-16 17:26:58 +09:00
geonhee-min
4a3896a313 issue #60
All checks were successful
Test CI / build (push) Successful in 21s
- 일정 상세 조회 기능 구현 중
2025-12-15 17:34:52 +09:00
9173556204 issue # 이메일 인증 api 주소 수정
All checks were successful
Test CI / build (push) Successful in 19s
2025-12-14 20:02:37 +09:00
64540e397e issue #60
All checks were successful
Test CI / build (push) Successful in 18s
- z-index 이슈 수정
2025-12-14 02:59:10 +09:00
fdbfd80462 issue #60
All checks were successful
Test CI / build (push) Successful in 17s
- z-index 이슈 해결
2025-12-14 02:56:24 +09:00
geonhee-min
8015eb45db issue #60
All checks were successful
Test CI / build (push) Successful in 37s
- 일정 목록 조회 ui 일부 수정
- 일정 상세 조회 로직 구현 중
2025-12-12 17:05:08 +09:00
geonhee-min
78e3bdbda0 issue #60
All checks were successful
Test CI / build (push) Successful in 15s
- 일정 목록 조회 1차 구현
- 일정 당일 목록 조회 1차 구현
2025-12-11 17:03:25 +09:00
b23b58e680 issue #60
All checks were successful
Test CI / build (push) Successful in 17s
- 일정 목록 조회 기능 구현 중
2025-12-10 20:56:40 +09:00
geonhee-min
e86fb3bac2 issue #60
All checks were successful
Test CI / build (push) Successful in 17s
- 일정 추가 로직 1차 구현
- 일정 목록 화면 구현 중
2025-12-10 17:12:29 +09:00
0c13854257 issue #60
All checks were successful
Test CI / build (push) Successful in 16s
- 일정 참여자 화면 구현
2025-12-09 22:01:18 +09:00
geonhee-min
a30c2bbb32 issue #60
All checks were successful
Test CI / build (push) Successful in 24s
- 시작/종료 시간 설정 화면 및 로직 구현
- 일정 상세 사항 화면 및 로직 구현
- 참여자 추가 화면 구현 중
2025-12-09 17:09:31 +09:00
geonhee-min
47d2eae519 issue #60
All checks were successful
Test CI / build (push) Successful in 16s
- 일정 시간 picker ui 수정(적용/취소 추가)
2025-12-08 17:25:34 +09:00
geonhee-min
6bbffbcb50 issue #60
All checks were successful
Test CI / build (push) Successful in 16s
- 일정 색상 로직 수정: 커스텀 컬러 삭제
- 일정 시작/종료일 설정 구현
- 일정 타입 구현
- 일정 시작/종료 시간 구현 중
2025-12-08 16:57:09 +09:00
c0941d0680 issue #32
All checks were successful
Test CI / build (push) Successful in 15s
- 자동 로그인 cookie 로 개선
2025-12-07 22:47:05 +09:00
2c8dcf9db7 issue #60
All checks were successful
Test CI / build (push) Successful in 17s
- 일정 등록 및 조회 컴포넌트 설계 및 구현 중
2025-12-06 00:19:25 +09:00
geonhee-min
4a8e761b3d issue #60
All checks were successful
Test CI / build (push) Successful in 18s
- 날짜 선택 및 해당 날짜 일정 조회 화면 구현 중
2025-12-05 17:10:58 +09:00
0c8e0893c7 issue #60
All checks were successful
Test CI / build (push) Successful in 17s
- 일정 등록 및 조회 컴포넌트 설계 및 구현 중
2025-12-05 00:05:33 +09:00
geonhee-min
7df60fe004 issue #60
All checks were successful
Test CI / build (push) Successful in 17s
- 캘린더 ui 구현 중
2025-12-04 17:00:02 +09:00
daab622638 issue #59
All checks were successful
Test CI / build (push) Successful in 17s
- 일정 메인 화면 구현 중
2025-12-03 22:51:13 +09:00
geonhee-min
ea7861b63a issue #
All checks were successful
Test CI / build (push) Successful in 17s
- 로그인 화면, 회원가입 화면, 비밀번호 초기화 화면 모바일 ui 대비 작업
2025-12-03 17:06:20 +09:00
geonhee-min
069f58075b issue #52
- 사이드바 Sheet 스타일로 변경
- 로그아웃 버튼 구현
2025-12-03 17:05:48 +09:00
geonhee-min
edef4273c0 issue #48
- 홈 화면 및 전체적 ui 수정
2025-12-03 17:05:19 +09:00
geonhee-min
e3091494b1 issue #32
- 새로고침 시 로그인 해제 오류 해결 및 자동로그인 기능 구현
2025-12-03 12:59:08 +09:00
geonhee-min
3859099074 issue #32
- 로그인 토스트 동작 오류 개선
2025-12-03 11:20:33 +09:00
geonhee-min
54c84dbc87 issue #
Enter키 동작 구현
2025-12-03 10:13:37 +09:00
1a0cc9376f issue #49
All checks were successful
Test CI / build (push) Successful in 16s
- 홈 화면 라우팅 및 기본 파일 생성
2025-12-02 22:40:04 +09:00
b730945d34 issue #37
- 비밀번호 초기화 화면 기능 구현
2025-12-02 22:39:47 +09:00
17e27fca70 issue #36
- 화면 구현 완료
2025-12-02 22:39:31 +09:00
geonhee-min
af3fa26f3b issue #37
All checks were successful
Test CI / build (push) Successful in 16s
- 기능 구현 1차 완료(동작 확인 필요)
2025-12-02 16:50:24 +09:00
geonhee-min
eec883ac32 issue #36
- 화면 구현 완료
2025-12-02 16:49:54 +09:00
99 changed files with 7021 additions and 601 deletions

View File

@@ -1 +1 @@
VITE_API_URL=http://localhost:8080
VITE_API_URL=/dev-api

View File

@@ -1 +1 @@
VITE_API_URL=https://api.scheduler.bkdhome.p-e.kr
VITE_API_URL=/api

3
.npmrc Normal file
View 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
View 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
View 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-----

View File

@@ -18,5 +18,8 @@
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
"registries": {
"@reui": "https://reui.io/r/{name}.json",
"@diceui": "https://diceui.com/r/{name}.json"
}
}

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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"

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 113 KiB

View File

@@ -117,4 +117,4 @@
body {
@apply bg-background text-foreground;
}
}
}

View File

@@ -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>

View File

@@ -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 &&

View File

@@ -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}

View 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 };

View File

@@ -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}

View File

@@ -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

View File

@@ -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,

View File

@@ -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" />,

View 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,
};

View File

@@ -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
View 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'
}
}

View File

@@ -0,0 +1,9 @@
export const ScheduleDay: Record<number, string> = {
1: '일',
2: '월',
3: '화',
4: '수',
5: '목',
6: '금',
7: '토'
}

View File

@@ -0,0 +1 @@
export type SchedulePopoverMode = 'list' | 'create' | 'detail' | 'update';

View File

@@ -0,0 +1,6 @@
export type ScheduleStatus = 'yet' | 'completed';
export const ScheduleStatusLabel: Record<ScheduleStatus, string> = {
'yet': '미완료',
'completed': '완료'
};

View File

@@ -0,0 +1,9 @@
export type ScheduleType = 'once' | 'daily' | 'weekly' | 'monthly' | 'annual';
export const ScheduleTypeLabel: Record<ScheduleType, string> = {
'once': '반복없음',
'daily': '매일',
'weekly': '매주',
'monthly': '매월',
'annual': '매년'
};

View File

@@ -1,4 +1,3 @@
export type AuthData = {
accessToken: string;
refreshToken: string;
}

View 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!@#$%^]+$/, "비밀번호는 영소문자로 시작하여 숫자, 특수문자(!@#$)를 한 개 이상 포함하여야 합니다.")
});

View 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: "비밀번호가 일치하지 않습니다."
});

View 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: "비밀번호가 일치하지 않습니다."
});

View File

@@ -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';

View File

@@ -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])(?=.*[!@#$]).*$/, "비밀번호는 영소문자로 시작하여 숫자, 특수문자(!@#$)를 한 개 이상 포함하여야 합니다.")
});

View File

@@ -1,6 +0,0 @@
import * as z from 'zod';
export const ResetPasswordSchema = z.object({
email: z
.email()
});

View 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()
});

View 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()
});

View 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()
});

View File

@@ -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: "비밀번호가 일치하지 않습니다."
});

View File

@@ -0,0 +1,4 @@
export class ResetPasswordRequest {
email!: string;
password!: string;
}

View File

@@ -0,0 +1,3 @@
export class SendResetPasswordCodeRequest {
email!: string;
}

View File

@@ -0,0 +1,4 @@
export class VerifyResetPasswordCodeRequest {
email!: string;
code!: string;
}

View File

@@ -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';

View 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;
}
}

View File

@@ -0,0 +1,7 @@
export class DeleteScheduleRequest {
id: string;
constructor(id: string) {
this.id = id;
}
}

View 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;
}

View 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;
}
}

View File

@@ -1,4 +1,5 @@
export class BaseResponse {
success!: boolean;
message?: string;
error?: string;
}

View File

@@ -1,7 +1,5 @@
import { BaseResponse } from "../BaseResponse";
export class LoginResponse extends BaseResponse {
success!: boolean;
accessToken?: string;
refreshToken?: string;
}

View File

@@ -2,5 +2,5 @@ import { BaseResponse } from "../BaseResponse";
export class RefreshAccessTokenResponse extends BaseResponse {
accessToken!: string;
refreshToken!: string;
refreshToken?: string;
}

View File

@@ -0,0 +1,5 @@
import { BaseResponse } from "../BaseResponse";
export class ResetPasswordResponse extends BaseResponse {
}

View File

@@ -0,0 +1,5 @@
import { BaseResponse } from "../BaseResponse";
export class SendResetPasswordCodeResponse extends BaseResponse {
}

View File

@@ -1,5 +1,5 @@
import { BaseResponse } from "../BaseResponse";
export class SendVerificationCodeResponse extends BaseResponse {
success!: boolean;
}

View File

@@ -1,6 +1,5 @@
import { BaseResponse } from "../BaseResponse";
export class SignupResponse extends BaseResponse {
success!: boolean;
}

View File

@@ -0,0 +1,5 @@
import { BaseResponse } from "../BaseResponse";
export class VerifyResetPasswordCodeResponse extends BaseResponse {
verified!: boolean;
}

View File

@@ -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';

View File

@@ -0,0 +1,3 @@
import { BaseResponse } from "../BaseResponse";
export class CreateScheduleResponse extends BaseResponse {}

View 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;
}

View 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[];
}

View File

@@ -0,0 +1,3 @@
import { BaseResponse } from "../BaseResponse";
export class UpdateScheduleResponse extends BaseResponse {}

57
src/hooks/use-palette.ts Normal file
View 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
View 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
View 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
View 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;

View File

@@ -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!;
}

View File

@@ -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>
</>

View File

@@ -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
);
}
}

View File

@@ -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
});
}
}

View 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
);
}
}

View File

@@ -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)
}
)
);

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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>
)
}

View 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"
),
}

View File

@@ -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();
}
})

View 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";

View 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;
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -0,0 +1,11 @@
import { Popover, PopoverTrigger } from "@/components/ui/popover"
export const FilterPopover = () => {
return (
<Popover>
<PopoverTrigger asChild>
</PopoverTrigger>
</Popover>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -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"

View 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>
)
}

View 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>
);
}

View File

@@ -0,0 +1,7 @@
export const HomePage = () => {
return (
<div className="w-full h-full flex flex-column">
HomePage
</div>
)
};

View 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 />);
}

View File

@@ -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>
)
}

View 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>
)
}

View File

@@ -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
View 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);
}
}

View File

@@ -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
View 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"
]
}
}
}
}

View File

@@ -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()