Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a952d2a062 | |||
| d2273fa723 |
@@ -1,5 +1,13 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-04-07 v1.1.13
|
||||
- 팔로우 피드도 본질적으로는 “공개 티어표 목록 화면”이므로, 홈/템플릿/즐겨찾기와 같은 `viewToggle` 문법을 공유하는 편이 맞다고 정리했다.
|
||||
|
||||
## 2026-04-07 v1.1.12
|
||||
- `viewToggle`은 특정 주제 화면에만 남겨둘 기능이 아니라, 카드형/리스트형을 공통 문법으로 쓰는 주요 목록 화면 전반에서 일관되게 제공하는 편이 맞다고 정리했다.
|
||||
- 현재 주요 목록 화면은 데이터 규모가 아직 크지 않아 전부 한 번에 조회하는 구조를 유지하되, 이후 공개 티어표와 즐겨찾기 수가 늘어나면 페이지네이션이나 점진 로딩을 후속 과제로 검토하기로 했다.
|
||||
- 티어표 즐겨찾기는 “다른 사람 작품 보관”만이 아니라 “내가 자주 참고하는 내 작업 고정” 용도로도 쓸 수 있으므로, 작성자 본인 티어표도 프런트에서 막지 않는 방향이 더 자연스럽다고 판단했다.
|
||||
|
||||
## 2026-04-07 v1.1.11
|
||||
- 즐겨찾기 페이지는 단순 모아보기만으로 끝나면 관리 화면 역할이 약하므로, 카드 안에서 바로 해제할 수 있게 두는 편이 맞다고 정리했다. 별도 상세 화면으로 들어가서 해제하는 흐름은 불필요하게 길다.
|
||||
|
||||
|
||||
10
docs/map.md
10
docs/map.md
@@ -2,12 +2,12 @@
|
||||
|
||||
## `/`
|
||||
- 화면 파일: `frontend/src/views/HomeView.vue`
|
||||
- 역할: 공개 티어표 홈 피드, 상단 `추천 티어표`와 아래 `최신 공개 티어표` 목록을 같은 카드 문법으로 표시, 검색어(`q`)가 있으면 공개 티어표 제목/작성자 기준으로 필터링, 카드 클릭 시 해당 티어표 화면으로 이동
|
||||
- 역할: 공개 티어표 홈 피드, 상단 `추천 티어표`와 아래 `최신 공개 티어표` 목록을 같은 카드 문법으로 표시, 검색어(`q`)가 있으면 공개 티어표 제목/작성자 기준으로 필터링, 상단 공통 `viewToggle`로 카드형/리스트형 전환, 카드 클릭 시 해당 티어표 화면으로 이동
|
||||
- 연동 API: `GET /api/tierlists/public?q=...`
|
||||
|
||||
## `/templates`
|
||||
- 화면 파일: `frontend/src/views/TemplatesView.vue`
|
||||
- 역할: 공개 템플릿 전용 목록, 관리자 수동 순서와 즐겨찾기 여부를 반영한 주제 템플릿 카드 목록 표시, 템플릿 즐겨찾기 토글, 검색어(`q`)가 있으면 템플릿 이름/slug 기준으로 즉시 필터링
|
||||
- 역할: 공개 템플릿 전용 목록, 관리자 수동 순서와 즐겨찾기 여부를 반영한 주제 템플릿 카드 목록 표시, 템플릿 즐겨찾기 토글, 검색어(`q`)가 있으면 템플릿 이름/slug 기준으로 즉시 필터링, 상단 공통 `viewToggle`로 카드형/리스트형 전환
|
||||
- 연동 API: `GET /api/topics`, `POST /api/topics/:topicId/favorite`, `DELETE /api/topics/:topicId/favorite`
|
||||
|
||||
## `/topics/:topicId`
|
||||
@@ -32,17 +32,17 @@
|
||||
|
||||
## `/me`
|
||||
- 화면 파일: `frontend/src/views/MyTierListsView.vue`
|
||||
- 역할: 내 티어표 목록 조회, 4열 라이브러리 카드형 썸네일 표시, 편집 화면으로 이동, 작성자 본인 티어표 삭제
|
||||
- 역할: 내 티어표 목록 조회, 상단 공통 `viewToggle`로 카드형/리스트형 전환, 편집 화면으로 이동, 작성자 본인 티어표 삭제
|
||||
- 연동 API: `GET /api/tierlists/me`, `DELETE /api/tierlists/:id`
|
||||
|
||||
## `/favorites`
|
||||
- 화면 파일: `frontend/src/views/FavoriteTierListsView.vue`
|
||||
- 역할: 즐겨찾기한 티어표 목록 조회, 검색/정렬, 라이브러리 카드형 표시, 편집 화면 이동, 카드 우측 상단 `즐겨찾기 해제` 버튼으로 즉시 제거
|
||||
- 역할: 즐겨찾기한 티어표 목록 조회, 검색/정렬, 상단 공통 `viewToggle`로 카드형/리스트형 전환, 편집 화면 이동, 카드 우측 상단 `즐겨찾기 해제` 버튼으로 즉시 제거
|
||||
- 연동 API: `GET /api/tierlists/favorites/me`, `DELETE /api/tierlists/:id/favorite`
|
||||
|
||||
## `/following`
|
||||
- 화면 파일: `frontend/src/views/FollowingFeedView.vue`
|
||||
- 역할: 팔로우한 작성자의 공개 티어표를 최신 업데이트순 카드 목록으로 모아보기, 제목/주제/작성자 검색, 티어표 상세 이동, 작성자 프로필 이동
|
||||
- 역할: 팔로우한 작성자의 공개 티어표를 최신 업데이트순 카드 목록으로 모아보기, 상단 공통 `viewToggle`로 카드형/리스트형 전환, 제목/주제/작성자 검색, 티어표 상세 이동, 작성자 프로필 이동
|
||||
- 연동 API: `GET /api/users/following-feed`
|
||||
|
||||
## `/users/:userId`
|
||||
|
||||
@@ -48,6 +48,8 @@
|
||||
- 홈 피드(`/`)는 `GET /api/tierlists/public?q=...`를 사용한다.
|
||||
- `featuredTierLists`: 상단 추천 티어표
|
||||
- `tierLists`: 추천 제외 최신 공개 티어표
|
||||
- 홈, 템플릿, 나의 티어표, 즐겨찾기, 팔로우 피드 화면은 공통 `viewToggle`로 `그리드 / 리스트` 보기를 전환하며, 상태는 현재 라우트의 `?view=list` 쿼리로 반영한다.
|
||||
- 위 네 화면의 목록 데이터는 현재 페이지네이션이나 무한 스크롤 없이 조회 결과 전체를 한 번에 렌더링한다.
|
||||
- 저장된 티어표에는 댓글 스레드가 붙을 수 있다. 작성자 본인 편집 화면에서는 `작업 팁` 아래, 작성자가 아닌 사용자의 보기 전용 화면에서는 `preview` 보드 아래에서 같은 댓글 카드를 사용한다.
|
||||
- 댓글 알림 메뉴는 좌측 사이드 `댓글 관리`로 노출하며, 읽지 않은 댓글이 하나라도 있으면 빨간 dot을 표시한다.
|
||||
- 댓글 관리(`/comments`)는 기본적으로 `안 읽은 댓글만 보기`를 켠 상태로 시작한다.
|
||||
@@ -56,6 +58,7 @@
|
||||
- 댓글 관리 카드(`/comments`)는 좌측 `16:9 썸네일 + 티어표 제목 + 템플릿 이름`, 우측 `알림 제목 + 루트 댓글 정보 + 새 댓글/답글 정보`의 2열 구조를 사용한다.
|
||||
- 댓글 관리 카드의 상단 우측 배지는 상태 라벨이 아니라 개별 `읽음 처리` 액션으로 사용한다.
|
||||
- 티어표 즐겨찾기 API(`POST/DELETE /api/tierlists/:id/favorite`)는 이미 존재하며, 보기 화면 우측 레일에는 이를 직접 호출하는 단독 CTA를 노출한다.
|
||||
- 티어표 즐겨찾기는 작성자 본인 저장 티어표에도 사용할 수 있다.
|
||||
- `/favorites` 목록 카드에서도 같은 `DELETE /api/tierlists/:id/favorite`를 직접 호출해 즉시 해제할 수 있다.
|
||||
- 우측 패널
|
||||
- 현재 화면 문맥에 맞는 설명, 빠른 액션, 계정 상태 같은 보조 정보를 배치한다.
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
# 할 일 및 이슈
|
||||
|
||||
## 단기 확인
|
||||
- `v1.1.13` 이후 팔로우 피드에서도 공통 `viewToggle`이 보이고, 리스트형 보기에서 작성자 카드와 썸네일 정렬이 어색하지 않은지 확인한다.
|
||||
- `v1.1.12` 이후 홈/템플릿/나의 티어표/즐겨찾기에서 공통 `viewToggle`이 모두 같은 위치/같은 동작으로 보이는지 확인한다.
|
||||
- 리스트형 보기에서 홈/템플릿/나의 티어표/즐겨찾기 카드가 데스크톱과 모바일 모두에서 썸네일 비율과 제목 overflow 없이 안정적으로 보이는지 확인한다.
|
||||
- 내가 만든 저장 티어표도 즐겨찾기에 추가되고 `/favorites`에 나타나는지, 비공개 내 티어표를 즐겨찾기했을 때 접근/표시 규칙이 자연스러운지 확인한다.
|
||||
- 현재 주요 목록 화면은 전체 데이터를 한 번에 가져오는 구조이므로, 실제 데이터가 많아졌을 때 페이지네이션 또는 무한 스크롤이 필요한 시점을 추후 점검한다.
|
||||
- `v1.1.11` 이후 즐겨찾기 페이지 카드 우측 상단 `즐겨찾기 해제` 버튼이 카드 열기와 충돌하지 않는지, 해제 직후 목록에서 즉시 빠지고 새로고침 후에도 유지되는지 확인한다.
|
||||
- `v1.1.10` 이후 댓글 관리 화면이 기본적으로 안 읽은 댓글만 보이므로, 사용자가 처음 들어왔을 때 빈 화면처럼 느끼지 않는지와 `전체 보기`로 돌렸을 때도 자연스러운지 확인한다.
|
||||
- 개별 `읽음 처리` 버튼을 눌렀을 때 카드가 즉시 사라지고 좌측 메뉴 unread dot도 함께 줄어드는지, 마지막 unread 카드까지 처리하면 dot이 사라지는지 확인한다.
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# 업데이트 로그
|
||||
|
||||
## 2026-04-07 v1.1.13
|
||||
- 팔로우 피드도 다른 티어표 목록 화면과 같은 성격이므로, 공통 `viewToggle` 대상에 포함했다.
|
||||
- `FollowingFeedView`에 그리드형/리스트형 보기 전환을 추가했다. 이제 팔로우한 작성자의 공개 티어표도 상단 공통 토글로 카드형과 가로 리스트형을 오갈 수 있다.
|
||||
- 팔로우 피드 리스트형 레이아웃에서는 썸네일 좌측, 정보 우측 구조로 정리해 다른 목록 화면과 같은 문법을 따른다.
|
||||
- 확인: `npm run build`
|
||||
|
||||
## 2026-04-07 v1.1.12
|
||||
- 티어표 즐겨찾기 프런트 제한을 풀었다. 이제 내가 만든 저장된 티어표도 즐겨찾기에 넣을 수 있고, 같은 즐겨찾기 목록(`/favorites`)에서 다시 확인할 수 있다.
|
||||
- 홈, 템플릿, 나의 티어표, 즐겨찾기 화면에 공통 `viewToggle`을 다시 연결했다. 기존처럼 카드형 그리드와 가로 리스트형 보기 전환을 URL `?view=list` 기준으로 같은 방식으로 유지한다.
|
||||
- 홈/나의 티어표/즐겨찾기 화면에는 리스트형 레이아웃을 추가했고, 템플릿 화면도 같은 토글로 카드형과 리스트형을 오갈 수 있게 맞췄다.
|
||||
- 즐겨찾기 페이지 카드에서 썸네일/제목/메타가 카드 밖으로 넘치거나 잘려 보이던 구조를 `min-width`, overflow, title row grid 정리로 보정했다.
|
||||
- 현재 홈, 템플릿, 나의 티어표, 즐겨찾기 목록은 모두 페이지네이션이나 무한 스크롤 없이 “현재 조회 결과 전체를 한 번에 로드”하는 구조임을 다시 확인했다.
|
||||
- 확인: `npm run build`
|
||||
|
||||
## 2026-04-07 v1.1.11
|
||||
- `즐겨찾기` 페이지 카드에서도 바로 해제할 수 있게 정리했다. 이제 목록 화면에서 각 카드 우측 상단 `즐겨찾기 해제` 버튼으로 해당 티어표를 즉시 제거할 수 있다.
|
||||
- 카드 본문 열기와 해제 버튼 동작이 섞이지 않도록 분리했다. 버튼은 카드 클릭과 독립적으로 처리되고, 성공 시 목록에서도 바로 빠져 정리 흐름이 자연스럽다.
|
||||
|
||||
@@ -32,7 +32,14 @@ const leftRailCollapsed = ref(false)
|
||||
const mobileLeftNavOpen = ref(false)
|
||||
const rightRailOpen = ref(true)
|
||||
const searchQuery = ref('')
|
||||
const leftRailSearchPlaceholder = computed(() => (route.name === 'templates' ? '주제 템플릿 검색' : '공개 티어표 검색'))
|
||||
const leftRailSearchPlaceholder = computed(() => {
|
||||
if (route.name === 'templates') return '주제 템플릿 검색'
|
||||
if (route.name === 'topicHub') return '이 템플릿의 공개 티어표 검색'
|
||||
if (route.name === 'followingFeed') return '팔로우한 사람의 공개 티어표 검색'
|
||||
if (route.name === 'favorites') return '즐겨찾기한 티어표 검색'
|
||||
if (route.name === 'me') return '내 티어표 검색'
|
||||
return '공개 티어표 검색'
|
||||
})
|
||||
const isCollapsedSearchOpen = ref(false)
|
||||
const isGuideModalOpen = ref(false)
|
||||
const themeMode = ref('dark')
|
||||
@@ -157,7 +164,7 @@ const isGuideNextDisabled = computed(() => guideStepIndex.value >= guideSteps.le
|
||||
const isLightTheme = computed(() => themeMode.value === 'light')
|
||||
const themeToggleLabel = computed(() => (isLightTheme.value ? '다크 모드' : '라이트 모드'))
|
||||
const showSettingsThemePanel = computed(() => route.name === 'profile')
|
||||
const showTopicViewToggle = computed(() => route.name === 'topicHub')
|
||||
const showTopicViewToggle = computed(() => ['home', 'templates', 'topicHub', 'me', 'favorites', 'followingFeed'].includes(String(route.name || '')))
|
||||
const topicViewMode = computed(() => (route.query.view === 'list' ? 'list' : 'grid'))
|
||||
const showBackendFallback = computed(() => !isPreviewMode.value && ['maintenance', 'offline'].includes(backendState.value))
|
||||
const shouldLockRightRailBodyScroll = computed(() => isRightRailOverlay.value && rightRailOpen.value && !showBackendFallback.value)
|
||||
@@ -547,7 +554,7 @@ function toggleRightRail() {
|
||||
}
|
||||
|
||||
function setTopicViewMode(mode) {
|
||||
if (route.name !== 'topicHub') return
|
||||
if (!showTopicViewToggle.value) return
|
||||
const nextQuery = { ...route.query }
|
||||
if (mode === 'list') nextQuery.view = 'list'
|
||||
else delete nextQuery.view
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { api } from '../lib/api'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
import { useToast } from '../composables/useToast'
|
||||
@@ -8,12 +8,14 @@ import { editorPath, loginPath } from '../lib/paths'
|
||||
import { displayInitialFrom } from '../lib/display'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const toast = useToast()
|
||||
|
||||
const favorites = ref([])
|
||||
const query = ref('')
|
||||
const sort = ref('favorited')
|
||||
const busyTierListId = ref('')
|
||||
const isListView = computed(() => route.query.view === 'list')
|
||||
|
||||
function fmt(ts) {
|
||||
return new Date(ts).toLocaleDateString(undefined, {
|
||||
@@ -92,8 +94,8 @@ onMounted(loadFavorites)
|
||||
</div>
|
||||
|
||||
<div v-if="favorites.length === 0" class="empty">즐겨찾기한 티어표가 없어요.</div>
|
||||
<div v-else class="list">
|
||||
<article v-for="tierList in favorites" :key="tierList.id" class="boardCard">
|
||||
<div v-else class="list" :class="{ 'list--table': isListView }">
|
||||
<article v-for="tierList in favorites" :key="tierList.id" class="boardCard" :class="{ 'boardCard--list': isListView }">
|
||||
<button
|
||||
class="boardCard__favoriteAction"
|
||||
type="button"
|
||||
@@ -102,7 +104,7 @@ onMounted(loadFavorites)
|
||||
>
|
||||
{{ busyTierListId === tierList.id ? '처리 중...' : '즐겨찾기 해제' }}
|
||||
</button>
|
||||
<button class="boardCard__body" @click="openTierList(tierList)">
|
||||
<button class="boardCard__body" :class="{ 'boardCard__body--list': isListView }" @click="openTierList(tierList)">
|
||||
<div class="boardCard__thumbWrap">
|
||||
<img v-if="tierListThumbnailUrl(tierList)" class="boardCard__thumb" :src="tierListThumbnailUrl(tierList)" :alt="tierList.title" draggable="false" />
|
||||
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
|
||||
@@ -158,8 +160,12 @@ onMounted(loadFavorites)
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
.list--table {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.boardCard {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
border-radius: 22px;
|
||||
border: 1px solid var(--theme-card-border);
|
||||
background: var(--theme-card-bg);
|
||||
@@ -175,6 +181,7 @@ onMounted(loadFavorites)
|
||||
background: var(--theme-card-bg-hover);
|
||||
}
|
||||
.boardCard__body {
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
@@ -182,6 +189,8 @@ onMounted(loadFavorites)
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
.boardCard__favoriteAction {
|
||||
position: absolute;
|
||||
@@ -203,6 +212,7 @@ onMounted(loadFavorites)
|
||||
opacity: 0.72;
|
||||
}
|
||||
.boardCard__thumbWrap {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
padding: 14px 14px 0;
|
||||
@@ -227,16 +237,35 @@ onMounted(loadFavorites)
|
||||
font-weight: 700;
|
||||
}
|
||||
.boardCard__head {
|
||||
min-width: 0;
|
||||
padding: 16px 18px 18px;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.boardCard--list .boardCard__head {
|
||||
align-content: center;
|
||||
padding: 16px 18px 16px 0;
|
||||
}
|
||||
.boardCard__body--list {
|
||||
grid-template-columns: 200px minmax(0, 1fr);
|
||||
align-items: stretch;
|
||||
}
|
||||
.boardCard__body--list .boardCard__thumbWrap {
|
||||
height: 100%;
|
||||
padding: 14px;
|
||||
}
|
||||
.boardCard__body--list .boardCard__thumb,
|
||||
.boardCard__body--list .boardCard__thumbPlaceholder {
|
||||
min-height: 100%;
|
||||
}
|
||||
.boardCard__titleRow,
|
||||
.boardCard__metaRow {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.boardCard__metaRow {
|
||||
@@ -246,17 +275,23 @@ onMounted(loadFavorites)
|
||||
min-width: 0;
|
||||
font-weight: 800;
|
||||
font-size: 18px;
|
||||
white-space: nowrap;
|
||||
line-height: 1.35;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
word-break: break-word;
|
||||
}
|
||||
.boardCard__author {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
opacity: 0.86;
|
||||
overflow: hidden;
|
||||
}
|
||||
.boardCard__authorName {
|
||||
min-width: 0;
|
||||
@@ -282,9 +317,13 @@ onMounted(loadFavorites)
|
||||
.boardCard__date,
|
||||
.favoriteStat {
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
font-size: 13px;
|
||||
color: var(--theme-text-faint);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.boardCard__date {
|
||||
@@ -304,6 +343,15 @@ onMounted(loadFavorites)
|
||||
.list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.boardCard__body--list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.boardCard--list .boardCard__head {
|
||||
padding: 16px 18px 18px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { api } from '../lib/api'
|
||||
import { editorPath, loginPath, userProfilePath } from '../lib/paths'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
@@ -8,6 +8,7 @@ import { useToast } from '../composables/useToast'
|
||||
import { displayInitialFrom } from '../lib/display'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const toast = useToast()
|
||||
|
||||
const tierLists = ref([])
|
||||
@@ -15,6 +16,7 @@ const query = ref('')
|
||||
const isLoading = ref(false)
|
||||
const error = ref('')
|
||||
const brokenThumbnailIds = ref({})
|
||||
const isListView = computed(() => route.query.view === 'list')
|
||||
|
||||
watch(error, (message) => {
|
||||
if (!message) return
|
||||
@@ -95,9 +97,9 @@ onMounted(loadFollowingFeed)
|
||||
<section class="panel">
|
||||
<div v-if="isLoading" class="empty">팔로우 피드를 불러오고 있어요.</div>
|
||||
<div v-else-if="tierLists.length === 0" class="empty">아직 팔로우한 작성자의 공개 티어표가 없어요.</div>
|
||||
<div v-else class="list">
|
||||
<article v-for="tierList in tierLists" :key="tierList.id" class="boardCard">
|
||||
<button class="boardCard__body" type="button" @click="openTierList(tierList)">
|
||||
<div v-else class="list" :class="{ 'list--table': isListView }">
|
||||
<article v-for="tierList in tierLists" :key="tierList.id" class="boardCard" :class="{ 'boardCard--list': isListView }">
|
||||
<button class="boardCard__body" :class="{ 'boardCard__body--list': isListView }" type="button" @click="openTierList(tierList)">
|
||||
<div class="boardCard__thumbWrap">
|
||||
<img
|
||||
v-if="tierListThumbnailUrl(tierList)"
|
||||
@@ -174,6 +176,9 @@ onMounted(loadFollowingFeed)
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
.list--table {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.boardCard {
|
||||
min-width: 0;
|
||||
border-radius: 22px;
|
||||
@@ -200,6 +205,19 @@ onMounted(loadFollowingFeed)
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
overflow: hidden;
|
||||
}
|
||||
.boardCard__body--list {
|
||||
grid-template-columns: 200px minmax(0, 1fr);
|
||||
align-items: stretch;
|
||||
}
|
||||
.boardCard__body--list .boardCard__thumbWrap {
|
||||
height: 100%;
|
||||
padding: 14px;
|
||||
}
|
||||
.boardCard__body--list .boardCard__thumb,
|
||||
.boardCard__body--list .boardCard__thumbPlaceholder {
|
||||
min-height: 100%;
|
||||
}
|
||||
.boardCard__thumbWrap {
|
||||
width: 100%;
|
||||
@@ -230,6 +248,11 @@ onMounted(loadFollowingFeed)
|
||||
padding: 16px 18px 0;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.boardCard--list .boardCard__head {
|
||||
align-content: center;
|
||||
padding: 16px 18px 16px 0;
|
||||
}
|
||||
.boardCard__titleRow {
|
||||
min-width: 0;
|
||||
@@ -321,6 +344,14 @@ onMounted(loadFollowingFeed)
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.boardCard__body--list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.boardCard--list .boardCard__head {
|
||||
padding: 16px 18px 0;
|
||||
}
|
||||
|
||||
.input {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
|
||||
@@ -13,6 +13,7 @@ const featuredTierLists = ref([])
|
||||
const tierLists = ref([])
|
||||
const error = ref('')
|
||||
const query = computed(() => (typeof route.query.q === 'string' ? route.query.q.trim() : ''))
|
||||
const isListView = computed(() => route.query.view === 'list')
|
||||
const brokenThumbnailIds = ref({})
|
||||
|
||||
function fmt(ts) {
|
||||
@@ -85,9 +86,9 @@ watch(() => route.query.q, loadHomeFeed)
|
||||
</div>
|
||||
<div class="featuredHead__count">{{ featuredTierLists.length }}개</div>
|
||||
</div>
|
||||
<div class="list">
|
||||
<article v-for="tierList in featuredTierLists" :key="`featured-${tierList.id}`" class="boardCard boardCard--featured">
|
||||
<button class="boardCard__body" type="button" @click="openTierList(tierList)">
|
||||
<div class="list" :class="{ 'list--table': isListView }">
|
||||
<article v-for="tierList in featuredTierLists" :key="`featured-${tierList.id}`" class="boardCard boardCard--featured" :class="{ 'boardCard--list': isListView }">
|
||||
<button class="boardCard__body" :class="{ 'boardCard__body--list': isListView }" type="button" @click="openTierList(tierList)">
|
||||
<div class="boardCard__thumbWrap">
|
||||
<img
|
||||
v-if="tierListThumbnailUrl(tierList)"
|
||||
@@ -122,9 +123,9 @@ watch(() => route.query.q, loadHomeFeed)
|
||||
<section class="panel">
|
||||
<div class="sectionLabel">최신 공개 티어표</div>
|
||||
<div v-if="tierLists.length === 0" class="empty">{{ query ? '검색어에 맞는 공개 티어표가 없어요.' : '아직 공개 티어표가 없어요.' }}</div>
|
||||
<div v-else class="list">
|
||||
<article v-for="tierList in tierLists" :key="tierList.id" class="boardCard">
|
||||
<button class="boardCard__body" type="button" @click="openTierList(tierList)">
|
||||
<div v-else class="list" :class="{ 'list--table': isListView }">
|
||||
<article v-for="tierList in tierLists" :key="tierList.id" class="boardCard" :class="{ 'boardCard--list': isListView }">
|
||||
<button class="boardCard__body" :class="{ 'boardCard__body--list': isListView }" type="button" @click="openTierList(tierList)">
|
||||
<div class="boardCard__thumbWrap">
|
||||
<img
|
||||
v-if="tierListThumbnailUrl(tierList)"
|
||||
@@ -222,6 +223,9 @@ watch(() => route.query.q, loadHomeFeed)
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
.list--table {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.boardCard {
|
||||
min-width: 0;
|
||||
border-radius: 22px;
|
||||
@@ -282,6 +286,22 @@ watch(() => route.query.q, loadHomeFeed)
|
||||
gap: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.boardCard--list .boardCard__head {
|
||||
align-content: center;
|
||||
padding: 16px 18px 16px 0;
|
||||
}
|
||||
.boardCard__body--list {
|
||||
grid-template-columns: 200px minmax(0, 1fr);
|
||||
align-items: stretch;
|
||||
}
|
||||
.boardCard__body--list .boardCard__thumbWrap {
|
||||
height: 100%;
|
||||
padding: 14px;
|
||||
}
|
||||
.boardCard__body--list .boardCard__thumb,
|
||||
.boardCard__body--list .boardCard__thumbPlaceholder {
|
||||
min-height: 100%;
|
||||
}
|
||||
.boardCard__titleRow,
|
||||
.boardCard__metaRow {
|
||||
min-width: 0;
|
||||
@@ -370,5 +390,13 @@ watch(() => route.query.q, loadHomeFeed)
|
||||
.list {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.boardCard__body--list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.boardCard--list .boardCard__head {
|
||||
padding: 16px 18px 18px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { api } from '../lib/api'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
import { useToast } from '../composables/useToast'
|
||||
@@ -8,10 +8,12 @@ import { editorPath, loginPath } from '../lib/paths'
|
||||
import { displayInitialFrom } from '../lib/display'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const toast = useToast()
|
||||
const myLists = ref([])
|
||||
const error = ref('')
|
||||
const brokenThumbnailIds = ref({})
|
||||
const isListView = computed(() => route.query.view === 'list')
|
||||
|
||||
watch(error, (message) => {
|
||||
if (!message) return
|
||||
@@ -76,9 +78,9 @@ function openList(t) {
|
||||
|
||||
<section class="panel">
|
||||
<div v-if="myLists.length === 0" class="empty">아직 저장한 티어표가 없어요.</div>
|
||||
<div v-else class="list">
|
||||
<article v-for="t in myLists" :key="t.id" class="boardCard">
|
||||
<button class="boardCard__body" @click="openList(t)">
|
||||
<div v-else class="list" :class="{ 'list--table': isListView }">
|
||||
<article v-for="t in myLists" :key="t.id" class="boardCard" :class="{ 'boardCard--list': isListView }">
|
||||
<button class="boardCard__body" :class="{ 'boardCard__body--list': isListView }" @click="openList(t)">
|
||||
<div class="boardCard__thumbWrap">
|
||||
<img
|
||||
v-if="tierListThumbnailUrl(t)"
|
||||
@@ -124,6 +126,9 @@ function openList(t) {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
.list--table {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.boardCard {
|
||||
min-width: 0;
|
||||
border-radius: 22px;
|
||||
@@ -196,6 +201,22 @@ function openList(t) {
|
||||
gap: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.boardCard--list .boardCard__head {
|
||||
align-content: center;
|
||||
padding: 16px 18px 16px 0;
|
||||
}
|
||||
.boardCard__body--list {
|
||||
grid-template-columns: 200px minmax(0, 1fr);
|
||||
align-items: stretch;
|
||||
}
|
||||
.boardCard__body--list .boardCard__thumbWrap {
|
||||
height: 100%;
|
||||
padding: 14px;
|
||||
}
|
||||
.boardCard__body--list .boardCard__thumb,
|
||||
.boardCard__body--list .boardCard__thumbPlaceholder {
|
||||
min-height: 100%;
|
||||
}
|
||||
.boardCard__titleRow,
|
||||
.boardCard__metaRow {
|
||||
min-width: 0;
|
||||
@@ -268,5 +289,13 @@ function openList(t) {
|
||||
.list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.boardCard__body--list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.boardCard--list .boardCard__head {
|
||||
padding: 16px 18px 18px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -16,6 +16,7 @@ const templateRecords = ref([])
|
||||
const error = ref('')
|
||||
const loadingFavoriteId = ref('')
|
||||
const query = computed(() => (typeof route.query.q === 'string' ? route.query.q.trim().toLowerCase() : ''))
|
||||
const isListView = computed(() => route.query.view === 'list')
|
||||
const templates = computed(() => {
|
||||
const filtered = templateRecords.value
|
||||
.filter((item) => item.id !== 'freeform')
|
||||
@@ -88,8 +89,8 @@ function templateThumbUrl(template) {
|
||||
</section>
|
||||
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
<TransitionGroup v-if="templates.length" name="libraryCard" tag="section" class="libraryGrid">
|
||||
<article v-for="template in templates" :key="template.id" class="libraryCard">
|
||||
<TransitionGroup v-if="templates.length" name="libraryCard" tag="section" class="libraryGrid" :class="{ 'libraryGrid--list': isListView }">
|
||||
<article v-for="template in templates" :key="template.id" class="libraryCard" :class="{ 'libraryCard--list': isListView }">
|
||||
<button
|
||||
class="libraryCard__favorite"
|
||||
type="button"
|
||||
@@ -99,7 +100,7 @@ function templateThumbUrl(template) {
|
||||
>
|
||||
<SvgIcon class="libraryCard__favoriteIcon" :src="kidStarIcon" :size="18" />
|
||||
</button>
|
||||
<button class="libraryCard__main" type="button" @click="openTopic(template)">
|
||||
<button class="libraryCard__main" :class="{ 'libraryCard__main--list': isListView }" type="button" @click="openTopic(template)">
|
||||
<div class="libraryCard__thumbWrap">
|
||||
<img v-if="templateThumbUrl(template)" class="libraryCard__thumb" :src="templateThumbUrl(template)" :alt="template.name" draggable="false" />
|
||||
<div v-else class="libraryCard__thumbFallback">대표 썸네일</div>
|
||||
@@ -120,6 +121,9 @@ function templateThumbUrl(template) {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
.libraryGrid--list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.error {
|
||||
margin: 0 0 16px;
|
||||
padding: 10px 12px;
|
||||
@@ -161,6 +165,17 @@ function templateThumbUrl(template) {
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.libraryCard__main--list {
|
||||
grid-template-columns: 200px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
}
|
||||
.libraryCard__main--list .libraryCard__thumbWrap {
|
||||
height: 100%;
|
||||
}
|
||||
.libraryCard--list .libraryCard__favorite {
|
||||
top: 14px;
|
||||
bottom: auto;
|
||||
}
|
||||
.libraryCard__favorite {
|
||||
position: absolute;
|
||||
bottom: 24px;
|
||||
@@ -259,5 +274,9 @@ function templateThumbUrl(template) {
|
||||
.libraryGrid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.libraryCard__main--list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -138,7 +138,7 @@ const untitledWarning = computed(
|
||||
!hasCustomTitle.value &&
|
||||
'제목 없이 저장된 티어표는 무분별한 도배 방지를 위해 관리자에 의해 임의 삭제될 수 있어요.'
|
||||
)
|
||||
const canFavorite = computed(() => !!auth.user && !isNewTierList.value && !canEdit.value)
|
||||
const canFavorite = computed(() => !!auth.user && hasSavedTierList.value && !isNewTierList.value)
|
||||
const canDuplicate = computed(() => !!auth.user && hasSavedTierList.value)
|
||||
const canSwitchToViewerMode = computed(() => isOwnTierList.value && hasSavedTierList.value && !previewMode.value)
|
||||
const canSwitchToEditMode = computed(() => isOwnTierList.value && hasSavedTierList.value && previewMode.value)
|
||||
|
||||
Reference in New Issue
Block a user