diff --git a/package-lock.json b/package-lock.json index 2b9363e..d78a7e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,8 +12,13 @@ "@emotion/react": "^11.13.3", "@emotion/styled": "^11.13.0", "@radix-ui/react-checkbox": "^1.1.3", + "@radix-ui/react-dialog": "^1.1.4", + "@radix-ui/react-dropdown-menu": "^2.1.4", + "@radix-ui/react-label": "^2.1.1", "@radix-ui/react-popover": "^1.1.4", + "@radix-ui/react-separator": "^1.1.1", "@radix-ui/react-slot": "^1.1.1", + "@radix-ui/react-tooltip": "^1.1.6", "@uiw/react-md-editor": "^3.6.0", "axios": "^1.7.7", "class-variance-authority": "^0.7.1", @@ -1040,6 +1045,32 @@ } } }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.1.tgz", + "integrity": "sha512-LwT3pSho9Dljg+wY2KN2mrrh6y3qELfftINERIzBUO9e0N+t0oMTyn3k9iv+ZqgrwGkRnLpNJrsMv9BZlt2yuA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-slot": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-compose-refs": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz", @@ -1070,6 +1101,57 @@ } } }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.4.tgz", + "integrity": "sha512-Ur7EV1IwQGCyaAuyDRiOLA5JIUZxELJljF+MbM/2NC0BYwfuRrbpS30BiQBJrVruscgUkieKkqXYDOoByaxIoA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.3", + "@radix-ui/react-focus-guards": "1.1.1", + "@radix-ui/react-focus-scope": "1.1.1", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-portal": "1.1.3", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-slot": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.1.0", + "aria-hidden": "^1.1.1", + "react-remove-scroll": "^2.6.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz", + "integrity": "sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-dismissable-layer": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.3.tgz", @@ -1097,6 +1179,35 @@ } } }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.4.tgz", + "integrity": "sha512-iXU1Ab5ecM+yEepGAWK8ZhMyKX4ubFdCNtol4sT9D0OVErG9PNElfx3TQhjw7n7BC5nFVz68/5//clWy+8TXzA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-menu": "2.1.4", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-focus-guards": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.1.tgz", @@ -1155,6 +1266,69 @@ } } }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.1.tgz", + "integrity": "sha512-UUw5E4e/2+4kFMH7+YxORXGWggtY6sM8WIwh5RZchhLuUg2H1hc98Py+pr8HMz6rdaYrK2t296ZEjYLOCO5uUw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.4.tgz", + "integrity": "sha512-BnOgVoL6YYdHAG6DtXONaR29Eq4nvbi8rutrV/xlr3RQCMMb3yqP85Qiw/3NReozrSW+4dfLkK+rc1hb4wPU/A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collection": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-dismissable-layer": "1.1.3", + "@radix-ui/react-focus-guards": "1.1.1", + "@radix-ui/react-focus-scope": "1.1.1", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-popper": "1.2.1", + "@radix-ui/react-portal": "1.1.3", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-roving-focus": "1.1.1", + "@radix-ui/react-slot": "1.1.1", + "@radix-ui/react-use-callback-ref": "1.1.0", + "aria-hidden": "^1.1.1", + "react-remove-scroll": "^2.6.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-popover": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.4.tgz", @@ -1295,6 +1469,60 @@ } } }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.1.tgz", + "integrity": "sha512-QE1RoxPGJ/Nm8Qmk0PxP8ojmoaS67i0s7hVssS7KuI2FQoc/uzVlZsqKfQvxPE6D8hICCPHJ4D88zNhT3OOmkw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collection": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.1.tgz", + "integrity": "sha512-RRiNRSrD8iUiXriq/Y5n4/3iE8HzqgLHsusUSg5jVpU2+3tqcUFPJXHDymwEypunc2sWxDUS3UC+rkZRlHedsw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-slot": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz", @@ -1313,6 +1541,40 @@ } } }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.1.6.tgz", + "integrity": "sha512-TLB5D8QLExS1uDn7+wH/bjEmRurNMTzNrtq7IjaS4kjion9NtzsTGkvR5+i7yc9q01Pi2KMM2cN3f8UG4IvvXA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.3", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-popper": "1.2.1", + "@radix-ui/react-portal": "1.1.3", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-slot": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-visually-hidden": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", @@ -1430,6 +1692,29 @@ } } }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.1.1.tgz", + "integrity": "sha512-vVfA2IZ9q/J+gEamvj761Oq1FpWgCDaNOOIfbPVp2MVPLEomUr5+Vf7kJGwQ24YxZSlQVar7Bes8kyTo5Dshpg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/rect": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.0.tgz", diff --git a/package.json b/package.json index 2be35e6..59a4bb9 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,13 @@ "@emotion/react": "^11.13.3", "@emotion/styled": "^11.13.0", "@radix-ui/react-checkbox": "^1.1.3", + "@radix-ui/react-dialog": "^1.1.4", + "@radix-ui/react-dropdown-menu": "^2.1.4", + "@radix-ui/react-label": "^2.1.1", "@radix-ui/react-popover": "^1.1.4", + "@radix-ui/react-separator": "^1.1.1", "@radix-ui/react-slot": "^1.1.1", + "@radix-ui/react-tooltip": "^1.1.6", "@uiw/react-md-editor": "^3.6.0", "axios": "^1.7.7", "class-variance-authority": "^0.7.1", diff --git a/src/components/EditProject/EditProject.tsx b/src/components/EditProject/EditProject.tsx index 43fe178..090a4df 100644 --- a/src/components/EditProject/EditProject.tsx +++ b/src/components/EditProject/EditProject.tsx @@ -10,7 +10,7 @@ import { useSelectTypes } from "@/stores/selectStackType/selectTypesStore"; import { ProjectCategory, getCategoryKey, -} from "@/libs/enum/projectCategoryEnum"; +} from "@/lib/enum/projectCategoryEnum"; import { useRouter } from "next/router"; import { useEffect } from "react"; import { useEditProject } from "@/stores/editProjectStore"; diff --git a/src/components/Mypage/MyProjects/MyProjects.tsx b/src/components/Mypage/MyProjects/MyProjects.tsx new file mode 100644 index 0000000..6d989ac --- /dev/null +++ b/src/components/Mypage/MyProjects/MyProjects.tsx @@ -0,0 +1,116 @@ +import { SEARCH_PROJECT } from "@/services/gql/searchProject"; +import { useSearchProject } from "@/stores/searchProjectStore"; +import { useQuery } from "@apollo/client"; +import { useEffect, useRef, useState } from "react"; +import MypageProjectCard from "@/components/ProjectCard/Mypage/MypageProjectCard"; + +export default function MyProjectComponents() { + const { page, hasNext, projects, setPage, setHasNext, setProjects, reset } = + useSearchProject(); + + const SIZE = 5; + const [isLoading, setIsLoading] = useState(true); + const observerRef = useRef<HTMLDivElement>(null); + + const { data, loading, fetchMore } = useQuery(SEARCH_PROJECT, { + variables: { + page: page, + size: SIZE, + title: "", + stackNames: [], + categories: [], + }, + }); + + useEffect(() => { + reset(); + }, []); + + // 무한 스크롤 + useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && hasNext && !loading) { + loadMoreProjects(); + } + }, + { threshold: 0.8 }, + ); + + if (observerRef.current) observer.observe(observerRef.current); + return () => observer.disconnect(); + }, [observerRef, data, hasNext, loading]); + + // projects 배열에 새로운 데이터 추가 + useEffect(() => { + if (data) { + if (page === 0) { + // 초기 페이지일 경우, 기존 데이터를 초기화 + setProjects(data.searchProject.projects); + } else { + // 추가 데이터만 병합 + setProjects([...projects, ...data.searchProject.projects]); + } + setHasNext(data.searchProject.hasNext); + setIsLoading(false); + } + }, [data]); + + const loadMoreProjects = async () => { + await fetchMore({ + variables: { + page: page, + size: 36, + title: "", + stackNames: [], + categories: [], + }, + updateQuery: (prevResult, { fetchMoreResult }) => { + if (!fetchMoreResult) return prevResult; + else { + setHasNext(fetchMoreResult.searchProject.hasNext); + setPage(page + 1); + } + return { + searchProject: { + ...fetchMoreResult.searchProject, + projects: [ + // ...prevResult.getAllProjectsByPagination.projects, + ...fetchMoreResult.searchProject.projects, + ], + }, + }; + }, + }); + }; + + return ( + <div> + {!isLoading && projects.length === 0 ? ( + <p style={{ marginTop: "20px", fontSize: "20px" }}> + 등록한 프로젝트가 없습니다. + </p> + ) : ( + <div> + {projects.map((project, index) => ( + <div key={index}> + <MypageProjectCard projectCard={project} /> + <hr + style={{ + marginTop: "50px", + marginBottom: "50px", + borderColor: "black", + }} + ></hr> + </div> + ))} + + <div + ref={observerRef} + style={{ height: "1px", backgroundColor: "transparent" }} + /> + </div> + )} + </div> + ); +} diff --git a/src/components/Mypage/MypageLayout.tsx b/src/components/Mypage/MypageLayout.tsx new file mode 100644 index 0000000..a864948 --- /dev/null +++ b/src/components/Mypage/MypageLayout.tsx @@ -0,0 +1,84 @@ +import * as Styles from "./styles"; +import Image from "next/image"; +import Link from "next/link"; +import { useSidebarStore } from "@/stores/mypageSidebarStore"; +import { LuFileText, LuHeart, LuSettings } from "react-icons/lu"; +import { useAuthStore } from "@/stores/authStore"; + +function MypageMyInfo() { + const { user } = useAuthStore(); + return ( + <Styles.MypageMyinfo> + <Image + src="https://avatars.githubusercontent.com/u/55120784?v=4" + alt="" + width={200} + height={200} + style={{ borderRadius: "50%" }} + /> + <Styles.MypageNickname>{user?.data?.username}</Styles.MypageNickname> + <Styles.MypageEmail>{user?.data?.email}</Styles.MypageEmail> + </Styles.MypageMyinfo> + ); +} + +function MypageSidebar() { + const items = [ + { + icon: <LuFileText />, + label: "나의 프로젝트", + link: "myprojects", + }, + { + icon: <LuHeart />, + label: "좋아요한 프로젝트", + link: "likeprojects", + }, + { + icon: <LuSettings />, + label: "개인정보 변경", + link: "editprofile", + }, + ]; + + const { sidebarIndex, setSidebarIndex } = useSidebarStore(); + + return ( + <Styles.MypageSidebar> + {items.map((item, index) => ( + <Link + key={index} + href={`/mypage/${item.link}`} + passHref + style={{ width: "100%" }} + > + <button + onClick={() => { + setSidebarIndex(index); + }} + className={sidebarIndex === index ? "active" : ""} + > + {item.icon} + {item.label} + </button> + </Link> + ))} + </Styles.MypageSidebar> + ); +} + +type MypageLayoutProps = { + children: React.ReactNode; +}; + +export default function MypageLayout({ children }: MypageLayoutProps) { + return ( + <Styles.MypageContainer> + <Styles.MypageSidebarContainer> + <MypageMyInfo /> + <MypageSidebar /> + </Styles.MypageSidebarContainer> + <Styles.MypageContentContainer>{children}</Styles.MypageContentContainer> + </Styles.MypageContainer> + ); +} diff --git a/src/components/Mypage/styles.ts b/src/components/Mypage/styles.ts new file mode 100644 index 0000000..06383dc --- /dev/null +++ b/src/components/Mypage/styles.ts @@ -0,0 +1,69 @@ +import styled from "@emotion/styled"; + +const grey = "#59636e"; +const lightgrey = "#DEE4E9"; +const sidebarHover = "#F2F2F2"; + +export const MypageContainer = styled.div` + max-width: 1200px; + padding: 60px 60px 60px 60px; + margin: auto; + display: flex; + justify-content: center; + gap: 50px; +`; + +export const MypageSidebarContainer = styled.div` + width: 200px; + height: 100%; + position: sticky; + top: 140px; +`; + +export const MypageMyinfo = styled.div` + width: 200px; + border-bottom: solid 2px ${lightgrey}; + p { + padding-left: 10px; + } +`; + +export const MypageNickname = styled.p` + font-size: 20px; + margin-top: 20px; +`; + +export const MypageEmail = styled.p` + color: ${grey}; + margin-bottom: 20px; +`; + +export const MypageSidebar = styled.div` + width: 200px; + margin-top: 17px; + display: flex; + flex-direction: column; + + button { + display: flex; + align-items: center; + width: 100%; + margin-top: 3px; + padding: 5px 10px 5px; + border-radius: 10px; + gap: 15px; + font-size: 18px; + + &:hover { + background-color: ${sidebarHover}; + } + } + .active { + background-color: ${sidebarHover}; + } +`; + +export const MypageContentContainer = styled.div` + flex: 1; + /* min-width: 400px; */ +`; diff --git a/src/components/Navigation/Navigation.tsx b/src/components/Navigation/Navigation.tsx index 7248316..8ed3345 100644 --- a/src/components/Navigation/Navigation.tsx +++ b/src/components/Navigation/Navigation.tsx @@ -20,6 +20,7 @@ import userIcon from "../../../public/icons/user.svg"; import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; import { Button } from "../ui/button"; +import Link from "next/link"; const Navigation: React.FC = () => { const [isModalOpen, setIsModalOpen] = useState(false); @@ -115,8 +116,8 @@ const Navigation: React.FC = () => { > <Logo href="/">POFO</Logo> <NavItems> - <StyledNavLink href="/newpost">Home</StyledNavLink> - <StyledNavLink href="/project/1">MyPage</StyledNavLink> + <StyledNavLink href="/">Home</StyledNavLink> + {/* <StyledNavLink href="/mypage">MyPage</StyledNavLink> */} </NavItems> </div> </div> @@ -141,16 +142,22 @@ const Navigation: React.FC = () => { /> </PopoverTrigger> <PopoverContent> - <Button variant="ghost" className="w-full justify-start"> - <Image - style={{ cursor: "pointer", marginRight: "8px" }} - src={"/icons/user_2.svg"} - width={18} - height={18} - alt="mypage" - /> - 내 정보 - </Button> + <Link href="/mypage"> + <Button + variant="ghost" + className="w-full justify-start" + onClick={() => {}} + > + <Image + style={{ cursor: "pointer", marginRight: "8px" }} + src={"/icons/user_2.svg"} + width={18} + height={18} + alt="mypage" + /> + 내 정보 + </Button> + </Link> <Button variant="ghost" className="w-full justify-start"> <Image style={{ cursor: "pointer", marginRight: "8px" }} diff --git a/src/components/Newpost/Newpost.tsx b/src/components/Newpost/Newpost.tsx index 83fe1b1..4f93531 100644 --- a/src/components/Newpost/Newpost.tsx +++ b/src/components/Newpost/Newpost.tsx @@ -12,7 +12,7 @@ import { useSelectTypes } from "@/stores/selectStackType/selectTypesStore"; import { ProjectCategory, getCategoryKey, -} from "@/libs/enum/projectCategoryEnum"; +} from "@/lib/enum/projectCategoryEnum"; import { useRouter } from "next/router"; import { useEffect } from "react"; diff --git a/src/components/Project/MDEditor/MdeditorViewer.tsx b/src/components/Project/MDEditor/MdeditorViewer.tsx index f7cd739..195f99a 100644 --- a/src/components/Project/MDEditor/MdeditorViewer.tsx +++ b/src/components/Project/MDEditor/MdeditorViewer.tsx @@ -2,7 +2,7 @@ import "@uiw/react-md-editor/markdown-editor.css"; import "@uiw/react-markdown-preview/markdown.css"; import styles from "./styles.module.css"; import dynamic from "next/dynamic"; -import { IProjectProps } from "@/libs/interface/iProject"; +import { IProjectProps } from "@/lib/interface/iProject"; const MDEditor = dynamic(() => import("@uiw/react-md-editor"), { ssr: false }); diff --git a/src/components/Project/Project.tsx b/src/components/Project/Project.tsx index 346b0ca..b7e0815 100644 --- a/src/components/Project/Project.tsx +++ b/src/components/Project/Project.tsx @@ -6,7 +6,7 @@ import Link from "next/link"; import { useState } from "react"; import { useQuery } from "@apollo/client"; import { GET_PROJECT_BY_ID } from "@/services/gql/getProjectDetailById"; -import { IProject, IProjectProps } from "@/libs/interface/iProject"; +import { IProject, IProjectProps } from "@/lib/interface/iProject"; import { useAuthStore } from "@/stores/authStore"; import { FaHeart, FaShare, FaEdit } from "react-icons/fa"; @@ -47,8 +47,6 @@ function ProjectIntroduction({ project }: IProjectProps) { function ProjectLikeShare({ project }: IProjectProps) { const { user } = useAuthStore(); - console.log("authorName: ", project.authorName); - return ( <div> <div diff --git a/src/components/ProjectCard/Mypage/MypageProjectCard.tsx b/src/components/ProjectCard/Mypage/MypageProjectCard.tsx new file mode 100644 index 0000000..880d18b --- /dev/null +++ b/src/components/ProjectCard/Mypage/MypageProjectCard.tsx @@ -0,0 +1,53 @@ +import React from "react"; +import * as Styles from "./styles"; +import empty_heart from "../../../../public/icons/empty_heart.svg"; +import fill_heart from "../../../../public/icons/fill_heart.svg"; +import Image from "next/image"; +import Link from "next/link"; +import { IProjectCardProps } from "@/lib/interface/iProjectCard"; + +export default function MypageProjectCard(projectCard: IProjectCardProps) { + // const [likeCount, setLikeCount] = useState(likes); + // const [liked, setLiked] = useState(false); + + const handleLike = () => { + // setLikeCount(likeCount + (liked ? -1 : 1)); + // setLiked(!liked); + }; + + const { id, title, imageUrls, bio } = projectCard.projectCard; + return ( + <Link style={{ width: "100%" }} href={`/project/${id}`}> + <Styles.Card> + <Styles.ImageWrapper> + <Image + src={ + imageUrls !== null && imageUrls.length > 0 + ? imageUrls[0] + : "https://velog.velcdn.com/images/yena1025/post/295eb434-5b73-421f-bbe4-6bc13acd4c33/image.png" + } + alt={title} + layout="fill" + objectFit="cover" + /> + </Styles.ImageWrapper> + <Styles.Content> + <Styles.Title>{title}</Styles.Title> + <Styles.Description>{bio}</Styles.Description> + <Styles.Author>{id}</Styles.Author> + <Styles.LikeSection> + <Styles.LikeButton onClick={handleLike}> + <Image + src={true ? fill_heart : empty_heart} + alt="like button" + width={24} + height={24} + /> + </Styles.LikeButton> + <Styles.LikeCount>{100} likes</Styles.LikeCount> + </Styles.LikeSection> + </Styles.Content> + </Styles.Card> + </Link> + ); +} diff --git a/src/components/ProjectCard/Mypage/styles.ts b/src/components/ProjectCard/Mypage/styles.ts new file mode 100644 index 0000000..3a2718a --- /dev/null +++ b/src/components/ProjectCard/Mypage/styles.ts @@ -0,0 +1,56 @@ +import styled from "@emotion/styled"; + +export const Card = styled.div` + display: flex; + align-items: center; + gap: 50px; + + overflow: hidden; + cursor: pointer; +`; + +export const ImageWrapper = styled.div` + position: relative; + width: 180px; + min-width: 180px; + height: 180px; + min-height: 180px; + overflow: hidden; +`; + +export const Content = styled.div``; + +export const Title = styled.h2` + font-size: 25px; + margin-bottom: 8px; +`; + +export const Description = styled.p` + font-size: 18px; + color: #555; + margin-bottom: 8px; +`; + +export const Author = styled.p` + font-size: 16px; + color: #888; + margin-bottom: 8px; +`; + +export const LikeSection = styled.div` + display: flex; + align-items: center; +`; + +export const LikeButton = styled.button` + background: none; + border: none; + cursor: pointer; + font-size: 20px; +`; + +export const LikeCount = styled.span` + margin-left: 8px; + font-size: 14px; + color: #333; +`; diff --git a/src/components/ProjectCard/ProjectCard.tsx b/src/components/ProjectCard/ProjectCard.tsx index 96fb2ba..9b3253e 100644 --- a/src/components/ProjectCard/ProjectCard.tsx +++ b/src/components/ProjectCard/ProjectCard.tsx @@ -4,7 +4,7 @@ import Image from "next/image"; import empty_heart from "../../../public/icons/empty_heart.svg"; import fill_heart from "../../../public/icons/fill_heart.svg"; import Link from "next/link"; -import { IProjectCardProps } from "@/libs/interface/iProjectCard"; +import { IProjectCardProps } from "@/lib/interface/iProjectCard"; const ProjectCard = forwardRef<HTMLDivElement, IProjectCardProps>( (projectCard, ref) => { diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 2102aab..d09a695 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -2,7 +2,7 @@ import * as React from "react"; import { Slot } from "@radix-ui/react-slot"; import { cva, type VariantProps } from "class-variance-authority"; -import { cn } from "@/libs/utils"; +import { cn } from "@/lib/utils"; const buttonVariants = cva( "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", diff --git a/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx index 8bbd56b..3f61f28 100644 --- a/src/components/ui/checkbox.tsx +++ b/src/components/ui/checkbox.tsx @@ -2,7 +2,7 @@ import * as React from "react"; import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; import { Check } from "lucide-react"; -import { cn } from "@/libs/utils"; +import { cn } from "@/lib/utils"; const Checkbox = React.forwardRef< React.ElementRef<typeof CheckboxPrimitive.Root>, diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..ce8cfa2 --- /dev/null +++ b/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,199 @@ +import * as React from "react"; +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; +import { Check, ChevronRight, Circle } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const DropdownMenu = DropdownMenuPrimitive.Root; + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; + +const DropdownMenuGroup = DropdownMenuPrimitive.Group; + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal; + +const DropdownMenuSub = DropdownMenuPrimitive.Sub; + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>, + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & { + inset?: boolean; + } +>(({ className, inset, children, ...props }, ref) => ( + <DropdownMenuPrimitive.SubTrigger + ref={ref} + className={cn( + "flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + inset && "pl-8", + className, + )} + {...props} + > + {children} + <ChevronRight className="ml-auto" /> + </DropdownMenuPrimitive.SubTrigger> +)); +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName; + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef<typeof DropdownMenuPrimitive.SubContent>, + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent> +>(({ className, ...props }, ref) => ( + <DropdownMenuPrimitive.SubContent + ref={ref} + className={cn( + "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg 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", + className, + )} + {...props} + /> +)); +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName; + +const DropdownMenuContent = React.forwardRef< + React.ElementRef<typeof DropdownMenuPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> +>(({ className, sideOffset = 4, ...props }, ref) => ( + <DropdownMenuPrimitive.Portal> + <DropdownMenuPrimitive.Content + ref={ref} + sideOffset={sideOffset} + className={cn( + "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md", + "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", + className, + )} + {...props} + /> + </DropdownMenuPrimitive.Portal> +)); +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; + +const DropdownMenuItem = React.forwardRef< + React.ElementRef<typeof DropdownMenuPrimitive.Item>, + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + <DropdownMenuPrimitive.Item + ref={ref} + className={cn( + "relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0", + inset && "pl-8", + className, + )} + {...props} + /> +)); +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>, + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> +>(({ className, children, checked, ...props }, ref) => ( + <DropdownMenuPrimitive.CheckboxItem + ref={ref} + className={cn( + "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", + className, + )} + checked={checked} + {...props} + > + <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> + <DropdownMenuPrimitive.ItemIndicator> + <Check className="h-4 w-4" /> + </DropdownMenuPrimitive.ItemIndicator> + </span> + {children} + </DropdownMenuPrimitive.CheckboxItem> +)); +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName; + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>, + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> +>(({ className, children, ...props }, ref) => ( + <DropdownMenuPrimitive.RadioItem + ref={ref} + className={cn( + "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", + className, + )} + {...props} + > + <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> + <DropdownMenuPrimitive.ItemIndicator> + <Circle className="h-2 w-2 fill-current" /> + </DropdownMenuPrimitive.ItemIndicator> + </span> + {children} + </DropdownMenuPrimitive.RadioItem> +)); +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef<typeof DropdownMenuPrimitive.Label>, + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + <DropdownMenuPrimitive.Label + ref={ref} + className={cn( + "px-2 py-1.5 text-sm font-semibold", + inset && "pl-8", + className, + )} + {...props} + /> +)); +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef<typeof DropdownMenuPrimitive.Separator>, + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator> +>(({ className, ...props }, ref) => ( + <DropdownMenuPrimitive.Separator + ref={ref} + className={cn("-mx-1 my-1 h-px bg-muted", className)} + {...props} + /> +)); +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; + +const DropdownMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes<HTMLSpanElement>) => { + return ( + <span + className={cn("ml-auto text-xs tracking-widest opacity-60", className)} + {...props} + /> + ); +}; +DropdownMenuShortcut.displayName = "DropdownMenuShortcut"; + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +}; diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx index f647a0f..7db5241 100644 --- a/src/components/ui/input.tsx +++ b/src/components/ui/input.tsx @@ -1,6 +1,6 @@ import * as React from "react"; -import { cn } from "@/libs/utils"; +import { cn } from "@/lib/utils"; const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>( ({ className, type, ...props }, ref) => { diff --git a/src/components/ui/label.tsx b/src/components/ui/label.tsx new file mode 100644 index 0000000..683faa7 --- /dev/null +++ b/src/components/ui/label.tsx @@ -0,0 +1,24 @@ +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const labelVariants = cva( + "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" +) + +const Label = React.forwardRef< + React.ElementRef<typeof LabelPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & + VariantProps<typeof labelVariants> +>(({ className, ...props }, ref) => ( + <LabelPrimitive.Root + ref={ref} + className={cn(labelVariants(), className)} + {...props} + /> +)) +Label.displayName = LabelPrimitive.Root.displayName + +export { Label } diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx index 8a6243d..34a106f 100644 --- a/src/components/ui/popover.tsx +++ b/src/components/ui/popover.tsx @@ -1,7 +1,7 @@ import * as React from "react"; import * as PopoverPrimitive from "@radix-ui/react-popover"; -import { cn } from "@/libs/utils"; +import { cn } from "@/lib/utils"; const Popover = PopoverPrimitive.Root; diff --git a/src/components/ui/separator.tsx b/src/components/ui/separator.tsx new file mode 100644 index 0000000..9ac3b95 --- /dev/null +++ b/src/components/ui/separator.tsx @@ -0,0 +1,31 @@ +"use client"; + +import * as React from "react"; +import * as SeparatorPrimitive from "@radix-ui/react-separator"; + +import { cn } from "@/lib/utils"; + +const Separator = React.forwardRef< + React.ElementRef<typeof SeparatorPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root> +>( + ( + { className, orientation = "horizontal", decorative = true, ...props }, + ref, + ) => ( + <SeparatorPrimitive.Root + ref={ref} + decorative={decorative} + orientation={orientation} + className={cn( + "shrink-0 bg-border", + orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]", + className, + )} + {...props} + /> + ), +); +Separator.displayName = SeparatorPrimitive.Root.displayName; + +export { Separator }; diff --git a/src/components/ui/sheet.tsx b/src/components/ui/sheet.tsx new file mode 100644 index 0000000..0fda387 --- /dev/null +++ b/src/components/ui/sheet.tsx @@ -0,0 +1,138 @@ +import * as React from "react"; +import * as SheetPrimitive from "@radix-ui/react-dialog"; +import { cva, type VariantProps } from "class-variance-authority"; +import { X } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const Sheet = SheetPrimitive.Root; + +const SheetTrigger = SheetPrimitive.Trigger; + +const SheetClose = SheetPrimitive.Close; + +const SheetPortal = SheetPrimitive.Portal; + +const SheetOverlay = React.forwardRef< + React.ElementRef<typeof SheetPrimitive.Overlay>, + React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay> +>(({ className, ...props }, ref) => ( + <SheetPrimitive.Overlay + className={cn( + "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", + className, + )} + {...props} + ref={ref} + /> +)); +SheetOverlay.displayName = SheetPrimitive.Overlay.displayName; + +const sheetVariants = cva( + "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out", + { + variants: { + side: { + top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top", + bottom: + "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom", + left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm", + right: + "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm", + }, + }, + defaultVariants: { + side: "right", + }, + }, +); + +interface SheetContentProps + extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>, + VariantProps<typeof sheetVariants> {} + +const SheetContent = React.forwardRef< + React.ElementRef<typeof SheetPrimitive.Content>, + SheetContentProps +>(({ side = "right", className, children, ...props }, ref) => ( + <SheetPortal> + <SheetOverlay /> + <SheetPrimitive.Content + ref={ref} + className={cn(sheetVariants({ side }), className)} + {...props} + > + <SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary"> + <X className="h-4 w-4" /> + <span className="sr-only">Close</span> + </SheetPrimitive.Close> + {children} + </SheetPrimitive.Content> + </SheetPortal> +)); +SheetContent.displayName = SheetPrimitive.Content.displayName; + +const SheetHeader = ({ + className, + ...props +}: React.HTMLAttributes<HTMLDivElement>) => ( + <div + className={cn( + "flex flex-col space-y-2 text-center sm:text-left", + className, + )} + {...props} + /> +); +SheetHeader.displayName = "SheetHeader"; + +const SheetFooter = ({ + className, + ...props +}: React.HTMLAttributes<HTMLDivElement>) => ( + <div + className={cn( + "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", + className, + )} + {...props} + /> +); +SheetFooter.displayName = "SheetFooter"; + +const SheetTitle = React.forwardRef< + React.ElementRef<typeof SheetPrimitive.Title>, + React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title> +>(({ className, ...props }, ref) => ( + <SheetPrimitive.Title + ref={ref} + className={cn("text-lg font-semibold text-foreground", className)} + {...props} + /> +)); +SheetTitle.displayName = SheetPrimitive.Title.displayName; + +const SheetDescription = React.forwardRef< + React.ElementRef<typeof SheetPrimitive.Description>, + React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description> +>(({ className, ...props }, ref) => ( + <SheetPrimitive.Description + ref={ref} + className={cn("text-sm text-muted-foreground", className)} + {...props} + /> +)); +SheetDescription.displayName = SheetPrimitive.Description.displayName; + +export { + Sheet, + SheetPortal, + SheetOverlay, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +}; diff --git a/src/components/ui/sidebar.tsx b/src/components/ui/sidebar.tsx new file mode 100644 index 0000000..23dc79c --- /dev/null +++ b/src/components/ui/sidebar.tsx @@ -0,0 +1,769 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { VariantProps, cva } from "class-variance-authority"; +import { PanelLeft } from "lucide-react"; + +import { useIsMobile } from "@/hooks/use-mobile"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Separator } from "@/components/ui/separator"; +import { Sheet, SheetContent } from "@/components/ui/sheet"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; + +const SIDEBAR_COOKIE_NAME = "sidebar:state"; +const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; +const SIDEBAR_WIDTH = "16rem"; +const SIDEBAR_WIDTH_MOBILE = "18rem"; +const SIDEBAR_WIDTH_ICON = "3rem"; +const SIDEBAR_KEYBOARD_SHORTCUT = "b"; + +type SidebarContext = { + state: "expanded" | "collapsed"; + open: boolean; + setOpen: (open: boolean) => void; + openMobile: boolean; + setOpenMobile: (open: boolean) => void; + isMobile: boolean; + toggleSidebar: () => void; +}; + +const SidebarContext = React.createContext<SidebarContext | null>(null); + +function useSidebar() { + const context = React.useContext(SidebarContext); + if (!context) { + throw new Error("useSidebar must be used within a SidebarProvider."); + } + + return context; +} + +const SidebarProvider = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { + defaultOpen?: boolean; + open?: boolean; + onOpenChange?: (open: boolean) => void; + } +>( + ( + { + defaultOpen = true, + open: openProp, + onOpenChange: setOpenProp, + className, + style, + children, + ...props + }, + ref, + ) => { + const isMobile = useIsMobile(); + const [openMobile, setOpenMobile] = React.useState(false); + + // This is the internal state of the sidebar. + // We use openProp and setOpenProp for control from outside the component. + const [_open, _setOpen] = React.useState(defaultOpen); + const open = openProp ?? _open; + const setOpen = React.useCallback( + (value: boolean | ((value: boolean) => boolean)) => { + const openState = typeof value === "function" ? value(open) : value; + if (setOpenProp) { + setOpenProp(openState); + } else { + _setOpen(openState); + } + + // This sets the cookie to keep the sidebar state. + document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`; + }, + [setOpenProp, open], + ); + + // Helper to toggle the sidebar. + const toggleSidebar = React.useCallback(() => { + return isMobile + ? setOpenMobile((open) => !open) + : setOpen((open) => !open); + }, [isMobile, setOpen, setOpenMobile]); + + // Adds a keyboard shortcut to toggle the sidebar. + React.useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if ( + event.key === SIDEBAR_KEYBOARD_SHORTCUT && + (event.metaKey || event.ctrlKey) + ) { + event.preventDefault(); + toggleSidebar(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [toggleSidebar]); + + // We add a state so that we can do data-state="expanded" or "collapsed". + // This makes it easier to style the sidebar with Tailwind classes. + const state = open ? "expanded" : "collapsed"; + + const contextValue = React.useMemo<SidebarContext>( + () => ({ + state, + open, + setOpen, + isMobile, + openMobile, + setOpenMobile, + toggleSidebar, + }), + [ + state, + open, + setOpen, + isMobile, + openMobile, + setOpenMobile, + toggleSidebar, + ], + ); + + return ( + <SidebarContext.Provider value={contextValue}> + <TooltipProvider delayDuration={0}> + <div + style={ + { + "--sidebar-width": SIDEBAR_WIDTH, + "--sidebar-width-icon": SIDEBAR_WIDTH_ICON, + ...style, + } as React.CSSProperties + } + className={cn( + "group/sidebar-wrapper has-[[data-variant=inset]]:bg-sidebar flex min-h-svh w-full", + className, + )} + ref={ref} + {...props} + > + {children} + </div> + </TooltipProvider> + </SidebarContext.Provider> + ); + }, +); +SidebarProvider.displayName = "SidebarProvider"; + +const Sidebar = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { + side?: "left" | "right"; + variant?: "sidebar" | "floating" | "inset"; + collapsible?: "offcanvas" | "icon" | "none"; + } +>( + ( + { + side = "left", + variant = "sidebar", + collapsible = "offcanvas", + className, + children, + ...props + }, + ref, + ) => { + const { isMobile, state, openMobile, setOpenMobile } = useSidebar(); + + if (collapsible === "none") { + return ( + <div + className={cn( + "bg-sidebar text-sidebar-foreground flex h-full w-[--sidebar-width] flex-col", + className, + )} + ref={ref} + {...props} + > + {children} + </div> + ); + } + + if (isMobile) { + return ( + <Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}> + <SheetContent + data-sidebar="sidebar" + data-mobile="true" + className="bg-sidebar text-sidebar-foreground w-[--sidebar-width] p-0 [&>button]:hidden" + style={ + { + "--sidebar-width": SIDEBAR_WIDTH_MOBILE, + } as React.CSSProperties + } + side={side} + > + <div className="flex h-full w-full flex-col">{children}</div> + </SheetContent> + </Sheet> + ); + } + + return ( + <div + ref={ref} + className="text-sidebar-foreground group peer hidden md:block" + data-state={state} + data-collapsible={state === "collapsed" ? collapsible : ""} + data-variant={variant} + data-side={side} + > + {/* This is what handles the sidebar gap on desktop */} + <div + className={cn( + "relative h-svh w-[--sidebar-width] bg-transparent transition-[width] duration-200 ease-linear", + "group-data-[collapsible=offcanvas]:w-0", + "group-data-[side=right]:rotate-180", + variant === "floating" || variant === "inset" + ? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]" + : "group-data-[collapsible=icon]:w-[--sidebar-width-icon]", + )} + /> + <div + className={cn( + "fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] duration-200 ease-linear md:flex", + side === "left" + ? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]" + : "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]", + // Adjust the padding for floating and inset variants. + variant === "floating" || variant === "inset" + ? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]" + : "group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l", + className, + )} + {...props} + > + <div + data-sidebar="sidebar" + className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow" + > + {children} + </div> + </div> + </div> + ); + }, +); +Sidebar.displayName = "Sidebar"; + +const SidebarTrigger = React.forwardRef< + React.ElementRef<typeof Button>, + React.ComponentProps<typeof Button> +>(({ className, onClick, ...props }, ref) => { + const { toggleSidebar } = useSidebar(); + + return ( + <Button + ref={ref} + data-sidebar="trigger" + variant="ghost" + size="icon" + className={cn("h-7 w-7", className)} + onClick={(event) => { + onClick?.(event); + toggleSidebar(); + }} + {...props} + > + <PanelLeft /> + <span className="sr-only">Toggle Sidebar</span> + </Button> + ); +}); +SidebarTrigger.displayName = "SidebarTrigger"; + +const SidebarRail = React.forwardRef< + HTMLButtonElement, + React.ComponentProps<"button"> +>(({ className, ...props }, ref) => { + const { toggleSidebar } = useSidebar(); + + return ( + <button + ref={ref} + data-sidebar="rail" + aria-label="Toggle Sidebar" + tabIndex={-1} + onClick={toggleSidebar} + title="Toggle Sidebar" + className={cn( + "hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex", + "[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize", + "[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize", + "group-data-[collapsible=offcanvas]:hover:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full", + "[[data-side=left][data-collapsible=offcanvas]_&]:-right-2", + "[[data-side=right][data-collapsible=offcanvas]_&]:-left-2", + className, + )} + {...props} + /> + ); +}); +SidebarRail.displayName = "SidebarRail"; + +const SidebarInset = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"main"> +>(({ className, ...props }, ref) => { + return ( + <main + ref={ref} + className={cn( + "relative flex min-h-svh flex-1 flex-col bg-background", + "peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow", + className, + )} + {...props} + /> + ); +}); +SidebarInset.displayName = "SidebarInset"; + +const SidebarInput = React.forwardRef< + React.ElementRef<typeof Input>, + React.ComponentProps<typeof Input> +>(({ className, ...props }, ref) => { + return ( + <Input + ref={ref} + data-sidebar="input" + className={cn( + "focus-visible:ring-sidebar-ring h-8 w-full bg-background shadow-none focus-visible:ring-2", + className, + )} + {...props} + /> + ); +}); +SidebarInput.displayName = "SidebarInput"; + +const SidebarHeader = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> +>(({ className, ...props }, ref) => { + return ( + <div + ref={ref} + data-sidebar="header" + className={cn("flex flex-col gap-2 p-2", className)} + {...props} + /> + ); +}); +SidebarHeader.displayName = "SidebarHeader"; + +const SidebarFooter = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> +>(({ className, ...props }, ref) => { + return ( + <div + ref={ref} + data-sidebar="footer" + className={cn("flex flex-col gap-2 p-2", className)} + {...props} + /> + ); +}); +SidebarFooter.displayName = "SidebarFooter"; + +const SidebarSeparator = React.forwardRef< + React.ElementRef<typeof Separator>, + React.ComponentProps<typeof Separator> +>(({ className, ...props }, ref) => { + return ( + <Separator + ref={ref} + data-sidebar="separator" + className={cn("bg-sidebar-border mx-2 w-auto", className)} + {...props} + /> + ); +}); +SidebarSeparator.displayName = "SidebarSeparator"; + +const SidebarContent = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> +>(({ className, ...props }, ref) => { + return ( + <div + ref={ref} + data-sidebar="content" + className={cn( + "flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden", + className, + )} + {...props} + /> + ); +}); +SidebarContent.displayName = "SidebarContent"; + +const SidebarGroup = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> +>(({ className, ...props }, ref) => { + return ( + <div + ref={ref} + data-sidebar="group" + className={cn("relative flex w-full min-w-0 flex-col p-2", className)} + {...props} + /> + ); +}); +SidebarGroup.displayName = "SidebarGroup"; + +const SidebarGroupLabel = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { asChild?: boolean } +>(({ className, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "div"; + + return ( + <Comp + ref={ref} + data-sidebar="group-label" + className={cn( + "text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-none transition-[margin,opa] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", + "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0", + className, + )} + {...props} + /> + ); +}); +SidebarGroupLabel.displayName = "SidebarGroupLabel"; + +const SidebarGroupAction = React.forwardRef< + HTMLButtonElement, + React.ComponentProps<"button"> & { asChild?: boolean } +>(({ className, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + + return ( + <Comp + ref={ref} + data-sidebar="group-action" + className={cn( + "text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-none transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", + // Increases the hit area of the button on mobile. + "after:absolute after:-inset-2 after:md:hidden", + "group-data-[collapsible=icon]:hidden", + className, + )} + {...props} + /> + ); +}); +SidebarGroupAction.displayName = "SidebarGroupAction"; + +const SidebarGroupContent = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> +>(({ className, ...props }, ref) => ( + <div + ref={ref} + data-sidebar="group-content" + className={cn("w-full text-sm", className)} + {...props} + /> +)); +SidebarGroupContent.displayName = "SidebarGroupContent"; + +const SidebarMenu = React.forwardRef< + HTMLUListElement, + React.ComponentProps<"ul"> +>(({ className, ...props }, ref) => ( + <ul + ref={ref} + data-sidebar="menu" + className={cn("flex w-full min-w-0 flex-col gap-1", className)} + {...props} + /> +)); +SidebarMenu.displayName = "SidebarMenu"; + +const SidebarMenuItem = React.forwardRef< + HTMLLIElement, + React.ComponentProps<"li"> +>(({ className, ...props }, ref) => ( + <li + ref={ref} + data-sidebar="menu-item" + className={cn("group/menu-item relative", className)} + {...props} + /> +)); +SidebarMenuItem.displayName = "SidebarMenuItem"; + +const sidebarMenuButtonVariants = cva( + "peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0", + { + variants: { + variant: { + default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground", + outline: + "bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]", + }, + size: { + default: "h-8 text-sm", + sm: "h-7 text-xs", + lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +const SidebarMenuButton = React.forwardRef< + HTMLButtonElement, + React.ComponentProps<"button"> & { + asChild?: boolean; + isActive?: boolean; + tooltip?: string | React.ComponentProps<typeof TooltipContent>; + } & VariantProps<typeof sidebarMenuButtonVariants> +>( + ( + { + asChild = false, + isActive = false, + variant = "default", + size = "default", + tooltip, + className, + ...props + }, + ref, + ) => { + const Comp = asChild ? Slot : "button"; + const { isMobile, state } = useSidebar(); + + const button = ( + <Comp + ref={ref} + data-sidebar="menu-button" + data-size={size} + data-active={isActive} + className={cn(sidebarMenuButtonVariants({ variant, size }), className)} + {...props} + /> + ); + + if (!tooltip) { + return button; + } + + if (typeof tooltip === "string") { + tooltip = { + children: tooltip, + }; + } + + return ( + <Tooltip> + <TooltipTrigger asChild>{button}</TooltipTrigger> + <TooltipContent + side="right" + align="center" + hidden={state !== "collapsed" || isMobile} + {...tooltip} + /> + </Tooltip> + ); + }, +); +SidebarMenuButton.displayName = "SidebarMenuButton"; + +const SidebarMenuAction = React.forwardRef< + HTMLButtonElement, + React.ComponentProps<"button"> & { + asChild?: boolean; + showOnHover?: boolean; + } +>(({ className, asChild = false, showOnHover = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + + return ( + <Comp + ref={ref} + data-sidebar="menu-action" + className={cn( + "text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-none transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", + // Increases the hit area of the button on mobile. + "after:absolute after:-inset-2 after:md:hidden", + "peer-data-[size=sm]/menu-button:top-1", + "peer-data-[size=default]/menu-button:top-1.5", + "peer-data-[size=lg]/menu-button:top-2.5", + "group-data-[collapsible=icon]:hidden", + showOnHover && + "peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0", + className, + )} + {...props} + /> + ); +}); +SidebarMenuAction.displayName = "SidebarMenuAction"; + +const SidebarMenuBadge = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> +>(({ className, ...props }, ref) => ( + <div + ref={ref} + data-sidebar="menu-badge" + className={cn( + "text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums", + "peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground", + "peer-data-[size=sm]/menu-button:top-1", + "peer-data-[size=default]/menu-button:top-1.5", + "peer-data-[size=lg]/menu-button:top-2.5", + "group-data-[collapsible=icon]:hidden", + className, + )} + {...props} + /> +)); +SidebarMenuBadge.displayName = "SidebarMenuBadge"; + +const SidebarMenuSkeleton = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { + showIcon?: boolean; + } +>(({ className, showIcon = false, ...props }, ref) => { + // Random width between 50 to 90%. + const width = React.useMemo(() => { + return `${Math.floor(Math.random() * 40) + 50}%`; + }, []); + + return ( + <div + ref={ref} + data-sidebar="menu-skeleton" + className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)} + {...props} + > + {showIcon && ( + <Skeleton + className="size-4 rounded-md" + data-sidebar="menu-skeleton-icon" + /> + )} + <Skeleton + className="h-4 max-w-[--skeleton-width] flex-1" + data-sidebar="menu-skeleton-text" + style={ + { + "--skeleton-width": width, + } as React.CSSProperties + } + /> + </div> + ); +}); +SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton"; + +const SidebarMenuSub = React.forwardRef< + HTMLUListElement, + React.ComponentProps<"ul"> +>(({ className, ...props }, ref) => ( + <ul + ref={ref} + data-sidebar="menu-sub" + className={cn( + "border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5", + "group-data-[collapsible=icon]:hidden", + className, + )} + {...props} + /> +)); +SidebarMenuSub.displayName = "SidebarMenuSub"; + +const SidebarMenuSubItem = React.forwardRef< + HTMLLIElement, + React.ComponentProps<"li"> +>(({ ...props }, ref) => <li ref={ref} {...props} />); +SidebarMenuSubItem.displayName = "SidebarMenuSubItem"; + +const SidebarMenuSubButton = React.forwardRef< + HTMLAnchorElement, + React.ComponentProps<"a"> & { + asChild?: boolean; + size?: "sm" | "md"; + isActive?: boolean; + } +>(({ asChild = false, size = "md", isActive, className, ...props }, ref) => { + const Comp = asChild ? Slot : "a"; + + return ( + <Comp + ref={ref} + data-sidebar="menu-sub-button" + data-size={size} + data-active={isActive} + className={cn( + "text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-none focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0", + "data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground", + size === "sm" && "text-xs", + size === "md" && "text-sm", + "group-data-[collapsible=icon]:hidden", + className, + )} + {...props} + /> + ); +}); +SidebarMenuSubButton.displayName = "SidebarMenuSubButton"; + +export { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarGroupAction, + SidebarGroupContent, + SidebarGroupLabel, + SidebarHeader, + SidebarInput, + SidebarInset, + SidebarMenu, + SidebarMenuAction, + SidebarMenuBadge, + SidebarMenuButton, + SidebarMenuItem, + SidebarMenuSkeleton, + SidebarMenuSub, + SidebarMenuSubButton, + SidebarMenuSubItem, + SidebarProvider, + SidebarRail, + SidebarSeparator, + SidebarTrigger, + useSidebar, +}; diff --git a/src/components/ui/skeleton.tsx b/src/components/ui/skeleton.tsx new file mode 100644 index 0000000..c26c4ea --- /dev/null +++ b/src/components/ui/skeleton.tsx @@ -0,0 +1,15 @@ +import { cn } from "@/lib/utils"; + +function Skeleton({ + className, + ...props +}: React.HTMLAttributes<HTMLDivElement>) { + return ( + <div + className={cn("animate-pulse rounded-md bg-primary/10", className)} + {...props} + /> + ); +} + +export { Skeleton }; diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx new file mode 100644 index 0000000..3e3f0be --- /dev/null +++ b/src/components/ui/tooltip.tsx @@ -0,0 +1,32 @@ +"use client"; + +import * as React from "react"; +import * as TooltipPrimitive from "@radix-ui/react-tooltip"; + +import { cn } from "@/lib/utils"; + +const TooltipProvider = TooltipPrimitive.Provider; + +const Tooltip = TooltipPrimitive.Root; + +const TooltipTrigger = TooltipPrimitive.Trigger; + +const TooltipContent = React.forwardRef< + React.ElementRef<typeof TooltipPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content> +>(({ className, sideOffset = 4, ...props }, ref) => ( + <TooltipPrimitive.Portal> + <TooltipPrimitive.Content + ref={ref} + sideOffset={sideOffset} + className={cn( + "z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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", + className, + )} + {...props} + /> + </TooltipPrimitive.Portal> +)); +TooltipContent.displayName = TooltipPrimitive.Content.displayName; + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; diff --git a/src/hooks/use-mobile.tsx b/src/hooks/use-mobile.tsx new file mode 100644 index 0000000..2b0fe1d --- /dev/null +++ b/src/hooks/use-mobile.tsx @@ -0,0 +1,19 @@ +import * as React from "react" + +const MOBILE_BREAKPOINT = 768 + +export function useIsMobile() { + const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined) + + React.useEffect(() => { + const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) + const onChange = () => { + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) + } + mql.addEventListener("change", onChange) + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) + return () => mql.removeEventListener("change", onChange) + }, []) + + return !!isMobile +} diff --git a/src/libs/apolloClient.ts b/src/lib/apolloClient.ts similarity index 100% rename from src/libs/apolloClient.ts rename to src/lib/apolloClient.ts diff --git a/src/libs/enum/loginInitialStep.ts b/src/lib/enum/loginInitialStep.ts similarity index 100% rename from src/libs/enum/loginInitialStep.ts rename to src/lib/enum/loginInitialStep.ts diff --git a/src/libs/enum/projectCategoryEnum.ts b/src/lib/enum/projectCategoryEnum.ts similarity index 100% rename from src/libs/enum/projectCategoryEnum.ts rename to src/lib/enum/projectCategoryEnum.ts diff --git a/src/libs/interface/iProject.ts b/src/lib/interface/iProject.ts similarity index 100% rename from src/libs/interface/iProject.ts rename to src/lib/interface/iProject.ts diff --git a/src/libs/interface/iProjectCard.ts b/src/lib/interface/iProjectCard.ts similarity index 100% rename from src/libs/interface/iProjectCard.ts rename to src/lib/interface/iProjectCard.ts diff --git a/src/libs/interface/iUser.ts b/src/lib/interface/iUser.ts similarity index 100% rename from src/libs/interface/iUser.ts rename to src/lib/interface/iUser.ts diff --git a/src/libs/utils.ts b/src/lib/utils.ts similarity index 100% rename from src/libs/utils.ts rename to src/lib/utils.ts diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 1585e25..0dd05ed 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -4,7 +4,7 @@ import "@/styles/mdeditor.css"; import type { AppProps } from "next/app"; import localFont from "next/font/local"; import { ApolloProvider } from "@apollo/client"; -import client from "@/libs/apolloClient"; +import client from "@/lib/apolloClient"; const myFont = localFont({ src: "../fonts/PretendardVariable.woff2" }); export default function App({ Component, pageProps }: AppProps) { diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 0aacfe9..7a5a6e5 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -10,7 +10,7 @@ import { SEARCH_PROJECT } from "@/services/gql/searchProject"; import { useSearchProject } from "@/stores/searchProjectStore"; import { useSelectStacks } from "@/stores/selectStackType/selectStacksStore"; import { useSelectTypes } from "@/stores/selectStackType/selectTypesStore"; -import { getCategoryKey } from "@/libs/enum/projectCategoryEnum"; +import { getCategoryKey } from "@/lib/enum/projectCategoryEnum"; export default function Home() { const [isLoading, setIsLoading] = useState(true); // 초기 로딩 상태 추가 @@ -47,7 +47,7 @@ export default function Home() { resetType(); }, [resetStack, resetType]); - const SIZE = 36; + const SIZE = 5; const observerRef = useRef<HTMLDivElement>(null); const { data, loading, fetchMore, refetch } = useQuery(SEARCH_PROJECT, { @@ -152,7 +152,6 @@ export default function Home() { // if (loading) return <p>Loading...</p>; // 로딩중일 때 카드 스켈레톤 보여주기 // if (error) return <p>Error: {error.message}</p>; - return ( <> <div diff --git a/src/pages/mypage/editprofile.tsx b/src/pages/mypage/editprofile.tsx new file mode 100644 index 0000000..fdaacae --- /dev/null +++ b/src/pages/mypage/editprofile.tsx @@ -0,0 +1,12 @@ +import React from "react"; +import MypageLayout from "@/components/Mypage/MypageLayout"; + +const EditProfile: React.FC = () => { + return ( + <MypageLayout> + <p>개인정보 변경 ^^</p> + </MypageLayout> + ); +}; + +export default EditProfile; diff --git a/src/pages/mypage/index.tsx b/src/pages/mypage/index.tsx new file mode 100644 index 0000000..0750b87 --- /dev/null +++ b/src/pages/mypage/index.tsx @@ -0,0 +1,17 @@ +import { useAuthStore } from "@/stores/authStore"; +import { useRouter } from "next/router"; +import { useEffect } from "react"; + +export default function Mypage() { + const router = useRouter(); + const { isLoggedIn } = useAuthStore(); + + useEffect(() => { + if (isLoggedIn) { + router.replace("/mypage/myprojects"); + console.log(isLoggedIn); + } + }, [isLoggedIn, router]); + + return null; +} diff --git a/src/pages/mypage/likeprojects.tsx b/src/pages/mypage/likeprojects.tsx new file mode 100644 index 0000000..e8a2497 --- /dev/null +++ b/src/pages/mypage/likeprojects.tsx @@ -0,0 +1,12 @@ +import React from "react"; +import MypageLayout from "@/components/Mypage/MypageLayout"; + +const LikeProjects: React.FC = () => { + return ( + <MypageLayout> + <p>좋아요한 프로젝트 ^^</p> + </MypageLayout> + ); +}; + +export default LikeProjects; diff --git a/src/pages/mypage/myprojects.tsx b/src/pages/mypage/myprojects.tsx new file mode 100644 index 0000000..2a6c570 --- /dev/null +++ b/src/pages/mypage/myprojects.tsx @@ -0,0 +1,19 @@ +import React from "react"; +import MypageLayout from "@/components/Mypage/MypageLayout"; +import MyProjectComponents from "@/components/Mypage/MyProjects/MyProjects"; +import { useAuthStore } from "@/stores/authStore"; + +const MyProjects: React.FC = () => { + const { isLoggedIn } = useAuthStore(); + if (isLoggedIn) { + return ( + <MypageLayout> + <MyProjectComponents /> + </MypageLayout> + ); + } else { + return null; + } +}; + +export default MyProjects; diff --git a/src/services/gql/searchProject.ts b/src/services/gql/searchProject.ts index cb21033..8c5789d 100644 --- a/src/services/gql/searchProject.ts +++ b/src/services/gql/searchProject.ts @@ -24,6 +24,7 @@ export const SEARCH_PROJECT = gql` title imageUrls likes + bio } } } diff --git a/src/stores/authStore.ts b/src/stores/authStore.ts index 688b6fc..f3cd497 100644 --- a/src/stores/authStore.ts +++ b/src/stores/authStore.ts @@ -1,4 +1,4 @@ -import { IUser } from "@/libs/interface/iUser"; +import { IUser } from "@/lib/interface/iUser"; import { create } from "zustand"; interface AuthState { diff --git a/src/stores/createProjectStore.ts b/src/stores/createProjectStore.ts index 482d931..2ebef72 100644 --- a/src/stores/createProjectStore.ts +++ b/src/stores/createProjectStore.ts @@ -1,4 +1,4 @@ -import { ProjectCategory } from "@/libs/enum/projectCategoryEnum"; +import { ProjectCategory } from "@/lib/enum/projectCategoryEnum"; import { create } from "zustand"; export interface CreateProject { diff --git a/src/stores/editProjectStore.ts b/src/stores/editProjectStore.ts index 724480f..f5355ef 100644 --- a/src/stores/editProjectStore.ts +++ b/src/stores/editProjectStore.ts @@ -1,4 +1,4 @@ -import { ProjectCategory } from "@/libs/enum/projectCategoryEnum"; +import { ProjectCategory } from "@/lib/enum/projectCategoryEnum"; import { create } from "zustand"; export interface EditProject { diff --git a/src/stores/mypageSidebarStore.ts b/src/stores/mypageSidebarStore.ts new file mode 100644 index 0000000..44af802 --- /dev/null +++ b/src/stores/mypageSidebarStore.ts @@ -0,0 +1,11 @@ +import { create } from "zustand"; + +type SidebarStore = { + sidebarIndex: number; + setSidebarIndex: (index: number) => void; +}; + +export const useSidebarStore = create<SidebarStore>((set) => ({ + sidebarIndex: 0, + setSidebarIndex: (index: number) => set({ sidebarIndex: index }), +})); diff --git a/src/stores/searchProjectStore.ts b/src/stores/searchProjectStore.ts index 1f351ed..da21f4d 100644 --- a/src/stores/searchProjectStore.ts +++ b/src/stores/searchProjectStore.ts @@ -1,7 +1,7 @@ -import { IProjectCard } from "@/libs/interface/iProjectCard"; +import { IProjectCard } from "@/lib/interface/iProjectCard"; import { create } from "zustand"; -export interface SearchProject { +export type SearchProject = { page: number; hasNext: boolean; title: string; @@ -9,6 +9,9 @@ export interface SearchProject { categories: string[]; stackNames: string[]; projects: IProjectCard[]; +}; + +type SearchProjectActions = { setPage: (input: number) => void; setHasNext: (input: boolean) => void; setTitle: (input: string) => void; @@ -16,9 +19,10 @@ export interface SearchProject { setProjects: (input: IProjectCard[]) => void; setStackNames: (input: string[]) => void; setCategories: (input: string[]) => void; -} + reset: () => void; +}; -export const useSearchProject = create<SearchProject>((set) => ({ +const initialState: SearchProject = { page: 0, hasNext: false, title: "", @@ -26,25 +30,40 @@ export const useSearchProject = create<SearchProject>((set) => ({ categories: [], stackNames: [], projects: [], - setPage: (input: number) => { - set({ page: input }); - }, - setHasNext: (input: boolean) => { - set({ hasNext: input }); - }, - setTitle: (input: string) => { - set({ title: input }); - }, - setSearchTitle: (input: string) => { - set({ searchTitle: input }); - }, - setProjects: (input: IProjectCard[]) => { - set({ projects: input }); - }, - setStackNames: (input: string[]) => { - set({ stackNames: input }); - }, - setCategories: (input: string[]) => { - set({ categories: input }); - }, -})); +}; + +export const useSearchProject = create<SearchProject & SearchProjectActions>( + (set) => ({ + page: 0, + hasNext: false, + title: "", + searchTitle: "", + categories: [], + stackNames: [], + projects: [], + setPage: (input: number) => { + set({ page: input }); + }, + setHasNext: (input: boolean) => { + set({ hasNext: input }); + }, + setTitle: (input: string) => { + set({ title: input }); + }, + setSearchTitle: (input: string) => { + set({ searchTitle: input }); + }, + setProjects: (input: IProjectCard[]) => { + set({ projects: input }); + }, + setStackNames: (input: string[]) => { + set({ stackNames: input }); + }, + setCategories: (input: string[]) => { + set({ categories: input }); + }, + reset: () => { + set(initialState); + }, + }), +); diff --git a/src/stores/selectStackType/selectTypesStore.ts b/src/stores/selectStackType/selectTypesStore.ts index 73e9e43..5a4ba0d 100644 --- a/src/stores/selectStackType/selectTypesStore.ts +++ b/src/stores/selectStackType/selectTypesStore.ts @@ -1,5 +1,5 @@ import { create } from "zustand"; -import { ProjectCategory } from "@/libs/enum/projectCategoryEnum"; +import { ProjectCategory } from "@/lib/enum/projectCategoryEnum"; interface SelectTypes { typeToggle: boolean; diff --git a/src/styles/globals.css b/src/styles/globals.css index 89b55f8..67a1728 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -28,7 +28,15 @@ --chart-4: 43 74% 66%; --chart-5: 27 87% 67%; --radius: 0.5rem - } + ; + --sidebar-background: 0 0% 98%; + --sidebar-foreground: 240 5.3% 26.1%; + --sidebar-primary: 240 5.9% 10%; + --sidebar-primary-foreground: 0 0% 98%; + --sidebar-accent: 240 4.8% 95.9%; + --sidebar-accent-foreground: 240 5.9% 10%; + --sidebar-border: 220 13% 91%; + --sidebar-ring: 217.2 91.2% 59.8%} .dark { --background: 240 10% 3.9%; --foreground: 0 0% 98%; @@ -54,7 +62,15 @@ --chart-3: 30 80% 55%; --chart-4: 280 65% 60%; --chart-5: 340 75% 55% - } + ; + --sidebar-background: 240 5.9% 10%; + --sidebar-foreground: 240 4.8% 95.9%; + --sidebar-primary: 224.3 76.3% 48%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 240 3.7% 15.9%; + --sidebar-accent-foreground: 240 4.8% 95.9%; + --sidebar-border: 240 3.7% 15.9%; + --sidebar-ring: 217.2 91.2% 59.8%} } @layer base { * { diff --git a/tailwind.config.js b/tailwind.config.js index 055affd..4c54436 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -3,55 +3,65 @@ module.exports = { darkMode: ["class"], content: ["./src/**/*.{js,ts,jsx,tsx,mdx}"], theme: { - extend: { - borderRadius: { - lg: "var(--radius)", - md: "calc(var(--radius) - 2px)", - sm: "calc(var(--radius) - 4px)", - }, - colors: { - background: "hsl(var(--background))", - foreground: "hsl(var(--foreground))", - card: { - DEFAULT: "hsl(var(--card))", - foreground: "hsl(var(--card-foreground))", - }, - popover: { - DEFAULT: "hsl(var(--popover))", - foreground: "hsl(var(--popover-foreground))", - }, - primary: { - DEFAULT: "hsl(var(--primary))", - foreground: "hsl(var(--primary-foreground))", - }, - secondary: { - DEFAULT: "hsl(var(--secondary))", - foreground: "hsl(var(--secondary-foreground))", - }, - muted: { - DEFAULT: "hsl(var(--muted))", - foreground: "hsl(var(--muted-foreground))", - }, - accent: { - DEFAULT: "hsl(var(--accent))", - foreground: "hsl(var(--accent-foreground))", - }, - destructive: { - DEFAULT: "hsl(var(--destructive))", - foreground: "hsl(var(--destructive-foreground))", - }, - border: "hsl(var(--border))", - input: "hsl(var(--input))", - ring: "hsl(var(--ring))", - chart: { - 1: "hsl(var(--chart-1))", - 2: "hsl(var(--chart-2))", - 3: "hsl(var(--chart-3))", - 4: "hsl(var(--chart-4))", - 5: "hsl(var(--chart-5))", - }, - }, - }, + extend: { + borderRadius: { + lg: 'var(--radius)', + md: 'calc(var(--radius) - 2px)', + sm: 'calc(var(--radius) - 4px)' + }, + colors: { + background: 'hsl(var(--background))', + foreground: 'hsl(var(--foreground))', + card: { + DEFAULT: 'hsl(var(--card))', + foreground: 'hsl(var(--card-foreground))' + }, + popover: { + DEFAULT: 'hsl(var(--popover))', + foreground: 'hsl(var(--popover-foreground))' + }, + primary: { + DEFAULT: 'hsl(var(--primary))', + foreground: 'hsl(var(--primary-foreground))' + }, + secondary: { + DEFAULT: 'hsl(var(--secondary))', + foreground: 'hsl(var(--secondary-foreground))' + }, + muted: { + DEFAULT: 'hsl(var(--muted))', + foreground: 'hsl(var(--muted-foreground))' + }, + accent: { + DEFAULT: 'hsl(var(--accent))', + foreground: 'hsl(var(--accent-foreground))' + }, + destructive: { + DEFAULT: 'hsl(var(--destructive))', + foreground: 'hsl(var(--destructive-foreground))' + }, + border: 'hsl(var(--border))', + input: 'hsl(var(--input))', + ring: 'hsl(var(--ring))', + chart: { + '1': 'hsl(var(--chart-1))', + '2': 'hsl(var(--chart-2))', + '3': 'hsl(var(--chart-3))', + '4': 'hsl(var(--chart-4))', + '5': 'hsl(var(--chart-5))' + }, + sidebar: { + DEFAULT: 'hsl(var(--sidebar-background))', + foreground: 'hsl(var(--sidebar-foreground))', + primary: 'hsl(var(--sidebar-primary))', + 'primary-foreground': 'hsl(var(--sidebar-primary-foreground))', + accent: 'hsl(var(--sidebar-accent))', + 'accent-foreground': 'hsl(var(--sidebar-accent-foreground))', + border: 'hsl(var(--sidebar-border))', + ring: 'hsl(var(--sidebar-ring))' + } + } + } }, plugins: [require("tailwindcss-animate")], };