feat(playlist): show thumbnails in import preview

플레이리스트 가져오기 미리보기 목록에 각 항목 썸네일을 표시한다.
YouTube video id 로 mqdefault 썸네일 URL 을 만들고, id 가 없으면
yt-dlp 가 준 thumbnail 필드로 폴백. 깨진 이미지는 빈 박스 처리.

- youtube.ts: PlaylistEntry 에 thumbnailUrl 추가 + flat-playlist 파싱에서 채움
- playlist.js: probe 매핑에 thumbnailUrl 반영, 각 행에 img 렌더(lazy + onerror)
- styles.css: 행 그리드에 썸네일 칼럼(96px 16:9) 추가

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
This commit is contained in:
Claude (owner)
2026-06-01 22:52:05 +09:00
parent 0e39836c1f
commit 0faf7af42a
4 changed files with 34 additions and 3 deletions

View File

@@ -175,6 +175,7 @@
li.innerHTML = ''
+ '<span class="dragHandle" title="드래그로 순서 변경">≡</span>'
+ '<span class="entryNum">' + (idx + 1) + '</span>'
+ '<div class="entryThumb"></div>'
+ '<div class="entryFields">'
+ '<input type="text" class="entryTitleInput" />'
+ '<div class="entryIdRow">'
@@ -185,11 +186,24 @@
+ '<span class="entryDuration muted"></span>'
+ '<button type="button" class="entryRemove" title="제외">✕</button>'
var thumbBox = li.querySelector('.entryThumb')
var titleInput = li.querySelector('.entryTitleInput')
var idInput = li.querySelector('.entryIdInput')
var durSpan = li.querySelector('.entryDuration')
var removeBtn = li.querySelector('.entryRemove')
if (entry.thumbnailUrl) {
var img = document.createElement('img')
img.src = entry.thumbnailUrl
img.alt = ''
img.loading = 'lazy'
// 이미지가 깨지면(예: 비공개/삭제 영상) 빈 박스로 둔다.
img.addEventListener('error', function () { thumbBox.classList.add('entryThumbEmpty') })
thumbBox.appendChild(img)
} else {
thumbBox.classList.add('entryThumbEmpty')
}
titleInput.value = entry.title || ''
idInput.value = entry.videoId || ''
durSpan.textContent = formatDuration(entry.durationSec)
@@ -364,6 +378,7 @@
url: e.url,
title: e.title || '제목 없음',
durationSec: e.durationSec,
thumbnailUrl: e.thumbnailUrl || null,
videoId: '',
idStatus: 'pending',
idReason: ''

View File

@@ -353,7 +353,7 @@ body.siteBody.centerLayout { display: flex; align-items: center; justify-content
}
.playlistEntryRow {
display: grid;
grid-template-columns: 26px 36px 1fr 70px 28px;
grid-template-columns: 26px 36px 96px 1fr 70px 28px;
align-items: center; gap: 10px;
background: var(--bg-card); border: 1px solid var(--border);
border-radius: 10px; padding: 8px 10px;
@@ -367,6 +367,13 @@ body.siteBody.centerLayout { display: flex; align-items: center; justify-content
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
color: var(--text-muted); font-size: 13px; text-align: right;
}
.entryThumb {
width: 96px; aspect-ratio: 16 / 9;
border-radius: 6px; overflow: hidden;
background: var(--bg); border: 1px solid var(--border);
}
.entryThumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
.entryThumbEmpty { opacity: 0.6; }
.entryFields {
display: flex; flex-direction: column; gap: 4px; min-width: 0;
}