릴리스: v1.4.40 뷰어 모드 전환 및 템플릿 병합 보강

This commit is contained in:
2026-04-03 10:30:49 +09:00
parent 713b07a1de
commit 73a269d61d
7 changed files with 158 additions and 10 deletions

View File

@@ -8,6 +8,7 @@ import addColumnRightIcon from '../assets/icons/add_column_right.svg'
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 RightRailAd from '../components/RightRailAd.vue'
import { api } from '../lib/api'
import { editorNewPath, editorPath, loginPath, mePath, topicPath } from '../lib/paths'
import { toApiUrl } from '../lib/runtime'
@@ -86,7 +87,8 @@ const poolSortable = ref(null)
const dropSortables = ref([])
const isNewTierList = computed(() => tierListId.value === 'new')
const canEdit = computed(() => !!auth.user && (!ownerId.value || ownerId.value === auth.user.id))
const isOwnTierList = computed(() => !!auth.user && !!ownerId.value && ownerId.value === auth.user.id)
const canEdit = computed(() => !!auth.user && !previewMode.value && (!ownerId.value || ownerId.value === auth.user.id))
const iconSizeOptions = [48, 64, 80, 96, 112]
const hasCustomTitle = computed(() => !!(title.value || '').trim())
const fallbackTimestamp = computed(() => (updatedAt.value ? updatedAt.value : Date.now()))
@@ -114,7 +116,9 @@ const untitledWarning = computed(
'제목 없이 저장된 티어표는 무분별한 도배 방지를 위해 관리자에 의해 임의 삭제될 수 있어요.'
)
const canFavorite = computed(() => !!auth.user && !isNewTierList.value && !canEdit.value)
const canDuplicate = computed(() => !!auth.user && hasSavedTierList.value)
const canDuplicate = computed(() => !!auth.user && hasSavedTierList.value && !isOwnTierList.value)
const canSwitchToViewerMode = computed(() => isOwnTierList.value && hasSavedTierList.value && !previewMode.value)
const canSwitchToEditMode = computed(() => isOwnTierList.value && hasSavedTierList.value && previewMode.value)
const copiedFromLabel = computed(() => {
if (!sourceTierListId.value) return ''
const parts = []
@@ -193,6 +197,20 @@ function getOrderedItems() {
return getOrderedItemIds().map((itemId) => itemsById.value[itemId]).filter(Boolean)
}
function mergeLatestTemplateItemsIntoPool(savedItemsMap, savedPoolIds, currentTemplateItems, groupedIds, editable) {
const nextMap = { ...(savedItemsMap || {}) }
const nextPoolIds = Array.isArray(savedPoolIds) ? [...savedPoolIds] : []
if (!editable) return { nextMap, nextPoolIds }
;(currentTemplateItems || []).forEach((item) => {
if (!item?.id || nextMap[item.id]) return
nextMap[item.id] = item
if (!groupedIds?.has(item.id)) nextPoolIds.push(item.id)
})
return { nextMap, nextPoolIds }
}
function isPoolItemVisible(itemId) {
const query = normalizedPoolSearchQuery.value
if (!query) return true
@@ -756,6 +774,16 @@ async function copyShareUrl() {
}
}
function openViewerMode() {
if (!canSwitchToViewerMode.value) return
router.push(editorPath(templateId.value, persistedTierListId.value || tierListId.value, { preview: true }))
}
function openEditMode() {
if (!canSwitchToEditMode.value) return
router.push(editorPath(templateId.value, persistedTierListId.value || tierListId.value))
}
function closeSaveModal() {
isSaveModalOpen.value = false
}
@@ -906,6 +934,7 @@ onMounted(() => {
return
}
let currentTemplateItems = []
try {
const topicRes = await api.getTopic(templateId.value)
templateName.value = topicRes.topic?.name || templateId.value
@@ -915,6 +944,7 @@ onMounted(() => {
label: img.label,
origin: 'template',
}))
currentTemplateItems = base
const map = {}
base.forEach((it) => (map[it.id] = it))
itemsById.value = map
@@ -943,14 +973,27 @@ onMounted(() => {
sourceSnapshotAuthor.value = t.sourceSnapshotAuthor || ''
favoriteCount.value = Number(t.favoriteCount || 0)
isFavorited.value = !!t.isFavorited
if (!previewMode.value && !canEdit.value) {
router.replace(editorPath(templateId.value, t.id, { preview: true }))
return
}
columns.value = normalizeLoadedColumns(t.groups)
groups.value = normalizeLoadedGroups(t.groups, columns.value)
const map = {}
;(t.pool || []).forEach((it) => (map[it.id] = it))
itemsById.value = map
const grouped = new Set()
groups.value.forEach((group) => group.itemIds.forEach((id) => grouped.add(id)))
pool.value = Object.keys(itemsById.value).filter((id) => !grouped.has(id))
const merged = mergeLatestTemplateItemsIntoPool(
map,
Object.keys(map).filter((id) => !grouped.has(id)),
currentTemplateItems,
grouped,
canEdit.value && !previewMode.value
)
itemsById.value = merged.nextMap
pool.value = merged.nextPoolIds
} catch (e) {
error.value = '티어표를 불러오지 못했어요.'
}
@@ -1014,6 +1057,31 @@ onUnmounted(() => {
<span>{{ formatExportDate(fallbackTimestamp) }}</span>
</div>
</div>
<Teleport :to="localRightRailTarget">
<template v-if="globalRightRailOpen">
<RightRailAd />
<div class="viewerSidebar__section">
<div class="viewerSidebar__eyebrow">Viewer Mode</div>
<div class="viewerSidebar__title">공유 티어표 보기</div>
<p class="viewerSidebar__desc">
{{ canSwitchToEditMode ? '지금은 드래그 없는 뷰어 모드입니다. 원하면 바로 수정 모드로 돌아갈 수 있어요.' : '드래그나 편집 없이 완성본만 보는 뷰어 모드입니다.' }}
</p>
<div class="viewerSidebar__actions">
<button v-if="hasSavedTierList" class="btn btn--ghost viewerSidebar__button" type="button" @click="copyShareUrl">
공유하기
</button>
<button v-if="canDuplicate" class="btn btn--save viewerSidebar__button" type="button" @click="duplicateCurrentTierList">
티어표로 복사
</button>
<button v-if="canSwitchToEditMode" class="btn btn--save viewerSidebar__button" type="button" @click="openEditMode">
수정 모드로 전환
</button>
</div>
</div>
</template>
</Teleport>
</section>
<template v-else>
@@ -1400,6 +1468,7 @@ onUnmounted(() => {
<button v-if="canEdit" class="btn btn--save editorSidebar__button" :disabled="isSaving" @click="save">{{ isSaving ? '저장중...' : '저장' }}</button>
</div>
<div class="editorSidebar__utilityLinks">
<button v-if="canSwitchToViewerMode" class="editorSidebar__utilityLink" @click="openViewerMode">뷰어 모드로 보기</button>
<button v-if="hasSavedTierList" class="editorSidebar__utilityLink editorSidebar__utilityLink--share" @click="copyShareUrl">
<SvgIcon :src="shareIcon" :size="16" />
<span>공유하기</span>
@@ -1584,6 +1653,48 @@ onUnmounted(() => {
color: var(--theme-text-soft);
font-size: 13px;
}
.viewerSidebar__section {
margin-top: auto;
display: grid;
gap: 10px;
padding: 18px;
border-radius: 22px;
border: 1px solid var(--theme-border);
background: var(--theme-pill-bg);
}
.viewerSidebar__eyebrow {
color: var(--theme-text-faint);
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.viewerSidebar__title {
font-size: 22px;
font-weight: 900;
letter-spacing: -0.04em;
color: var(--theme-text-strong);
}
.viewerSidebar__desc {
margin: 0;
color: var(--theme-text-muted);
font-size: 13px;
line-height: 1.6;
}
.viewerSidebar__actions {
display: grid;
gap: 10px;
}
.viewerSidebar__button {
width: 100%;
min-height: 44px;
margin-top: 0;
}
.toggleSwitch {
display: inline-flex;
align-items: center;

View File

@@ -96,7 +96,8 @@ watch(
</script>
<template>
<section class="pageHead">
<section class="pageWrap">
<section class="pageHead">
<div class="pageHead__main">
<div class="pageHead__eyebrow">Collection</div>
<h2 class="pageHead__title">{{ topicTitle }}</h2>
@@ -106,10 +107,10 @@ watch(
<input v-model="query" class="input" placeholder="제목 또는 작성자 검색" @keydown.enter.prevent="submitSearch" />
<button class="btn" @click="submitSearch">검색</button>
</div>
</section>
</section>
<div v-if="error" class="error">{{ error }}</div>
<section class="panel">
<div v-if="error" class="error">{{ error }}</div>
<section class="panel">
<div v-if="tierLists.length === 0" class="empty">아직 공개 티어표가 없어요.</div>
<div v-else class="list" :class="{ 'list--table': isListView }">
<article v-for="t in tierLists" :key="t.id" class="boardCard" :class="{ 'boardCard--list': isListView }">
@@ -137,6 +138,7 @@ watch(
</button>
</article>
</div>
</section>
</section>
</template>