+
{qnaData.map((item, index) => (
toggle(index)}>
- A. {item.answer}
+ A. {item.answer}
))}
@@ -82,6 +99,11 @@ const ItemWrapper = styled.div`
cursor: pointer;
transition: all 0.2s ease-in-out;
margin-bottom: 20px;
+
+ @media (max-width: ${breakpoints.mobile}px) {
+ padding: 10px 12px 10px 20px;
+ margin-bottom: 10px;
+ }
`;
const Header = styled.div`
@@ -95,11 +117,13 @@ const Header = styled.div`
`;
const Answer = styled.div<{ isOpen: boolean }>`
+ display: flex;
+ gap: 12px;
padding-right: 30px;
font-size: 18px;
- color: #333;
+ color: #767676;
font-weight: 500;
- line-height: 140%;
+ line-height: 150%;
letter-spacing: -0.18px;
max-height: ${({ isOpen }) => (isOpen ? '100px' : '0')};
overflow: hidden;
@@ -109,4 +133,11 @@ const Answer = styled.div<{ isOpen: boolean }>`
max-height 0.3s ease,
opacity 0.3s ease,
margin-top 0.3s ease;
+
+ @media (max-width: ${breakpoints.mobile}px) {
+ font-size: 9px;
+ line-height: 140%;
+ letter-spacing: -0.12px;
+ gap: 6px;
+ }
`;
diff --git a/src/components/features/Landing/AnimatedImages.tsx b/src/components/features/Landing/AnimatedImages.tsx
new file mode 100644
index 00000000..d7f80130
--- /dev/null
+++ b/src/components/features/Landing/AnimatedImages.tsx
@@ -0,0 +1,356 @@
+import BackLeft from '@/assets/Landing/ampersand.png';
+import LogoBox from "@/assets/Landing/logo_box.png";
+import StarText from "@/assets/Landing/star_text.png";
+import FrontLeft from '@/assets/Landing/twisted_4.png';
+import FrontRight from '@/assets/Landing/twisted_5.png';
+import BackRight from '@/assets/Landing/twisted_6.png';
+import { breakpoints } from '@/constants/breakpoints';
+import { useWindowWidth } from '@/hooks/useWindowWidth';
+import styled from '@emotion/styled';
+import { useEffect, useRef, useState } from 'react';
+
+const BackgroundGroup = () => {
+ const [ratio, setRatio] = useState(0);
+ const wrapperRef = useRef
(null);
+ const [direction, setDirection] = useState<'up' | 'down' | null>(null);
+ const [isAnimating, setIsAnimating] = useState(false);
+ const isMobile = useWindowWidth() < breakpoints.mobile;
+
+ const isInViewport = (el: HTMLElement) => {
+ const rect = el.getBoundingClientRect();
+ return rect.top < window.innerHeight && rect.bottom > 0;
+ };
+
+ useEffect(() => {
+ const preventScroll = (e: Event) => {
+ e.preventDefault();
+ };
+
+ if (isAnimating && !isMobile) {
+ window.addEventListener('wheel', preventScroll, { passive: false });
+ window.addEventListener('touchmove', preventScroll, { passive: false });
+ } else {
+ window.removeEventListener('wheel', preventScroll);
+ window.removeEventListener('touchmove', preventScroll);
+ }
+
+ return () => {
+ window.removeEventListener('wheel', preventScroll);
+ window.removeEventListener('touchmove', preventScroll);
+ };
+ }, [isAnimating, isMobile]);
+
+
+
+ const onWheel = (e: WheelEvent) => {
+ if (isMobile) return;
+ if (!wrapperRef.current || !isInViewport(wrapperRef.current)) return;
+ if (isAnimating) {
+ e.preventDefault();
+ return;
+ }
+ if (e.deltaY > 0 && ratio === 0) {
+ e.preventDefault();
+ setDirection('down');
+ }
+ if (e.deltaY < 0 && ratio === 1) {
+ e.preventDefault();
+ setDirection('up');
+ }
+ };
+
+ useEffect(() => {
+ if (isMobile) return;
+ window.addEventListener('wheel', onWheel, { passive: false });
+ return () => window.removeEventListener('wheel', onWheel);
+ }, [isAnimating, ratio, isMobile]);
+
+ useEffect(() => {
+ if (!direction) return;
+
+ setIsAnimating(true);
+ let start: number | null = null;
+ const from = ratio;
+ const to = direction === 'down' ? 1 : 0;
+ const duration = 1000;
+
+ const animate = (t: number) => {
+ if (start === null) start = t;
+ const elapsed = t - start;
+ const progress = Math.min(elapsed / duration, 1);
+ const next = from + (to - from) * progress;
+
+ setRatio(next);
+
+ if (progress < 1) {
+ requestAnimationFrame(animate);
+ } else {
+ setIsAnimating(false);
+ setDirection(null);
+ }
+ };
+
+ requestAnimationFrame(animate);
+ }, [direction]);
+
+
+ return (
+
+
+
+
+
+
+ 코딩테스트 준비해야 하지 않나요?
+ 팀 을 찾고 꾸준히 풀어보세요
+
+
+
+
+
+
+ const prepareCodingTest = () => {
+ console.log("팀을 찾고 꾸준히 풀어보세요!");
+ return "코드몬스터";
+ };
+
+ prepareCodingTest(); // 준비 시작!
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default BackgroundGroup;
+
+const Wrapper = styled.div`
+ position: relative;
+ width: 100%;
+ height: 300px;
+ overflow: hidden;
+
+ @media (max-width: ${breakpoints.mobile}px) {
+ height: 150px;
+ overflow: visible;
+ }
+`;
+
+const ContentWrapper = styled.div`
+ position: relative;
+ width: 100%;
+ height: 100%;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ z-index: 10;
+ `;
+
+ const FadeContent = styled.div`
+ position: absolute;
+ inset: 0;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ transition: opacity 0.5s ease;
+ pointer-events: none;
+
+ @media (max-width: ${breakpoints.mobile}px) {
+ display: flex;
+ flex-direction: row;
+ height: fit-content;
+ align-items: center;
+ justify-content: center;
+ }
+`;
+
+const AnimatedImage = styled.img<{ ratio: number }>`
+ position: absolute;
+ will-change: transform, filter, opacity;
+`;
+
+const Image1 = styled(AnimatedImage)`
+ bottom: 0;
+ right: 0;
+ width: 144px;
+ height: 144px;
+ transform: ${({ ratio }) =>
+ ratio
+ ? 'translateX(0) translateY(-62px) scale(0.8)'
+ : 'translateX(calc(-50vw)) translateY(0) scale(1)'};
+ filter: ${({ ratio }) => (ratio ? 'blur(0)' : 'blur(0)')};
+ opacity: ${({ ratio }) => (ratio ? 1 : 1)};
+ transition:
+ transform 1s ease-in-out,
+ filter 1s ease-in-out 0.5s,
+ opacity 1s ease-in-out 0.5s;
+ z-index: 2;
+
+ @media (max-width: ${breakpoints.mobile}px) {
+ position: absolute;
+ width: 50px;
+ height: 50px;
+ filter: blur(0) !important;
+ opacity: 1 !important;
+ left: 50vw;
+ top: 50px;
+ }
+`;
+
+const Image2 = styled(AnimatedImage)`
+ bottom: 62px;
+ right: 0;
+ width: 137px;
+ height: 137px;
+ transform: ${({ ratio }) =>
+ ratio ? 'translate(-668px, -106px) scale(0.75)' : 'translate(0, 0) scale(1)'};
+ filter: ${({ ratio }) => (ratio ? 'blur(4px)' : 'blur(0)')};
+ opacity: ${({ ratio }) => (ratio ? 0.8 : 1)};
+ transition:
+ transform 1s ease-in-out,
+ filter 1s ease-in-out 0.5s,
+ opacity 1s ease-in-out 0.5s;
+
+ @media (max-width: ${breakpoints.mobile}px) {
+ width: 0px;
+ height: 0px;
+ filter: blur(0) !important;
+ opacity: 1 !important;
+ }
+`;
+
+const Image3 = styled(AnimatedImage)`
+ top: 0;
+ left: 145px;
+ width: 162px;
+ height: 182px;
+ transform: ${({ ratio }) =>
+ ratio ? 'translate(-135px, 140px) scale(1)' : 'translate(0, 0) scale(0.75)'};
+ filter: ${({ ratio }) => (ratio ? 'blur(0)' : 'blur(4px)')};
+ opacity: ${({ ratio }) => (ratio ? 1 : 0.8)};
+ transition:
+ transform 1s ease-in-out,
+ filter 1s ease-in-out 0.5s,
+ opacity 1s ease-in-out 0.5s;
+
+ @media (max-width: ${breakpoints.mobile}px) {
+ left: 30px;
+ top: -15px;
+ width: 48px;
+ height: 52px;
+ filter: blur(3px) !important;
+ opacity: 1 !important;
+ }
+`;
+
+const Image4 = styled.img`
+ position: absolute;
+ top: 128px;
+ right: 260px;
+ width: 98px;
+ height: 98px;
+ z-index: 1;
+
+ @media (max-width: ${breakpoints.mobile}px) {
+ top: 44px;
+ right: 16px;
+ width: 35px;
+ height: 35px;
+ opacity: 1 !important;
+ }
+`;
+
+const InteractionContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ height: 306px;
+ z-index: 10;
+ align-items: center;
+ justify-content: center;
+`;
+
+const ImgContainer = styled.div`
+ position: absolute;
+ left: 50%;
+ transform: translateX(calc(-50% - 420px));
+ top: 48px;
+ display: flex;
+ gap: 3px;
+ align-items: flex-start;
+ z-index: 10;
+
+ @media (max-width: ${breakpoints.mobile}px) {
+ transform: none;
+ left: -5px;
+ top: 25px;
+ height: fit-content;
+ }
+`;
+
+const StarTextImg = styled.img`
+ width: 45px;
+ height: 20px;
+ margin-top: 5px;
+
+ @media (max-width: ${breakpoints.mobile}px) {
+ width: 18px;
+ height: 8px;
+ margin-top: 0;
+ }
+`;
+
+const LogoBoxImg = styled.img`
+ width: 70px;
+ height: 40px;
+
+ @media (max-width: ${breakpoints.mobile}px) {
+ width: 26px;
+ height: 16px;
+ }
+`;
+
+const TextContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-start;
+ align-items: flex-start;
+ height: 228px;
+
+ @media (max-width: ${breakpoints.mobile}px) {
+ height: 100px;
+ justify-content: center;
+ margin-left: 42px;
+ }
+`;
+
+const Title = styled.div`
+ font-size: 50px;
+ font-weight: 700;
+ line-height: 1.4;
+ color: #212529;
+
+ @media (max-width: ${breakpoints.mobile}px) {
+ font-size: 24px;
+ }
+`;
+
+const CodeText = styled.span`
+ color: #FF65B2;
+ font-family: 'MoneygraphyPixel';
+ font-size: 32px;
+ font-style: normal;
+ font-weight: 400;
+ line-height: 38px;
+ letter-spacing: 0.32px;
+`;
\ No newline at end of file
diff --git a/src/components/features/Landing/AnimatedLanding.tsx b/src/components/features/Landing/AnimatedLanding.tsx
new file mode 100644
index 00000000..f56146db
--- /dev/null
+++ b/src/components/features/Landing/AnimatedLanding.tsx
@@ -0,0 +1,141 @@
+import RightArrowIcon from "@/assets/Landing/right_arrow.png";
+import { Spacer } from "@/components/commons/Spacer";
+import { breakpoints } from "@/constants/breakpoints";
+import { useWindowWidth } from "@/hooks/useWindowWidth";
+import { PATH } from "@/routes/path";
+import styled from "@emotion/styled";
+import { useNavigate } from "react-router-dom";
+import AnimatedImages from "./AnimatedImages";
+
+export const AnimatedLanding = () => {
+
+ const navigate = useNavigate();
+ const isMobile = useWindowWidth() < breakpoints.mobile;
+
+ const handleClick = () => {
+ navigate(`${PATH.TEAM_RECRUIT}/list`);
+ }
+ return (
+
+
+
+
+
+ 이미 약 200명의 개발자가
+ 코몬에서 팀원들과 풀이를 공유하고 있어요.
+
+
+ 우리 모두 꾸준히 해야한다는 걸 알면서
+ 조금씩 미루곤 하는 코테 준비.
+
+
+ 코몬에서 알맞은 팀을 찾거나, 혹은 새로 팀을 만들어
+ 목표를 향해 달려가 보는 건 어떨까요?
+
+
+ 함께 가면 더 멀리 갈 수 있으니까요!
+
+
+ 팀 찾아보기
+ {!isMobile ? : null}
+
+
+
+ );
+}
+
+const Container = styled.div`
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ align-items: flex-start;
+ justify-content: flex-start;
+ padding: 0 195px;
+ box-sizing: border-box;
+
+ @media (max-width: ${breakpoints.mobile}px) {
+ padding: 0 36px;
+ }
+`;
+
+
+const ContentContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ width: 100%;
+ position: relative;
+
+`;
+
+const Content = styled.div`
+ display: flex;
+ font-size: 18px;
+ line-height: 1.4;
+ color: #333;
+ font-weight: 500;
+
+ @media (max-width: ${breakpoints.mobile}px) {
+ font-size: 12px;
+ font-family: NanumSquareNeo;
+ }
+`;
+
+const Button = styled.div`
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+
+ position: absolute;
+ top: 0;
+ right: 0;
+ height: 74px;
+ box-sizing: border-box;
+
+ padding: 20px 28px;
+ font-size: 20px;
+ font-weight: bold;
+ color: white;
+ background: #333;
+ border-radius: 9999px;
+
+ width: 180px;
+ transition: width 0.3s ease, background 0.3s ease;
+ overflow: hidden;
+ cursor: pointer;
+
+ .icon {
+ opacity: 0;
+ transform: translateX(-8px);
+ transition: all 0.3s ease;
+ width: 0;
+ height: 0;
+ }
+
+ &:hover {
+ width: 220px;
+
+ .icon {
+ opacity: 1;
+ transform: translateX(0);
+ width: 24px;
+ height: 24px;
+ }
+ }
+
+ @media (max-width: ${breakpoints.mobile}px) {
+ width: 105px;
+ height: 42px;
+ position: relative;
+ font-size: 14px;
+ font-weight: 500;
+ margin-top: 45px;
+ align-self: center;
+ padding: 10px 12px;
+ }
+`;
+
+const Icon = styled.img`
+ display: inline-flex;
+`;
diff --git a/src/components/features/Landing/Banner.tsx b/src/components/features/Landing/Banner.tsx
new file mode 100644
index 00000000..d5d863b8
--- /dev/null
+++ b/src/components/features/Landing/Banner.tsx
@@ -0,0 +1,85 @@
+import { Spacer } from "@/components/commons/Spacer";
+import { breakpoints } from "@/constants/breakpoints";
+import styled from "@emotion/styled";
+
+interface BannerProps {
+ title?: string;
+ description1?: string;
+ description2?: string;
+ src?: string;
+};
+
+export const Banner = ({title, description1, description2, src}: BannerProps) => {
+ return (
+
+
+
+ {title}
+
+
+ {description1}
+ {description2}
+
+
+ );
+}
+
+const Wrapper = styled.div`
+ display: flex;
+ width: 350px;
+ height: 204px;
+ padding: 33px 0;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ gap: 10px;
+ border-radius: 20px;
+ background: #FFF;
+ box-shadow: 2px 2px 20px 0px rgba(94, 96, 153, 0.20);
+ box-sizing: border-box;
+
+ @media (max-width: ${breakpoints.mobile}px) {
+ height: 124px;
+ gap: 5px;
+ }
+`;
+
+const Icon = styled.img`
+ width: 45px;
+ height: 45px;
+
+ @media (max-width: ${breakpoints.mobile}px) {
+ width: 24px;
+ height: 24px;
+ }
+`;
+
+const Title = styled.div`
+ color: #333;
+ text-align: center;
+ font-size: 20px;
+ font-style: normal;
+ font-weight: 600;
+ line-height: 19px;
+ letter-spacing: -0.32px;
+
+ @media (max-width: ${breakpoints.mobile}px) {
+ font-size: 14px;
+ line-height: 20px;
+ }
+`;
+
+const Description = styled.div`
+ color: #767676;
+ text-align: center;
+ font-size: 16px;
+ font-style: normal;
+ font-weight: 400;
+ line-height: 19px;
+ letter-spacing: -0.28px;
+
+ @media (max-width: ${breakpoints.mobile}px) {
+ font-size: 12px;
+ line-height: 18px;
+ }
+`;
\ No newline at end of file
diff --git a/src/components/features/Landing/FeatureCard.tsx b/src/components/features/Landing/FeatureCard.tsx
new file mode 100644
index 00000000..16beb7e5
--- /dev/null
+++ b/src/components/features/Landing/FeatureCard.tsx
@@ -0,0 +1,92 @@
+import { Spacer } from "@/components/commons/Spacer";
+import styled from "@emotion/styled";
+
+interface FeatureCardProps {
+ title: string;
+ content: string;
+ tag1: string;
+ tag2: string;
+};
+
+export const FeatureCard = ({title, content, tag1, tag2}: FeatureCardProps) => {
+ return (
+
+
+
+ {title}
+
+ {content}
+
+
+ {tag1}
+ {tag2}
+
+
+
+
+ );
+}
+
+const CardWrapper = styled.div`
+ background: linear-gradient(235.72deg, #848484 11.56%, #1E1E1E 88.44%);
+ border-radius: 20px;
+ padding: 0.5px;
+ width: 316px;
+ height: 204px;
+`;
+
+const CardBackground = styled.div`
+ width: 316px;
+ height: 204px;
+ background: #000;
+ border-radius: 20px;
+`;
+
+
+const CardContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ justify-content: center;
+ background: linear-gradient(67.79deg, rgba(38, 38, 38, 0.4) 78.95%, rgba(110, 116, 250, 0.4) 99.25%);
+ border-radius: 20px;
+ box-shadow: 2px 2px 20px 0px #5E609933;
+ box-sizing: border-box;
+ padding: 36px;
+ width: 316px;
+ height: 204px;
+`;
+
+const CardTitle = styled.div`
+ font-size: 18px;
+ line-height: 19px;
+ font-weight: 600;
+ color: #fff;
+`;
+
+const CardContent = styled.div`
+ font-size: 14px;
+ line-height: 19px;
+ font-weight: 500;
+ color: #A7A7A7;
+`;
+
+const CardTagContainer = styled.div`
+ display: flex;
+ gap: 10px;
+`;
+
+const CardTag = styled.div`
+ font-size: 12px;
+ color: #fff;
+ width: 70px;
+ height: 24px;
+ background-color: #434697;
+ border-radius: 40px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ box-sizing: border-box;
+`;
+
+export default FeatureCard;
\ No newline at end of file
diff --git a/src/components/features/Landing/ServiceStrength.tsx b/src/components/features/Landing/ServiceStrength.tsx
new file mode 100644
index 00000000..2015d13f
--- /dev/null
+++ b/src/components/features/Landing/ServiceStrength.tsx
@@ -0,0 +1,95 @@
+import HalfComonImage from "@/assets/Landing/half_comon.png";
+import { Spacer } from "@/components/commons/Spacer";
+import FeatureCard from "@/components/features/Landing/FeatureCard";
+import { breakpoints } from "@/constants/breakpoints";
+import { useWindowWidth } from "@/hooks/useWindowWidth";
+import styled from "@emotion/styled";
+
+const content = [
+ {
+ title: "손쉬운 관리, 한 눈에 보는 기록",
+ content: "내가 푼 코딩테스트 문제들을 캘린더에서 쉽게 찾아보고, 풀이 과정을 회고 하며 체계적으로 관리할 수 있습니다",
+ tag1: "팀 캘린더",
+ tag2: "마이페이지"
+ },
+ {
+ title: "편한 코드 입력, 코드블럭",
+ content: "자동 들여쓰기와 문법 강조 기능이 지원되는 코드블럭으로, 복잡한 풀이도 깔끔하게 기록하고 공유할 수 있습니다",
+ tag1: "코드블럭",
+ tag2: "오늘의풀이"
+ },
+ {
+ title: "함께하는 성장, 팀으로 함께",
+ content: "목표가 같은 동료들과 팀을 이루어 서로의 성장을 돕고, 풀이를 비교 분석하며 더 빠르게 성장해보세요.",
+ tag1: "팀 페이지",
+ tag2: "팀모집"
+ }
+];
+
+
+export const ServiceStrength = () => {
+ const isMobile = useWindowWidth() < breakpoints.mobile;
+ return (
+
+
+ "코드몬스터만의 강점"
+
+
+ {content.map((item, index) => (
+
+ ))}
+
+
+
+ );
+}
+
+const Container = styled.div`
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+`;
+
+const Title = styled.div`
+ font-size: 42px;
+ font-weight: 700;
+ color: #fff;
+
+ @media (max-width: ${breakpoints.mobile}px) {
+ font-size: 21px;
+ }
+`;
+
+const CardContainer = styled.div`
+ display: flex;
+ gap: 27px;
+ margin-bottom: 200px;
+
+ @media (max-width: ${breakpoints.mobile}px) {
+ flex-direction: column;
+ margin-bottom: 60px;
+ }
+`;
+
+const HalfComonImg = styled.img`
+ width: 411px;
+ height: auto;
+ margin-top: 20px;
+ position: absolute;
+ bottom: 0;
+ left: 50%;
+ transform: translateX(-50%);
+
+ @media (max-width: ${breakpoints.mobile}px) {
+ width: 200px;
+ }
+`;
+
+export default ServiceStrength;
\ No newline at end of file
diff --git a/src/components/features/Landing/UsageExample.tsx b/src/components/features/Landing/UsageExample.tsx
new file mode 100644
index 00000000..9329aae4
--- /dev/null
+++ b/src/components/features/Landing/UsageExample.tsx
@@ -0,0 +1,303 @@
+import CaretLeftIcon from '@/assets/Landing/caret_left.svg';
+import CaretRightIcon from '@/assets/Landing/caret_right.svg';
+import exampleImage1 from '@/assets/Landing/example_img1.png';
+import exampleImage2 from '@/assets/Landing/example_img2.png';
+import exampleImage3 from '@/assets/Landing/example_img3.png';
+import Twisted1 from '@/assets/Landing/twisted_1.png';
+import Twisted2 from '@/assets/Landing/twisted_2.png';
+import Twisted3 from '@/assets/Landing/twisted_3.png';
+import { Spacer } from '@/components/commons/Spacer';
+import { breakpoints } from '@/constants/breakpoints';
+import { useWindowWidth } from '@/hooks/useWindowWidth';
+import styled from '@emotion/styled';
+import { useRef } from 'react';
+import Slider from 'react-slick';
+import "slick-carousel/slick/slick-theme.css";
+import "slick-carousel/slick/slick.css";
+
+const usageExamples = (isMobile: boolean) => [
+ {
+ title: '코테 기록을 더 쉽게! ✨',
+ content: isMobile ? [
+ '오늘의 문제 풀이에서 바로 문제를 펼쳐보고 작성해보세요!',
+ '기본적인 마크다운 단축키와 코드블록으로',
+ '깔끔한 입력을 지원합니다 ✨'
+ ] : [
+ '오늘의 문제 풀이에서 바로 문제를 펼쳐보고 작성해보세요!',
+ '기본적인 마크다운 단축키와 코드블록으로 깔끔한 입력을 지원합니다 ✨',
+ ],
+ image: exampleImage1,
+ style: { width: '476px', height: '357px' },
+ mobileStyle: { width: '238px', height: '178px' },
+ },
+ {
+ title: '꾸준한 학습습관 만들기🏃',
+ content: isMobile ? [
+ '날마다 팀원들과 함께 활동을 기록하세요!',
+ '캘린더를 통해 문제를 확인하고,',
+ '하루에 한 문제를 함께 풀어보아요✨'
+ ] : [
+ '날마다 팀원들과 함께 활동을 기록하세요!',
+ '캘린더를 통해 문제를 확인하고, 하루에 한 문제를 함께 풀어보아요✨',
+ ],
+ image: exampleImage2,
+ style: { width: '944px', height: '357px' },
+ mobileStyle: { width: '280px', height: '160px' },
+ },
+ {
+ title: '나에게 맞는 팀을 찾아보세요!',
+ content: [
+ '나의 코드테스트 목표와 레벨에 맞는 팀을 찾아보세요!',
+ '원하는 팀에 참여하거나 직접 팀을 꾸려 함께 성장해봐요✨',
+ ],
+ image: exampleImage3,
+ style: { width: '680px', height: '357px' },
+ mobileStyle: { width: '280px', height: '160px' },
+ },
+];
+
+const UsageExampleCard = ({ index }: { index: number }) => {
+ const isMobile = useWindowWidth() < breakpoints.mobile;
+ const { title, content, image, style, mobileStyle } = usageExamples(isMobile)[index];
+
+ return (
+
+ {title}
+
+
+
+ {content[0]}
+ {content[1]}
+ { content[2] && {content[2]} }
+
+
+
+
+
+
+
+
+ );
+};
+
+export const UsageExample = () => {
+ const sliderRef = useRef(null);
+ const isMobile = useWindowWidth() < breakpoints.mobile;
+
+ return (
+
+
+
+
+
+
+
+
+
+ sliderRef.current?.slickPrev()}>
+
+
+ sliderRef.current?.slickNext()}>
+
+
+
+ );
+};
+
+const StyledSlider = styled(Slider)`
+ width: 100%;
+ .slick-slide,
+ .slick-track {
+ width: 950px;
+ height: 600px;
+}
+ .slick-list {
+ background: transparent !important;
+ box-shadow: none !important;
+ border: none !important;
+ }
+
+ @media (max-width: ${breakpoints.mobile}px) {
+ .slick-slide,
+ .slick-track {
+ width: 340px;
+ height: 400px;
+ }
+ }
+`;
+
+const SliderWrapper = styled.div`
+ position: relative;
+ display: flex;
+ justify-content: center;
+ align-items: flex-start;
+`;
+
+const NavButton = styled.div`
+ position: absolute;
+ top: 50%;
+ transform: translateY(-50%);
+ width: 56px;
+ height: 56px;
+ border-radius: 100px;
+ background-color: #00000029;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+
+ @media (max-width: ${breakpoints.mobile}px) {
+ transform: translateY(-100%);
+ width: 28px;
+ height: 28px;
+ }
+`;
+
+const CaretIcon = styled.img`
+ width: 24px;
+ height: 24px;
+
+ @media (max-width: ${breakpoints.mobile}px) {
+ width: 12px;
+ height: 12px;
+ }
+`;
+
+const Container = styled.div`
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ position: relative;
+ margin-top: 64px;
+ width: 100%;
+ height: 100%;
+
+ @media (max-width: ${breakpoints.mobile}px) {
+ margin-top: 48px;
+ }
+`;
+
+const MainBox = styled.div`
+ display: flex;
+ flex-direction: column;
+ width: 900px;
+ height: 518px;
+ background-color: #FFFFFF66;
+ box-shadow: 2px 2px 20px 0px #5E609933;
+ border-radius: 40px;
+ border: 3px solid #FFFFFF;
+ justify-content: center;
+ align-items: center;
+
+ @media (max-width: ${breakpoints.mobile}px) {
+ width: 314px;
+ height: 288px;
+ justify-content: flex-start;
+ padding-top: 30px;
+ box-sizing: border-box;
+ z-index: 1;
+ border: 1.5px solid #FFFFFF;
+ border-radius: 20px;
+ }
+`;
+
+const TitleBox = styled.div`
+ position: absolute;
+ top: -40px;
+ left: 50%;
+ transform: translateX(-50%);
+ width: 376px;
+ height: 68px;
+ border-radius: 100px;
+ box-shadow: 0px 4px 10px 0px #0000000A;
+ background: #FCFCFF;
+ z-index: 2;
+ color: #111111;
+ font-size: 28px;
+ font-weight: 600;
+ justify-content: center;
+ align-items: center;
+ display: flex;
+
+ @media (max-width: ${breakpoints.mobile}px) {
+ width: 188px;
+ height: 34px;
+ top: -20px;
+ font-size: 16px;
+ }
+`;
+
+const ContentContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+`;
+
+const ContentText = styled.div`
+ font-size: 18px;
+ color: #111;
+ font-weight: 400;
+ line-height: 1.4;
+ text-align: center;
+
+ @media (max-width: ${breakpoints.mobile}px) {
+ font-size: 12px;
+ }
+`;
+
+const TwistedDecoration1 = styled.img`
+ position: absolute;
+ top: 31px;
+ left: 96px;
+ width: 74px;
+ height: 74px;
+
+ @media (max-width: ${breakpoints.mobile}px) {
+ top: -30px;
+ left: 0px;
+ width: 62px;
+ height: 62px;
+ }
+`;
+
+const TwistedDecoration2 = styled.img`
+ position: absolute;
+ top: 31px;
+ right: 88px;
+ width: 72px;
+ height: 72px;
+
+ @media (max-width: ${breakpoints.mobile}px) {
+ top: -30px;
+ right: 0px;
+ width: 62px;
+ height: 62px;
+ }
+`;
+
+const TwistedDecoration3 = styled.img`
+ position: absolute;
+ top: 101px;
+ right: 159px;
+ width: 35px;
+ height: 35px;
+
+ @media (max-width: ${breakpoints.mobile}px) {
+ top: 20px;
+ right: 70px;
+ width: 30px;
+ height: 30px;
+ }
+`;
+
+export default UsageExample;
diff --git a/src/components/features/Landing/UserReviewSlider.tsx b/src/components/features/Landing/UserReviewSlider.tsx
new file mode 100644
index 00000000..ecbbbfa1
--- /dev/null
+++ b/src/components/features/Landing/UserReviewSlider.tsx
@@ -0,0 +1,348 @@
+import CaretLeftIcon from '@/assets/Landing/caret_left.svg';
+import CaretRightIcon from '@/assets/Landing/caret_right.svg';
+import { Spacer } from '@/components/commons/Spacer';
+import { breakpoints } from '@/constants/breakpoints';
+import { useWindowWidth } from '@/hooks/useWindowWidth';
+import styled from '@emotion/styled';
+import { useEffect, useRef, useState } from 'react';
+import Slider from 'react-slick';
+import 'slick-carousel/slick/slick-theme.css';
+import 'slick-carousel/slick/slick.css';
+
+const reviewList = [
+ {
+ name: '김철수',
+ position: '코몬_4days',
+ content: '"처음엔 코드테스트가 낯설었지만, 팀과 함께 학습하며 자신감이 생겼어요. 못하던 부분도 차근차근 극복해나가는 과정이 가장 큰 수확이었습니다!"',
+ color: '#FF5780',
+ },
+ {
+ name: '애순이',
+ position: '코몬_4days',
+ content: '"코몬 4days의 루틴이 제게는 약이 됐어요. \'오늘은 쉴까\' 했던 날도 팀원들의 인증 사진을 보면 결국 책상 앞에 앉게 되더라구요. 3개월간 주 4회+α로 풀며 카카오 코테 1차를 통과할 만큼 실력이 쑥쑥 자랐습니다!"',
+ color: '#6E74FA',
+ },
+ {
+ name: '홍길동',
+ position: '코몬_4days',
+ content: '"원래 일주일에 한두 문제 풀던 제가, 코몬 4days 팀에 합류하니 일주일 4회는 기본이 되더라고요. 처음엔 벅찼지만, 팀원들과 서로 리마인드하고 피드백 주고받으니 2달 만에 목표했던 실버 등급 달성까지 성공했어요!"',
+ color: ' #FF5780',
+ },
+ {
+ name: '족발',
+ position: '코몬_6days',
+ content:
+ '"혼자서는 매일 하기 힘들던 코테를, 팀원들과 약속으로 꾸준히 풀게 됐어요. 특히 스터디 전용 깃허브에 기록하니 빠진 날엔 바로 채워야 한다는 책임감이 생기더라구요. 덕분에 이전보다 문제 유형 분석 속도가 2배 빨라졌네요!"',
+ color: '#F15CA7',
+ },
+ {
+ name: '동대구',
+ position: '싸피 a형 대비방',
+ content: '"매일 성실하게 문제를 풀다 보니, 어려웠던 유형도 자연스럽게 익혀졌어요. 함께 공부하는 동기부여가 되어 더 열심히 할 수 있었던 것 같아요!"',
+ color: '#6E74FA',
+ },
+];
+
+const ReviewCard = ({
+ name,
+ position,
+ content,
+ color,
+}: {
+ name: string;
+ position: string;
+ content: string;
+ color: string;
+}) => {
+ const isMobile = useWindowWidth() < breakpoints.mobile;
+ return (
+
+ {content}
+
+
+
{name}
+
{position}
+
+
+ );
+};
+
+export const UserReviewSlider = () => {
+ const sliderRef = useRef(null);
+ const [isMobile, setIsMobile] = useState(
+ typeof window !== 'undefined' && window.innerWidth < breakpoints.mobile,
+ );
+
+ useEffect(() => {
+ const handleResize = () => {
+ setIsMobile(window.innerWidth < breakpoints.mobile);
+ };
+
+ window.addEventListener('resize', handleResize);
+ return () => window.removeEventListener('resize', handleResize);
+ }, []);
+
+ const settings = {
+ className: 'center',
+ centerMode: true,
+ infinite: true,
+ slidesToShow: isMobile ? 1 : 3,
+ slidesToScroll: 1,
+ speed: 500,
+ responsive: [
+ {
+ breakpoint: breakpoints.mobile,
+ settings: {
+ slidesToShow: 1,
+ centerPadding: '145px',
+ centerMode: true,
+ slidesToScroll: 1,
+ },
+ },
+ {
+ breakpoint: 1024,
+ settings: {
+ slidesToShow: 3,
+ centerMode: true,
+ slidesToScroll: 1,
+ },
+ },
+ ],
+ };
+
+ return (
+ <>
+ 함께한 사람들의 후기
+ 코드몬스터와 함께 성장한 동료들의 생생한 후기✨
+
+
+
+
+ {reviewList.map((review, index) => (
+
+ ))}
+
+
+ sliderRef.current?.slickPrev()}
+ >
+
+
+ sliderRef.current?.slickNext()}
+ >
+
+
+
+
+
+ >
+ );
+};
+
+const Title = styled.div`
+ font-size: 36px;
+ font-weight: 700;
+ color: #111;
+ margin-bottom: 20px;
+
+ @media (max-width: ${breakpoints.mobile}px) {
+ font-size: 24px;
+ margin-bottom: 10px;
+ }
+`;
+const SubTitle = styled.div`
+ font-size: 24px;
+ font-weight: 300;
+ color: #111;
+
+ @media (max-width: ${breakpoints.mobile}px) {
+ font-size: 14px;
+ }
+`;
+
+const StyledSlider = styled(Slider)`
+ .slick-list {
+ box-sizing: content-box;
+ margin: 0 100px;
+ @media (max-width: ${breakpoints.mobile}px) {
+ margin: 0 -120px;
+ }
+ }
+ .slick-slide {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ padding: 10px 0;
+ box-sizing: border-box;
+ }
+
+ .slick-center .review-card {
+ z-index: 10;
+ }
+`;
+
+const SliderWrapper = styled.div`
+ position: relative;
+ display: flex;
+ justify-content: center;
+ align-items: flex-start;
+ width: 100%;
+ box-sizing: border-box;
+ padding: 20px 0;
+`;
+
+const NavButton = styled.div`
+ position: absolute;
+ top: 50%;
+ transform: translateY(-50%);
+ width: 56px;
+ height: 56px;
+ border-radius: 100px;
+ background-color: #00000029;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ z-index: 10;
+
+ @media (max-width: ${breakpoints.mobile}px) {
+ width: 28px;
+ height: 28px;
+ }
+`;
+
+const NavButtonPrev = styled(NavButton)`
+ left: 50%;
+ transform: translateY(-50%) translateX(-500px);
+
+ @media (max-width: ${breakpoints.mobile}px) {
+ left: 15px;
+ transform: translateY(-50%) translateX(-50%);
+ }
+`;
+
+const NavButtonNext = styled(NavButton)`
+ right: 50%;
+ transform: translateY(-50%) translateX(500px);
+
+ @media (max-width: ${breakpoints.mobile}px) {
+ right: 0;
+ transform: translateY(-50%);
+ }
+`;
+
+
+const CaretIcon = styled.img`
+ width: 24px;
+ height: 24px;
+ @media (max-width: ${breakpoints.mobile}px) {
+ width: 12px;
+ height: 12px;
+ }
+`;
+
+const CardContainer = styled.div`
+ position: relative;
+ padding: 30px 40px;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+ width: 316px;
+ height: 180px;
+ border-radius: 20px;
+ background: #fff;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ text-align: left;
+ box-sizing: border-box;
+ transition: transform 0.3s ease-in-out, border 0.3s ease-in-out;
+ border: 1px solid transparent;
+
+ .slick-center & {
+ transform: scale(1.05);
+ border: 1px solid #8488ec !important;
+ z-index: 10;
+ }
+
+ @media (max-width: ${breakpoints.mobile}px) {
+ width: 316px;
+ height: 180px;
+ padding: 35px 26px;
+ }
+`;
+
+const Circle = styled.div`
+ width: 20px;
+ height: 20px;
+ border-radius: 50%;
+
+ @media (max-width: ${breakpoints.mobile}px) {
+ width: 18px;
+ height: 18px;
+ }
+`;
+
+const Name = styled.div`
+ font-size: 16px;
+ font-weight: 600;
+ color: #333;
+
+ @media (max-width: ${breakpoints.mobile}px) {
+ font-size: 14px;
+ }
+`;
+
+const Position = styled.div`
+ font-size: 14px;
+ color: #111;
+ font-weight: 400;
+
+ @media (max-width: ${breakpoints.mobile}px) {
+ font-size: 12px;
+ }
+`;
+
+const Content = styled.div`
+ font-size: 14px;
+ color: #767676;
+ line-height: 19px;
+ min-height: 100px;
+ font-weight: 400;
+ overflow: hidden;
+
+ .slick-center & {
+ color: #111 !important;
+ font-weight: 400 !important;
+ }
+
+ @media (max-width: ${breakpoints.mobile}px) {
+ font-size: 12px;
+ line-height: 18px;
+ min-height: 48px;
+ }
+`;
+
+const Shadow = styled.div`
+ background: #D4D4D466;
+ filter: blur(4px);
+ width: 60%;
+ border-radius: 50%;
+ height: 19px;
+
+ @media (max-width: ${breakpoints.mobile}px) {
+ width: 100%;
+ height: 10px;
+ }
+`;
+
+export default UserReviewSlider;
\ No newline at end of file
diff --git a/src/components/features/Post/PostEditor.tsx b/src/components/features/Post/PostEditor.tsx
index 6004823f..92fa2c1b 100644
--- a/src/components/features/Post/PostEditor.tsx
+++ b/src/components/features/Post/PostEditor.tsx
@@ -1,5 +1,7 @@
import { viewStyle } from '@/utils/viewStyle';
+import { useImageCompressor } from '@/hooks/useImageCompressor.ts';
+
import { ImageNode } from '@/components/features/Post/nodes/ImageNode';
import { ClipboardPlugin } from '@/components/features/Post/plugins/ClipboardPlugin.ts';
import { CodeActionPlugin } from '@/components/features/Post/plugins/CodeActionPlugin';
@@ -25,11 +27,12 @@ import {
memo,
useCallback,
useEffect,
+ useRef,
useState,
} from 'react';
-import { requestPresignedUrl, toS3 } from '@/api/presignedurl.ts';
import { breakpoints } from '@/constants/breakpoints';
+import { postImagesAtom } from '@/store/posting';
import styled from '@emotion/styled';
import { CodeHighlightNode, CodeNode } from '@lexical/code';
import { AutoLinkNode, LinkNode } from '@lexical/link';
@@ -46,7 +49,11 @@ import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
import { TabIndentationPlugin } from '@lexical/react/LexicalTabIndentationPlugin';
import { HeadingNode, QuoteNode } from '@lexical/rich-text';
import { addClassNamesToElement } from '@lexical/utils';
+import { useAtom } from 'jotai';
import {
+ $getNearestNodeFromDOMNode,
+ $getNodeByKey,
+ $getRoot,
$isLineBreakNode,
DOMExportOutput,
EditorThemeClasses,
@@ -312,207 +319,205 @@ const EditorPlaceholder = styled.div`
}
`;
-// TODO: 사용자 이벤트 추적하면서 이미지 순서 변경, 삭제, 추가를 트래킹하는 로직인데 나중에 필요할 수도
-// const blobUrlToFile = async (blobUrl: string, fileName: string) => {
-// return await fetch(blobUrl, {
-// mode: 'cors',
-// // headers: {
-// // 'Access-Control-Allow-Origin': 'https://test.codemonster.site/',
-// // Origin: 'https://test.codemonster.site/',
-// // },
-// })
-// .then((res) => res.blob())
-// .then((blob) => new File([blob], fileName, { type: blob.type }));
-// };
-//
-// const findImgElement = (element: HTMLElement): Promise => {
-// return new Promise((resolve, reject) => {
-// const maxAttempts = 10; // 10 * 100ms
-// let attempts = 0;
-//
-// const intervalId = setInterval(() => {
-// attempts++;
-// const img = element.querySelector('img');
-//
-// if (img) {
-// clearInterval(intervalId);
-// resolve(img);
-// } else if (attempts >= maxAttempts) {
-// clearInterval(intervalId);
-// reject(new Error('Failed to find img element after max attempts'));
-// }
-// }, 100);
-// });
-// };
-
-// const useDetectImageMutation = () => {
-// const [editor] = useLexicalComposerContext();
-// const [images, setImages] = useAtom(postImagesAtom);
-// const { compressImage } = useImageCompressor({ quality: 1, maxSizeMb: 1 });
-// const firstNodeKey = useRef('');
-//
-// useEffect(() => {
-// Promise.resolve().then(() => {
-// // if (firstNodeKey.current !== '') {
-// editor.read(() => {
-// const rootElement = editor.getRootElement();
-// if (!rootElement) {
-// return;
-// }
-// const imgs = rootElement.querySelectorAll('.editor-image');
-// const imgNodes = Array.from(imgs)
-// .map((img) => $getNearestNodeFromDOMNode(img))
-// .filter((imgNode) => imgNode !== null);
-//
-// if (imgNodes.length > 0) {
-// firstNodeKey.current = imgNodes[0]?.getKey();
-// }
-// });
-// // }
-// });
-// }, [editor]);
-//
-// useEffect(() => {
-// const unregisterMutationListener = editor.registerMutationListener(
-// ImageNode,
-// (mutations) => {
-// mutations.forEach((mutation, nodeKey) => {
-// if (mutation === 'created') {
-// editor.update(() => {
-// const element = editor.getElementByKey(nodeKey);
-// if (!element) {
-// return;
-// }
-//
-// const node = $getNodeByKey(nodeKey);
-// if (!node) {
-// return;
-// }
-//
-// console.log('nodeKey', firstNodeKey.current, images);
-// // if (images.length === 0 && firstNodeKey.current === '') {
-// if (firstNodeKey.current === '') {
-// firstNodeKey.current = nodeKey;
-// }
-// // 이미지 최대 하나로 제한
-// else {
-// if (nodeKey !== firstNodeKey.current) {
-// node.remove();
-// alert('이미지는 최대 하나 까지만 넣을 수 있어요');
-// }
-// return;
-// }
-//
-// const parentNodeKey = node
-// .getParentKeys()
-// .filter((key) => key !== 'root')[0];
-// const parent = editor.getElementByKey(parentNodeKey);
-// if (!parent) {
-// return;
-// }
-//
-// const imgs = parent.querySelectorAll('.editor-image');
-// const line = $getRoot()
-// .getChildren()
-// .findIndex((node) => node.getKey() === parentNodeKey);
-//
-// // 이거 아직 이미지가 하나
-// Promise.all(
-// [...imgs].map((img) =>
-// findImgElement(img as HTMLElement).then((foundImg) => {
-// let myNodeKey = '';
-// editor.read(() => {
-// const node = $getNearestNodeFromDOMNode(img);
-// if (node) {
-// myNodeKey = node.getKey();
-// }
-// });
-// return blobUrlToFile(foundImg.src, `img-${myNodeKey}`);
-// })
-// )
-// )
-// .then(async (results) => {
-// const compressedImg = [];
-//
-// for (const imgFile of results) {
-// const requestId = `${imgFile.name}-${Date.now()}.jpg`;
-// const res = await compressImage(requestId, imgFile);
-// compressedImg.push(res);
-// }
-//
-// const imgObjs = compressedImg.map((img, idx) => ({
-// key: img.name.split('-')[1].split('.')[0],
-// img: img,
-// line: line,
-// idx: idx,
-// }));
-//
-// setImages((prev) => {
-// const filteredNewImages = imgObjs.filter(
-// (newImg) =>
-// !prev.some(
-// (img) =>
-// img.line === newImg.line && img.idx === newImg.idx
-// )
-// );
-// if (filteredNewImages.length === 0) {
-// return prev;
-// }
-//
-// const targetNewLine = line;
-// const rearrangedArr = prev.map((imgObj) => {
-// if (imgObj.line > targetNewLine) {
-// return { ...imgObj, line: imgObj.line + 1 };
-// }
-// return imgObj;
-// });
-// return [...rearrangedArr, ...filteredNewImages];
-// });
-// })
-// .catch((err) => {
-// console.error('Promise.all error', err);
-// editor.update(() => {
-// const node = $getNodeByKey(nodeKey);
-// if (node) {
-// node.remove();
-// setTimeout(() => {
-// alert('이미지 파일로 전환을 실패했습니다.');
-// }, 300);
-// }
-// });
-// });
-// });
-// return;
-// }
-//
-// if (mutation === 'destroyed') {
-// setImages((prev) =>
-// prev.filter((imgObj) => imgObj.key !== nodeKey)
-// );
-// if (firstNodeKey.current === nodeKey) {
-// firstNodeKey.current = '';
-// }
-// }
-// });
-// }
-// );
-//
-// return () => {
-// // firstNodeKey.current = '';
-// unregisterMutationListener();
-// };
-// }, [editor, images]);
-// };
+const blobUrlToFile = async (blobUrl: string, fileName: string) => {
+ return await fetch(blobUrl, {
+ mode: 'cors',
+ // headers: {
+ // 'Access-Control-Allow-Origin': 'https://test.codemonster.site/',
+ // Origin: 'https://test.codemonster.site/',
+ // },
+ })
+ .then((res) => res.blob())
+ .then((blob) => new File([blob], fileName, { type: blob.type }));
+};
+
+const findImgElement = (element: HTMLElement): Promise => {
+ return new Promise((resolve, reject) => {
+ const maxAttempts = 10; // 10 * 100ms
+ let attempts = 0;
+
+ const intervalId = setInterval(() => {
+ attempts++;
+ const img = element.querySelector('img');
+
+ if (img) {
+ clearInterval(intervalId);
+ resolve(img);
+ } else if (attempts >= maxAttempts) {
+ clearInterval(intervalId);
+ reject(new Error('Failed to find img element after max attempts'));
+ }
+ }, 100);
+ });
+};
+
+const useDetectImageMutation = () => {
+ const [editor] = useLexicalComposerContext();
+ const [images, setImages] = useAtom(postImagesAtom);
+ const { compressImage } = useImageCompressor({ quality: 1, maxSizeMb: 1 });
+ const firstNodeKey = useRef('');
+
+ useEffect(() => {
+ Promise.resolve().then(() => {
+ // if (firstNodeKey.current !== '') {
+ editor.read(() => {
+ const rootElement = editor.getRootElement();
+ if (!rootElement) {
+ return;
+ }
+ const imgs = rootElement.querySelectorAll('.editor-image');
+ const imgNodes = Array.from(imgs)
+ .map((img) => $getNearestNodeFromDOMNode(img))
+ .filter((imgNode) => imgNode !== null);
+
+ if (imgNodes.length > 0) {
+ firstNodeKey.current = imgNodes[0]?.getKey();
+ }
+ });
+ // }
+ });
+ }, [editor]);
+
+ useEffect(() => {
+ const unregisterMutationListener = editor.registerMutationListener(
+ ImageNode,
+ (mutations) => {
+ mutations.forEach((mutation, nodeKey) => {
+ if (mutation === 'created') {
+ editor.update(() => {
+ const element = editor.getElementByKey(nodeKey);
+ if (!element) {
+ return;
+ }
+
+ const node = $getNodeByKey(nodeKey);
+ if (!node) {
+ return;
+ }
+
+ console.log('nodeKey', firstNodeKey.current, images);
+ // if (images.length === 0 && firstNodeKey.current === '') {
+ if (firstNodeKey.current === '') {
+ firstNodeKey.current = nodeKey;
+ }
+ // 이미지 최대 하나로 제한
+ else {
+ if (nodeKey !== firstNodeKey.current) {
+ node.remove();
+ alert('이미지는 최대 하나 까지만 넣을 수 있어요');
+ }
+ return;
+ }
+
+ const parentNodeKey = node
+ .getParentKeys()
+ .filter((key) => key !== 'root')[0];
+ const parent = editor.getElementByKey(parentNodeKey);
+ if (!parent) {
+ return;
+ }
+
+ const imgs = parent.querySelectorAll('.editor-image');
+ const line = $getRoot()
+ .getChildren()
+ .findIndex((node) => node.getKey() === parentNodeKey);
+
+ // 이거 아직 이미지가 하나
+ Promise.all(
+ [...imgs].map((img) =>
+ findImgElement(img as HTMLElement).then((foundImg) => {
+ let myNodeKey = '';
+ editor.read(() => {
+ const node = $getNearestNodeFromDOMNode(img);
+ if (node) {
+ myNodeKey = node.getKey();
+ }
+ });
+ return blobUrlToFile(foundImg.src, `img-${myNodeKey}`);
+ })
+ )
+ )
+ .then(async (results) => {
+ const compressedImg = [];
+
+ for (const imgFile of results) {
+ const requestId = `${imgFile.name}-${Date.now()}.jpg`;
+ const res = await compressImage(requestId, imgFile);
+ compressedImg.push(res);
+ }
+
+ const imgObjs = compressedImg.map((img, idx) => ({
+ key: img.name.split('-')[1].split('.')[0],
+ img: img,
+ line: line,
+ idx: idx,
+ }));
+
+ setImages((prev) => {
+ const filteredNewImages = imgObjs.filter(
+ (newImg) =>
+ !prev.some(
+ (img) =>
+ img.line === newImg.line && img.idx === newImg.idx
+ )
+ );
+ if (filteredNewImages.length === 0) {
+ return prev;
+ }
+
+ const targetNewLine = line;
+ const rearrangedArr = prev.map((imgObj) => {
+ if (imgObj.line > targetNewLine) {
+ return { ...imgObj, line: imgObj.line + 1 };
+ }
+ return imgObj;
+ });
+ return [...rearrangedArr, ...filteredNewImages];
+ });
+ })
+ .catch((err) => {
+ console.error('Promise.all error', err);
+ editor.update(() => {
+ const node = $getNodeByKey(nodeKey);
+ if (node) {
+ node.remove();
+ setTimeout(() => {
+ alert('이미지 파일로 전환을 실패했습니다.');
+ }, 300);
+ }
+ });
+ });
+ });
+ return;
+ }
+
+ if (mutation === 'destroyed') {
+ setImages((prev) =>
+ prev.filter((imgObj) => imgObj.key !== nodeKey)
+ );
+ if (firstNodeKey.current === nodeKey) {
+ firstNodeKey.current = '';
+ }
+ }
+ });
+ }
+ );
+
+ return () => {
+ // firstNodeKey.current = '';
+ unregisterMutationListener();
+ };
+ }, [editor, images]);
+};
const PostWriteSection = forwardRef<
HTMLDivElement,
{
- imageCategory: string;
children: React.ReactNode;
}
->(({ imageCategory, children }, ref) => {
+>(({ children }, ref) => {
const [editor] = useLexicalComposerContext();
- // useDetectImageMutation();
+ useDetectImageMutation();
const onPaste = useCallback(
(e: ClipboardEvent) => {
@@ -523,40 +528,13 @@ const PostWriteSection = forwardRef<
if (items[i].type.startsWith('image/')) {
const file = items[i].getAsFile();
if (file) {
- if (file.type.startsWith('image/')) {
- const contentType = file.type;
- const fileName = file.name;
- const req = {
- contentType: contentType,
- fileName: fileName,
- };
-
- requestPresignedUrl({
- imageCategory: imageCategory,
- requests: req,
- file: file,
- })
- .then(async (data) => {
- const { contentType, presignedUrl } = data;
- await toS3({
- url: presignedUrl,
- contentType: contentType,
- file: file,
- });
- return presignedUrl;
- })
- .then((url) => {
- const imgPayload: InsertImagePayload = {
- altText: '붙여넣은 이미지',
- maxWidth: 600,
- src: url.split('?')[0],
- };
- editor.dispatchCommand(INSERT_IMAGE_COMMAND, imgPayload);
- })
- .catch((err) => {
- alert(err.response.message);
- });
- }
+ const imageURL = URL.createObjectURL(file);
+ const imgPayload: InsertImagePayload = {
+ altText: '붙여넣은 이미지',
+ maxWidth: 600,
+ src: imageURL,
+ };
+ editor.dispatchCommand(INSERT_IMAGE_COMMAND, imgPayload);
}
break;
}
@@ -616,9 +594,8 @@ const TitleInput = styled.input`
const PostSectionWrap: React.FC<{
shouldHighlight?: boolean;
- imageCategory: string;
children: ReactNode;
-}> = ({ shouldHighlight, imageCategory, children }) => {
+}> = ({ shouldHighlight, children }) => {
const [editor] = useLexicalComposerContext();
const onDrop = useCallback((event: React.DragEvent) => {
@@ -626,51 +603,14 @@ const PostSectionWrap: React.FC<{
const files = event.dataTransfer.files;
if (files.length > 0) {
const file = files[0];
- console.log('??', file);
if (file.type.startsWith('image/')) {
- const contentType = file.type;
- const fileName = file.name;
- const req = {
- contentType: contentType,
- fileName: fileName,
+ const imageURL = URL.createObjectURL(file);
+ const imgPayload: InsertImagePayload = {
+ altText: '붙여넣은 이미지',
+ maxWidth: 600,
+ src: imageURL,
};
- requestPresignedUrl({
- imageCategory: imageCategory,
- requests: req,
- file: file,
- })
- .then(async (data) => {
- const { contentType, presignedUrl } = data;
- console.log('??', file);
- await toS3({
- url: presignedUrl,
- contentType: contentType,
- file: file, // 사람이 붙여넣은 이미지 파일
- });
- return presignedUrl;
- })
- .then((url) => {
- const aa = url.split('?')[0];
- const imgPayload: InsertImagePayload = {
- altText: '붙여넣은 이미지',
- maxWidth: 600,
- src: aa,
- };
- editor.dispatchCommand(INSERT_IMAGE_COMMAND, imgPayload);
- })
- .catch((err) => {
- alert('이미지 업로드중 에러가 발생했습니다');
- console.error(err);
- });
-
- // console.log('contentType', req);
- // const imageURL = URL.createObjectURL(file);
- // const imgPayload: InsertImagePayload = {
- // altText: '붙여넣은 이미지',
- // maxWidth: 600,
- // src: imageURL,
- // };
- // editor.dispatchCommand(INSERT_IMAGE_COMMAND, imgPayload);
+ editor.dispatchCommand(INSERT_IMAGE_COMMAND, imgPayload);
}
}
}, []);
@@ -691,22 +631,13 @@ const PostSectionWrap: React.FC<{
};
const PostEditor: React.FC<{
- imageCategory: string;
forwardTitle?: (title: string) => void;
forwardContent?: (content: string) => void;
content?: string;
title?: string;
tag?: string;
setTag?: (tag: string) => void;
-}> = ({
- imageCategory,
- forwardContent,
- forwardTitle,
- content,
- setTag,
- title,
- tag,
-}) => {
+}> = ({ forwardContent, forwardTitle, content, setTag, title, tag }) => {
const [floatingAnchorElem, setFloatingAnchorElem] =
useState(null);
const [isLinkEditMode, setIsLinkEditMode] = useState(false);
@@ -718,10 +649,7 @@ const PostEditor: React.FC<{
return (
-
+
-
+
}
ErrorBoundary={LexicalErrorBoundary}
diff --git a/src/components/features/Post/plugins/ClipboardPlugin.ts b/src/components/features/Post/plugins/ClipboardPlugin.ts
index 985aa9e9..ff647417 100644
--- a/src/components/features/Post/plugins/ClipboardPlugin.ts
+++ b/src/components/features/Post/plugins/ClipboardPlugin.ts
@@ -1,7 +1,6 @@
import { useEffect } from 'react';
import { $createCodeNode, DEFAULT_CODE_LANGUAGE } from '@lexical/code';
-import { $generateNodesFromDOM } from '@lexical/html';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import {
$createRangeSelection,
@@ -356,28 +355,7 @@ const registerCopyCommand = (editor: LexicalEditor) => {
if ($isRangeSelection(selection) && selection.isCollapsed()) {
shouldUseFallback = !copyCurrentLine(editor);
} else if ($isRangeSelection(selection) && !selection.isCollapsed()) {
- let selectedText = '';
- let nodesToCopy: Array = [];
-
- editor.getEditorState().read(() => {
- const selection = $getSelection();
- if (!$isRangeSelection(selection) || selection.isCollapsed()) {
- return;
- }
-
- selectedText = selection.getTextContent();
-
- nodesToCopy = selection.getNodes();
- });
-
- if (nodesToCopy.length > 0) {
- copyNodesToClipboard(nodesToCopy, selectedText).then((success) => {
- if (!success) {
- navigator.clipboard.writeText(selectedText);
- }
- });
- shouldUseFallback = false;
- }
+ shouldUseFallback = true;
} else if (!$isRangeSelection(selection)) {
shouldUseFallback = true;
}
@@ -426,40 +404,19 @@ const registerPasteCommand = (editor: LexicalEditor) => {
try {
const lexicalData = event.clipboardData.getData(LEXICAL_CLIPBOARD_TYPE);
if (lexicalData) {
- console.log('??', lexicalData);
return false;
}
- const viewerData = event.clipboardData.getData('text/html-viewer');
- if (viewerData) {
- const parser = new DOMParser();
- const doc = parser.parseFromString(viewerData, 'text/html');
-
- editor.update(() => {
- const selection = $getSelection();
-
- if (!$isRangeSelection(selection)) return;
-
- const nodes = $generateNodesFromDOM(editor, doc);
-
- if (nodes.length > 0) {
- selection.insertNodes(nodes);
- }
- });
- return true;
- }
-
const plainText = event.clipboardData.getData('text/plain');
if (plainText) {
if (looksLikeCode(plainText)) {
- // event.preventDefault();
+ event.preventDefault();
const language = detectLanguageByPrism(plainText);
editor.update(() => {
const codeNode = $createCodeNode();
codeNode.setLanguage(language);
codeNode.append($createTextNode(plainText));
-
$insertNodes([codeNode]);
});
diff --git a/src/components/features/Post/plugins/DraggablePlugin.tsx b/src/components/features/Post/plugins/DraggablePlugin.tsx
index db2881d8..f3133401 100644
--- a/src/components/features/Post/plugins/DraggablePlugin.tsx
+++ b/src/components/features/Post/plugins/DraggablePlugin.tsx
@@ -1,7 +1,7 @@
import { Fragment, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
-// import { postImagesAtom } from '@/store/posting.ts';
+import { postImagesAtom } from '@/store/posting.ts';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { eventFiles } from '@lexical/rich-text';
import {
@@ -9,7 +9,7 @@ import {
isHTMLElement,
mergeRegister,
} from '@lexical/utils';
-// import { useSetAtom } from 'jotai';
+import { useSetAtom } from 'jotai';
import {
$getNearestNodeFromDOMNode,
$getNodeByKey,
@@ -482,7 +482,7 @@ const useDraggableBlockMenu = (
const isDraggingBlockRef = useRef(false);
const [draggableBlockElem, setDraggableBlockElem] =
useState(null);
- // const setPostImages = useSetAtom(postImagesAtom);
+ const setPostImages = useSetAtom(postImagesAtom);
useEffect(() => {
const onMouseMove = (event: MouseEvent) => {
@@ -606,57 +606,57 @@ const useDraggableBlockMenu = (
return;
}
- // const line = $getRoot()
- // .getChildren()
- // .findIndex((node) => node.getKey() === dragData);
- // const imgNodeKeys = imgArray
- // .map((img) => $getNearestNodeFromDOMNode(img)?.getKey())
- // .filter((key) => key !== undefined);
+ const line = $getRoot()
+ .getChildren()
+ .findIndex((node) => node.getKey() === dragData);
+ const imgNodeKeys = imgArray
+ .map((img) => $getNearestNodeFromDOMNode(img)?.getKey())
+ .filter((key) => key !== undefined);
// console.log('my img keys', imgArray, imgNodeKeys);
- // 사용자 드래그 트래킹
- // setPostImages((prev) => {
- // const targets = prev.filter((img) => imgNodeKeys.includes(img.key));
- // const targetOriginLine = targets[0].line;
- // const targetNewLine = line;
- // if (targetOriginLine === targetNewLine) {
- // return prev;
- // }
- //
- // const rangeStart = Math.min(targetOriginLine, targetNewLine);
- // const rangeEnd = Math.max(targetOriginLine, targetNewLine);
- //
- // const rearrangedArr = prev.map((imgObj) => {
- // if (imgNodeKeys.includes(imgObj.key)) {
- // // console.log('이동됨', imgObj.key);
- // return { ...imgObj, line: targetNewLine };
- // }
- //
- // if (imgObj.line >= rangeStart && imgObj.line <= rangeEnd) {
- // if (
- // targetNewLine < targetOriginLine &&
- // imgObj.line >= targetNewLine
- // // && imgObj.line < targetOriginLine
- // ) {
- // // console.log('아래로 밀러남', imgObj.key);
- // return { ...imgObj, line: imgObj.line + 1 };
- // }
- //
- // if (
- // targetNewLine > targetOriginLine &&
- // imgObj.line > targetOriginLine
- // // && imgObj.line <= targetNewLine
- // ) {
- // // console.log('위로 밀려남', imgObj.key);
- // return { ...imgObj, line: imgObj.line - 1 };
- // }
- // }
- // // console.log('그대로', imgObj.key);
- // return imgObj;
- // });
- // console.error('rearrange fin', rearrangedArr);
- // return rearrangedArr;
- // });
+
+ setPostImages((prev) => {
+ const targets = prev.filter((img) => imgNodeKeys.includes(img.key));
+ const targetOriginLine = targets[0].line;
+ const targetNewLine = line;
+ if (targetOriginLine === targetNewLine) {
+ return prev;
+ }
+
+ const rangeStart = Math.min(targetOriginLine, targetNewLine);
+ const rangeEnd = Math.max(targetOriginLine, targetNewLine);
+
+ const rearrangedArr = prev.map((imgObj) => {
+ if (imgNodeKeys.includes(imgObj.key)) {
+ // console.log('이동됨', imgObj.key);
+ return { ...imgObj, line: targetNewLine };
+ }
+
+ if (imgObj.line >= rangeStart && imgObj.line <= rangeEnd) {
+ if (
+ targetNewLine < targetOriginLine &&
+ imgObj.line >= targetNewLine
+ // && imgObj.line < targetOriginLine
+ ) {
+ // console.log('아래로 밀러남', imgObj.key);
+ return { ...imgObj, line: imgObj.line + 1 };
+ }
+
+ if (
+ targetNewLine > targetOriginLine &&
+ imgObj.line > targetOriginLine
+ // && imgObj.line <= targetNewLine
+ ) {
+ // console.log('위로 밀려남', imgObj.key);
+ return { ...imgObj, line: imgObj.line - 1 };
+ }
+ }
+ // console.log('그대로', imgObj.key);
+ return imgObj;
+ });
+ console.error('rearrange fin', rearrangedArr);
+ return rearrangedArr;
+ });
});
setDraggableBlockElem(null);
diff --git a/src/components/features/Post/plugins/ToolbarPlugin.tsx b/src/components/features/Post/plugins/ToolbarPlugin.tsx
index 8b5f499e..29511e33 100644
--- a/src/components/features/Post/plugins/ToolbarPlugin.tsx
+++ b/src/components/features/Post/plugins/ToolbarPlugin.tsx
@@ -71,11 +71,10 @@ const TAG_LIST: {
];
export const ToolbarPlugin: React.FC<{
- imageCategory: string;
setIsLinkEditMode: Dispatch;
setTag?: (tag: string) => void;
articleCategory?: string;
-}> = ({ imageCategory, setIsLinkEditMode, setTag, articleCategory }) => {
+}> = ({ setIsLinkEditMode, setTag, articleCategory }) => {
const [editor] = useLexicalComposerContext();
const [activeEditor, setActiveEditor] = useState(editor);
@@ -223,18 +222,16 @@ export const ToolbarPlugin: React.FC<{
>
) : (
-
- {!isRecruitPost && (
-
탬플릿
+
+ { !isRecruitPost && 탬플릿 }
+
)}
-
- )}
}
@@ -344,7 +340,7 @@ export const ToolbarPlugin: React.FC<{
justifyContent: 'flex-end',
}}
>
- {!setTag && !isRecruitPost && (
+ { ( !setTag && !isRecruitPost) && (
탬플릿
)}
}
- imageCategory={imageCategory}
/>
diff --git a/src/components/features/Post/segments/ImageInputBox.tsx b/src/components/features/Post/segments/ImageInputBox.tsx
index 45d780da..ced2a216 100644
--- a/src/components/features/Post/segments/ImageInputBox.tsx
+++ b/src/components/features/Post/segments/ImageInputBox.tsx
@@ -1,6 +1,5 @@
import { DragEventHandler, MutableRefObject, useState } from 'react';
-import { requestPresignedUrl, toS3 } from '@/api/presignedurl.ts';
// import { postImagesAtom } from '@/store/posting';
import styled from '@emotion/styled';
@@ -68,14 +67,14 @@ interface ImageInputBoxProps {
imageInputRef: MutableRefObject