Skip to content
Merged
7 changes: 7 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
77 changes: 76 additions & 1 deletion src/assets/styles/_mixins.scss
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
@mixin responsive-width($mobile, $tablet, $desktop) {
& {
width: $mobile;
}
}

@media screen and (min-width: 768px) and (max-width: 1247px) {
width: $tablet;
Expand Down Expand Up @@ -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%;
}
}
1 change: 0 additions & 1 deletion src/components/Editor/Editor.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down
53 changes: 53 additions & 0 deletions src/components/GradientImage/GradientImage.jsx
Original file line number Diff line number Diff line change
@@ -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] 기타 <img> 속성
*/
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 (
<div
className={classNames(className, styles['gradient-image'], {
[styles['gradient-image--loaded']]: loaded,
})}
onClick={onClick}
>
{src && (
<img
src={src}
alt={alt}
className={styles['gradient-image__img']}
onLoad={handleLoad}
{...rest}
/>
)}
</div>
);
}
69 changes: 69 additions & 0 deletions src/components/GradientImage/GradientImage.module.scss
Original file line number Diff line number Diff line change
@@ -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;
}
}
33 changes: 33 additions & 0 deletions src/components/LoadingLabel/LoadingLabel.jsx
Original file line number Diff line number Diff line change
@@ -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 - 최상위 <label>에 들어갈 클래스
*/
export default function LoadingLabel({
loading,
loadingText = '로딩 중...',
loadedText = '완료',
className = '',
}) {
return (
<label className={cn(styles.wrapper, className, { [styles.loaded]: !loading })}>
{/* 깜빡임 애니메이션 텍스트 */}
<span className={styles.loadingText}>
{[...loadingText].map((char, i) => (
<span key={i} style={{ animationDelay: `${i * 0.1}s` }}>
{char === ' ' ? '\u00A0' : char}
</span>
))}
</span>
{/* 로드 완료 후 표시될 텍스트 */}
<span className={styles.loadedText}>{loadedText}</span>
</label>
);
}
48 changes: 48 additions & 0 deletions src/components/LoadingLabel/LoadingLabel.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// src/components/LoadingLabel/LoadingLabel.module.scss

@keyframes loading01 {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}

.wrapper {
position: relative;
display: inline-block;

/* 로딩 텍스트 */
.loadingText {
white-space: pre;
span {
display: inline-block;
animation: loading01 1.4s infinite alternate;
}
/* 기본 보이기 */
opacity: 1;
transition: opacity 0.4s ease;
}

/* 로드 완료 텍스트 */
.loadedText {
position: absolute;
top: 0;
left: 0;
width: 100%;
/* 겹쳐서 감추기 */
opacity: 0;
transition: opacity 0.4s ease;
}

/* 로딩이 끝났을 때 */
&.loaded {
.loadingText {
opacity: 0;
}
.loadedText {
opacity: 1;
}
}
}
38 changes: 38 additions & 0 deletions src/components/Skeleton/Skeleton.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// src/components/Skeleton/Skeleton.jsx
import React from 'react';
import cn from 'classnames';
import styles from './Skeleton.module.scss';

/**
* @param {object} props
* @param {number} [props.count=1] - 몇 개의 블록을 렌더할지
* @param {string|number} [props.width] - 각 블록 너비 (default: '100%')
* @param {string|number} [props.height] - 각 블록 높이 (default: '1em')
* @param {boolean} [props.circle=false] - 원형 모드 (avatar 등)
* @param {string} [props.className] - 추가 클래스
* @param {object} [props.style] - 인라인 스타일 추가 (width/height 덮어쓰기 가능)
*/
export default function Skeleton({
count = 1,
width = '100%',
height = '1em',
circle = false,
className = '',
style = {},
...rest
}) {
const items = Array.from({ length: count });

return items.map((_, idx) => (
<span
key={idx}
className={cn(styles.skeleton, circle && styles.circle, className)}
style={{
width,
height,
...style,
}}
{...rest}
/>
));
}
10 changes: 10 additions & 0 deletions src/components/Skeleton/Skeleton.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
.skeleton {
display: inline-block;
// mixin 적용
@include skeleton-style;
}

.circle {
// 원형 옵션
border-radius: 50% !important;
}
2 changes: 1 addition & 1 deletion src/components/Textfield.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ const Textfield = ({
true: 'textfield__message--success',
};

const showMessage = !disabled && message;
const showMessage = !disabled && message && isValid === false;

return (
<>
Expand Down
Loading
Loading