diff --git a/package-lock.json b/package-lock.json index e48c004..2f5141a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@lexical/react": "^0.32.1", "@lexical/rich-text": "^0.32.1", "axios": "^1.9.0", + "classnames": "^2.5.1", "dompurify": "^3.2.6", "emoji-picker-react": "^4.12.2", "eslint-plugin-react": "^7.37.5", @@ -2498,6 +2499,12 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, "node_modules/cli-cursor": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", diff --git a/package.json b/package.json index 12176fd..2d89d1c 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "@lexical/react": "^0.32.1", "@lexical/rich-text": "^0.32.1", "axios": "^1.9.0", + "classnames": "^2.5.1", "dompurify": "^3.2.6", "emoji-picker-react": "^4.12.2", "eslint-plugin-react": "^7.37.5", diff --git a/src/assets/styles/_mixins.scss b/src/assets/styles/_mixins.scss index bb002ac..1f32bf8 100644 --- a/src/assets/styles/_mixins.scss +++ b/src/assets/styles/_mixins.scss @@ -2,7 +2,7 @@ @mixin responsive-width($mobile, $tablet, $desktop) { & { width: $mobile; - } + } @media screen and (min-width: 768px) and (max-width: 1247px) { width: $tablet; @@ -79,3 +79,78 @@ } } } + +// 1️⃣ 애니메이션 키프레임 +@keyframes glowPlaceholder { + 0% { + background-position: 0% 50%; + } + 50% { + background-position: 100% 50%; + } + 100% { + background-position: 0% 50%; + } +} + +// 2️⃣ 글로우 플레이스홀더 믹스인 +@mixin gradientGlow( + $start-color, + $end-color, + $duration: 8s, + $blur: 40px, + $scale: 1.3, + $opacity: 0.5 +) { + &::after { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + + /* 움직이는 그라디언트 */ + background: linear-gradient(90deg, $start-color, $end-color); + background-size: 200% 200%; + animation: glowPlaceholder $duration ease infinite; + + /* 퍼짐 효과 */ + filter: blur($blur); + transform: scale($scale); + + /* 전체 투명도 */ + opacity: $opacity; + transition: opacity 0.8s ease-in-out; + + /* z-index 설정 */ + z-index: 1; + } +} +$skeleton-dark: #e5e5e5; +$skeleton-light: #f0f0f0; + +@mixin skeleton-style { + border-radius: 8px; + background: linear-gradient( + 90deg, + $skeleton-dark, + $skeleton-dark, + $skeleton-dark, + $skeleton-light, + $skeleton-dark, + $skeleton-dark, + $skeleton-dark + ); + background-size: 800% 800%; + animation: skeleton-loading 1.5s infinite ease-in-out; +} + +@keyframes skeleton-loading { + 0% { + background-position: 0% 50%; + } + 100% { + background-position: 100% 50%; + } +} diff --git a/src/components/Editor/Editor.jsx b/src/components/Editor/Editor.jsx index 5c67ca1..3f4afce 100644 --- a/src/components/Editor/Editor.jsx +++ b/src/components/Editor/Editor.jsx @@ -58,7 +58,6 @@ export default function Editor({ strikethrough: styles.editor__textStrikethrough, }, }; - console.log('foooont', font, 'getFontFamily(font)', getFontFamily(font)); // 2) initialEditorState: content가 있으면 파싱해서 DOM => 노드 트리로 초기화 const initialEditorState = useMemo(() => { diff --git a/src/components/GradientImage/GradientImage.jsx b/src/components/GradientImage/GradientImage.jsx new file mode 100644 index 0000000..640097b --- /dev/null +++ b/src/components/GradientImage/GradientImage.jsx @@ -0,0 +1,53 @@ +// src/components/GradientImage/GradientImage.jsx +import React, { useState } from 'react'; +import classNames from 'classnames'; +import styles from './GradientImage.module.scss'; + +/** + * GradientImage + * - 로딩 중엔 그라디언트 애니메이션 플레이스홀더 + * - 이미지가 onLoad 되면 fade-in, 플레이스홀더는 fade-out + * + * @param {Object} props + * @param {string} props.src 실제 이미지 URL + * @param {string} [props.alt] 대체 텍스트 + * @param {string} [props.className] 추가 클래스 + * @param {Function} [props.onLoaded] 이미지 로딩 완료 시 호출될 콜백 + * @param {Object} [props.rest] 기타 속성 + */ +export default function GradientImage({ + src, + alt = '', + className = '', + onClick, + onLoaded, + ...rest +}) { + const [loaded, setLoaded] = useState(false); + + const handleLoad = (event) => { + setLoaded(true); + if (typeof onLoaded === 'function') { + onLoaded(event); + } + }; + + return ( +
+ {src && ( + {alt} + )} +
+ ); +} diff --git a/src/components/GradientImage/GradientImage.module.scss b/src/components/GradientImage/GradientImage.module.scss new file mode 100644 index 0000000..094d52c --- /dev/null +++ b/src/components/GradientImage/GradientImage.module.scss @@ -0,0 +1,69 @@ +/* src/components/GradientImage/GradientImage.module.scss */ + +@keyframes glowPlaceholder { + 0% { + background-position: 0% 50%; + } + 50% { + background-position: 100% 50%; + } + 100% { + background-position: 0% 50%; + } +} + +.gradient-image { + position: relative; + overflow: hidden; + display: inline-block; + border: none; + pointer-events: none; + cursor: default; + + &::before { + content: ''; + position: absolute; + inset: 0; + + /* 그라디언트 색상 움직임 */ + background: linear-gradient( + 90deg, + rgba(127, 0, 255, 0.6), + rgba(0, 255, 193, 0.6), + rgba(127, 0, 255, 0.6) + ); + background-size: 300% 300%; + animation: glowPlaceholder 8s ease infinite; + + /* 더 크게, 더 번지게, 더 은은하게 */ + transform: scale(1.5); + filter: blur(60px); + opacity: 0.5; + + transition: opacity 1s ease-in-out; + z-index: 1; + } + + &__img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; + opacity: 0; + transition: opacity 0.8s ease-in-out 0.2s; + will-change: opacity; + position: relative; + z-index: 2; + } + + &--loaded { + &::before { + opacity: 0; + } + .gradient-image__img { + opacity: 1; + } + pointer-events: auto; + cursor: pointer; + } +} diff --git a/src/components/LoadingLabel/LoadingLabel.jsx b/src/components/LoadingLabel/LoadingLabel.jsx new file mode 100644 index 0000000..06b5bf7 --- /dev/null +++ b/src/components/LoadingLabel/LoadingLabel.jsx @@ -0,0 +1,33 @@ +// src/components/LoadingLabel/LoadingLabel.jsx +import React from 'react'; +import cn from 'classnames'; +import styles from './LoadingLabel.module.scss'; + +/** + * @param {Object} props + * @param {boolean} props.loading - 로딩 중이면 애니메이션, 아니면 loadedText + * @param {string} props.loadingText - 로딩 중일 때 표시할 텍스트 + * @param {string} props.loadedText - 로딩 완료 후 표시할 텍스트 + * @param {string} props.className - 최상위