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:
2026-05-18 07:30:25 +09:00
parent 2a9c8cb619
commit 46589c05b1
2 changed files with 186 additions and 23 deletions

View File

@@ -1,5 +1,8 @@
/* ── 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; }
@@ -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; }
@keyframes ic-spin { to { transform: rotate(360deg); } }
/* 레이아웃: 모바일 1컬럼, 데스크탑 2컬럼 */
.ic-layout { display: grid; grid-template-columns: 1fr; gap: 20px; }
/* 레이아웃: 모바일 1컬럼, 데스크탑 2컬럼.
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) {
.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__score { font-size: 0.75rem; font-weight: 700; color: #ec4899; min-width: 36px; text-align: right; }
/* 슬레이트 그리드 */
.ic-slates-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 12px; }
/* 슬레이트 그리드 — 모바일 2칸 강제, 데스크탑 auto-fill */
.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:hover { border-color: rgba(236,72,153,.4); }
.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--failed { background: rgba(239,68,68,.12); color: #ef4444; }
/* 슬레이트 상세 패널 */
.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으로 자식 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;
min-width: 0; max-width: 100%;
}
.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-pages-strip { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 8px; margin-bottom: 14px; scroll-snap-type: x mandatory; }
.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); }
/* ── pages strip wrapper (chevron + fade + indicator 캐러셀) ── */
.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__label { font-size: 0.7rem; font-weight: 700; color: rgba(255,255,255,.4); text-transform: uppercase; margin-bottom: 6px; }

View File

@@ -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 }) {
const pages = slate.assets || [];
@@ -712,19 +797,9 @@ function SlateDetail({ slate, onDelete, onRender }) {
</div>
</div>
{/* 페이지 이미지 스트립 */}
{/* 페이지 이미지 스트립 (캐러셀: chevron + indicator + ←/→ 키보드) */}
{(slate.status === 'rendered' || slate.status === 'sent') ? (
<div className="ic-pages-strip">
{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>
<PagesStrip slateId={slate.id} pageCount={pageCount} />
) : (
<div className="ic-empty" style={{ padding: '20px 0' }}>
{slate.status === 'failed' ? '렌더 실패 — 재렌더를 시도하세요.' : '렌더링 전입니다.'}