Compare commits

..

2 Commits

9 changed files with 175 additions and 14 deletions

View File

@@ -89,7 +89,7 @@ function buildItemLabelFromSrc(src) {
return normalized || 'item'
}
const upload = createMemoryUpload(multer, { fileSize: 8 * 1024 * 1024, maxCount: 50 })
const upload = createMemoryUpload(multer, { fileSize: 20 * 1024 * 1024, maxCount: 100 })
const avatarUpload = createMemoryUpload(multer, { fileSize: 4 * 1024 * 1024 })
function decorateAdminUser(user, primaryAdmin) {
@@ -199,7 +199,7 @@ router.post('/templates/:templateId/thumbnail', requireAdmin, upload.single('thu
res.json({ template: updated })
})
router.post('/templates/:templateId/images', requireAdmin, upload.array('images', 50), async (req, res) => {
router.post('/templates/:templateId/images', requireAdmin, upload.array('images', 100), async (req, res) => {
const files = Array.isArray(req.files) ? req.files : []
if (!files.length) return res.status(400).json({ error: 'file_required' })
const templateId = getTemplateIdFromParams(req)

View File

@@ -1,5 +1,19 @@
# 의사결정 이력
## 2026-04-03 v1.4.41
- 관리자 기본 아이템 업로드는 운영자가 한 번에 많은 캐릭터 이미지를 정리하는 작업이 잦으므로, 서버 개별 파일 제한뿐 아니라 한 요청당 업로드 개수와 프록시 본문 크기 제한도 같이 넉넉하게 올려두는 편이 맞다고 판단했다.
- 다중 업로드가 프런트에서 한 번의 `FormData` 요청으로 묶여 나가는 구조라면, 백엔드 `multer`만 올리고 Nginx `client_max_body_size`를 그대로 두면 병목이 남을 수 있으므로 프런트 프록시 제한도 함께 상향하는 쪽으로 정리했다.
## 2026-04-03 v1.4.40
- 공유 링크 진입 화면은 사용자가 조작 가능한 편집 화면보다 `뷰어 모드`로 명확히 분리하는 편이 안전하므로, 비로그인 사용자와 타인 티어표는 기본적으로 드래그 없는 완성본 열람 상태를 보여주기로 정리했다.
- 공유 링크를 받은 비로그인 사용자도 다시 전달할 수 있어야 하므로 `공유하기`는 로그인 여부와 무관하게 뷰어 모드에서 열어두고, `내 티어표로 복사`는 로그인한 타인 열람자에게만 노출하는 쪽으로 권한을 나눴다.
- 작성자 본인도 공유 화면이 어떻게 보이는지 확인할 필요가 있으므로, 본인 티어표는 `수정 모드``뷰어 모드`를 양방향 전환할 수 있게 두는 편이 자연스럽다고 판단했다.
- 뷰어 모드 우측 레일은 빈 공간을 크게 남기기보다 다른 화면처럼 광고를 상단에 두고, 실제 행동 버튼은 하단 카드로 모아 시각 균형과 전환 흐름을 함께 맞추는 방향으로 정리했다.
## 2026-04-03 v1.4.39
- 기존 저장 티어표를 다시 열 때마다 최신 템플릿 아이템으로 전체를 덮어쓰면 과거 결과물이 깨질 수 있으므로, 저장본의 기존 그룹/풀은 보존하고 새로 추가된 템플릿 아이템만 미사용 풀에 합류시키는 병합 방식이 더 안전하다고 판단했다.
- 관리자 템플릿에서 삭제된 기본 아이템을 과거 저장본에서도 자동 삭제하면 사용자 결과물 보존성이 떨어지므로, 삭제는 이후 새 티어표 생성에만 반영하고 기존 저장본은 유지하는 정책을 계속 유지하기로 정리했다.
## 2026-04-03 v1.4.38
- 최고 관리자 보호는 서버에서만 막고 프런트 버튼은 열어두면 운영자가 계속 실패 액션을 누르게 되므로, 최고 관리자 대상 행은 일반 운영자 화면에서 버튼 자체를 비활성화하는 편이 맞다고 판단했다.
- 예약어 닉네임은 일반 가입/프로필 수정에서는 계속 막아야 하지만, 운영자가 직접 회원 계정을 정리할 때는 `공식`, `운영자` 같은 표기를 의도적으로 부여해야 할 수 있으므로 관리자 수정 API만 예외를 두는 쪽으로 정리했다.

View File

@@ -135,7 +135,7 @@
- `POST /api/admin/templates`
- `POST /api/admin/templates/:templateId/thumbnail`
- `POST /api/admin/templates/:templateId/images`
- 여러 이미지를 한 번에 업로드할 수 있고, 별도 라벨이 없으면 파일명 기준으로 기본 아이템 이름을 만든다.
- 여러 이미지를 한 번에 최대 `100개`까지 업로드할 수 있고, 별도 라벨이 없으면 파일명 기준으로 기본 아이템 이름을 만든다.
- `PATCH /api/admin/templates/:templateId/items/:itemId`
- `GET /api/admin/tierlists`
- `GET /api/admin/template-requests`
@@ -179,7 +179,11 @@
## 티어표 접근 메모
- `new` 작성 경로는 로그인한 사용자만 진입할 수 있다.
- 비로그인 사용자는 공개된 티어표를 열람만 할 수 있고, 편집 UI와 저장 동작은 비활성화된다.
- 공유 링크로 여는 `preview=1` 화면은 `뷰어 모드`로 취급하며, 드래그/행열 편집/저장 같은 편집 UI 없이 완성본만 렌더링한다.
- 비로그인 사용자나 작성자 본인이 아닌 로그인 사용자는 저장된 티어표를 기본적으로 뷰어 모드로 열람하며, 일반 편집 URL로 직접 진입해도 소유자가 아니면 `preview=1` 주소로 자동 전환된다.
- 비로그인 사용자도 뷰어 모드 우측 레일의 `공유하기` 버튼으로 현재 공유 링크를 복사할 수 있다.
- 로그인한 타인 티어표 열람자는 뷰어 모드 우측 레일에서 `내 티어표로 복사`를 사용할 수 있고, 작성자 본인은 `수정 모드로 전환`을 사용할 수 있다.
- 작성자 본인이 일반 편집 화면에서 저장된 본인 티어표를 보고 있을 때는 우측 패널의 `뷰어 모드로 보기`로 공유 화면 형태를 바로 확인할 수 있다.
- 비공개 티어표라도 관리자는 편집 화면에서 완성본을 열람할 수 있다.
- 공개 티어표는 목록과 상세 화면에서 즐겨찾기 토글 및 개수를 함께 표시한다.
- 카드형 목록에서는 즐겨찾기 수/상태만 표시하고, 실제 토글은 상세 화면에서 처리한다.
@@ -189,6 +193,8 @@
- 사용자가 직접 추가한 커스텀 아이템 이름은 편집 화면 우측 목록에서 정리할 수 있고, 저장 시 원본 커스텀 아이템 라벨과 함께 동기화된다.
- 티어 행은 기본 5단으로 시작하지만, 사용자가 직접 추가하거나 제거할 수 있다.
- 티어 행에 넣은 아이템은 작은 제거 버튼으로 다시 우측 아이템 풀로 되돌릴 수 있다.
- 기존 저장 티어표/복사본을 수정 가능한 상태로 다시 열 때는 저장본의 그룹/풀을 먼저 복원하고, 이후 현재 템플릿에 새로 추가된 기본 아이템만 미사용 풀 끝에 병합한다.
- 관리자 템플릿에서 삭제된 기본 아이템이라도 이미 저장된 티어표의 그룹/풀에 포함되어 있던 항목은 자동 제거하지 않고 그대로 보존한다.
- 신규 티어표의 공개 여부 기본값은 `ON`이며, 기존 티어표는 편집 화면과 `내 티어표` 목록에서 직접 삭제할 수 있다.
- 제목이 비어 있는 상태로 저장하면 내부 제목은 현재 게임명을 기본값으로 사용한다.
- 제목 입력이 비어 있는 동안에는 무분별한 도배 방지를 위한 관리자 임의 삭제 가능성 안내 문구를 표시한다.
@@ -200,6 +206,7 @@
- 티어표에 썸네일을 직접 지정하지 않으면 저장 시 대표 아이템 이미지 하나를 기본 썸네일로 자동 선택한다.
- 편집 화면 상단 헤더는 좌측 제목/설명, 우측 썸네일 카드 구조를 사용하며 모바일에서는 한 열로 접힌다.
- 티어표 편집 화면의 우측 패널은 공통 `rightRail``localRightRailRoot`에 직접 section들을 쌓는 구조이며, 별도 외곽 래퍼 카드 없이 공통 오른쪽 컬럼 문법을 그대로 따른다.
- 뷰어 모드 우측 패널도 같은 `localRightRailRoot`를 사용하며, 상단에는 광고 블록을, 하단에는 공유/복사/수정 전환 액션 카드를 배치한다.
- 공통 앱 셸은 좌측/중앙/우측 컬럼마다 높이 `56px`의 상단 헤더 블록을 유지하며, 중앙 헤더에는 사이트 타이틀 `Tier Maker`와 현재 서비스 설명을 표시한다.
- 티어표 편집기의 아이콘 기본 크기는 `80px`이며, 사용자가 `48 / 60 / 80 / 100 / 120` 단계로 즉시 조절할 수 있다.
- 공개 티어표 목록과 `내 티어표` 목록은 제목 옆에 작성자 아바타와 표시명을 함께 보여준다.
@@ -213,7 +220,9 @@
## 업로드 제한 메모
- 프로필 아바타 업로드는 파일당 최대 `3MB`다.
- 관리자 게임 썸네일/기본 아이템 업로드와 사용자 커스텀 이미지 업로드는 파일당 최대 `6MB`다.
- 관리자 템플릿 썸네일/기본 아이템 업로드는 파일당 최대 `20MB`다.
- 사용자 커스텀 이미지 업로드는 파일당 최대 `6MB`다.
- 운영 프런트 Nginx는 다중 이미지 업로드 한 번의 요청 본문을 최대 `1024MB`까지 허용한다.
- 현재는 업로드 전에 이미지 리사이즈/압축을 하지 않고 원본 파일을 그대로 저장한다.
## 운영 환경 변수

View File

@@ -1,6 +1,14 @@
# 할 일 및 이슈
## 단기 확인
- `v1.4.41`에서 관리자 템플릿 기본 아이템 다중 업로드를 `100개/파일당 20MB`와 Nginx `client_max_body_size 1024m`으로 올렸으므로, 운영 NAS 앞단 리버스 프록시에도 별도 본문 크기 제한이 있으면 같은 수준으로 맞춰야 하는지 확인한다.
- 실제 QA에서는 10개 이상, 50개 이상, 100개 근처의 이미지 묶음을 한 번에 올렸을 때 브라우저/프런트 Nginx/백엔드 중 어느 단계에서도 `413`이나 업로드 실패가 나지 않는지 확인한다.
- `v1.4.40`에서 `preview=1` 공유 화면을 뷰어 모드로 정리했으므로, 비로그인/로그인한 타인/작성자 본인 세 경우에 드래그 편집이 막히고 오른쪽 레일 버튼이 각각 `공유하기`, `내 티어표로 복사`, `수정 모드로 전환` 조건대로 노출되는지 확인한다. 특히 비로그인/타인이 일반 편집 URL로 직접 들어왔을 때도 자동으로 `preview=1`로 바뀌는지 본다.
- 작성자 본인 편집 화면에는 `뷰어 모드로 보기`가 추가됐으므로, 저장된 본인 티어표에서 뷰어 모드로 진입한 뒤 다시 `수정 모드로 전환`으로 돌아오는 왕복 라우팅이 자연스러운지 QA한다.
- 뷰어 모드 오른쪽 레일이 공통 로컬 레일 마운트를 다시 사용하게 바뀌었으므로, 데스크톱/태블릿 폭에서 광고가 상단에 나오고 액션 카드가 하단으로 내려가며, 우측 레일 접기/펼치기 시 콘텐츠가 깨지지 않는지 확인한다.
- `v1.4.39`에서 기존 저장 티어표/복사본을 다시 열 때 최신 템플릿 기본 아이템이 미사용 풀로 합류하도록 바꿨으므로, `12345`로 저장한 티어표를 만든 뒤 관리자 템플릿에 `6789`를 추가하고 다시 열거나 복사했을 때 `6789`만 미사용 상태로 나타나는지 확인한다.
- 같은 정책에서 관리자 템플릿에서 삭제한 기존 아이템은 과거 저장 티어표의 그룹/풀 안에 남아 있으면 계속 보존되어야 하므로, `5`번을 삭제해도 이미 사용한 옛 티어표에서 사라지지 않는지 다시 확인한다.
- `v1.4.39`에서 주제별 공개 티어표 화면을 `pageWrap`으로 감쌌으므로, 좁은 브라우저 폭에서 검색창이 아래 줄로 내려온 상태에서도 검색창과 카드 목록 사이 간격이 홈/즐겨찾기 화면과 비슷하게 유지되는지 확인한다.
- `v1.4.38`에서 운영자 계정의 최고 관리자 관리 버튼을 프런트에서 비활성화했으므로, 최고 관리자/일반 운영자/일반 회원 세 계정 조합으로 회원 정보 수정, 썸네일 변경, 비밀번호 초기화, 삭제 버튼 활성 상태와 서버 차단 응답이 기대대로 맞는지 확인한다.
- 관리자 회원 정보 수정에서는 예약어 닉네임을 허용하도록 예외를 열었으므로, 일반 회원가입/개인 프로필 수정은 여전히 예약어가 막히고 관리자 수정만 저장되는지 한 번 더 QA한다.
- `preview=1` 화면이 공통 앱 셸을 그대로 쓰도록 바뀌었으므로, 좌측 레일 접기/펼치기, 우측 레일 닫기/열기, 중앙 헤더 타이틀, 메인 배경색, 오른쪽 광고와 카피라이트 정렬이 홈 화면과 같은 문법으로 보이는지 비교 QA한다.

View File

@@ -1,5 +1,21 @@
# 업데이트 로그
## 2026-04-03 v1.4.41
- 관리자 템플릿 기본 아이템 다중 업로드 제한을 한 번에 `100개`, 파일당 `20MB`까지 받을 수 있도록 백엔드 `multer` 설정과 업로드 라우트 배열 제한을 함께 상향했다.
- 프런트 Nginx 프록시에도 `client_max_body_size 1024m`을 추가해, 여러 이미지를 한 번의 `FormData` 요청으로 올릴 때 합산 본문 크기 제한 때문에 먼저 `413`으로 막히는 상황을 줄였다.
## 2026-04-03 v1.4.40
- 공유 링크로 여는 `preview=1` 화면을 `뷰어 모드`로 정의하고, 드래그/편집 없이 완성본만 보이는 상태에서 오른쪽 레일 상단에는 광고, 하단에는 공유·복사·수정 전환 액션을 노출하도록 정리했다.
- 비로그인 사용자나 작성자 본인이 아닌 사용자가 일반 편집 URL로 저장된 티어표를 직접 열어도 자동으로 `preview=1` 뷰어 모드 주소로 전환되도록 로딩 후 라우팅을 보정했다.
- 비로그인 사용자도 뷰어 모드에서 `공유하기` 버튼을 사용할 수 있고, 로그인한 타인 티어표는 `내 티어표로 복사`, 작성자 본인 티어표는 `수정 모드로 전환`을 사용할 수 있게 권한별 액션을 분기했다.
- 작성자 본인이 수정 화면에 있을 때는 우측 패널에 `뷰어 모드로 보기`를 추가해, 본인도 공유 화면과 같은 뷰어 모드를 바로 확인할 수 있게 했다.
- 뷰어 모드에서도 에디터 로컬 오른쪽 레일을 마운트하도록 공통 앱 셸 조건을 보정해, 광고와 액션 카드가 실제 우측 패널에 안정적으로 렌더링되게 맞췄다.
## 2026-04-03 v1.4.39
- 기존 저장 티어표/복사본을 수정 가능한 상태로 다시 열 때, 저장본에 없던 최신 템플릿 기본 아이템만 미사용 풀 맨 뒤에 자동 합류하도록 병합 로딩을 추가했다.
- 관리자 템플릿에서 삭제된 기본 아이템이라도 이미 저장된 티어표의 그룹/풀에 남아 있는 항목은 그대로 보존해, 과거 결과물이 템플릿 정리 때문에 깨지지 않도록 유지했다.
- 주제별 공개 티어표 목록 화면은 좁은 브라우저 폭에서 상단 검색 툴바가 아래 줄로 내려오면 카드 목록과 간격이 붙어 보일 수 있었으므로, 홈/즐겨찾기 화면과 같은 `pageWrap` 구조로 감싸 상단 영역과 목록 사이 여백을 유지하도록 정리했다.
## 2026-04-03 v1.4.38
- 관리자 회원 관리에서 운영자 계정으로는 최고 관리자 계정의 썸네일 변경, 비밀번호 초기화, 회원 삭제, 회원 정보 수정 버튼이 비활성화되도록 프런트 보호를 추가했고, 자기 자신 삭제 버튼도 함께 막았다.
- 관리자 회원 정보 수정에서는 운영자/관리자 예약어가 들어간 닉네임도 저장할 수 있도록 서버 검증 예외를 분리했고, 일반 회원가입과 개인 프로필 수정의 예약어 차단은 그대로 유지했다.

View File

@@ -1,6 +1,7 @@
server {
listen 80;
server_name _;
client_max_body_size 1024m;
root /usr/share/nginx/html;
index index.html;

View File

@@ -44,7 +44,7 @@ const isAdmin = computed(() => authReady.value && !!auth.user?.isAdmin)
const isPreviewMode = computed(() => route.query.preview === '1')
const isAdminRoute = computed(() => String(route.name || '').startsWith('admin'))
const usesLocalRightRail = computed(
() => !isPreviewMode.value && (['editEditor', 'newEditor'].includes(String(route.name || '')) || isAdminRoute.value)
() => ['editEditor', 'newEditor'].includes(String(route.name || '')) || isAdminRoute.value
)
const isRightRailOverlay = computed(() => viewportWidth.value <= 1200)
const isMobileLayout = computed(() => viewportWidth.value <= 860)

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>