feat(insta): 슬레이트 캐러셀 + 반응형 레이아웃 전면 개선
문제: - 페이지 1~10 미리보기가 가로 overflow인데 시각 affordance 없어서 page 2~10 못 봄 - 슬레이트 목록(.ic-slates-grid)이 모바일에서 어색 + 카드 자체가 viewport 밖으로 밀림 수정: - PagesStrip 컴포넌트 신설: 좌/우 chevron + page 인디케이터(3/10) + 양옆 fade gradient + 키보드 ←/→ + scroll-snap + 클릭 페이지 이동 + 활성 카드 핑크 테두리/scale - .ic-page-img width를 clamp(140px, 42vw, 220px)로 viewport 비례 - .ic-slates-grid 모바일 2칸 강제, 640px+ 부터 auto-fill - .ic-detail에 min-width: 0 + max-width: 100% (자식이 부모 안 밀게) - .ic-layout grid-template-columns에 minmax(0, 1fr) — 자식 overflow 시 부모 안정 - .ic 모바일 좌우 padding 12px (768px+ 16px)
This commit is contained in:
@@ -1,5 +1,8 @@
|
|||||||
/* ── InstaCards ──────────────────────────────────────────────────────────── */
|
/* ── InstaCards ──────────────────────────────────────────────────────────── */
|
||||||
.ic { max-width: 1100px; margin: 0 auto; padding: 24px 16px 80px; }
|
.ic { max-width: 1100px; margin: 0 auto; padding: 16px 12px 80px; }
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.ic { padding: 24px 16px 80px; }
|
||||||
|
}
|
||||||
|
|
||||||
/* 헤더 */
|
/* 헤더 */
|
||||||
.ic-header { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; }
|
.ic-header { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; }
|
||||||
@@ -24,10 +27,12 @@
|
|||||||
.ic-spinner { width: 14px; height: 14px; border: 2px solid rgba(255,255,255,.3); border-top-color: #fff; border-radius: 50%; animation: ic-spin .6s linear infinite; display: inline-block; }
|
.ic-spinner { width: 14px; height: 14px; border: 2px solid rgba(255,255,255,.3); border-top-color: #fff; border-radius: 50%; animation: ic-spin .6s linear infinite; display: inline-block; }
|
||||||
@keyframes ic-spin { to { transform: rotate(360deg); } }
|
@keyframes ic-spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
/* 레이아웃: 모바일 1컬럼, 데스크탑 2컬럼 */
|
/* 레이아웃: 모바일 1컬럼, 데스크탑 2컬럼.
|
||||||
.ic-layout { display: grid; grid-template-columns: 1fr; gap: 20px; }
|
minmax(0, 1fr)로 자식 overflow가 부모를 밀어내지 않도록 함 */
|
||||||
|
.ic-layout { display: grid; grid-template-columns: minmax(0, 1fr); gap: 20px; }
|
||||||
|
.ic-layout > * { min-width: 0; }
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
.ic-layout { grid-template-columns: 320px 1fr; }
|
.ic-layout { grid-template-columns: 320px minmax(0, 1fr); }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 섹션 카드 */
|
/* 섹션 카드 */
|
||||||
@@ -54,8 +59,15 @@
|
|||||||
.ic-keyword-row__meta { font-size: 0.72rem; color: rgba(255,255,255,.35); white-space: nowrap; }
|
.ic-keyword-row__meta { font-size: 0.72rem; color: rgba(255,255,255,.35); white-space: nowrap; }
|
||||||
.ic-keyword-row__score { font-size: 0.75rem; font-weight: 700; color: #ec4899; min-width: 36px; text-align: right; }
|
.ic-keyword-row__score { font-size: 0.75rem; font-weight: 700; color: #ec4899; min-width: 36px; text-align: right; }
|
||||||
|
|
||||||
/* 슬레이트 그리드 */
|
/* 슬레이트 그리드 — 모바일 2칸 강제, 데스크탑 auto-fill */
|
||||||
.ic-slates-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 12px; }
|
.ic-slates-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.ic-slates-grid { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 12px; }
|
||||||
|
}
|
||||||
.ic-slate-card { background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.06); border-radius: 10px; overflow: hidden; cursor: pointer; transition: border-color .15s; }
|
.ic-slate-card { background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.06); border-radius: 10px; overflow: hidden; cursor: pointer; transition: border-color .15s; }
|
||||||
.ic-slate-card:hover { border-color: rgba(236,72,153,.4); }
|
.ic-slate-card:hover { border-color: rgba(236,72,153,.4); }
|
||||||
.ic-slate-card--active { border-color: #ec4899; }
|
.ic-slate-card--active { border-color: #ec4899; }
|
||||||
@@ -73,14 +85,90 @@
|
|||||||
.ic-status-badge--sent { background: rgba(16,185,129,.15); color: #10b981; }
|
.ic-status-badge--sent { background: rgba(16,185,129,.15); color: #10b981; }
|
||||||
.ic-status-badge--failed { background: rgba(239,68,68,.12); color: #ef4444; }
|
.ic-status-badge--failed { background: rgba(239,68,68,.12); color: #ef4444; }
|
||||||
|
|
||||||
/* 슬레이트 상세 패널 */
|
/* 슬레이트 상세 패널 — min-width: 0으로 자식 overflow가 부모 밀지 않게 */
|
||||||
.ic-detail { margin-top: 20px; padding: 16px; background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.06); border-radius: 12px; }
|
.ic-detail {
|
||||||
|
margin-top: 20px; padding: 16px;
|
||||||
|
background: rgba(255,255,255,.04);
|
||||||
|
border: 1px solid rgba(255,255,255,.06); border-radius: 12px;
|
||||||
|
min-width: 0; max-width: 100%;
|
||||||
|
}
|
||||||
.ic-detail__header { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; flex-wrap: wrap; }
|
.ic-detail__header { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; flex-wrap: wrap; }
|
||||||
.ic-detail__title { font-size: 1rem; font-weight: 700; color: var(--text-primary, #e4e4e7); flex: 1; }
|
.ic-detail__title { font-size: 1rem; font-weight: 700; color: var(--text-primary, #e4e4e7); flex: 1; min-width: 0; }
|
||||||
.ic-detail__actions { display: flex; gap: 8px; }
|
.ic-detail__actions { display: flex; gap: 8px; }
|
||||||
|
|
||||||
.ic-pages-strip { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 8px; margin-bottom: 14px; scroll-snap-type: x mandatory; }
|
/* ── pages strip wrapper (chevron + fade + indicator 캐러셀) ── */
|
||||||
.ic-page-img { width: 120px; flex-shrink: 0; aspect-ratio: 4/5; border-radius: 6px; object-fit: cover; scroll-snap-align: start; border: 1px solid rgba(255,255,255,.08); background: rgba(255,255,255,.04); }
|
.ic-pages-wrap {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.ic-pages-strip {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
scroll-snap-type: x mandatory;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
padding: 4px 48px 12px;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
/* 양옆 fade로 "더 있다" affordance */
|
||||||
|
mask-image: linear-gradient(to right,
|
||||||
|
transparent 0, #000 48px, #000 calc(100% - 48px), transparent 100%);
|
||||||
|
-webkit-mask-image: linear-gradient(to right,
|
||||||
|
transparent 0, #000 48px, #000 calc(100% - 48px), transparent 100%);
|
||||||
|
}
|
||||||
|
.ic-pages-strip::-webkit-scrollbar { height: 6px; }
|
||||||
|
.ic-pages-strip::-webkit-scrollbar-thumb { background: rgba(236,72,153,.4); border-radius: 3px; }
|
||||||
|
.ic-pages-strip::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
|
||||||
|
.ic-page-img {
|
||||||
|
width: clamp(140px, 42vw, 220px);
|
||||||
|
flex-shrink: 0;
|
||||||
|
aspect-ratio: 4/5;
|
||||||
|
border-radius: 8px;
|
||||||
|
object-fit: cover;
|
||||||
|
scroll-snap-align: center;
|
||||||
|
border: 2px solid rgba(255,255,255,.08);
|
||||||
|
background: rgba(255,255,255,.04);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform .15s, border-color .15s;
|
||||||
|
}
|
||||||
|
.ic-page-img.is-active {
|
||||||
|
border-color: #ec4899;
|
||||||
|
transform: scale(1.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ic-pages-nav {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(50% - 6px);
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 40px; height: 40px;
|
||||||
|
border-radius: 50%; border: 0;
|
||||||
|
background: rgba(0,0,0,.65); color: #fff;
|
||||||
|
font-size: 24px; font-weight: 700; line-height: 1;
|
||||||
|
cursor: pointer; z-index: 2;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
transition: opacity .15s, background .15s;
|
||||||
|
}
|
||||||
|
.ic-pages-nav:hover:not(:disabled) { background: rgba(236,72,153,.9); }
|
||||||
|
.ic-pages-nav:disabled { opacity: .2; cursor: not-allowed; }
|
||||||
|
.ic-pages-nav--prev { left: 0; }
|
||||||
|
.ic-pages-nav--next { right: 0; }
|
||||||
|
|
||||||
|
.ic-pages-indicator {
|
||||||
|
display: inline-flex; align-items: baseline; gap: 4px;
|
||||||
|
margin: 4px auto 0;
|
||||||
|
padding: 4px 12px;
|
||||||
|
background: rgba(255,255,255,.06);
|
||||||
|
border-radius: 99px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: rgba(255,255,255,.7);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.ic-pages-indicator-row { display: flex; justify-content: center; }
|
||||||
|
.ic-pages-indicator__current { color: #ec4899; font-weight: 700; }
|
||||||
|
.ic-pages-indicator__sep { opacity: 0.5; }
|
||||||
|
.ic-pages-indicator__total { opacity: 0.7; }
|
||||||
|
|
||||||
.ic-caption-box { background: rgba(255,255,255,.03); border-radius: 8px; padding: 12px; margin-bottom: 10px; }
|
.ic-caption-box { background: rgba(255,255,255,.03); border-radius: 8px; padding: 12px; margin-bottom: 10px; }
|
||||||
.ic-caption-box__label { font-size: 0.7rem; font-weight: 700; color: rgba(255,255,255,.4); text-transform: uppercase; margin-bottom: 6px; }
|
.ic-caption-box__label { font-size: 0.7rem; font-weight: 700; color: rgba(255,255,255,.4); text-transform: uppercase; margin-bottom: 6px; }
|
||||||
|
|||||||
@@ -689,6 +689,91 @@ function SlatesPanel({ selectedId, onSelect }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ══════════════════════ 페이지 스트립 (chevron + indicator) ═══════════ */
|
||||||
|
function PagesStrip({ slateId, pageCount }) {
|
||||||
|
const stripRef = useRef(null);
|
||||||
|
const [activePage, setActivePage] = useState(1);
|
||||||
|
|
||||||
|
const scrollToPage = useCallback((pageNo) => {
|
||||||
|
const strip = stripRef.current;
|
||||||
|
if (!strip) return;
|
||||||
|
const next = Math.max(1, Math.min(pageCount, pageNo));
|
||||||
|
const child = strip.children[next - 1];
|
||||||
|
if (child) {
|
||||||
|
child.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
|
||||||
|
setActivePage(next);
|
||||||
|
}
|
||||||
|
}, [pageCount]);
|
||||||
|
|
||||||
|
// 스크롤/드래그 시 가운데 카드 감지
|
||||||
|
const onScroll = useCallback(() => {
|
||||||
|
const strip = stripRef.current;
|
||||||
|
if (!strip) return;
|
||||||
|
const rect = strip.getBoundingClientRect();
|
||||||
|
const centerX = rect.left + rect.width / 2;
|
||||||
|
let best = 1, bestDist = Infinity;
|
||||||
|
Array.from(strip.children).forEach((child, i) => {
|
||||||
|
const cRect = child.getBoundingClientRect();
|
||||||
|
const cCenter = cRect.left + cRect.width / 2;
|
||||||
|
const dist = Math.abs(cCenter - centerX);
|
||||||
|
if (dist < bestDist) { bestDist = dist; best = i + 1; }
|
||||||
|
});
|
||||||
|
if (best !== activePage) setActivePage(best);
|
||||||
|
}, [activePage]);
|
||||||
|
|
||||||
|
// 키보드 ←/→
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e) => {
|
||||||
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
||||||
|
if (e.key === 'ArrowLeft') { scrollToPage(activePage - 1); e.preventDefault(); }
|
||||||
|
else if (e.key === 'ArrowRight') { scrollToPage(activePage + 1); e.preventDefault(); }
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [activePage, scrollToPage]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ic-pages-wrap">
|
||||||
|
<button
|
||||||
|
className="ic-pages-nav ic-pages-nav--prev"
|
||||||
|
onClick={() => scrollToPage(activePage - 1)}
|
||||||
|
disabled={activePage <= 1}
|
||||||
|
aria-label="이전 페이지"
|
||||||
|
type="button"
|
||||||
|
>‹</button>
|
||||||
|
|
||||||
|
<div className="ic-pages-strip" ref={stripRef} onScroll={onScroll}>
|
||||||
|
{Array.from({ length: pageCount }, (_, i) => i + 1).map((page) => (
|
||||||
|
<img
|
||||||
|
key={page}
|
||||||
|
className={`ic-page-img ${activePage === page ? 'is-active' : ''}`}
|
||||||
|
src={getInstaAssetUrl(slateId, page)}
|
||||||
|
alt={`Page ${page}`}
|
||||||
|
loading="lazy"
|
||||||
|
onClick={() => scrollToPage(page)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="ic-pages-nav ic-pages-nav--next"
|
||||||
|
onClick={() => scrollToPage(activePage + 1)}
|
||||||
|
disabled={activePage >= pageCount}
|
||||||
|
aria-label="다음 페이지"
|
||||||
|
type="button"
|
||||||
|
>›</button>
|
||||||
|
|
||||||
|
<div className="ic-pages-indicator-row">
|
||||||
|
<span className="ic-pages-indicator">
|
||||||
|
<span className="ic-pages-indicator__current">{activePage}</span>
|
||||||
|
<span className="ic-pages-indicator__sep">/</span>
|
||||||
|
<span className="ic-pages-indicator__total">{pageCount}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/* ══════════════════════ 슬레이트 상세 ══════════════════════════════════ */
|
/* ══════════════════════ 슬레이트 상세 ══════════════════════════════════ */
|
||||||
function SlateDetail({ slate, onDelete, onRender }) {
|
function SlateDetail({ slate, onDelete, onRender }) {
|
||||||
const pages = slate.assets || [];
|
const pages = slate.assets || [];
|
||||||
@@ -712,19 +797,9 @@ function SlateDetail({ slate, onDelete, onRender }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 페이지 이미지 스트립 */}
|
{/* 페이지 이미지 스트립 (캐러셀: chevron + indicator + ←/→ 키보드) */}
|
||||||
{(slate.status === 'rendered' || slate.status === 'sent') ? (
|
{(slate.status === 'rendered' || slate.status === 'sent') ? (
|
||||||
<div className="ic-pages-strip">
|
<PagesStrip slateId={slate.id} pageCount={pageCount} />
|
||||||
{Array.from({ length: pageCount }, (_, i) => i + 1).map((page) => (
|
|
||||||
<img
|
|
||||||
key={page}
|
|
||||||
className="ic-page-img"
|
|
||||||
src={getInstaAssetUrl(slate.id, page)}
|
|
||||||
alt={`Page ${page}`}
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="ic-empty" style={{ padding: '20px 0' }}>
|
<div className="ic-empty" style={{ padding: '20px 0' }}>
|
||||||
{slate.status === 'failed' ? '렌더 실패 — 재렌더를 시도하세요.' : '렌더링 전입니다.'}
|
{slate.status === 'failed' ? '렌더 실패 — 재렌더를 시도하세요.' : '렌더링 전입니다.'}
|
||||||
|
|||||||
Reference in New Issue
Block a user