Compare commits

..

4 Commits

Author SHA1 Message Date
bc5a34bbb7 내 티어표 검색 연결 2026-04-07 14:43:05 +09:00
ede348be96 검색 동선 통일 2026-04-07 14:41:20 +09:00
a952d2a062 팔로우 피드 보기 전환 추가 2026-04-07 14:37:10 +09:00
d2273fa723 목록 보기 전환 정리 2026-04-07 14:33:13 +09:00
13 changed files with 282 additions and 160 deletions

View File

@@ -1,5 +1,20 @@
# 의사결정 이력
## 2026-04-07 v1.1.15
- `나의 티어표`도 주요 목록 화면 중 하나이므로 왼쪽 공통 검색창 범위에서 빼는 것보다 포함하는 편이 더 일관적이라고 정리했다.
## 2026-04-07 v1.1.14
- 티어표 목록 화면마다 별도 검색창을 두기보다, 왼쪽 공통 검색창이 “현재 보고 있는 화면 범위만 검색한다”는 규칙으로 통일하는 편이 더 단순하고 예측 가능하다고 정리했다.
- 즐겨찾기 목록에서는 해제 버튼을 즉시 노출하기보다, 해당 티어표를 방문해 한 번 더 확인한 뒤 우측 사이드 CTA로 해제하는 흐름이 실수 방지 측면에서 낫다고 판단했다.
## 2026-04-07 v1.1.13
- 팔로우 피드도 본질적으로는 “공개 티어표 목록 화면”이므로, 홈/템플릿/즐겨찾기와 같은 `viewToggle` 문법을 공유하는 편이 맞다고 정리했다.
## 2026-04-07 v1.1.12
- `viewToggle`은 특정 주제 화면에만 남겨둘 기능이 아니라, 카드형/리스트형을 공통 문법으로 쓰는 주요 목록 화면 전반에서 일관되게 제공하는 편이 맞다고 정리했다.
- 현재 주요 목록 화면은 데이터 규모가 아직 크지 않아 전부 한 번에 조회하는 구조를 유지하되, 이후 공개 티어표와 즐겨찾기 수가 늘어나면 페이지네이션이나 점진 로딩을 후속 과제로 검토하기로 했다.
- 티어표 즐겨찾기는 “다른 사람 작품 보관”만이 아니라 “내가 자주 참고하는 내 작업 고정” 용도로도 쓸 수 있으므로, 작성자 본인 티어표도 프런트에서 막지 않는 방향이 더 자연스럽다고 판단했다.
## 2026-04-07 v1.1.11
- 즐겨찾기 페이지는 단순 모아보기만으로 끝나면 관리 화면 역할이 약하므로, 카드 안에서 바로 해제할 수 있게 두는 편이 맞다고 정리했다. 별도 상세 화면으로 들어가서 해제하는 흐름은 불필요하게 길다.

View File

