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 && (
+

+ )}
+
+ );
+}
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 - 최상위