diff --git a/docs/history.md b/docs/history.md index d841512..3de9509 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,11 @@ # 의사결정 이력 +## 2026-04-03 v1.4.71 +- 모바일에서 공통 본문 하단이 딱 붙어 보이는 문제는 로그인 화면 하나만 고치는 것보다 `workspaceBody` 공통 하단 여백을 safe-area까지 포함해 보강하는 편이 이후 모든 본문 화면에 일괄 적용되어 유지보수상 낫다고 판단했다. +- 모바일 왼쪽 네비게이션은 데스크톱의 폭 축소형 접기와 목적이 다르므로, 기존 `leftRailCollapsed`를 억지로 재사용하기보다 `mobileLeftNavOpen` 상태를 분리하고 유저 카드 우측 버튼으로 검색/메뉴 묶음만 접는 방식이 더 자연스럽다고 정리했다. +- 오른쪽 레일은 모바일에서 기본 자동 열림이 실제 조작 공간을 빼앗는 경우가 많으므로, 모바일 진입과 라우트 이동 시 기본 닫힘으로 두되 PC 레이아웃으로 돌아오면 다시 기본 열림을 복원하는 쪽으로 맞췄다. +- 모바일 터치에서는 짧은 탭 선택과 드래그 시작이 같은 포인터 입력에서 충돌하기 쉬우므로, Sortable에 터치 전용 지연과 threshold를 둬 탭은 선택, 길게 누르고 움직이면 드래그가 되도록 의도를 분리했다. + ## 2026-04-03 v1.4.70 - 카카오톡/디스코드/X 공유 미리보기는 대개 프런트 SPA 자바스크립트를 실행하기 전에 HTML 메타를 먼저 읽으므로, 기존 `index.html` 고정 메타를 프런트 런타임에서 바꾸는 방식만으로는 티어표별 썸네일/제목/설명을 안정적으로 보여주기 어렵다고 판단했다. - 현재 운영 구조가 프런트 Nginx 정적 서빙 + 백엔드 API 분리 형태이므로, 모든 SPA 경로를 SSR로 바꾸기보다 공유 버튼만 `/share/editor/...` 서버 렌더링 경로를 사용하게 하고, 이 경로에서 OG 메타를 만든 뒤 기존 `preview=1` 화면으로 넘기는 방식이 가장 작은 변경이라고 정리했다. diff --git a/docs/update.md b/docs/update.md index 30277e6..ba3996d 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,12 @@ # 업데이트 로그 +## 2026-04-03 v1.4.71 +- 모바일에서 본문 페이지나 로그인 화면 하단이 카드/버튼 바로 아래에서 끊겨 보여 답답했던 부분을 줄이기 위해, 공통 워크스페이스 본문 하단에 모바일 safe-area 기반 여백을 추가했다. +- 모바일 왼쪽 네비게이션은 유저 프로필 카드 오른쪽 토글 버튼으로 접고 펼칠 수 있게 바꾸고, 닫힘/열림 전환 시 검색창과 메뉴가 위아래로 부드럽게 스르륵 접히는 애니메이션을 추가했다. +- 모바일 진입 시 오른쪽 레일은 기본 닫힘으로 시작하고, 모바일에서 직접 오른쪽 레일을 열었을 때도 레일 하단 컨텐츠가 화면 바닥에 붙지 않도록 safe-area 여백을 더했다. +- 티어표 편집기 모바일 터치 조작에서 아이템을 짧게 탭하면 선택만 하고, 길게 누른 뒤 움직일 때 드래그가 시작되도록 Sortable 터치 시작 지연과 이동 임계값을 추가했다. +- 서버 점검 안내 문구는 `서비스 내부 점검이 필요합니다.` 대신 `서비스 내부 점검중입니다.`로 다듬었고, 프런트 프로덕션 빌드(`npm run build`) 통과를 확인했다. + ## 2026-04-03 v1.4.70 - 저장된 티어표의 `공유하기` 버튼이 기존 `preview=1` 편집기 주소 대신 `/share/editor/:topicId/:tierListId` 공유 전용 주소를 복사하도록 바꿨다. - 이 공유 전용 주소는 공개 티어표인 경우 해당 티어표의 제목, 설명, 썸네일을 기반으로 Open Graph/Twitter 메타 태그를 서버에서 동적으로 생성한 뒤, 실제 뷰어 화면 `/editor/:topicId/:tierListId?preview=1`로 즉시 이동시킨다. diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 54b6450..11fe690 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -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() {
-
+
avatar
{{ accountName[0]?.toUpperCase() || 'U' }}
@@ -491,40 +516,52 @@ function reloadApp() {
{{ accountName }}
+
-
- - -
+
+
+ + +
- + + + + + + + {{ item.label }} + + +
@@ -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; } diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index e275a3c..3a2c773 100644 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -39,7 +39,7 @@ async function request(path, { method = 'GET', body, headers } = {}) { } else if (res.status >= 500) { emitBackendStatus({ state: 'maintenance', - message: '서비스 내부 점검이 필요합니다. 잠시 후 다시 이용해주세요.', + message: '서비스 내부 점검중입니다. 잠시 후 다시 이용해주세요.', path, }) } diff --git a/frontend/src/views/TierEditorView.vue b/frontend/src/views/TierEditorView.vue index 665d3f3..2036457 100644 --- a/frontend/src/views/TierEditorView.vue +++ b/frontend/src/views/TierEditorView.vue @@ -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]',