@@ -2,17 +2,17 @@
## `/`
- 화면 파일: `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`
- 화면 파일: `frontend/src/views/TopicHubView.vue`
- 역할: 선택한 주제 slug 기준 정보 표시, 관리자 추천 티어표 상단 강조 섹션과 일반 공개 티어표 목록 분리 표시, 제목/작성자 검색, 티어표별 `상단 썸네일 / 제목+좋아요 / 작성자+최종 수정일` 카드 표시, 새 티어표 작성은 우측 하단 CTA로 진입
- 역할: 선택한 주제 slug 기준 정보 표시, 관리자 추천 티어표 상단 강조 섹션과 일반 공개 티어표 목록 분리 표시, 왼쪽 공통 검색창으로 해당 주제의 공개 티어표만 검색, 티어표별 `상단 썸네일 / 제목+좋아요 / 작성자+최종 수정일` 카드 표시, 새 티어표 작성은 우측 하단 CTA로 진입
- 연동 API: `GET /api/topics/:topicId`, `GET /api/tierlists/public`, `POST /api/tierlists/:id/favorite`, `DELETE /api/tierlists/:id/favorite`
## `/editor/:topicId/new`, `/editor/:topicId/:tierListId`
@@ -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`

View File

@@ -48,6 +48,15 @@
- 홈 피드(`/`)는 `GET /api/tierlists/public?q=...`를 사용한다.
- `featuredTierLists`: 상단 추천 티어표
- `tierLists`: 추천 제외 최신 공개 티어표
- 홈, 템플릿, 나의 티어표, 즐겨찾기, 팔로우 피드 화면은 공통 `viewToggle``그리드 / 리스트` 보기를 전환하며, 상태는 현재 라우트의 `?view=list` 쿼리로 반영한다.
- 왼쪽 공통 검색창은 현재 화면 범위만 검색한다.
- 홈: 전체 공개 티어표
- 템플릿: 공개 템플릿
- 나의 티어표: 내 저장 티어표
- 특정 주제 화면: 해당 주제의 공개 티어표
- 팔로우 피드: 팔로우한 작성자의 공개 티어표
- 즐겨찾기: 내가 즐겨찾기한 티어표
- 위 네 화면의 목록 데이터는 현재 페이지네이션이나 무한 스크롤 없이 조회 결과 전체를 한 번에 렌더링한다.
- 저장된 티어표에는 댓글 스레드가 붙을 수 있다. 작성자 본인 편집 화면에서는 `작업 팁` 아래, 작성자가 아닌 사용자의 보기 전용 화면에서는 `preview` 보드 아래에서 같은 댓글 카드를 사용한다.
- 댓글 알림 메뉴는 좌측 사이드 `댓글 관리`로 노출하며, 읽지 않은 댓글이 하나라도 있으면 빨간 dot을 표시한다.
- 댓글 관리(`/comments`)는 기본적으로 `안 읽은 댓글만 보기`를 켠 상태로 시작한다.
@@ -56,7 +65,7 @@
- 댓글 관리 카드(`/comments`)는 좌측 `16:9 썸네일 + 티어표 제목 + 템플릿 이름`, 우측 `알림 제목 + 루트 댓글 정보 + 새 댓글/답글 정보`의 2열 구조를 사용한다.
- 댓글 관리 카드의 상단 우측 배지는 상태 라벨이 아니라 개별 `읽음 처리` 액션으로 사용한다.
- 티어표 즐겨찾기 API(`POST/DELETE /api/tierlists/:id/favorite`)는 이미 존재하며, 보기 화면 우측 레일에는 이를 직접 호출하는 단독 CTA를 노출한다.
- `/favorites` 목록 카드에서도 같은 `DELETE /api/tierlists/:id/favorite`를 직접 호출해 즉시 해제할 수 있다.
- 티어표 즐겨찾기는 작성자 본인 저장 티어표에도 사용할 수 있다.
- 우측 패널
- 현재 화면 문맥에 맞는 설명, 빠른 액션, 계정 상태 같은 보조 정보를 배치한다.
- 에디터/관리자 세부 옵션은 후속 단계에서 이 패널로 점진 이관한다.

View File

@@ -1,6 +1,15 @@
# 할 일 및 이슈
## 단기 확인
- `v1.1.15` 이후 `나의 티어표`에서도 왼쪽 공통 검색창이 정상 동작하고, 검색 결과가 없을 때 전용 빈 상태 문구가 자연스럽게 보이는지 확인한다.
- `v1.1.14` 이후 왼쪽 공통 검색창이 홈/템플릿/주제/팔로우 피드/즐겨찾기 각각의 범위만 정확히 검색하는지 확인한다.
- `v1.1.14` 이후 주제 허브, 팔로우 피드, 즐겨찾기 화면 상단에서 중복 검색창이 모두 사라졌는지 확인한다.
- 즐겨찾기 목록에서는 해제 버튼이 숨겨지고, 실제 해제는 해당 티어표 화면 우측 CTA에서만 가능한 흐름이 사용자 의도와 맞는지 확인한다.
- `v1.1.13` 이후 팔로우 피드에서도 공통 `viewToggle`이 보이고, 리스트형 보기에서 작성자 카드와 썸네일 정렬이 어색하지 않은지 확인한다.
- `v1.1.12` 이후 홈/템플릿/나의 티어표/즐겨찾기에서 공통 `viewToggle`이 모두 같은 위치/같은 동작으로 보이는지 확인한다.
- 리스트형 보기에서 홈/템플릿/나의 티어표/즐겨찾기 카드가 데스크톱과 모바일 모두에서 썸네일 비율과 제목 overflow 없이 안정적으로 보이는지 확인한다.
- 내가 만든 저장 티어표도 즐겨찾기에 추가되고 `/favorites`에 나타나는지, 비공개 내 티어표를 즐겨찾기했을 때 접근/표시 규칙이 자연스러운지 확인한다.
- 현재 주요 목록 화면은 전체 데이터를 한 번에 가져오는 구조이므로, 실제 데이터가 많아졌을 때 페이지네이션 또는 무한 스크롤이 필요한 시점을 추후 점검한다.
- `v1.1.11` 이후 즐겨찾기 페이지 카드 우측 상단 `즐겨찾기 해제` 버튼이 카드 열기와 충돌하지 않는지, 해제 직후 목록에서 즉시 빠지고 새로고침 후에도 유지되는지 확인한다.
- `v1.1.10` 이후 댓글 관리 화면이 기본적으로 안 읽은 댓글만 보이므로, 사용자가 처음 들어왔을 때 빈 화면처럼 느끼지 않는지와 `전체 보기`로 돌렸을 때도 자연스러운지 확인한다.
- 개별 `읽음 처리` 버튼을 눌렀을 때 카드가 즉시 사라지고 좌측 메뉴 unread dot도 함께 줄어드는지, 마지막 unread 카드까지 처리하면 dot이 사라지는지 확인한다.

View File

@@ -1,5 +1,31 @@
# 업데이트 로그
## 2026-04-07 v1.1.15
- `나의 티어표`도 왼쪽 공통 검색창 범위에 포함했다. 이제 `/me` 화면에서 입력한 검색어는 내 저장 티어표 제목/주제명/작성자 이름 기준으로 즉시 필터링된다.
- `MyTierListsView``route.query.q`를 받아 클라이언트에서 목록을 필터링하고, 검색 결과가 없으면 전용 빈 상태 문구를 표시한다.
- 확인: `npm run build`
## 2026-04-07 v1.1.14
- 왼쪽 공통 검색창이 현재 화면 문맥만 검색하도록 라우팅을 정리했다. 이제 홈은 전체 공개 티어표, 템플릿은 공개 템플릿, 특정 템플릿 화면은 해당 주제의 공개 티어표, 팔로우 피드는 팔로우한 작성자의 공개 티어표, 즐겨찾기는 내가 즐겨찾기한 티어표 안에서만 검색한다.
- `TopicHubView`, `FollowingFeedView`, `FavoriteTierListsView` 상단의 중복 검색 입력창은 제거했다. 검색은 왼쪽 공통 검색창 하나로만 수행하도록 정리했다.
- 즐겨찾기 목록에서는 카드 우측 상단 `즐겨찾기 해제` 버튼을 다시 숨겼다. 실수 방지를 위해 목록에서는 방문만 하고, 실제 해제는 해당 티어표 화면 우측 즐겨찾기 CTA에서 하도록 흐름을 되돌렸다.
- 즐겨찾기 화면은 검색 입력을 제거하고 정렬 셀렉트만 유지한다.
- 확인: `npm run build`
## 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
- `즐겨찾기` 페이지 카드에서도 바로 해제할 수 있게 정리했다. 이제 목록 화면에서 각 카드 우측 상단 `즐겨찾기 해제` 버튼으로 해당 티어표를 즉시 제거할 수 있다.
- 카드 본문 열기와 해제 버튼 동작이 섞이지 않도록 분리했다. 버튼은 카드 클릭과 독립적으로 처리되고, 성공 시 목록에서도 바로 빠져 정리 흐름이 자연스럽다.

View File

@@ -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
@@ -597,6 +604,13 @@ function handleLeftRailSearch() {
function submitGlobalSearch() {
const query = (searchQuery.value || '').trim()
isCollapsedSearchOpen.value = false
if (['home', 'templates', 'topicHub', 'followingFeed', 'favorites', 'me'].includes(String(route.name || ''))) {
const nextQuery = { ...route.query }
if (query) nextQuery.q = query
else delete nextQuery.q
router.push({ path: route.path, query: nextQuery })
return
}
router.push(route.name === 'templates' ? templatesPath(query) : homePath(query))
}

View File

@@ -1,6 +1,6 @@
<script setup>
import { computed, onMounted, ref } 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,12 +8,13 @@ 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')
const query = computed(() => (typeof route.query.q === 'string' ? route.query.q.trim() : ''))
function fmt(ts) {
return new Date(ts).toLocaleDateString(undefined, {
@@ -53,23 +54,8 @@ function openTierList(tierList) {
router.push(editorPath(tierList.topicSlug || tierList.topicId, tierList.id))
}
async function removeFavorite(tierListId) {
if (!tierListId || busyTierListId.value) return
busyTierListId.value = tierListId
const original = favorites.value.slice()
favorites.value = favorites.value.filter((tierList) => tierList.id !== tierListId)
try {
await api.unfavoriteTierList(tierListId)
toast.success('즐겨찾기에서 제거했어요.')
} catch (error) {
favorites.value = original
toast.error('즐겨찾기 해제에 실패했어요.')
} finally {
busyTierListId.value = ''
}
}
onMounted(loadFavorites)
watch([query, sort], loadFavorites)
</script>
<template>
@@ -81,28 +67,18 @@ onMounted(loadFavorites)
<div class="pageHead__desc">마음에 드는 티어표를 모아보고, 원하는 기준으로 정렬할 있어요.</div>
</div>
<div class="pageHead__aside toolbar">
<input v-model="query" class="input" placeholder="제목, 주제, 작성자 검색" @keydown.enter.prevent="loadFavorites" />
<select v-model="sort" class="select" @change="loadFavorites">
<select v-model="sort" class="select">
<option value="favorited">즐겨찾기한 </option>
<option value="updated">최신 업데이트순</option>
<option value="favorites">인기순</option>
</select>
<button class="btn" @click="loadFavorites">검색</button>
</div>
</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">
<button
class="boardCard__favoriteAction"
type="button"
:disabled="!!busyTierListId"
@click.stop="removeFavorite(tierList.id)"
>
{{ busyTierListId === tierList.id ? '처리 중...' : '즐겨찾기 해제' }}
</button>
<button class="boardCard__body" @click="openTierList(tierList)">
<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__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>
@@ -141,15 +117,6 @@ onMounted(loadFavorites)
background: var(--theme-surface-soft);
color: var(--theme-text);
}
.btn {
padding: 11px 13px;
border-radius: 14px;
border: 1px solid var(--theme-border);
background: var(--theme-surface-soft-2);
color: var(--theme-text);
font-weight: 800;
cursor: pointer;
}
.empty {
opacity: 0.76;
}
@@ -158,8 +125,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 +146,7 @@ onMounted(loadFavorites)
background: var(--theme-card-bg-hover);
}
.boardCard__body {
min-width: 0;
border: 0;
background: transparent;
color: inherit;
@@ -182,27 +154,11 @@ onMounted(loadFavorites)
text-align: left;
cursor: pointer;
display: grid;
}
.boardCard__favoriteAction {
position: absolute;
top: 14px;
right: 14px;
z-index: 1;
min-height: 32px;
padding: 0 12px;
border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--theme-danger-border) 70%, var(--theme-border));
background: color-mix(in srgb, var(--theme-surface) 92%, var(--theme-danger-bg));
color: var(--theme-text);
font-size: 12px;
font-weight: 800;
cursor: pointer;
}
.boardCard__favoriteAction:disabled {
cursor: default;
opacity: 0.72;
width: 100%;
overflow: hidden;
}
.boardCard__thumbWrap {
min-width: 0;
width: 100%;
aspect-ratio: 16 / 9;
padding: 14px 14px 0;
@@ -227,16 +183,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 +221,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 +263,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,12 +289,19 @@ 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%;
}
.input,
.select,
.btn {
.select {
width: 100%;
}
}

View File

@@ -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,13 +8,15 @@ import { useToast } from '../composables/useToast'
import { displayInitialFrom } from '../lib/display'
const router = useRouter()
const route = useRoute()
const toast = useToast()
const tierLists = ref([])
const query = ref('')
const query = computed(() => (typeof route.query.q === 'string' ? route.query.q.trim() : ''))
const isLoading = ref(false)
const error = ref('')
const brokenThumbnailIds = ref({})
const isListView = computed(() => route.query.view === 'list')
watch(error, (message) => {
if (!message) return
@@ -76,6 +78,7 @@ function openAuthorProfile(tierList) {
}
onMounted(loadFollowingFeed)
watch(query, loadFollowingFeed)
</script>
<template>
@@ -86,18 +89,14 @@ onMounted(loadFollowingFeed)
<h2 class="pageHead__title">팔로우 피드</h2>
<div class="pageHead__desc">팔로우한 작성자가 공개한 티어표를 최신 업데이트순으로 모아봅니다.</div>
</div>
<div class="pageHead__aside toolbar">
<input v-model="query" class="input" placeholder="제목, 주제, 작성자 검색" @keydown.enter.prevent="loadFollowingFeed" />
<button class="btn" :disabled="isLoading" @click="loadFollowingFeed">{{ isLoading ? '검색중...' : '검색' }}</button>
</div>
</section>
<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)"
@@ -144,28 +143,6 @@ onMounted(loadFollowingFeed)
border-radius: 0;
padding: 0;
}
.toolbar {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.input {
min-width: 240px;
padding: 11px 13px;
border-radius: 14px;
border: 1px solid var(--theme-border);
background: var(--theme-surface-soft);
color: var(--theme-text);
}
.btn {
padding: 11px 13px;
border-radius: 14px;
border: 1px solid var(--theme-border);
background: var(--theme-surface-soft-2);
color: var(--theme-text);
font-weight: 800;
cursor: pointer;
}
.empty {
opacity: 0.75;
}
@@ -174,6 +151,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 +180,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 +223,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,9 +319,13 @@ onMounted(loadFollowingFeed)
grid-template-columns: 1fr;
}
.input {
min-width: 0;
width: 100%;
.boardCard__body--list {
grid-template-columns: 1fr;
}
.boardCard--list .boardCard__head {
padding: 16px 18px 0;
}
}
</style>

View File

@@ -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>

View File

@@ -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,20 @@ 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')
const query = computed(() => (typeof route.query.q === 'string' ? route.query.q.trim().toLowerCase() : ''))
const filteredMyLists = computed(() => {
if (!query.value) return myLists.value
return myLists.value.filter((tierList) => {
const haystack = `${tierList.title || ''} ${tierList.topicName || ''} ${tierList.authorName || ''}`.toLowerCase()
return haystack.includes(query.value)
})
})
watch(error, (message) => {
if (!message) return
@@ -76,9 +86,10 @@ 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-if="filteredMyLists.length === 0" class="empty">검색어에 맞는 티어표가 없어요.</div>
<div v-else class="list" :class="{ 'list--table': isListView }">
<article v-for="t in filteredMyLists" :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 +135,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 +210,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 +298,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>

View File

@@ -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>

View File

@@ -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)

View File

@@ -12,11 +12,11 @@ const router = useRouter()
const auth = useAuthStore()
const topicId = computed(() => route.params.topicId)
const query = computed(() => (typeof route.query.q === 'string' ? route.query.q.trim() : ''))
const topicName = ref('')
const featuredTierLists = ref([])
const tierLists = ref([])
const error = ref('')
const query = ref('')
const brokenThumbnailIds = ref({})
const isTopicLoading = ref(false)
const isListView = computed(() => route.query.view === 'list')
@@ -84,12 +84,8 @@ function openTierList(id) {
router.push(editorPath(topicId.value, id))
}
function submitSearch() {
loadTierLists()
}
watch(
topicId,
[topicId, query],
() => {
topicName.value = ''
error.value = ''
@@ -107,10 +103,6 @@ watch(
<h2 class="pageHead__title">{{ topicTitle }}</h2>
<div class="pageHead__desc"> 주제의 공개 티어표를 같은 카드 레이아웃으로 살펴보고 이어서 티어표를 만들 있어요.</div>
</div>
<div class="pageHead__aside toolbar">
<input v-model="query" class="input" placeholder="제목 또는 작성자 검색" @keydown.enter.prevent="submitSearch" />
<button class="btn" @click="submitSearch">검색</button>
</div>
</section>
<div v-if="error" class="error">{{ error }}</div>
@@ -245,28 +237,6 @@ watch(
.sectionLabel {
margin-bottom: 14px;
}
.toolbar {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.input {
min-width: 240px;
padding: 11px 13px;
border-radius: 14px;
border: 1px solid var(--theme-border);
background: var(--theme-surface-soft);
color: var(--theme-text);
}
.btn {
padding: 11px 13px;
border-radius: 14px;
border: 1px solid var(--theme-border);
background: var(--theme-surface-soft-2);
color: var(--theme-text);
font-weight: 800;
cursor: pointer;
}
.error {
margin: 10px 0 14px;
padding: 10px 12px;