ㄱㄱ
This commit is contained in:
114
src/app/NavBar.tsx
Normal file
114
src/app/NavBar.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
'use client';
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import MainLogoSvg from "./svgs/mainlogosvg";
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ label: "교육 과정 목록", href: "/menu" },
|
||||
{ label: "학습 자료실", href: "/resources" },
|
||||
{ label: "공지사항", href: "/notices" },
|
||||
];
|
||||
|
||||
export default function NavBar() {
|
||||
const pathname = usePathname();
|
||||
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
|
||||
const userMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const userButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isUserMenuOpen) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
const t = e.target as Node;
|
||||
if (
|
||||
userMenuRef.current &&
|
||||
!userMenuRef.current.contains(t) &&
|
||||
userButtonRef.current &&
|
||||
!userButtonRef.current.contains(t)
|
||||
) {
|
||||
setIsUserMenuOpen(false);
|
||||
}
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setIsUserMenuOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onDown);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDown);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [isUserMenuOpen]);
|
||||
|
||||
return (
|
||||
<header className="bg-[#060958] h-20">
|
||||
<div className="mx-auto flex h-full w-full max-w-[1440px] items-center justify-between px-8">
|
||||
<div className="flex flex-1 items-center gap-9">
|
||||
<Link href="/" aria-label="XR LMS 홈" className="flex items-center gap-2">
|
||||
<MainLogoSvg width={46.703} height={36} />
|
||||
<span className="text-2xl font-extrabold leading-[1.45] text-white">XR LMS</span>
|
||||
</Link>
|
||||
<nav className="flex h-full items-center">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const isActive = pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={[
|
||||
"px-4 py-2 text-[16px] font-semibold",
|
||||
isActive ? "text-white" : "text-white/80 hover:text-white",
|
||||
].join(" ")}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
<div className="relative flex items-center gap-2">
|
||||
<Link href="/my-courses" className="px-4 py-2 text-[16px] font-semibold text-white hover:text-white">
|
||||
내 강좌실
|
||||
</Link>
|
||||
<button
|
||||
ref={userButtonRef}
|
||||
type="button"
|
||||
onClick={() => setIsUserMenuOpen((v) => !v)}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={isUserMenuOpen}
|
||||
className="flex items-center gap-1 px-4 py-2 text-[16px] font-semibold text-white"
|
||||
>
|
||||
김이름
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden className="-rotate-90">
|
||||
<path d="M8 5l8 7-8 7" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
{isUserMenuOpen && (
|
||||
<div
|
||||
ref={userMenuRef}
|
||||
role="menu"
|
||||
aria-label="사용자 메뉴"
|
||||
className="absolute right-0 top-full mt-2 bg-white rounded-lg shadow-[0_0_8px_0_rgba(0,0,0,0.25)] p-3 z-50"
|
||||
>
|
||||
<button
|
||||
role="menuitem"
|
||||
className="w-full h-10 px-2 rounded-lg text-left text-[#333C47] text-[16px] font-medium leading-normal hover:bg-[rgba(236,240,255,0.5)] focus:bg-[rgba(236,240,255,0.5)] outline-none"
|
||||
>
|
||||
내 정보 수정
|
||||
</button>
|
||||
<button
|
||||
role="menuitem"
|
||||
className="w-full h-10 px-2 rounded-lg text-left text-[#333C47] text-[16px] font-medium leading-normal hover:bg-[rgba(236,240,255,0.5)] focus:bg-[rgba(236,240,255,0.5)] outline-none"
|
||||
>
|
||||
로그아웃
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
|
||||
type FindIdDevOptionProps = {
|
||||
doneEnabled?: boolean;
|
||||
setDoneEnabled?: (enabled: boolean) => void;
|
||||
failedEnabled?: boolean;
|
||||
setFailedEnabled?: (enabled: boolean) => void;
|
||||
};
|
||||
|
||||
export default function FindIdDevOption({
|
||||
doneEnabled,
|
||||
setDoneEnabled,
|
||||
failedEnabled,
|
||||
setFailedEnabled,
|
||||
}: FindIdDevOptionProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={`fixed bottom-2 right-2 bg-red-400 cursor-pointer rounded-full w-[40px] h-[40px] shadow-xl z-100`}
|
||||
>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="fixed inset-0 flex items-center justify-center z-50">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="옵션 닫기"
|
||||
className="absolute inset-0 bg-black/20 cursor-default"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
<div className="w-[500px] h-[600px] flex bg-white/80 p-10 border rounded-lg relative">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="옵션 닫기"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="absolute top-3 right-3 w-8 h-8 rounded-full bg-gray-200 hover:bg-gray-300 text-gray-700 flex items-center justify-center"
|
||||
>
|
||||
<span className="text-sm">✕</span>
|
||||
</button>
|
||||
<ul className="flex flex-col gap-4">
|
||||
<li className="flex items-center justify-between">
|
||||
<p className="mr-4">find-id done overlay</p>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="find-id done overlay 토글"
|
||||
aria-pressed={!!doneEnabled}
|
||||
onClick={() => setDoneEnabled?.(!doneEnabled)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${doneEnabled ? "bg-blue-600" : "bg-gray-300"}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-5 w-5 transform rounded-full bg-white transition ${doneEnabled ? "translate-x-5" : "translate-x-1"}`}
|
||||
/>
|
||||
</button>
|
||||
</li>
|
||||
<li className="flex items-center justify-between">
|
||||
<p className="mr-4">find-id failed overlay</p>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="find-id failed overlay 토글"
|
||||
aria-pressed={!!failedEnabled}
|
||||
onClick={() => setFailedEnabled?.(!failedEnabled)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${failedEnabled ? "bg-blue-600" : "bg-gray-300"}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-5 w-5 transform rounded-full bg-white transition ${failedEnabled ? "translate-x-5" : "translate-x-1"}`}
|
||||
/>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,131 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo, useState } from "react";
|
||||
import LoginInputSvg from "@/app/svgs/inputformx";
|
||||
import Link from "next/link";
|
||||
import React, { useState } from "react";
|
||||
|
||||
type FindIdOptionProps = {
|
||||
onOpenDone: (userId?: string) => void;
|
||||
onOpenFailed: () => void;
|
||||
type FindIdDevOptionProps = {
|
||||
doneEnabled?: boolean;
|
||||
setDoneEnabled?: (enabled: boolean) => void;
|
||||
failedEnabled?: boolean;
|
||||
setFailedEnabled?: (enabled: boolean) => void;
|
||||
};
|
||||
|
||||
export default function FindIdOption({ onOpenDone, onOpenFailed }: FindIdOptionProps) {
|
||||
const [name, setName] = useState("");
|
||||
const [phone, setPhone] = useState("");
|
||||
const [focused, setFocused] = useState<Record<string, boolean>>({});
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const isNameValid = useMemo(() => name.trim().length > 0, [name]);
|
||||
const isPhoneValid = useMemo(() => /^\d{9,11}$/.test(phone), [phone]);
|
||||
const canSubmit = useMemo(() => isNameValid && isPhoneValid, [isNameValid, isPhoneValid]);
|
||||
|
||||
function validateAll() {
|
||||
const next: Record<string, string> = {};
|
||||
if (!isNameValid) next.name = "이름을 입력해 주세요.";
|
||||
if (!isPhoneValid) next.phone = "휴대폰 번호를 숫자만 9~11자리로 입력해 주세요.";
|
||||
setErrors(next);
|
||||
return Object.keys(next).length === 0;
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
if (!validateAll()) return;
|
||||
const mockUserId = `${name.trim()}@example.com`;
|
||||
onOpenDone(mockUserId);
|
||||
}
|
||||
export default function FindIdDevOption({
|
||||
doneEnabled,
|
||||
setDoneEnabled,
|
||||
failedEnabled,
|
||||
setFailedEnabled,
|
||||
}: FindIdDevOptionProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl bg-white max-w-[560px] px-[40px] w-full relative">
|
||||
<Link
|
||||
href="/login"
|
||||
aria-label="닫기"
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={`fixed bottom-2 right-2 bg-red-400 cursor-pointer rounded-full w-[40px] h-[40px] shadow-xl z-100`}
|
||||
>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="fixed inset-0 flex items-center justify-center z-50">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="옵션 닫기"
|
||||
className="absolute inset-0 bg-black/20 cursor-default"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
<div className="w-[500px] h-[600px] flex bg-white/80 p-10 border rounded-lg relative">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="옵션 닫기"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="absolute top-3 right-3 w-8 h-8 rounded-full bg-gray-200 hover:bg-gray-300 text-gray-700 flex items-center justify-center"
|
||||
>
|
||||
<span className="text-sm">✕</span>
|
||||
</Link>
|
||||
<div className="my-15 flex flex-col items-center">
|
||||
<div className="text-[24px] font-extrabold leading-[150%] text-neutral-700">
|
||||
아이디 찾기
|
||||
</div>
|
||||
<p className="text-[18px] leading-[150%] text-[#6c7682] mt-[8px] text-center">
|
||||
가입 시 등록한 회원정보를 입력해 주세요.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="name" className="text-[15px] font-semibold text-[#6c7682]">
|
||||
이름
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onFocus={() => setFocused((p) => ({ ...p, name: true }))}
|
||||
onBlur={() => setFocused((p) => ({ ...p, name: false }))}
|
||||
placeholder="이름을 입력해 주세요."
|
||||
className="h-[40px] px-[12px] py-[7px] w-full rounded-[8px] border border-neutral-40 focus:outline-none focus:border-neutral-700 text-[18px] text-neutral-700 placeholder:text-input-placeholder-text pr-[40px]"
|
||||
/>
|
||||
{name.trim().length > 0 && focused.name && (
|
||||
</button>
|
||||
<ul className="flex flex-col gap-4">
|
||||
<li className="flex items-center justify-between">
|
||||
<p className="mr-4">find-id done overlay</p>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
setName("");
|
||||
}}
|
||||
aria-label="입력 지우기"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 cursor-pointer">
|
||||
<LoginInputSvg />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{errors.name && <p className="text-error text-[13px] leading-tight">{errors.name}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="phone" className="text-[15px] font-semibold text-[#6c7682]">
|
||||
휴대폰 번호
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="phone"
|
||||
name="phone"
|
||||
type="tel"
|
||||
inputMode="numeric"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value.replace(/[^0-9]/g, ""))}
|
||||
onFocus={() => setFocused((p) => ({ ...p, phone: true }))}
|
||||
onBlur={() => setFocused((p) => ({ ...p, phone: false }))}
|
||||
placeholder="-없이 입력해 주세요."
|
||||
className="h-[40px] px-[12px] py-[7px] w-full rounded-[8px] border border-neutral-40 focus:outline-none focus:border-neutral-700 text-[18px] text-neutral-700 placeholder:text-input-placeholder-text pr-[40px]"
|
||||
aria-label="find-id done overlay 토글"
|
||||
aria-pressed={!!doneEnabled}
|
||||
onClick={() => setDoneEnabled?.(!doneEnabled)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${doneEnabled ? "bg-blue-600" : "bg-gray-300"}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-5 w-5 transform rounded-full bg-white transition ${doneEnabled ? "translate-x-5" : "translate-x-1"}`}
|
||||
/>
|
||||
{phone.trim().length > 0 && focused.phone && (
|
||||
</button>
|
||||
</li>
|
||||
<li className="flex items-center justify-between">
|
||||
<p className="mr-4">find-id failed overlay</p>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
setPhone("");
|
||||
}}
|
||||
aria-label="입력 지우기"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 cursor-pointer">
|
||||
<LoginInputSvg />
|
||||
aria-label="find-id failed overlay 토글"
|
||||
aria-pressed={!!failedEnabled}
|
||||
onClick={() => setFailedEnabled?.(!failedEnabled)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${failedEnabled ? "bg-blue-600" : "bg-gray-300"}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-5 w-5 transform rounded-full bg-white transition ${failedEnabled ? "translate-x-5" : "translate-x-1"}`}
|
||||
/>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{errors.phone && <p className="text-error text-[13px] leading-tight">{errors.phone}</p>}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className={`h-[40px] w-full rounded-[12px] text-[18px] font-semibold text-white ${canSubmit ? "bg-active-button" : "bg-inactive-button"} cursor-pointer`}>
|
||||
다음
|
||||
</button>
|
||||
|
||||
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,15 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import IdFindDone from "./IdFindDone";
|
||||
import IdFindFailed from "./IdFindFailed";
|
||||
import FindIdOption from "./FindIdOption";
|
||||
import FindIdDevOption from "./FindIdDevOption";
|
||||
import LoginInputSvg from "@/app/svgs/inputformx";
|
||||
|
||||
export default function FindIdPage() {
|
||||
const [isDoneOpen, setIsDoneOpen] = useState(false);
|
||||
const [isFailedOpen, setIsFailedOpen] = useState(false);
|
||||
const [foundUserId, setFoundUserId] = useState<string | undefined>(undefined);
|
||||
const [name, setName] = useState("");
|
||||
const [phone, setPhone] = useState("");
|
||||
const [focused, setFocused] = useState<Record<string, boolean>>({});
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const isNameValid = useMemo(() => name.trim().length > 0, [name]);
|
||||
const isPhoneValid = useMemo(() => /^\d{9,11}$/.test(phone), [phone]);
|
||||
const canSubmit = useMemo(() => isNameValid && isPhoneValid, [isNameValid, isPhoneValid]);
|
||||
|
||||
function validateAll() {
|
||||
const next: Record<string, string> = {};
|
||||
if (!isNameValid) next.name = "이름을 입력해 주세요.";
|
||||
if (!isPhoneValid) next.phone = "휴대폰 번호를 숫자만 9~11자리로 입력해 주세요.";
|
||||
setErrors(next);
|
||||
return Object.keys(next).length === 0;
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
if (!validateAll()) return;
|
||||
const mockUserId = `${name.trim()}@example.com`;
|
||||
setFoundUserId(mockUserId);
|
||||
setIsDoneOpen(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen w-full flex flex-col items-center justify-between">
|
||||
@@ -23,17 +47,93 @@ export default function FindIdPage() {
|
||||
onClose={() => setIsFailedOpen(false)}
|
||||
/>
|
||||
|
||||
<FindIdOption
|
||||
onOpenDone={(id) => {
|
||||
setFoundUserId(id);
|
||||
setIsDoneOpen(true);
|
||||
}}
|
||||
onOpenFailed={() => {
|
||||
setIsFailedOpen(true);
|
||||
}}
|
||||
/>
|
||||
<div className="rounded-xl bg-white max-w-[560px] px-[40px] w-full relative">
|
||||
|
||||
<FindIdDevOption
|
||||
<div className="my-15 flex flex-col items-center">
|
||||
<div className="text-[24px] font-extrabold leading-[150%] text-neutral-700">
|
||||
아이디 찾기
|
||||
</div>
|
||||
<p className="text-[18px] leading-[150%] text-[#6c7682] mt-[8px] text-center">
|
||||
가입 시 등록한 회원정보를 입력해 주세요.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="name" className="text-[15px] font-semibold text-[#6c7682]">
|
||||
이름
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onFocus={() => setFocused((p) => ({ ...p, name: true }))}
|
||||
onBlur={() => setFocused((p) => ({ ...p, name: false }))}
|
||||
placeholder="이름을 입력해 주세요."
|
||||
className="h-[40px] px-[12px] py-[7px] w-full rounded-[8px] border border-neutral-40 focus:outline-none focus:border-neutral-700 text-[18px] text-neutral-700 placeholder:text-input-placeholder-text pr-[40px]"
|
||||
/>
|
||||
{name.trim().length > 0 && focused.name && (
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
setName("");
|
||||
}}
|
||||
aria-label="입력 지우기"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 cursor-pointer">
|
||||
<LoginInputSvg />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{errors.name && <p className="text-error text-[13px] leading-tight">{errors.name}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="phone" className="text-[15px] font-semibold text-[#6c7682]">
|
||||
휴대폰 번호
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="phone"
|
||||
name="phone"
|
||||
type="tel"
|
||||
inputMode="numeric"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value.replace(/[^0-9]/g, ""))}
|
||||
onFocus={() => setFocused((p) => ({ ...p, phone: true }))}
|
||||
onBlur={() => setFocused((p) => ({ ...p, phone: false }))}
|
||||
placeholder="-없이 입력해 주세요."
|
||||
className="h-[40px] px-[12px] py-[7px] w-full rounded-[8px] border border-neutral-40 focus:outline-none focus:border-neutral-700 text-[18px] text-neutral-700 placeholder:text-input-placeholder-text pr-[40px]"
|
||||
/>
|
||||
{phone.trim().length > 0 && focused.phone && (
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
setPhone("");
|
||||
}}
|
||||
aria-label="입력 지우기"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 cursor-pointer">
|
||||
<LoginInputSvg />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{errors.phone && <p className="text-error text-[13px] leading-tight">{errors.phone}</p>}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className={`h-[40px] w-full rounded-[12px] text-[18px] font-semibold text-white ${canSubmit ? "bg-active-button" : "bg-inactive-button"} cursor-pointer`}>
|
||||
다음
|
||||
</button>
|
||||
|
||||
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<FindIdOption
|
||||
doneEnabled={isDoneOpen}
|
||||
setDoneEnabled={setIsDoneOpen}
|
||||
failedEnabled={isFailedOpen}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
import { pretendard } from "./fonts";
|
||||
import NavBar from "./NavBar";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "XRLMS",
|
||||
@@ -13,6 +14,7 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="ko">
|
||||
<body className={pretendard.className}>
|
||||
<NavBar />
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
149
src/app/menu/ChangePasswordModal.tsx
Normal file
149
src/app/menu/ChangePasswordModal.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit?: (payload: { email: string; code: string; newPassword: string }) => void;
|
||||
};
|
||||
|
||||
export default function ChangePasswordModal({ open, onClose, onSubmit }: Props) {
|
||||
const [email, setEmail] = useState("xrlms2025@gmail.com");
|
||||
const [code, setCode] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null); // 인증번호 오류 등
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const handleSubmit = () => {
|
||||
setError(null);
|
||||
if (!code) {
|
||||
setError("인증번호를 입력해 주세요.");
|
||||
return;
|
||||
}
|
||||
if (!newPassword || !confirmPassword) {
|
||||
setError("새 비밀번호를 입력해 주세요.");
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError("새 비밀번호가 일치하지 않습니다.");
|
||||
return;
|
||||
}
|
||||
onSubmit?.({ email, code, newPassword });
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-[2px]"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div className="w-[480px] rounded-[12px] border border-[#dee1e6] bg-white shadow-[0_10px_30px_rgba(0,0,0,0.06)]">
|
||||
{/* header */}
|
||||
<div className="flex items-center justify-between p-6">
|
||||
<h2 className="text-[20px] font-bold leading-[1.5] text-[#333c47]">비밀번호 변경</h2>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="닫기"
|
||||
onClick={onClose}
|
||||
className="inline-flex size-6 items-center justify-center text-[#333c47] hover:opacity-80"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="size-[15.2px]">
|
||||
<path fillRule="evenodd" d="M6.225 4.811a1 1 0 0 1 1.414 0L12 9.172l4.361-4.361a1 1 0 1 1 1.414 1.414L13.414 10.586l4.361 4.361a1 1 0 0 1-1.414 1.414L12 12l-4.361 4.361a1 1 0 1 1-1.414-1.414l4.361-4.361-4.361-4.361a1 1 0 0 1 0-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* body */}
|
||||
<div className="flex flex-col gap-[10px] px-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="w-[100px] text-[15px] font-semibold leading-[1.5] text-[#6c7682]">아이디 (이메일)</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="h-10 flex-1 rounded-[8px] border border-[#dee1e6] bg-white px-3 text-[16px] leading-[1.5] text-[#333c47] placeholder-[#b1b8c0] outline-none"
|
||||
placeholder="이메일"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="h-10 w-[136px] rounded-[8px] bg-[#f1f3f5] px-3 text-[16px] font-semibold leading-[1.5] text-[#333c47]"
|
||||
>
|
||||
인증번호 재전송
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="w-[100px] text-[15px] font-semibold leading-[1.5] text-[#6c7682]">인증번호</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
className="h-10 flex-1 rounded-[8px] border border-[#dee1e6] bg-white px-3 text-[16px] leading-[1.5] text-[#333c47] placeholder-[#b1b8c0] outline-none"
|
||||
placeholder="인증번호 6자리"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="h-10 w-[136px] rounded-[8px] bg-[#f1f3f5] px-3 text-[16px] font-semibold leading-[1.5] text-[#4c5561]"
|
||||
>
|
||||
인증번호 확인
|
||||
</button>
|
||||
</div>
|
||||
{error ? (
|
||||
<p className="px-1 text-[13px] font-semibold leading-[1.4] text-[#f64c4c]">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-[15px] font-semibold leading-[1.5] text-[#6c7682]">새 비밀번호</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
className="h-10 rounded-[8px] border border-[#dee1e6] bg-neutral-50 px-3 text-[16px] leading-[1.5] text-[#333c47] placeholder-[#b1b8c0] outline-none"
|
||||
placeholder="새 비밀번호"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-[15px] font-semibold leading-[1.5] text-[#6c7682]">
|
||||
새 비밀번호 확인
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="h-10 rounded-[8px] border border-[#dee1e6] bg-neutral-50 px-3 text-[16px] leading-[1.5] text-[#333c47] placeholder-[#b1b8c0] outline-none"
|
||||
placeholder="새 비밀번호 확인"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* footer */}
|
||||
<div className="flex items-center justify-center gap-3 p-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="h-12 w-[136px] rounded-[10px] bg-[#f1f3f5] px-4 text-[16px] font-semibold leading-[1.5] text-[#4c5561]"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
className="h-12 w-[136px] rounded-[10px] bg-[#8598e8] px-4 text-[16px] font-semibold leading-[1.5] text-white"
|
||||
>
|
||||
비밀번호 변경
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
70
src/app/menu/MenuSidebar.tsx
Normal file
70
src/app/menu/MenuSidebar.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
'use client';
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
type NavItem = {
|
||||
label: string;
|
||||
href: string;
|
||||
};
|
||||
|
||||
const learningItems: NavItem[] = [
|
||||
{ label: "내 강좌실", href: "/menu/courses" },
|
||||
{ label: "학습 결과", href: "/menu/results" },
|
||||
];
|
||||
|
||||
const accountItems: NavItem[] = [
|
||||
{ label: "내 정보 수정", href: "/menu/account" },
|
||||
{ label: "로그아웃", href: "/logout" },
|
||||
];
|
||||
|
||||
export default function MenuSidebar() {
|
||||
const pathname = usePathname();
|
||||
|
||||
const renderItem = (item: NavItem) => {
|
||||
const isActive =
|
||||
pathname === item.href || (item.href !== "/logout" && pathname.startsWith(item.href));
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={[
|
||||
"flex h-12 items-center gap-2 rounded-lg px-2",
|
||||
isActive ? "bg-[rgba(236,240,255,0.5)]" : "",
|
||||
].join(" ")}
|
||||
>
|
||||
<span
|
||||
className={[
|
||||
"text-[16px] leading-[1.5]",
|
||||
isActive ? "font-bold text-[#1f2b91]" : "font-medium text-[#333c47]",
|
||||
].join(" ")}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="flex w-full flex-col gap-6">
|
||||
<div className="flex w-full flex-col gap-10">
|
||||
<div className="w-[288px]">
|
||||
<div className="px-2 pb-2">
|
||||
<span className="text-[13px] font-normal leading-[1.4] text-[#6c7682]">
|
||||
강좌 및 학습
|
||||
</span>
|
||||
</div>
|
||||
<div>{learningItems.map(renderItem)}</div>
|
||||
</div>
|
||||
<div className="w-[288px]">
|
||||
<div className="px-2 pb-2">
|
||||
<span className="text-[13px] font-normal leading-[1.4] text-[#6c7682]">계정</span>
|
||||
</div>
|
||||
<div>{accountItems.map(renderItem)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
60
src/app/menu/account/page.tsx
Normal file
60
src/app/menu/account/page.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from "react";
|
||||
import ChangePasswordModal from "../ChangePasswordModal";
|
||||
|
||||
export default function AccountPage() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<main className="flex w-full flex-col">
|
||||
<div className="flex h-[100px] items-center px-8">
|
||||
<h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]">내 정보 수정</h1>
|
||||
</div>
|
||||
<div className="px-8 pb-20">
|
||||
<div className="rounded-lg border border-[#dee1e6] bg-white p-8">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="w-[100px] text-[15px] font-semibold leading-[1.5] text-[#6c7682]">
|
||||
아이디 (이메일)
|
||||
</label>
|
||||
<div className="h-10 rounded-lg border border-[#dee1e6] bg-neutral-50 px-3 py-2">
|
||||
<span className="text-[16px] leading-[1.5] text-[#333c47]">skyblue@edu.com</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex flex-col gap-2">
|
||||
<label className="w-[100px] text-[15px] font-semibold leading-[1.5] text-[#6c7682]">
|
||||
비밀번호 변경
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 flex-1 rounded-lg border border-[#dee1e6] bg-neutral-50 px-3 py-2">
|
||||
<span className="text-[16px] leading-[1.5] text-[#333c47]">●●●●●●●●●●</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="h-10 rounded-lg bg-[#f1f3f5] px-4 text-[16px] font-semibold leading-[1.5] text-[#4c5561]"
|
||||
>
|
||||
비밀번호 변경
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<button className="text-[15px] font-medium leading-[1.5] text-[#f64c4c] underline">
|
||||
회원 탈퇴하기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ChangePasswordModal
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
onSubmit={() => {
|
||||
// TODO: integrate API
|
||||
}}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
14
src/app/menu/courses/page.tsx
Normal file
14
src/app/menu/courses/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
export default function CoursesPage() {
|
||||
return (
|
||||
<main className="flex w-full flex-col">
|
||||
<div className="flex h-[100px] items-center px-8">
|
||||
<h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]">내 강좌실</h1>
|
||||
</div>
|
||||
<div className="px-8 pb-20">
|
||||
<p className="text-[16px] leading-[1.5] text-[#4c5561]">콘텐츠 준비 중입니다.</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
15
src/app/menu/layout.tsx
Normal file
15
src/app/menu/layout.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { ReactNode } from "react";
|
||||
import MenuSidebar from "./MenuSidebar";
|
||||
|
||||
export default function MenuLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-[1440px]">
|
||||
<aside className="w-[320px] border-r border-[#dee1e6] px-4 py-6">
|
||||
<MenuSidebar />
|
||||
</aside>
|
||||
<section className="flex-1">{children}</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
12
src/app/menu/page.tsx
Normal file
12
src/app/menu/page.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
export default function MenuPage() {
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-[1440px] px-8 py-8">
|
||||
<h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]">교육 과정 목록</h1>
|
||||
<p className="mt-6 text-[16px] leading-[1.5] text-[#4c5561]">
|
||||
메뉴 페이지 준비 중입니다.
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
14
src/app/menu/results/page.tsx
Normal file
14
src/app/menu/results/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
export default function ResultsPage() {
|
||||
return (
|
||||
<main className="flex w-full flex-col">
|
||||
<div className="flex h-[100px] items-center px-8">
|
||||
<h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]">학습 결과</h1>
|
||||
</div>
|
||||
<div className="px-8 pb-20">
|
||||
<p className="text-[16px] leading-[1.5] text-[#4c5561]">콘텐츠 준비 중입니다.</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
378
src/app/page.tsx
378
src/app/page.tsx
@@ -1,7 +1,379 @@
|
||||
export default function Home() {
|
||||
return (
|
||||
<div>
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import MainLogoSvg from './svgs/mainlogosvg';
|
||||
|
||||
export default function Home() {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
|
||||
// 코스, 공지사항 더미 데이터
|
||||
const courseCards = useMemo(
|
||||
() =>
|
||||
[
|
||||
{
|
||||
id: 'c1',
|
||||
title: '원자력 운영 기초',
|
||||
meta: 'VOD • 초급 • 4시간 20분',
|
||||
image:
|
||||
'https://images.unsplash.com/photo-1555949963-ff9fe0c870eb?q=80&w=1200&auto=format&fit=crop',
|
||||
},
|
||||
{
|
||||
id: 'c2',
|
||||
title: '반도체',
|
||||
meta: 'VOD • 중급 • 3시간 10분',
|
||||
image:
|
||||
'https://images.unsplash.com/photo-1581092921461-eab62e97a780?q=80&w=1200&auto=format&fit=crop',
|
||||
},
|
||||
{
|
||||
id: 'c3',
|
||||
title: '방사선 안전',
|
||||
meta: 'VOD • 중급 • 4시간 20분',
|
||||
image:
|
||||
'https://images.unsplash.com/photo-1581090464777-f3220bbe1b8b?q=80&w=1200&auto=format&fit=crop',
|
||||
},
|
||||
{
|
||||
id: 'c4',
|
||||
title: '방사선 폐기물',
|
||||
meta: 'VOD • 중급 • 4시간 20분',
|
||||
image:
|
||||
'https://images.unsplash.com/photo-1581091220351-5a6a4e6f22c1?q=80&w=1200&auto=format&fit=crop',
|
||||
},
|
||||
{
|
||||
id: 'c5',
|
||||
title: '원자력 운전 개론',
|
||||
meta: 'VOD • 초급 • 3시간 00분',
|
||||
image:
|
||||
'https://images.unsplash.com/photo-1581090463520-5d09f3c456d2?q=80&w=1200&auto=format&fit=crop',
|
||||
},
|
||||
{
|
||||
id: 'c6',
|
||||
title: '안전 표지와 표준',
|
||||
meta: 'VOD • 초급 • 2시간 40분',
|
||||
image:
|
||||
'https://images.unsplash.com/photo-1470167290877-7d5d3446de4c?q=80&w=1200&auto=format&fit=crop',
|
||||
},
|
||||
{
|
||||
id: 'c7',
|
||||
title: '발전소 운영',
|
||||
meta: 'VOD • 중급 • 4시간 20분',
|
||||
image:
|
||||
'https://images.unsplash.com/photo-1581092160562-40aa08e78837?q=80&w=1200&auto=format&fit=crop',
|
||||
},
|
||||
{
|
||||
id: 'c8',
|
||||
title: '방사선 안전 실습',
|
||||
meta: 'VOD • 중급 • 3시간 30분',
|
||||
image:
|
||||
'https://images.unsplash.com/photo-1581093458791-9d181f5842fd?q=80&w=1200&auto=format&fit=crop',
|
||||
},
|
||||
{
|
||||
id: 'c9',
|
||||
title: '실험실 안전',
|
||||
meta: 'VOD • 초급 • 2시간 10분',
|
||||
image:
|
||||
'https://images.unsplash.com/photo-1559757175-08c6d5b3f4b4?q=80&w=1200&auto=format&fit=crop',
|
||||
},
|
||||
{
|
||||
id: 'c10',
|
||||
title: '기초 장비 운용',
|
||||
meta: 'VOD • 초급 • 2시간 50분',
|
||||
image:
|
||||
'https://images.unsplash.com/photo-1581092338398-16e5b28a2b13?q=80&w=1200&auto=format&fit=crop',
|
||||
},
|
||||
] as Array<{ id: string; title: string; meta: string; image: string }>,
|
||||
[]
|
||||
);
|
||||
const noticeRows = useMemo(
|
||||
() =>
|
||||
[
|
||||
{ id: 5, title: '(공지)시스템 개선이 완료되었...', date: '2025-09-10', views: 1320, writer: '운영팀' },
|
||||
{ id: 4, title: '(공지)서버 점검 안내(9/10 새벽)', date: '2025-09-10', views: 1210, writer: '운영팀' },
|
||||
{ id: 3, title: '(공지)서비스 개선 안내', date: '2025-09-10', views: 1230, writer: '운영팀' },
|
||||
{ id: 2, title: '(공지)시장점검 공지', date: '2025-09-10', views: 1320, writer: '관리자' },
|
||||
{ id: 1, title: '뉴: 봉사시간 안내 및 한눈에 보는 현황 정리', date: '2025-08-28', views: 594, writer: '운영팀' },
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
// NOTE: 실제 이미지 자산 연결 시 해당 src를 교체하세요.
|
||||
const slides = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: 1,
|
||||
title: '시스템 점검 안내',
|
||||
description: '11월 10일 새벽 2시~4시 시스템 점검이 진행됩니다.',
|
||||
imageSrc:
|
||||
'https://images.unsplash.com/photo-1581091226825-a6a2a5aee158?q=80&w=1600&auto=format&fit=crop',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '신규 과정 오픈',
|
||||
description: '최신 커리큘럼으로 업스킬링하세요.',
|
||||
imageSrc:
|
||||
'https://images.unsplash.com/photo-1550602921-a0d9a4d5b1a9?q=80&w=1600&auto=format&fit=crop',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: '수강 이벤트',
|
||||
description: '이번 달 수강 혜택을 확인해보세요.',
|
||||
imageSrc:
|
||||
'https://images.unsplash.com/photo-1545235617-9465d2a55698?q=80&w=1600&auto=format&fit=crop',
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const containerEl = containerRef.current;
|
||||
if (!containerEl) return;
|
||||
|
||||
const handleScroll = () => {
|
||||
const width = containerEl.clientWidth;
|
||||
const index = Math.round(containerEl.scrollLeft / Math.max(width, 1));
|
||||
setCurrentIndex(index);
|
||||
};
|
||||
|
||||
containerEl.addEventListener('scroll', handleScroll, { passive: true });
|
||||
return () => {
|
||||
containerEl.removeEventListener('scroll', handleScroll);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const scrollToIndex = (index: number) => {
|
||||
const containerEl = containerRef.current;
|
||||
if (!containerEl) return;
|
||||
const clamped = Math.max(0, Math.min(index, slides.length - 1));
|
||||
const width = containerEl.clientWidth;
|
||||
containerEl.scrollTo({ left: clamped * width, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const handlePrev = () => scrollToIndex(currentIndex - 1);
|
||||
const handleNext = () => scrollToIndex(currentIndex + 1);
|
||||
|
||||
return (
|
||||
<div className="w-full min-h-screen flex flex-col bg-white">
|
||||
<main className="flex-1">
|
||||
{/* 메인 컨테이너 */}
|
||||
<div className="w-full flex justify-center">
|
||||
<div className="w-full max-w-[1180px] px-3 py-6">
|
||||
{/* 배너 + 사이드 */}
|
||||
<div className="flex gap-6">
|
||||
{/* 배너 */}
|
||||
<section className="flex-1" aria-label="홈 상단 배너">
|
||||
<div className="relative">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex overflow-x-auto snap-x snap-mandatory scroll-smooth rounded-xl bg-[#F1F3F5]"
|
||||
>
|
||||
|
||||
{slides.map((slide) => (
|
||||
<div key={slide.id} className="flex-none w-full h-[360px] relative snap-start overflow-hidden">
|
||||
<img alt={slide.title} src={slide.imageSrc} className="w-full h-full object-cover block" />
|
||||
<div className="absolute left-0 right-0 bottom-0 h-1/2 bg-linear-to-b from-transparent via-black/55 to-black/75" />
|
||||
<div className="absolute left-6 bottom-6 text-white">
|
||||
<div className="font-bold text-[20px] leading-normal mb-1.5">{slide.title}</div>
|
||||
<div className="font-medium text-[14px] leading-normal opacity-95">
|
||||
{slide.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 좌/우 내비게이션 버튼 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePrev}
|
||||
aria-label="이전 배너"
|
||||
disabled={currentIndex <= 0}
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 w-9 h-9 rounded-full bg-white/90 text-[#1B2027] border border-[#DEE1E6] flex items-center justify-center cursor-pointer shadow disabled:opacity-60"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNext}
|
||||
aria-label="다음 배너"
|
||||
disabled={currentIndex >= slides.length - 1}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 w-9 h-9 rounded-full bg-white/90 text-[#1B2027] border border-[#DEE1E6] flex items-center justify-center cursor-pointer shadow disabled:opacity-60"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
|
||||
{/* 인디케이터 */}
|
||||
<div className="absolute left-1/2 bottom-3 -translate-x-1/2 flex gap-1.5" aria-hidden>
|
||||
{slides.map((_, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className={'w-1.5 h-1.5 rounded-full ' + (idx === currentIndex ? 'bg-white' : 'bg-white/50')}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 사이드 패널 (피그마 디자인 적용) */}
|
||||
<aside className="flex-none w-[400px]">
|
||||
<div className="bg-[#F1F3F5] rounded-[12px] overflow-hidden h-[510px]">
|
||||
{/* 상단 환영 및 통계 */}
|
||||
<div className="px-8 py-8">
|
||||
<div className="mb-6">
|
||||
<div className="text-[18px] leading-normal">
|
||||
<span className="font-bold text-[#333C47]">김하늘</span>
|
||||
<span className="font-normal text-[#333C47]">님 환영합니다.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-6">
|
||||
{[
|
||||
{ label: '수강중', value: 0 },
|
||||
{ label: '수강 완료', value: 0 },
|
||||
{ label: '문제 제출', value: 0 },
|
||||
{ label: '수료증 발급', value: 0 },
|
||||
].map((s) => (
|
||||
<div key={s.label} className="w-[64px] flex flex-col items-center justify-center gap-2">
|
||||
<div className="size-16 rounded-full bg-white flex items-center justify-center">
|
||||
<div className="text-[20px] font-bold text-[#333C47] leading-none">{s.value}</div>
|
||||
</div>
|
||||
<div className="text-[15px] font-semibold text-[#333C47] leading-none">{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* 구분선 */}
|
||||
<div className="h-px bg-[#DEE1E6] mx-8" />
|
||||
{/* 최근 수강 내역 */}
|
||||
<div className="px-8 pt-3 pb-6">
|
||||
<div className="h-[60px] w-full flex items-center justify-between">
|
||||
<div className="text-[18px] font-bold text-[#1B2027]">최근 수강 내역</div>
|
||||
<a href="#" className="flex items-center gap-0.5 text-[14px] font-medium text-[#6C7682] no-underline">
|
||||
전체보기
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden>
|
||||
<path d="M8 5l8 7-8 7" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{[
|
||||
{ cat: '원자로 운전 및 계통', title: '6. 원자로 시동, 운전 및 정지 절차' },
|
||||
{ cat: '원자로 운전 및 계통', title: '6. 원자로 시동, 운전 및 정지 절차' },
|
||||
{ cat: '원자로 운전 및 계통', title: '6. 원자로 시동, 운전 및 정지 절차' },
|
||||
].map((r, i) => (
|
||||
<div key={i} className="w-full rounded-[8px] bg-white px-5 py-3">
|
||||
<div className="text-[14px] font-semibold leading-normal">
|
||||
<p className="text-[#6C7682]">{r.cat}</p>
|
||||
<p className="text-[#333C47] whitespace-pre">{r.title}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{/* 교육 과정 */}
|
||||
<section className="mt-8">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<h2 className="m-0 text-[18px] font-bold text-[#333C47]">
|
||||
교육 과정
|
||||
</h2>
|
||||
<span className="text-[#8C95A1] text-[13px]">총 28건</span>
|
||||
</div>
|
||||
<a href="#" className="text-[#8C95A1] text-[13px] no-underline">
|
||||
전체보기 ›
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-5 gap-4">
|
||||
{courseCards.map((c) => (
|
||||
<article key={c.id} className="bg-white border border-[#DEE1E6] rounded-[10px] overflow-hidden">
|
||||
<div className="w-full h-[120px] overflow-hidden">
|
||||
<img alt={c.title} src={c.image} className="w-full h-full object-cover" />
|
||||
</div>
|
||||
<div className="p-2.5">
|
||||
<div className="text-[#333C47] font-semibold text-[14px] leading-[1.35] mb-1.5" title={c.title}>
|
||||
{c.title}
|
||||
</div>
|
||||
<div className="text-[#8C95A1] text-[12px]">{c.meta}</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 공지사항 */}
|
||||
<section className="mt-9">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<h2 className="m-0 text-[16px] font-bold text-[#333C47]">
|
||||
공지사항
|
||||
</h2>
|
||||
<span className="text-[#8C95A1] text-[13px]">총 102건</span>
|
||||
</div>
|
||||
<a href="#" className="text-[#8C95A1] text-[13px] no-underline">
|
||||
전체보기 ›
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-[#DEE1E6] rounded-[10px] overflow-hidden">
|
||||
<div className="grid grid-cols-[80px_1fr_140px_100px_90px] gap-0 bg-[#F9FAFB] text-[#6C7682] text-[13px] border-b border-[#DEE1E6]">
|
||||
{['번호', '제목', '작성일', '조회수', '작성자'].map((h) => (
|
||||
<div key={h} className="py-2.5 px-3">
|
||||
{h}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{noticeRows.map((r, idx) => (
|
||||
<div
|
||||
key={r.id}
|
||||
className={
|
||||
'grid grid-cols-[80px_1fr_140px_100px_90px] gap-0 text-[14px] text-[#4C5561] ' +
|
||||
(idx === noticeRows.length - 1 ? '' : 'border-b border-[#F1F3F5]')
|
||||
}
|
||||
>
|
||||
<div className="py-3 px-3">{r.id}</div>
|
||||
<div className="py-3 px-3 whitespace-nowrap overflow-hidden text-ellipsis" title={r.title}>
|
||||
{r.title}
|
||||
</div>
|
||||
<div className="py-3 px-3">{r.date}</div>
|
||||
<div className="py-3 px-3">{r.views}</div>
|
||||
<div className="py-3 px-3">{r.writer}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* 푸터 */}
|
||||
<footer className="mt-10 bg-[#F1F3F5] border-t border-[#DEE1E6]">
|
||||
<div className="max-w-[1180px] mx-auto px-3 pt-6 pb-10 text-[#4C5561]">
|
||||
<a href="/" aria-label="XR LMS 홈" className="flex items-center gap-3 no-underline">
|
||||
<MainLogoSvg width={46.703} height={36} />
|
||||
<strong className="text-[#333C47]">XL LMS</strong>
|
||||
</a>
|
||||
|
||||
<div className="flex flex-wrap gap-4 mt-3 text-[13px]">
|
||||
<a href="#" className="text-[#4C5561] no-underline">이용 약관</a>
|
||||
<a href="#" className="text-[#4C5561] no-underline">개인정보처리방침</a>
|
||||
<a href="#" className="text-[#4C5561] no-underline">고객센터</a>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-[12px] text-[#8C95A1]">
|
||||
(12345) 서울시 관악구 만덕로 123-12(가우하치) | 전화: 1234-1234 (평일 09:00 ~ 18:00) | 이메일: xper1234@xpl.co.kr
|
||||
</div>
|
||||
<div className="mt-2 text-[12px] text-[#8C95A1]">
|
||||
Copyright © 2025 XL LMS. All rights reserved
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ export default function RegisterDone({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="h-[40px] flex-1 rounded-[12px] bg-[#f1f3f5] text-[18px] font-semibold text-basic-text"
|
||||
className="h-[40px] flex-1 rounded-[12px] bg-[#f1f3f5] text-[18px] font-semibold text-basic-text cursor-pointer"
|
||||
>
|
||||
닫기
|
||||
</button>
|
||||
@@ -69,7 +69,7 @@ export default function RegisterDone({
|
||||
<button
|
||||
type="button"
|
||||
aria-label="닫기"
|
||||
className="absolute inset-0 -z-10 cursor-default"
|
||||
className="absolute inset-0 -z-10 cursor-pointer"
|
||||
onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -32,7 +32,7 @@ export default function RegisterOption({
|
||||
className="absolute inset-0 bg-black/20 cursor-default"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
<div className="relative w-[500px] h-[600px] flex bg-white p-10 border rounded-lg shadow-xl">
|
||||
<div className="relative w-[500px] h-[600px] flex bg-white/80 p-10 border rounded-lg shadow-xl">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="옵션 닫기"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export default function MainLogoSvg() {
|
||||
export default function MainLogoSvg(
|
||||
{ width = 72, height = 54 }: { width?: number | string; height?: number | string }
|
||||
) {
|
||||
return (
|
||||
<svg width="72" height="54" viewBox="0 0 72 54" fill="none" xmlns="http://www.w3.org/2000/svg" xmlnsXlink="http://www.w3.org/1999/xlink">
|
||||
<svg width={width} height={height} viewBox="0 0 72 54" fill="none" xmlns="http://www.w3.org/2000/svg" xmlnsXlink="http://www.w3.org/1999/xlink">
|
||||
<rect width="72" height="54" fill="url(#pattern0_96_9368)"/>
|
||||
<defs>
|
||||
<pattern id="pattern0_96_9368" patternContentUnits="objectBoundingBox" width="1" height="1">
|
||||
|
||||
Reference in New Issue
Block a user