diff --git a/public/images/default-avatar.svg b/public/images/default-avatar.svg
new file mode 100644
index 0000000..32c3bea
--- /dev/null
+++ b/public/images/default-avatar.svg
@@ -0,0 +1,5 @@
+
\ No newline at end of file
diff --git a/public/images/plus-square.svg b/public/images/plus-square.svg
new file mode 100644
index 0000000..c99ec79
--- /dev/null
+++ b/public/images/plus-square.svg
@@ -0,0 +1,5 @@
+
\ No newline at end of file
diff --git a/public/images/upload.svg b/public/images/upload.svg
new file mode 100644
index 0000000..74ec085
--- /dev/null
+++ b/public/images/upload.svg
@@ -0,0 +1,4 @@
+
\ No newline at end of file
diff --git a/src/app/(auth)/layout.js b/src/app/(auth)/layout.js
index 4347ac4..8377608 100644
--- a/src/app/(auth)/layout.js
+++ b/src/app/(auth)/layout.js
@@ -1,7 +1,5 @@
-"use client";
-
import { FullLayout } from "@/components/Layout";
-export default function Layout({ children }) {
+export default async function Layout({ children }) {
return {children};
}
diff --git a/src/app/(landing)/layout.js b/src/app/(landing)/layout.js
index affc3f1..2d950a3 100644
--- a/src/app/(landing)/layout.js
+++ b/src/app/(landing)/layout.js
@@ -1,5 +1,3 @@
-"use client";
-
import { LandingLayout } from "@/components/Layout";
export default function Layout({ children }) {
diff --git a/src/app/(mypage)/layout.js b/src/app/(mypage)/layout.js
index 785b2e6..a349b3b 100644
--- a/src/app/(mypage)/layout.js
+++ b/src/app/(mypage)/layout.js
@@ -1,5 +1,3 @@
-"use client";
-
import { MyPageLayout } from "@/components/Layout";
export default function Layout({ children }) {
diff --git a/src/app/(mypage)/me/edit/page.js b/src/app/(mypage)/me/edit/page.js
new file mode 100644
index 0000000..59e5f0a
--- /dev/null
+++ b/src/app/(mypage)/me/edit/page.js
@@ -0,0 +1,115 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { useRouter } from "next/navigation";
+import Label from "@/components/Label";
+import Input from "@/components/Input";
+import Button from "@/components/Button";
+import TextArea from "@/components/TextArea";
+import AvatarInput from "@/components/AvatarInput";
+import styles from "./page.module.css";
+import { useAuth } from "@/providers/AuthProvider";
+
+export default function EditPage() {
+ const [values, setValues] = useState({
+ avatar: "",
+ name: "",
+ email: "",
+ bio: "",
+ });
+ const { updateUser, user } = useAuth();
+ const router = useRouter();
+
+ useEffect(() => {
+ if (user) {
+ const { avatar, name, email, bio } = user;
+ setValues({
+ avatar: avatar || "",
+ name: name || "",
+ email: email || "",
+ bio: bio || "",
+ });
+ }
+ }, [user]);
+
+ function handleChange(name, value) {
+ setValues((prevValues) => ({
+ ...prevValues,
+ [name]: value,
+ }));
+ }
+
+ function handleInputChange(e) {
+ const { name, value } = e.target;
+ handleChange(name, value);
+ }
+
+ async function handleSubmit(e) {
+ e.preventDefault();
+ try {
+ const formData = new FormData();
+ formData.append("avatar", values.avatar);
+ formData.append("name", values.name);
+ formData.append("email", values.email);
+ formData.append("bio", values.bio);
+
+ await updateUser(formData);
+
+ router.push("/me");
+ } catch (error) {
+ console.error("Error updating profile:", error);
+ }
+ }
+
+ return (
+ <>
+
프로필 편집
+
+ >
+ );
+}
diff --git a/src/app/(mypage)/me/edit/page.module.css b/src/app/(mypage)/me/edit/page.module.css
new file mode 100644
index 0000000..b73dca1
--- /dev/null
+++ b/src/app/(mypage)/me/edit/page.module.css
@@ -0,0 +1,36 @@
+.Heading {
+ font-size: 24px;
+ font-weight: 700;
+ margin: 32px 0;
+}
+
+.Label,
+.Input,
+.TextArea,
+.Button,
+.GoogleButton {
+ width: 100%;
+}
+
+.Label {
+ margin: 0;
+}
+
+.Input {
+ margin: 8px 0 24px;
+}
+
+.TextArea {
+ resize: none;
+}
+
+.Button {
+ font-size: 20px;
+ font-weight: 700;
+ margin: 40px 0 32px;
+}
+
+.Form {
+ max-width: 480px;
+ margin: 0 auto;
+}
diff --git a/src/app/(mypage)/me/page.js b/src/app/(mypage)/me/page.js
index d62aff2..8bc7c25 100644
--- a/src/app/(mypage)/me/page.js
+++ b/src/app/(mypage)/me/page.js
@@ -1,5 +1,98 @@
-import MyPage from "@/components/pages/MyPage";
+"use client";
-export default function Me() {
- return ;
+import { useEffect, useState } from "react";
+import { useRouter } from "next/navigation";
+import styles from "./page.module.css";
+import { userService } from "@/lib/userService";
+import { useAuth } from "@/providers/AuthProvider";
+import Avatar from "@/components/Avatar";
+import Button from "@/components/Button";
+import Card from "@/components/Card";
+import NextLink from "@/components/Link";
+import HorizontalRule from "@/components/HorizontalRule";
+import LinkCard from "@/components/LinkCard";
+import Link from "next/link";
+
+export default function MyPage() {
+ const { user } = useAuth();
+ const [links, setLinks] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const router = useRouter();
+
+ async function getMyLinks() {
+ try {
+ const linksData = await userService.getMyLinks();
+ setLinks(linksData);
+ } catch (error) {
+ console.error("링크 정보 가져오기 실패:", error);
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ function handleEditClick(linkId) {
+ router.push(`/me/links/${linkId}/edit`);
+ }
+
+ async function handleDeleteClick(linkId) {
+ try {
+ await userService.deleteLink(linkId);
+ setLinks((prevLinks) => prevLinks.filter((link) => link.id !== linkId));
+ } catch (error) {
+ console.error("링크 삭제 실패:", error);
+ }
+ }
+
+ useEffect(() => {
+ getMyLinks();
+ }, []);
+
+ if (loading) {
+ return 로딩 중...
;
+ }
+
+ if (!user) {
+ return null;
+ }
+
+ return (
+ <>
+
+
+
+ {links.map((link) => (
+ -
+ handleEditClick(link.id)}
+ onDelete={() => handleDeleteClick(link.id)}
+ />
+
+ ))}
+ -
+
+
+ 링크 추가하기
+
+
+
+ >
+ );
}
diff --git a/src/app/(mypage)/me/page.module.css b/src/app/(mypage)/me/page.module.css
new file mode 100644
index 0000000..7d57b0a
--- /dev/null
+++ b/src/app/(mypage)/me/page.module.css
@@ -0,0 +1,76 @@
+.Header {
+ margin-bottom: 24px;
+}
+
+.Profile {
+ display: flex;
+ align-items: center;
+ margin-bottom: 16px;
+}
+
+.Values {
+ flex: 1;
+ margin: 0 16px;
+}
+
+.Name {
+ font-size: 20px;
+ font-weight: 700;
+ margin-bottom: 8px;
+}
+
+.Email {
+ font-size: 14px;
+ color: #8c8c8c;
+}
+
+.EditButton {
+ flex-shrink: 0;
+}
+
+.Bio {
+ font-size: 16px;
+ color: #333333;
+ margin: 16px 0;
+ line-height: 1.5;
+}
+
+.HorizontalRule {
+ margin: 32px 0;
+}
+
+.LinkList {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
+ gap: 16px;
+}
+
+.LinkItem {
+ position: relative;
+}
+
+.CreateLink {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ height: 120px;
+ background-color: #f5f5f5;
+ border-radius: 8px;
+ border: 2px dashed #d9d9d9;
+ text-decoration: none;
+ color: #7000ff;
+ font-weight: 600;
+ transition: background-color 0.2s;
+}
+
+.CreateLink:hover {
+ background-color: #f0f0f0;
+}
+
+.CreateLink img {
+ margin-bottom: 8px;
+}
diff --git a/src/app/layout.js b/src/app/layout.js
index e35a28c..954a5b5 100644
--- a/src/app/layout.js
+++ b/src/app/layout.js
@@ -1,5 +1,6 @@
import { Inter } from "next/font/google";
import "./globals.css";
+import Providers from "./providers";
const inter = Inter({ subsets: ["latin"] });
@@ -11,7 +12,9 @@ export const metadata = {
export default function RootLayout({ children }) {
return (
- {children}
+
+ {children}
+
);
}
diff --git a/src/app/providers.jsx b/src/app/providers.jsx
new file mode 100644
index 0000000..079f992
--- /dev/null
+++ b/src/app/providers.jsx
@@ -0,0 +1,13 @@
+"use client";
+
+import React from "react";
+import AuthProvider from "@/providers/AuthProvider";
+import RouteGuard from "@/providers/RouteGuard";
+
+export default function Providers({ children }) {
+ return (
+
+ {children}
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/Avatar.js b/src/components/Avatar.js
new file mode 100644
index 0000000..344972c
--- /dev/null
+++ b/src/components/Avatar.js
@@ -0,0 +1,14 @@
+import styles from "./Avatar.module.css";
+
+function Avatar({ className = "", size = "medium", src, alt, ...props }) {
+ return (
+
+ );
+}
+
+export default Avatar;
diff --git a/src/components/Avatar.module.css b/src/components/Avatar.module.css
new file mode 100644
index 0000000..54038eb
--- /dev/null
+++ b/src/components/Avatar.module.css
@@ -0,0 +1,21 @@
+.Avatar {
+ width: 212px;
+ height: 212px;
+ border-radius: 50%;
+ object-fit: cover;
+}
+
+.large {
+ width: 80px;
+ height: 80px;
+}
+
+.medium {
+ width: 54px;
+ height: 54px;
+}
+
+.small {
+ width: 48px;
+ height: 48px;
+}
diff --git a/src/components/AvatarInput.js b/src/components/AvatarInput.js
new file mode 100644
index 0000000..0321815
--- /dev/null
+++ b/src/components/AvatarInput.js
@@ -0,0 +1,59 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import styles from "./AvatarInput.module.css";
+import Avatar from "./Avatar";
+import Button from "./Button";
+
+function AvatarInput({ className = "", initialAvatar, name, onChange }) {
+ const [file, setFile] = useState(null);
+ const [avatar, setAvatar] = useState(initialAvatar);
+ const inputRef = useRef();
+
+ function handleChange(e) {
+ const file = e.target.files[0];
+ setFile(file);
+ onChange(name, file);
+ }
+
+ function handleUploadClick() {
+ inputRef.current?.click();
+ }
+
+ useEffect(() => {
+ if (!file) {
+ setAvatar("");
+ return;
+ }
+
+ const blobUrl = URL.createObjectURL(file);
+ setAvatar(blobUrl);
+
+ return () => {
+ URL.revokeObjectURL(blobUrl);
+ };
+ }, [file]);
+
+ return (
+
+
+
+
+
+ );
+}
+
+export default AvatarInput;
diff --git a/src/components/AvatarInput.module.css b/src/components/AvatarInput.module.css
new file mode 100644
index 0000000..97c0b86
--- /dev/null
+++ b/src/components/AvatarInput.module.css
@@ -0,0 +1,23 @@
+.AvatarInput {
+ display: flex;
+ align-items: center;
+ border-radius: 8px;
+ border: 1px solid #d9d9d9;
+ padding: 24px;
+ gap: 24px;
+}
+
+.UploadButton {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.HiddenInput {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 1px;
+ height: 1px;
+ opacity: 0;
+}
diff --git a/src/components/Card.js b/src/components/Card.js
new file mode 100644
index 0000000..b284168
--- /dev/null
+++ b/src/components/Card.js
@@ -0,0 +1,13 @@
+"use client";
+
+import styles from "./Card.module.css";
+
+function Card({ className = "", children, ...props }) {
+ return (
+
+ {children}
+
+ );
+}
+
+export default Card;
diff --git a/src/components/Card.module.css b/src/components/Card.module.css
new file mode 100644
index 0000000..7e2f061
--- /dev/null
+++ b/src/components/Card.module.css
@@ -0,0 +1,7 @@
+.Card {
+ background-color: #ffffff;
+ border-radius: 8px;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+ padding: 16px;
+ overflow: hidden;
+}
diff --git a/src/components/Link.js b/src/components/Link.js
index 831c225..7a26796 100644
--- a/src/components/Link.js
+++ b/src/components/Link.js
@@ -1,14 +1,14 @@
"use client";
+import Link from "next/link";
import React from "react";
-import NextLink from "next/link";
-function Link({ children, href, ...rest }) {
+function NextLink({ children, href = "/", ...rest }) {
return (
-
+
{children}
-
+
);
}
-export default Link;
+export default NextLink;
diff --git a/src/components/LinkCard.js b/src/components/LinkCard.js
new file mode 100644
index 0000000..c5e045d
--- /dev/null
+++ b/src/components/LinkCard.js
@@ -0,0 +1,50 @@
+"use client";
+
+import styles from "./LinkCard.module.css";
+import Button from "./Button";
+
+function LinkCard({ title, url, thumbUrl, onClick, onDelete }) {
+ function handleClick(e) {
+ e.preventDefault();
+ onClick?.();
+ }
+
+ function handleDeleteClick(e) {
+ e.stopPropagation();
+ onDelete?.();
+ }
+
+ return (
+
+ {thumbUrl ? (
+
+ ) : (
+ 이미지 없음
+ )}
+
+ {onDelete && (
+
+ )}
+
+ );
+}
+
+export default LinkCard;
diff --git a/src/components/LinkCard.module.css b/src/components/LinkCard.module.css
new file mode 100644
index 0000000..727f2b1
--- /dev/null
+++ b/src/components/LinkCard.module.css
@@ -0,0 +1,74 @@
+.LinkCard {
+ display: flex;
+ background-color: white;
+ border-radius: 8px;
+ border: 1px solid #d9d9d9;
+ padding: 20px;
+ cursor: pointer;
+ text-decoration: none;
+ color: inherit;
+ position: relative;
+}
+
+.Thumb {
+ width: 120px;
+ height: 80px;
+ background-color: #eeeeee;
+ border-radius: 4px;
+ background-size: cover;
+ background-position: center;
+ flex-shrink: 0;
+ margin-right: 16px;
+}
+
+.EmptyThumb {
+ width: 120px;
+ height: 80px;
+ background-color: #eeeeee;
+ border-radius: 4px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: #999;
+ font-size: 14px;
+ flex-shrink: 0;
+ margin-right: 16px;
+}
+
+.Info {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ overflow: hidden;
+}
+
+.Title {
+ font-weight: 600;
+ font-size: 16px;
+ margin-bottom: 8px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.Url {
+ font-size: 14px;
+ color: #666666;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.DeleteButton {
+ position: absolute;
+ top: 10px;
+ right: 10px;
+ background-color: #ff6b6b;
+ color: white;
+ border: none;
+ border-radius: 4px;
+ padding: 4px 8px;
+ font-size: 12px;
+ cursor: pointer;
+}
diff --git a/src/components/Nav.js b/src/components/Nav.js
index 086859e..48d99be 100644
--- a/src/components/Nav.js
+++ b/src/components/Nav.js
@@ -4,6 +4,8 @@ import React from "react";
import Link from "next/link";
import styles from "./Nav.module.css";
import Button from "./Button";
+import { useAuth } from "@/providers/AuthProvider";
+import Avatar from "./Avatar";
export function PublicNav() {
return (
@@ -18,6 +20,8 @@ export function PublicNav() {
}
function Nav() {
+ const { user, logout } = useAuth();
+
return (
diff --git a/src/components/Nav.module.css b/src/components/Nav.module.css
index 9675cfa..9b9a5d5 100644
--- a/src/components/Nav.module.css
+++ b/src/components/Nav.module.css
@@ -24,6 +24,13 @@
.Menu {
display: flex;
gap: 16px;
+ align-items: center;
+}
+
+.Divider {
+ width: 1px;
+ height: 24px;
+ background-color: #eeeeee;
}
@media (max-width: 640px) {
diff --git a/src/components/TextArea.js b/src/components/TextArea.js
new file mode 100644
index 0000000..418ce04
--- /dev/null
+++ b/src/components/TextArea.js
@@ -0,0 +1,13 @@
+import styles from "./TextArea.module.css";
+
+function TextArea({ className = "", value = "", ...rest }) {
+ return (
+
+ );
+}
+
+export default TextArea;
diff --git a/src/components/TextArea.module.css b/src/components/TextArea.module.css
new file mode 100644
index 0000000..62ef571
--- /dev/null
+++ b/src/components/TextArea.module.css
@@ -0,0 +1,10 @@
+.TextArea {
+ padding: 12px 16px;
+ border-radius: 8px;
+ border: 1px solid #d9d9d9;
+ color: #000000;
+}
+
+.TextArea::placeholder {
+ color: #8c8c8c;
+}
diff --git a/src/components/pages/LoginPage.js b/src/components/pages/LoginPage.js
index f383b96..5d016b6 100644
--- a/src/components/pages/LoginPage.js
+++ b/src/components/pages/LoginPage.js
@@ -7,13 +7,20 @@ import Button from "../Button";
import HorizontalRule from "../HorizontalRule";
import Link from "next/link";
import styles from "./LoginPage.module.css";
+import { useRouter } from "next/navigation";
+import { useAuth } from "@/providers/AuthProvider";
function LoginPage() {
const [values, setValues] = useState({
email: "",
password: "",
});
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const { login } = useAuth();
+
+ const router = useRouter();
function handleChange(e) {
const { name, value } = e.target;
@@ -25,12 +32,17 @@ function LoginPage() {
async function handleSubmit(e) {
e.preventDefault();
- // TODO: 로그인 처리
- // 1. fetch 를 사용하여 로그인 요청을 보냅니다.
- // 2. 성공 시 Application 에 쿠키 내 토큰 저장 여부를 확인합니다.
- // 3. 로딩 상태를 만들고 로딩중일 때는 로그인 버튼을 비활성화 합니다.
- // 4. 추가로 로딩중일 때는 로그인 버튼텍스트를 "로그인 중..."으로 변경합니다.
- // 5. 에러 상태를 만들고 로그인 요청이 실패 시 에러 메시지를 로그인버튼 바로 위에 표시합니다.
+ try {
+ setError(null);
+ setLoading(true);
+ await login(values.email, values.password);
+ alert("로그인에 성공했습니다.");
+ router.push("/me");
+ } catch (error) {
+ setError(error.message);
+ } finally {
+ setLoading(false);
+ }
}
return (
@@ -61,7 +73,10 @@ function LoginPage() {
value={values.password}
onChange={handleChange}
/>
-
+ {error && {error}
}
+
또는