개선: 모바일 레이아웃 여백과 네비게이션 전환 보강

This commit is contained in:
2026-04-03 17:52:47 +09:00
parent 28c6dafa02
commit 5b15ec12fa
5 changed files with 158 additions and 36 deletions

View File

@@ -27,6 +27,7 @@ const RIGHT_RAIL_COPYRIGHT_URL = 'https://x.com/zennbox'
const currentTopicId = computed(() => route.params.topicId || '')
const leftRailCollapsed = ref(false)
const mobileLeftNavOpen = ref(false)
const rightRailOpen = ref(true)
const searchQuery = ref('')
const leftRailSearchPlaceholder = '주제 템플릿 검색'
@@ -312,6 +313,12 @@ onMounted(async () => {
const saved = window.localStorage.getItem('tier-maker:right-rail-open')
if (saved === '0') rightRailOpen.value = false
}
if (isMobileLayout.value) {
mobileLeftNavOpen.value = false
rightRailOpen.value = false
} else {
rightRailOpen.value = true
}
searchQuery.value = typeof route.query.q === 'string' ? route.query.q : ''
})
@@ -339,13 +346,27 @@ watch(
searchQuery.value = typeof route.query.q === 'string' ? route.query.q : ''
isCollapsedSearchOpen.value = false
isGuideModalOpen.value = false
if (isMobileLayout.value) {
mobileLeftNavOpen.value = false
rightRailOpen.value = false
}
}
)
watch(
isMobileLayout,
(mobile) => {
if (mobile) leftRailCollapsed.value = false
if (mobile) {
leftRailCollapsed.value = false
mobileLeftNavOpen.value = false
rightRailOpen.value = false
return
}
mobileLeftNavOpen.value = false
rightRailOpen.value = true
if (typeof window !== 'undefined') {
window.localStorage.setItem('tier-maker:right-rail-open', '1')
}
},
{ immediate: true }
)
@@ -353,7 +374,7 @@ watch(
watch(
usesLocalRightRail,
(needed) => {
if (!needed || rightRailOpen.value) return
if (!needed || rightRailOpen.value || isMobileLayout.value) return
rightRailOpen.value = true
if (typeof window !== 'undefined') {
window.localStorage.setItem('tier-maker:right-rail-open', '1')
@@ -368,7 +389,10 @@ function isRouteActive(path) {
}
function toggleLeftRail() {
if (isMobileLayout.value) return
if (isMobileLayout.value) {
mobileLeftNavOpen.value = !mobileLeftNavOpen.value
return
}
leftRailCollapsed.value = !leftRailCollapsed.value
if (typeof window !== 'undefined') {
window.localStorage.setItem('tier-maker:left-rail-collapsed', leftRailCollapsed.value ? '1' : '0')
@@ -449,6 +473,7 @@ function reloadApp() {
class="appShell"
:class="{
'appShell--leftCollapsed': leftRailCollapsed,
'appShell--mobileNavClosed': isMobileLayout && !mobileLeftNavOpen,
'appShell--rightClosed': !rightRailOpen,
'appShell--rightOverlay': isRightRailOverlay,
}"
@@ -483,7 +508,7 @@ function reloadApp() {
<div class="leftRail__body">
<div class="leftRail__content">
<div v-if="authReady && auth.user" class="appUserCard">
<div v-if="authReady" class="appUserCard">
<div class="appUserCard__button">
<img v-if="avatarUrl" :src="avatarUrl" class="appUserCard__avatar" alt="avatar" draggable="false" />
<div v-else class="appUserCard__avatar appUserCard__avatar--fallback">{{ accountName[0]?.toUpperCase() || 'U' }}</div>
@@ -491,40 +516,52 @@ function reloadApp() {
<div class="appUserCard__name">{{ accountName }}</div>
<div class="appUserCard__email">{{ accountEmail }}</div>
</div>
<button
v-if="isMobileLayout"
class="appUserCard__navToggle"
type="button"
:aria-label="mobileLeftNavOpen ? '네비게이션 메뉴 닫기' : '네비게이션 메뉴 열기'"
:aria-expanded="mobileLeftNavOpen"
@click="toggleLeftRail"
>
<SvgIcon :src="mobileLeftNavOpen ? iconDockToLeft : iconDockToRight" :size="24" />
</button>
</div>
</div>
<form class="searchStub" @submit.prevent="submitGlobalSearch">
<button class="searchStub__iconButton" type="button" :aria-label="leftRailCollapsed ? '검색 열기' : '검색'" @click="handleLeftRailSearch">
<span class="searchStub__icon">
<SvgIcon :src="iconSearch" :size="24" />
</span>
</button>
<input v-model="searchQuery" class="searchStub__input" type="search" :placeholder="leftRailCollapsed ? '' : leftRailSearchPlaceholder" />
</form>
<div class="leftRail__mobileMenu">
<form class="searchStub" @submit.prevent="submitGlobalSearch">
<button class="searchStub__iconButton" type="button" :aria-label="leftRailCollapsed ? '검색 열기' : '검색'" @click="handleLeftRailSearch">
<span class="searchStub__icon">
<SvgIcon :src="iconSearch" :size="24" />
</span>
</button>
<input v-model="searchQuery" class="searchStub__input" type="search" :placeholder="leftRailCollapsed ? '' : leftRailSearchPlaceholder" />
</form>
<nav
class="leftNav"
:class="{ 'leftNav--hasActive': activeLeftNavIndex >= 0 }"
:style="{ '--left-nav-active-index': String(Math.max(activeLeftNavIndex, 0)) }"
>
<span class="leftNav__indicator" aria-hidden="true"></span>
<RouterLink
v-for="item in leftNavItems"
:key="item.key"
:to="item.path"
class="leftNav__item"
:class="{ 'leftNav__item--active': isRouteActive(item.path) }"
:title="leftRailCollapsed ? item.label : ''"
:aria-label="leftRailCollapsed ? item.label : undefined"
<nav
class="leftNav"
:class="{ 'leftNav--hasActive': activeLeftNavIndex >= 0 }"
:style="{ '--left-nav-active-index': String(Math.max(activeLeftNavIndex, 0)) }"
>
<span class="leftNav__glyph">
<SvgIcon v-if="item.iconSrc" :src="item.iconSrc" :size="24" />
<svg v-else viewBox="0 0 24 24" aria-hidden="true"><path :d="item.icon" /></svg>
</span>
<span class="leftNav__label">{{ item.label }}</span>
</RouterLink>
</nav>
<span class="leftNav__indicator" aria-hidden="true"></span>
<RouterLink
v-for="item in leftNavItems"
:key="item.key"
:to="item.path"
class="leftNav__item"
:class="{ 'leftNav__item--active': isRouteActive(item.path) }"
:title="leftRailCollapsed ? item.label : ''"
:aria-label="leftRailCollapsed ? item.label : undefined"
>
<span class="leftNav__glyph">
<SvgIcon v-if="item.iconSrc" :src="item.iconSrc" :size="24" />
<svg v-else viewBox="0 0 24 24" aria-hidden="true"><path :d="item.icon" /></svg>
</span>
<span class="leftNav__label">{{ item.label }}</span>
</RouterLink>
</nav>
</div>
</div>
<div class="leftRail__bottom">
@@ -968,6 +1005,25 @@ function reloadApp() {
transition: opacity 180ms ease, max-width 220ms ease, transform 220ms ease;
}
.leftRail__mobileMenu {
display: grid;
}
.appUserCard__navToggle {
display: none;
width: 42px;
height: 42px;
margin-left: auto;
border: 0;
border-radius: 14px;
background: var(--theme-surface-soft);
color: var(--theme-text-soft);
cursor: pointer;
align-items: center;
justify-content: center;
flex: 0 0 auto;
}
.appUserCard__name {
font-size: 14px;
font-weight: 800;
@@ -1994,6 +2050,22 @@ function reloadApp() {
padding: 12px 14px;
}
.appUserCard {
margin-bottom: 0;
}
.appUserCard__button {
padding: 8px 6px;
}
.appUserCard__meta {
max-width: none;
}
.appUserCard__navToggle {
display: inline-flex;
}
.appMain {
min-height: auto;
border-left: 0;
@@ -2011,6 +2083,18 @@ function reloadApp() {
overflow: visible;
}
.leftRail__mobileMenu {
max-height: 540px;
opacity: 1;
transform: translateY(0);
overflow: hidden;
transition:
max-height 260ms ease,
opacity 220ms ease,
transform 220ms ease,
margin-top 220ms ease;
}
.appShell--leftCollapsed .leftRail__top {
display: none;
}
@@ -2046,17 +2130,33 @@ function reloadApp() {
}
.workspaceBody {
padding: 0;
padding: 0 0 calc(28px + env(safe-area-inset-bottom));
border-radius: 0;
margin: 14px 14px 0;
}
.workspaceBody--localRail {
padding: 0;
padding: 0 0 calc(28px + env(safe-area-inset-bottom));
border-radius: 0;
margin: 14px 14px 0;
}
.appShell--mobileNavClosed .leftRail__mobileMenu {
max-height: 0;
margin-top: -8px;
opacity: 0;
transform: translateY(-8px);
pointer-events: none;
}
.appShell--mobileNavClosed .leftRail__bottom {
display: none;
}
.rightRail--overlay .rightRail__body {
padding-bottom: calc(14px + env(safe-area-inset-bottom));
}
.collapsedSearchModal {
padding: 72px 16px 16px;
}

View File

@@ -39,7 +39,7 @@ async function request(path, { method = 'GET', body, headers } = {}) {
} else if (res.status >= 500) {
emitBackendStatus({
state: 'maintenance',
message: '서비스 내부 점검이 필요합니다. 잠시 후 다시 이용해주세요.',
message: '서비스 내부 점검중입니다. 잠시 후 다시 이용해주세요.',
path,
})
}

View File

@@ -102,6 +102,12 @@ const isNewTierList = computed(() => tierListId.value === 'new')
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 touchSortableOptions = {
delayOnTouchOnly: true,
delay: 180,
touchStartThreshold: 8,
fallbackTolerance: 8,
}
const hasCustomTitle = computed(() => !!(title.value || '').trim())
const fallbackTimestamp = computed(() => (updatedAt.value ? updatedAt.value : Date.now()))
const effectiveAuthorName = computed(() => {
@@ -547,6 +553,7 @@ async function initSortables() {
destroySortables()
groupSortable.value = Sortable.create(groupListEl.value, {
...touchSortableOptions,
animation: 160,
handle: '[data-group-handle]',
ghostClass: 'ghost',
@@ -560,6 +567,7 @@ async function initSortables() {
})
poolSortable.value = Sortable.create(poolEl.value, {
...touchSortableOptions,
group: 'tier-items',
animation: 160,
draggable: '[data-item-id]',
@@ -577,6 +585,7 @@ async function initSortables() {
dropSortables.value = Object.entries(groupDropEls.value).map(([, el]) =>
Sortable.create(el, {
...touchSortableOptions,
group: 'tier-items',
animation: 160,
draggable: '[data-item-id]',