Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5eb08e1757 | |||
| 15c03835ef |
@@ -212,28 +212,57 @@ router.post('/templates/:templateId/images', requireAdmin, upload.array('images'
|
||||
const labels = Array.isArray(labelsRaw) ? labelsRaw : labelsRaw ? [labelsRaw] : []
|
||||
const normalizedLabels = labels.map((label) => (typeof label === 'string' ? label.trim().slice(0, 60) : ''))
|
||||
if (normalizedLabels.some((label) => label.length > 60)) return res.status(400).json({ error: 'bad_request' })
|
||||
const totalBytes = files.reduce((sum, file) => sum + Number(file?.size || 0), 0)
|
||||
|
||||
const items = await Promise.all(
|
||||
files.map(async (file, index) => {
|
||||
const optimized = await writeOptimizedImage({
|
||||
file,
|
||||
directory: 'topics',
|
||||
width: 512,
|
||||
height: 512,
|
||||
fit: 'inside',
|
||||
quality: 84,
|
||||
})
|
||||
console.info('[admin] template image upload start', {
|
||||
templateId: template.id,
|
||||
fileCount: files.length,
|
||||
totalBytes,
|
||||
fileNames: files.map((file) => file.originalname),
|
||||
})
|
||||
|
||||
return createTopicItem({
|
||||
id: nanoid(),
|
||||
topicId: template.id,
|
||||
src: optimized.src,
|
||||
label: normalizedLabels[index] || buildItemLabelFromFilename(file),
|
||||
try {
|
||||
const items = await Promise.all(
|
||||
files.map(async (file, index) => {
|
||||
const optimized = await writeOptimizedImage({
|
||||
file,
|
||||
directory: 'topics',
|
||||
width: 512,
|
||||
height: 512,
|
||||
fit: 'inside',
|
||||
quality: 84,
|
||||
})
|
||||
|
||||
return createTopicItem({
|
||||
id: nanoid(),
|
||||
topicId: template.id,
|
||||
src: optimized.src,
|
||||
label: normalizedLabels[index] || buildItemLabelFromFilename(file),
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
console.info('[admin] template image upload success', {
|
||||
templateId: template.id,
|
||||
fileCount: items.length,
|
||||
totalBytes,
|
||||
})
|
||||
)
|
||||
|
||||
res.json({ item: items[0], items })
|
||||
res.json({ item: items[0], items })
|
||||
} catch (error) {
|
||||
console.error('[admin] template image upload failed', {
|
||||
templateId: template.id,
|
||||
fileCount: files.length,
|
||||
totalBytes,
|
||||
message: error?.message || 'upload_failed',
|
||||
code: error?.code || '',
|
||||
stack: error?.stack || '',
|
||||
})
|
||||
res.status(500).json({
|
||||
error: 'template_image_upload_failed',
|
||||
detail: error?.message || 'upload_failed',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
router.delete('/templates/:templateId/items/:itemId', requireAdmin, async (req, res) => {
|
||||
|
||||
@@ -612,6 +612,15 @@
|
||||
- 게임 이미지 경로는 저장 시 상대 경로(`/uploads/...`)를 유지하는 방향으로 정리했다.
|
||||
- 현재 단계에서는 구조 변경 비용을 고려해 DB를 유지하되, 운영/확장성 요구가 커지기 전 RDB 이관 판단이 필요하다고 기록했다.
|
||||
|
||||
## 2026-04-03 v1.4.35
|
||||
- 실제 사용 테스트에서 아이템 수가 80개 안팎으로 늘어나면 “기능은 있는데 찾을 수 없는 상태”가 되기 쉬워, 편집기 풀에는 가벼운 이름 검색이 필수라고 정리했다.
|
||||
- 공유 링크는 완성본만 보여주는 데서 끝내지 말고 서비스 메인으로 돌아오는 손잡이를 함께 두는 편이 자연스럽다고 판단했다.
|
||||
- 공개 프리뷰의 작성 시각은 분 단위까지 노출할 필요가 낮아, 신뢰에 필요한 최소 정보만 남기는 쪽으로 줄이기로 했다.
|
||||
|
||||
## 2026-04-02 v1.4.34
|
||||
- 라이트모드는 단순 토글 존재만으로 충분하지 않고, 셸/카드/버튼/오버레이가 같은 색 문법을 공유해야 품질이 안정된다고 판단해 공통 토큰을 다시 정리했다.
|
||||
- 홈 카드 즐겨찾기 버튼처럼 다크 전용 하드코딩이 남아 있으면 전체 인상이 쉽게 무너지므로, 이후 테마 보정은 공통 변수 우선 원칙으로 계속 가져가기로 했다.
|
||||
|
||||
## 2026-03-19
|
||||
- 초기 저장소는 빠른 구현을 위해 `lowdb(JSON 파일)`를 채택했다.
|
||||
- 인증은 JWT 대신 서버 세션(`express-session` + `session-file-store`)을 사용했다.
|
||||
|
||||
@@ -113,6 +113,7 @@
|
||||
- 관리자 게임 아이템 순서 저장은 추가됐으므로, 다음 단계에서는 새 아이템 추가 직후 `자동 맨 앞 배치`와 `관리자 수동 고정 순서`의 우선순위를 실제 운영 흐름 기준으로 한 번 더 QA한다.
|
||||
- 관리자 템플릿 요청 미리보기는 실제 완성본 iframe 방식과의 체감 차이를 마지막으로 한 번 더 QA한다.
|
||||
- 라이트모드/다크모드 2차 보정까지 반영했으므로, 남은 작업은 전체 화면을 실제 사용 흐름으로 돌려 보며 대비·명도·아이콘 가독성을 미세하게 QA하는 최종 테마 점검 단계로 가져간다.
|
||||
- 라이트모드 공통 토큰 재정비와 카드/아바타/즐겨찾기 버튼 보정까지 반영했으므로, 다음 QA에서는 로그인/홈/주제 허브/에디터/관리자 순으로 실제 플로우를 돌리며 남은 하드코딩 색과 과한 대비가 없는지 확인한다.
|
||||
- 관리자용 티어표 승인/숨김 처리, 아이템 정렬 UI를 추가한다.
|
||||
- 회원 일괄 작업(다중 선택, 일괄 비밀번호 초기화, 활동 저조 계정 정리) 같은 관리 보조 기능을 추가한다.
|
||||
- 티어 행 프리셋 저장, 색상 관리, 행 복제 같은 고급 편집 기능을 추가한다.
|
||||
@@ -123,6 +124,7 @@
|
||||
- 책 아이콘 기반 사용법 모달은 제작 흐름뿐 아니라 복사, 템플릿 업데이트 요청, 새 템플릿 요청까지 확장했으므로, 실제 16:9 스크린샷 자산과 단계별 문구를 운영 톤에 맞게 채운다.
|
||||
- 관리자 아이템 라이브러리에서 동일 이미지(src)를 여러 템플릿이 공유하는 경우, 필요하면 묶어서 보거나 대표 카드로 합쳐 보는 후속 정리 옵션을 검토한다.
|
||||
- 라이트모드 최종 QA 시 홈/설정/관리자/에디터를 실제 사용 흐름으로 돌리며, 남아 있는 하드코딩 텍스트 색과 플레이스홀더 배경을 한 번 더 점검한다.
|
||||
- 템플릿 기본 아이템 다중 업로드는 8개까지 성공, 9개 이상 한 번에 전송 시 실패하는 사례가 있었으므로 NAS/리버스 프록시의 업로드 body 제한(`client_max_body_size` 등)과 실제 응답 코드를 운영 환경에서 확인한다.
|
||||
- 관리자 아이템 라이브러리는 보관 자산까지 노출되므로, 이후에는 `활성 템플릿 / 보관 자산` 분리 필터나 그룹 보기까지 검토한다.
|
||||
- 가이드 모달과 관리자 아이템 모달은 현재 같은 톤의 큰 셸을 쓰므로, 이후 공통 모달 레이아웃 컴포넌트로 분리할지 검토한다.
|
||||
- 관리자 아이템 라이브러리 이름 변경은 템플릿·사용자 업로드·보관 자산까지 모두 가능하므로, 이후에는 일괄 이름 정리나 중복 이름 감지 보조 기능까지 검토한다.
|
||||
|
||||
@@ -1104,6 +1104,21 @@
|
||||
- **티어표 데이터 정규화**: 게임 이미지 경로가 절대 로컬 URL로 저장되지 않도록 저장/조회 시 `/uploads/...` 상대 경로로 정규화
|
||||
- **프로젝트 점검 결과 문서화**: DB 구조, 화면-파일 매핑, 코딩 규칙, 기술 명세, 남은 위험 요소를 `docs/`에 신규 정리
|
||||
|
||||
## 2026-04-03 v1.4.35
|
||||
- **에디터 아이템 검색 추가**: 미배치 아이템이 많아졌을 때 바로 찾을 수 있도록 사이드바에 `아이템 이름 검색` 입력과 `표시 개수 / 전체 개수`를 추가
|
||||
- **검색 중 드래그 유지**: 아이템 풀 검색은 목록 순서를 바꾸지 않고 일치하지 않는 항목만 숨기는 방식으로 넣어, 검색 중에도 바로 드래그 배치할 수 있게 유지
|
||||
- **공유 프리뷰 유입선 보강**: 공유 링크 프리뷰 좌상단에 `Tier Maker` 로고 링크를 추가해, 미리보기에서 메인 화면으로 자연스럽게 돌아올 수 있게 함
|
||||
- **작성 시각 노출 축소**: 프리뷰와 이미지 저장 하단 메타 정보의 시간 표시를 제거하고 날짜까지만 남겨 개인 생활 패턴 노출을 줄임
|
||||
- **업로드 추적 로그 보강**: 관리자 템플릿 기본 아이템 업로드는 프런트/백엔드 양쪽에서 파일 수·총 용량·응답 상태를 콘솔에 남기도록 해, 다중 업로드 실패 원인을 다음 재현 때 바로 좁힐 수 있게 보강
|
||||
- **카피라이트 링크 변경**: 우측 레일 하단 카피라이트의 `zenn` 링크를 `https://x.com/zennbox`로 변경
|
||||
|
||||
## 2026-04-02 v1.4.34
|
||||
- **라이트모드 팔레트 재정비**: 공통 라이트 테마 색상을 회색 위주에서 더 정돈된 청회색 계열로 다시 잡고, 셸/레일/메인/카드 표면 대비를 처음부터 재조정
|
||||
- **공통 토큰 확장**: 강조색 강도, 강조 배경, 오버레이 스크림, 아바타 테두리, 즐겨찾기 버튼 상태색을 공통 변수로 분리해 화면별 하드코딩을 줄임
|
||||
- **홈 카드 보정**: 주제 카드 즐겨찾기 버튼이 라이트모드에서 검은 플로팅 버튼처럼 뜨던 문제를 테마 변수 기반으로 수정
|
||||
- **목록 카드 통일**: 주제 허브/나의 티어표/즐겨찾기/검색 결과 카드의 아바타 테두리를 공통 토큰으로 맞춰 라이트모드에서 카드 밀도가 덜 어색하게 보이도록 정리
|
||||
- **전역 셸 보정**: 백엔드 점검 안내 버튼과 가이드 모달 오버레이도 라이트모드에 맞는 공통 색상 체계로 통일
|
||||
|
||||
## 2026-03-19 v0.1.2
|
||||
- **로그인 UI 개선**: 로그인 카드 중앙 배치, 중복 타이틀 제거, 입력 overflow 수정, 엔터로 로그인/회원가입 제출
|
||||
- **안내문 조건화**: “첫 회원가입 계정은 admin” 문구는 유저가 0명일 때만 표시(`/api/auth/meta`)
|
||||
|
||||
@@ -22,7 +22,7 @@ const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const { toasts, dismissToast } = useToast()
|
||||
const RIGHT_RAIL_COPYRIGHT_URL = 'https://zenn.town/@murabito'
|
||||
const RIGHT_RAIL_COPYRIGHT_URL = 'https://x.com/zennbox'
|
||||
const currentTopicId = computed(() => route.params.topicId || '')
|
||||
|
||||
const leftRailCollapsed = ref(false)
|
||||
@@ -748,8 +748,8 @@ function reloadApp() {
|
||||
min-width: 128px;
|
||||
padding: 12px 18px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(98, 170, 255, 0.32);
|
||||
background: rgba(98, 170, 255, 0.18);
|
||||
border: 1px solid var(--theme-accent-soft-strong);
|
||||
background: var(--theme-accent-soft);
|
||||
color: var(--theme-text-strong);
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
@@ -929,7 +929,7 @@ function reloadApp() {
|
||||
border-radius: 999px;
|
||||
object-fit: cover;
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border: 1px solid var(--theme-avatar-border);
|
||||
background: var(--theme-surface-soft-3);
|
||||
}
|
||||
|
||||
@@ -1453,7 +1453,7 @@ function reloadApp() {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px 20px;
|
||||
background: rgba(0, 0, 0, 0.62);
|
||||
background: var(--theme-overlay-scrim);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
|
||||
@@ -278,9 +278,16 @@ export function useAdminTemplateManager({
|
||||
|
||||
const fileDrafts = uploadItemDrafts.value.filter((entry) => entry.kind === 'file')
|
||||
const requestDrafts = uploadItemDrafts.value.filter((entry) => entry.kind === 'request')
|
||||
const totalUploadBytes = fileDrafts.reduce((sum, entry) => sum + Number(entry.file?.size || 0), 0)
|
||||
let uploadCount = 0
|
||||
|
||||
if (fileDrafts.length) {
|
||||
console.info('[admin] template item upload start', {
|
||||
topicId: selectedTemplateId.value,
|
||||
fileCount: fileDrafts.length,
|
||||
totalBytes: totalUploadBytes,
|
||||
labels: fileDrafts.map((entry) => entry.label.trim()),
|
||||
})
|
||||
const fd = new FormData()
|
||||
fileDrafts.forEach((entry) => {
|
||||
fd.append('images', entry.file)
|
||||
@@ -291,7 +298,25 @@ export function useAdminTemplateManager({
|
||||
credentials: 'include',
|
||||
body: fd,
|
||||
})
|
||||
if (!res.ok) throw new Error('failed')
|
||||
if (!res.ok) {
|
||||
const responseText = await res.text().catch(() => '')
|
||||
console.error('[admin] template item upload failed', {
|
||||
topicId: selectedTemplateId.value,
|
||||
fileCount: fileDrafts.length,
|
||||
totalBytes: totalUploadBytes,
|
||||
status: res.status,
|
||||
body: responseText,
|
||||
})
|
||||
const uploadError = new Error('failed')
|
||||
uploadError.status = res.status
|
||||
uploadError.body = responseText
|
||||
throw uploadError
|
||||
}
|
||||
console.info('[admin] template item upload success', {
|
||||
topicId: selectedTemplateId.value,
|
||||
fileCount: fileDrafts.length,
|
||||
totalBytes: totalUploadBytes,
|
||||
})
|
||||
uploadCount += fileDrafts.length
|
||||
}
|
||||
|
||||
@@ -316,6 +341,12 @@ export function useAdminTemplateManager({
|
||||
await loadTemplate()
|
||||
success.value = `템플릿 기본 아이템 ${uploadCount}개 추가를 완료했어요.`
|
||||
} catch (e) {
|
||||
console.error('[admin] uploadItem error', {
|
||||
message: e?.message || '',
|
||||
status: e?.status || 0,
|
||||
body: e?.body || '',
|
||||
data: e?.data || null,
|
||||
})
|
||||
const apiError = e?.data?.error || ''
|
||||
if (apiError === 'no_items_selected') {
|
||||
error.value = '추가할 요청 아이템이 없어요.'
|
||||
@@ -330,6 +361,10 @@ export function useAdminTemplateManager({
|
||||
error.value = '선택한 템플릿을 찾지 못했어요.'
|
||||
return
|
||||
}
|
||||
if (e?.status === 413) {
|
||||
error.value = '한 번에 업로드한 파일 용량이 너무 커서 실패했어요.'
|
||||
return
|
||||
}
|
||||
error.value = '아이템 추가에 실패했어요.'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,37 +33,59 @@
|
||||
--theme-danger-bg: rgba(239, 68, 68, 0.1);
|
||||
--theme-danger-border: rgba(239, 68, 68, 0.18);
|
||||
--theme-accent-bg: rgba(76, 133, 245, 0.92);
|
||||
--theme-accent-strong: rgba(137, 183, 255, 0.96);
|
||||
--theme-accent-soft: rgba(76, 133, 245, 0.18);
|
||||
--theme-accent-soft-strong: rgba(76, 133, 245, 0.3);
|
||||
--theme-accent-text: #fff;
|
||||
--theme-overlay-scrim: rgba(0, 0, 0, 0.62);
|
||||
--theme-avatar-border: rgba(255, 255, 255, 0.14);
|
||||
--theme-favorite-bg: rgba(12, 14, 18, 0.72);
|
||||
--theme-favorite-border: rgba(255, 255, 255, 0.14);
|
||||
--theme-favorite-icon: rgba(255, 255, 255, 0.94);
|
||||
--theme-favorite-active-bg: rgba(54, 45, 10, 0.92);
|
||||
--theme-favorite-active-border: rgba(255, 216, 107, 0.28);
|
||||
--theme-favorite-active-icon: #ffd86b;
|
||||
--theme-icon-filter: brightness(0) saturate(100%) invert(94%) sepia(6%) saturate(207%) hue-rotate(186deg) brightness(96%) contrast(92%);
|
||||
}
|
||||
|
||||
:root[data-theme='light'] {
|
||||
--theme-body-bg: #e7ebf2;
|
||||
--theme-shell-bg: rgba(237, 241, 247, 0.98);
|
||||
--theme-rail-bg: rgba(243, 246, 251, 0.97);
|
||||
--theme-main-bg: rgba(232, 236, 243, 0.98);
|
||||
--theme-workspace-bg: rgba(247, 249, 252, 0.96);
|
||||
--theme-card-bg: rgba(252, 253, 255, 0.98);
|
||||
--theme-card-bg-hover: rgba(244, 247, 251, 0.98);
|
||||
--theme-card-border: rgba(31, 41, 55, 0.11);
|
||||
--theme-card-shadow: 0 18px 34px rgba(31, 41, 55, 0.07);
|
||||
--theme-surface-soft: rgba(30, 41, 59, 0.055);
|
||||
--theme-surface-soft-2: rgba(30, 41, 59, 0.075);
|
||||
--theme-surface-soft-3: rgba(30, 41, 59, 0.105);
|
||||
--theme-pill-bg: rgba(30, 41, 59, 0.045);
|
||||
--theme-border: rgba(30, 41, 59, 0.11);
|
||||
--theme-border-strong: rgba(30, 41, 59, 0.16);
|
||||
--theme-text: rgba(20, 27, 40, 0.92);
|
||||
--theme-text-strong: rgba(10, 15, 28, 0.98);
|
||||
--theme-text-muted: rgba(55, 65, 81, 0.76);
|
||||
--theme-text-soft: rgba(75, 85, 99, 0.72);
|
||||
--theme-text-faint: rgba(100, 116, 139, 0.88);
|
||||
--theme-thumb-fallback-bg: #f6f8fb;
|
||||
--theme-select-arrow: rgba(55, 65, 81, 0.74);
|
||||
--theme-body-bg: #eef2f8;
|
||||
--theme-shell-bg: rgba(241, 245, 251, 0.98);
|
||||
--theme-rail-bg: rgba(248, 250, 253, 0.98);
|
||||
--theme-main-bg: rgba(234, 239, 247, 0.98);
|
||||
--theme-workspace-bg: rgba(250, 252, 255, 0.97);
|
||||
--theme-card-bg: rgba(255, 255, 255, 0.96);
|
||||
--theme-card-bg-hover: rgba(246, 249, 253, 0.98);
|
||||
--theme-card-border: rgba(71, 85, 105, 0.12);
|
||||
--theme-card-shadow: 0 14px 30px rgba(57, 72, 92, 0.08);
|
||||
--theme-surface-soft: rgba(75, 85, 99, 0.052);
|
||||
--theme-surface-soft-2: rgba(75, 85, 99, 0.078);
|
||||
--theme-surface-soft-3: rgba(75, 85, 99, 0.11);
|
||||
--theme-pill-bg: rgba(75, 85, 99, 0.048);
|
||||
--theme-border: rgba(71, 85, 105, 0.12);
|
||||
--theme-border-strong: rgba(71, 85, 105, 0.18);
|
||||
--theme-text: rgba(24, 33, 48, 0.93);
|
||||
--theme-text-strong: rgba(11, 18, 32, 0.98);
|
||||
--theme-text-muted: rgba(51, 65, 85, 0.78);
|
||||
--theme-text-soft: rgba(71, 85, 105, 0.76);
|
||||
--theme-text-faint: rgba(100, 116, 139, 0.9);
|
||||
--theme-thumb-fallback-bg: #f3f6fb;
|
||||
--theme-select-arrow: rgba(51, 65, 85, 0.72);
|
||||
--theme-danger-bg: rgba(239, 68, 68, 0.1);
|
||||
--theme-danger-border: rgba(239, 68, 68, 0.22);
|
||||
--theme-accent-bg: rgba(64, 110, 226, 0.94);
|
||||
--theme-accent-bg: rgba(56, 105, 226, 0.94);
|
||||
--theme-accent-strong: rgba(47, 87, 194, 0.96);
|
||||
--theme-accent-soft: rgba(56, 105, 226, 0.12);
|
||||
--theme-accent-soft-strong: rgba(56, 105, 226, 0.22);
|
||||
--theme-accent-text: #fff;
|
||||
--theme-overlay-scrim: rgba(17, 24, 39, 0.28);
|
||||
--theme-avatar-border: rgba(71, 85, 105, 0.16);
|
||||
--theme-favorite-bg: rgba(255, 255, 255, 0.9);
|
||||
--theme-favorite-border: rgba(71, 85, 105, 0.16);
|
||||
--theme-favorite-icon: rgba(51, 65, 85, 0.92);
|
||||
--theme-favorite-active-bg: rgba(255, 243, 199, 0.96);
|
||||
--theme-favorite-active-border: rgba(217, 119, 6, 0.22);
|
||||
--theme-favorite-active-icon: #b45309;
|
||||
--theme-icon-filter: brightness(0) saturate(100%) invert(14%) sepia(14%) saturate(652%) hue-rotate(182deg) brightness(95%) contrast(91%);
|
||||
}
|
||||
|
||||
|
||||
@@ -223,6 +223,7 @@ onMounted(loadFavorites)
|
||||
height: 22px;
|
||||
border-radius: 9999px;
|
||||
object-fit: cover;
|
||||
border: 1px solid var(--theme-avatar-border);
|
||||
background: var(--theme-border);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
@@ -165,9 +165,9 @@ function templateThumbUrl(template) {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(15, 15, 15, 0.72);
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
border: 1px solid var(--theme-favorite-border);
|
||||
background: var(--theme-favorite-bg);
|
||||
color: var(--theme-favorite-icon);
|
||||
font-size: 17px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
@@ -177,16 +177,16 @@ function templateThumbUrl(template) {
|
||||
justify-content: center;
|
||||
}
|
||||
.libraryCard__favorite--active {
|
||||
background: rgba(54, 45, 10, 0.92);
|
||||
border-color: rgba(255, 216, 107, 0.28);
|
||||
background: var(--theme-favorite-active-bg);
|
||||
border-color: var(--theme-favorite-active-border);
|
||||
}
|
||||
.libraryCard__favoriteIcon {
|
||||
opacity: 0.76;
|
||||
color: rgba(255, 255, 255, 0.94);
|
||||
color: var(--theme-favorite-icon);
|
||||
}
|
||||
.libraryCard__favorite--active .libraryCard__favoriteIcon {
|
||||
opacity: 1;
|
||||
color: #ffd86b;
|
||||
color: var(--theme-favorite-active-icon);
|
||||
}
|
||||
.libraryCard__thumbWrap {
|
||||
width: 100%;
|
||||
|
||||
@@ -229,7 +229,7 @@ function openList(t) {
|
||||
height: 22px;
|
||||
border-radius: 9999px;
|
||||
object-fit: cover;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border: 1px solid var(--theme-avatar-border);
|
||||
background: var(--theme-border);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
@@ -215,6 +215,7 @@ watch(
|
||||
height: 22px;
|
||||
border-radius: 9999px;
|
||||
object-fit: cover;
|
||||
border: 1px solid var(--theme-avatar-border);
|
||||
background: var(--theme-border);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import addRowBelowIcon from '../assets/icons/add_row_below.svg'
|
||||
import addPhotoAlternateIcon from '../assets/icons/add_photo_alternate.svg'
|
||||
import shareIcon from '../assets/icons/share.svg'
|
||||
import { api } from '../lib/api'
|
||||
import { editorNewPath, editorPath, loginPath, mePath, topicPath } from '../lib/paths'
|
||||
import { editorNewPath, editorPath, homePath, loginPath, mePath, topicPath } from '../lib/paths'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useToast } from '../composables/useToast'
|
||||
@@ -72,6 +72,7 @@ const favoriteCount = ref(0)
|
||||
const isFavorited = ref(false)
|
||||
const isRequestingTemplate = ref(false)
|
||||
const isDeleting = ref(false)
|
||||
const poolSearchQuery = ref('')
|
||||
|
||||
const boardEl = ref(null)
|
||||
const exportBoardEl = ref(null)
|
||||
@@ -122,6 +123,7 @@ const copiedFromLabel = computed(() => {
|
||||
return parts.join(' · ') || '복사해 온 티어표'
|
||||
})
|
||||
const customItems = computed(() => getOrderedItems().filter((item) => item?.origin === 'custom'))
|
||||
const normalizedPoolSearchQuery = computed(() => poolSearchQuery.value.trim().toLowerCase())
|
||||
const hasSavedTierList = computed(() => !!(persistedTierListId.value || (tierListId.value && tierListId.value !== 'new')))
|
||||
const canRequestTemplateCreate = computed(
|
||||
() => canEdit.value && hasSavedTierList.value && templateId.value === 'freeform' && customItems.value.length > 0
|
||||
@@ -138,6 +140,7 @@ const shareTierListUrl = computed(() => {
|
||||
if (typeof window === 'undefined') return editorPath(templateId.value, savedTierListId, { preview: true })
|
||||
return new URL(editorPath(templateId.value, savedTierListId, { preview: true }), window.location.origin).toString()
|
||||
})
|
||||
const previewHomeUrl = computed(() => homePath())
|
||||
|
||||
watch(error, (message) => {
|
||||
if (!message) return
|
||||
@@ -166,8 +169,6 @@ function formatExportDate(ts) {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -194,6 +195,16 @@ function getOrderedItems() {
|
||||
return getOrderedItemIds().map((itemId) => itemsById.value[itemId]).filter(Boolean)
|
||||
}
|
||||
|
||||
function isPoolItemVisible(itemId) {
|
||||
const query = normalizedPoolSearchQuery.value
|
||||
if (!query) return true
|
||||
const item = itemsById.value[itemId]
|
||||
const label = String(item?.label || itemId || '').toLowerCase()
|
||||
return label.includes(query)
|
||||
}
|
||||
|
||||
const visiblePoolCount = computed(() => pool.value.filter((itemId) => isPoolItemVisible(itemId)).length)
|
||||
|
||||
function setIconSize(nextSize) {
|
||||
iconSize.value = nextSize
|
||||
}
|
||||
@@ -963,6 +974,10 @@ onUnmounted(() => {
|
||||
<template>
|
||||
<section v-if="previewMode" class="previewOnly" :style="{ '--thumb-size': `${iconSize}px` }">
|
||||
<div class="previewOnly__sheet">
|
||||
<a class="previewOnly__brand" :href="previewHomeUrl">
|
||||
<span class="previewOnly__brandMark">TM</span>
|
||||
<span class="previewOnly__brandText">Tier Maker</span>
|
||||
</a>
|
||||
<div class="previewOnly__title">{{ effectiveTitle }}</div>
|
||||
<div v-if="description" class="previewOnly__description">{{ description }}</div>
|
||||
<div v-if="columns.length > 1" class="previewOnly__columns">
|
||||
@@ -1264,12 +1279,28 @@ onUnmounted(() => {
|
||||
</div>
|
||||
|
||||
<div class="sidebar">
|
||||
<div class="sidebar__title">아이템</div>
|
||||
<div class="sidebar__titleRow">
|
||||
<div class="sidebar__title">아이템</div>
|
||||
<div class="sidebar__count">{{ visiblePoolCount }} / {{ pool.length }}</div>
|
||||
</div>
|
||||
<div class="sidebar__hint">
|
||||
{{ canEdit ? '등록된 아이템 리스트입니다. 드래그해서 표에 넣을 수 있습니다.' : '공개 티어표는 보기 전용입니다.' }}
|
||||
</div>
|
||||
<input
|
||||
v-model="poolSearchQuery"
|
||||
class="sidebar__search"
|
||||
type="text"
|
||||
maxlength="60"
|
||||
placeholder="아이템 이름 검색"
|
||||
/>
|
||||
<div ref="poolEl" class="pool" data-list-type="pool">
|
||||
<div v-for="id in pool" :key="id" class="poolItem" :class="{ 'poolItem--readonly': !canEdit }" :data-item-id="id">
|
||||
<div
|
||||
v-for="id in pool"
|
||||
:key="id"
|
||||
class="poolItem"
|
||||
:class="{ 'poolItem--readonly': !canEdit, 'poolItem--hidden': !isPoolItemVisible(id) }"
|
||||
:data-item-id="id"
|
||||
>
|
||||
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
|
||||
<div class="poolItem__label">{{ itemsById[id]?.label || id }}</div>
|
||||
<div v-if="!canEdit" class="poolItem__state">미배치</div>
|
||||
@@ -1454,6 +1485,31 @@ onUnmounted(() => {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.previewOnly__brand {
|
||||
width: fit-content;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
text-decoration: none;
|
||||
color: var(--theme-text-strong);
|
||||
}
|
||||
.previewOnly__brandMark {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 12px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--theme-accent-bg);
|
||||
color: var(--theme-accent-text);
|
||||
font-size: 13px;
|
||||
font-weight: 900;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
.previewOnly__brandText {
|
||||
font-size: 14px;
|
||||
font-weight: 900;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
.previewOnly__title {
|
||||
font-size: 28px;
|
||||
font-weight: 900;
|
||||
@@ -2443,6 +2499,30 @@ onUnmounted(() => {
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.sidebar__titleRow {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.sidebar__count {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--theme-text-soft);
|
||||
}
|
||||
.sidebar__search {
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
margin-bottom: 12px;
|
||||
padding: 11px 14px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--theme-border);
|
||||
background: var(--theme-card-bg);
|
||||
color: var(--theme-text);
|
||||
}
|
||||
.sidebar__search::placeholder {
|
||||
color: var(--theme-text-faint);
|
||||
}
|
||||
.pool {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
|
||||
@@ -2490,6 +2570,9 @@ onUnmounted(() => {
|
||||
text-transform: uppercase;
|
||||
color: var(--theme-text-soft);
|
||||
}
|
||||
.poolItem--hidden {
|
||||
display: none;
|
||||
}
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -319,7 +319,7 @@ watch(
|
||||
height: 22px;
|
||||
border-radius: 9999px;
|
||||
object-fit: cover;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border: 1px solid var(--theme-avatar-border);
|
||||
background: var(--theme-border);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user