feat: 홈 화면 UI 구현
.design/surinz 홈 아트보드를 프런트엔드로 옮긴다. 디자인 토큰 - assets/styles/tokens.css: 아트보드의 색·치수·라운드를 CSS 변수로 정리. 화면 코드에서는 여기 없는 값을 새로 만들지 않는다 - index.html: Pretendard 한글 동적 서브셋 연결 레이아웃 - AppHeader: 히어로가 화면 밖으로 나가면 돋보기와 그림자를 켠다. 모바일 서랍 - SearchHero / SearchSheet: 데스크톱 640px 카드, 모바일 전체 화면 - AppFooter 섹션 일곱 이벤트(스크롤 스냅) · 검색 랭킹 · 게임 랭킹 · 커뮤니티 · 서든어택 소식 · 서린즈 소식 · 지금 방송 중. 게임 랭킹은 데스크톱에서 세 갈래를 나란히, 태블릿 이하에서는 useMediaQuery 로 갈래 세그먼트 하나씩으로 바꾼다. 랭크전은 티어 이미지와 RP 를 한 칸에 붙여 둔다. 섹션별 상태 useSectionResource 가 섹션마다 로딩·성공·비어있음·실패를 따로 들고, SectionBoundary 가 분기한다. 한 섹션이 실패해도 그 섹션만 다시 부른다. 실패 문구는 화면에서 짓지 않고 useApi 의 에러 코드 맵에서 가져온다 — 이를 위해 resolveErrorMessage 를 export 하고, 요청 단위 silentError 를 더해 섹션 실패가 전역 알림을 띄우지 않게 했다. 백엔드에 홈 API 가 없어 mocks/home.fixtures.ts 로 화면을 먼저 세운다. 연동은 composables/useHome.ts 의 로더 일곱 개만 바꾸면 된다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DNo7LSAWohdk6vpEhmtqLY
This commit is contained in:
+10
-2
@@ -4,8 +4,16 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="프로젝트 베이스 템플릿" />
|
||||
<title>Project Base</title>
|
||||
<meta name="description" content="서든어택 전적 검색 · 랭킹 · 커뮤니티 — 서린즈" />
|
||||
<!--
|
||||
Pretendard — 한글 동적 서브셋. 자체 호스팅이 필요해지면
|
||||
pretendard 패키지를 설치해 이 링크를 걷어내면 된다.
|
||||
-->
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css"
|
||||
/>
|
||||
<title>서린즈</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 236 KiB |
@@ -0,0 +1,64 @@
|
||||
/* 전역 기본값 — 토큰을 먼저 읽은 뒤 적용한다 */
|
||||
@import './tokens.css';
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
/* 모든 rem 값의 기준 (16px = 1rem) */
|
||||
font-size: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background-color: var(--sz-surface);
|
||||
color: var(--sz-text);
|
||||
font-family: 'Pretendard Variable', 'Pretendard', -apple-system, 'Apple SD Gothic Neo',
|
||||
'Malgun Gothic', sans-serif;
|
||||
font-size: 1rem;
|
||||
/* 숫자 자리가 흔들리지 않도록 고정폭 숫자를 쓴다 (랭킹 표) */
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: -0.015em;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--sz-brand-text);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--sz-brand-text-hover);
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
letter-spacing: inherit;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* 키보드 이동 시에만 초점 테두리를 보인다 */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--sz-brand-text);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* 시각적으로 감추되 보조기기에는 읽히게 한다 */
|
||||
.sz-sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* 서린즈 디자인 토큰 — .design/surinz 아트보드(Foundations · Brand)의 값을 그대로 옮긴 것.
|
||||
* 화면 코드에서는 여기 없는 색·치수를 새로 만들지 않는다.
|
||||
*/
|
||||
:root {
|
||||
/* ---------- 브랜드 (탄피 오렌지) ---------- */
|
||||
/* 면 위에는 흰 글자, 흰 바탕 위 글자는 진한 변형을 쓴다 — 값이 서로 다르다 */
|
||||
--sz-brand: #ff6a00;
|
||||
--sz-brand-press: #d95a00;
|
||||
--sz-brand-text: #c24a00;
|
||||
--sz-brand-text-hover: #a03c00;
|
||||
|
||||
/* ---------- 글자 ---------- */
|
||||
--sz-text: #14161a;
|
||||
--sz-text-sub: #5b6169;
|
||||
--sz-text-dim: #7a828c;
|
||||
--sz-text-faint: #9ba1a9;
|
||||
--sz-text-disabled: #c9cdd3;
|
||||
--sz-text-invert: #ffffff;
|
||||
|
||||
/* ---------- 선 ---------- */
|
||||
--sz-line-strong: #e4e8ed;
|
||||
--sz-line: #edeff2;
|
||||
--sz-line-row: #f3f5f7;
|
||||
--sz-line-button: #d6dae0;
|
||||
|
||||
/* ---------- 면 ---------- */
|
||||
--sz-surface: #ffffff;
|
||||
--sz-surface-sub: #fafbfc;
|
||||
--sz-surface-card: #f5f6f8;
|
||||
--sz-surface-button: #f2f4f7;
|
||||
--sz-surface-badge: #f0f2f5;
|
||||
--sz-surface-placeholder: #edeff2;
|
||||
--sz-scrim: rgba(20, 22, 26, 0.5);
|
||||
--sz-scrim-strong: rgba(20, 22, 26, 0.55);
|
||||
|
||||
/* ---------- 의미 ---------- */
|
||||
--sz-up: #12805c;
|
||||
--sz-down: #c4453d;
|
||||
|
||||
/* ---------- 라운드 ---------- */
|
||||
--sz-radius-pill: 999px;
|
||||
--sz-radius-xl: 1.5rem; /* 24px — 큰 배너 */
|
||||
--sz-radius-lg: 1.25rem; /* 20px */
|
||||
--sz-radius-post: 1.125rem; /* 18px — 커뮤니티 카드 */
|
||||
--sz-radius-md: 1rem; /* 16px — 보통 카드 · 상태 블록 */
|
||||
--sz-radius-sm: 0.875rem; /* 14px */
|
||||
--sz-radius-thumb: 0.75rem; /* 12px — 썸네일 */
|
||||
--sz-radius-tile: 0.5rem; /* 8px — 계급 · 클랜 타일 */
|
||||
|
||||
/* ---------- 치수 ---------- */
|
||||
--sz-row-h: 2.875rem; /* 46px — 데스크톱 표 행 */
|
||||
--sz-row-h-mobile: 3.25rem; /* 52px — 모바일 표 행 */
|
||||
--sz-row-head-h: 2rem; /* 32px */
|
||||
--sz-tap-min: 2.75rem; /* 44px — 모바일 최소 터치 영역 */
|
||||
--sz-page-pad: 2.5rem; /* 40px */
|
||||
--sz-page-pad-tablet: 1.5rem; /* 24px */
|
||||
--sz-page-pad-mobile: 1.25rem; /* 20px */
|
||||
--sz-section-gap: 2.5rem; /* 40px */
|
||||
--sz-section-gap-mobile: 2rem; /* 32px */
|
||||
|
||||
/* ---------- 그림자 ---------- */
|
||||
--sz-shadow-hero: 0 1px 2px rgba(20, 22, 26, 0.04), 0 12px 32px rgba(20, 22, 26, 0.07);
|
||||
--sz-shadow-hero-mobile: 0 1px 2px rgba(20, 22, 26, 0.04), 0 8px 22px rgba(20, 22, 26, 0.06);
|
||||
--sz-shadow-header: 0 1px 12px rgba(20, 22, 26, 0.06);
|
||||
--sz-shadow-sheet: 0 8px 20px rgba(20, 22, 26, 0.08), 0 32px 64px rgba(20, 22, 26, 0.16);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
// 아이콘 — 좌표는 app-icon.ts 에 모여 있고, 이 컴포넌트는 그리기만 한다.
|
||||
// 장식용이므로 보조기기에서는 건너뛴다. 뜻은 항상 옆 글자가 전달한다.
|
||||
import { computed } from 'vue'
|
||||
import { icons, type IconDefinition, type IconName } from '@/components/common/app-icon'
|
||||
|
||||
interface Props {
|
||||
name: IconName
|
||||
/** 한 변의 크기 (px) */
|
||||
size?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
size: 16,
|
||||
})
|
||||
|
||||
const icon = computed<IconDefinition>(() => icons[props.name])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
class="app-icon"
|
||||
:width="size"
|
||||
:height="size"
|
||||
:viewBox="icon.viewBox"
|
||||
:fill="icon.mode === 'fill' ? 'currentColor' : 'none'"
|
||||
:stroke="icon.mode === 'stroke' ? 'currentColor' : undefined"
|
||||
:stroke-width="icon.strokeWidth"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<template
|
||||
v-for="(shape, index) in icon.shapes"
|
||||
:key="index"
|
||||
>
|
||||
<path
|
||||
v-if="shape.tag === 'path'"
|
||||
:d="shape.d"
|
||||
/>
|
||||
<circle
|
||||
v-else-if="shape.tag === 'circle'"
|
||||
:cx="shape.cx"
|
||||
:cy="shape.cy"
|
||||
:r="shape.r"
|
||||
/>
|
||||
<rect
|
||||
v-else
|
||||
:x="shape.x"
|
||||
:y="shape.y"
|
||||
:width="shape.width"
|
||||
:height="shape.height"
|
||||
:rx="shape.rx"
|
||||
/>
|
||||
</template>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-icon {
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
// 프로필 이미지 자리 — 실제 이미지가 없으면 단색 원으로 둔다.
|
||||
|
||||
interface Props {
|
||||
/** 지름 (rem) */
|
||||
size?: string
|
||||
imageUrl?: string
|
||||
/** 이미지가 없을 때 채울 색 */
|
||||
color?: string
|
||||
/** 이미지 설명 — 보통 닉네임 */
|
||||
alt?: string
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
size: '1.25rem',
|
||||
color: '#DDE4EE',
|
||||
imageUrl: undefined,
|
||||
alt: undefined,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="avatar-circle"
|
||||
:style="{ width: size, height: size, background: imageUrl ? undefined : color }"
|
||||
>
|
||||
<img
|
||||
v-if="imageUrl"
|
||||
class="avatar-circle__image"
|
||||
:src="imageUrl"
|
||||
:alt="alt ?? ''"
|
||||
>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.avatar-circle {
|
||||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--sz-radius-pill);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.avatar-circle__image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
// 서린즈 워드마크 — 아트보드 Logo.dc.html 의 선 그리기를 그대로 옮겼다.
|
||||
// 색은 currentColor 를 따르므로 어두운 바탕 위에서도 쓸 수 있다.
|
||||
|
||||
interface Props {
|
||||
/** 로고 너비 (px). 높이는 비율에 맞춰 따라간다 */
|
||||
width?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
width: 61,
|
||||
})
|
||||
|
||||
// 원본 비율 373 : 123 을 유지한다
|
||||
const height = () => Math.round((props.width * 123) / 373)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
class="brand-logo"
|
||||
:width="width"
|
||||
:height="height()"
|
||||
viewBox="-13.5 -7.5 373 123"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="15"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
role="img"
|
||||
aria-label="서린즈"
|
||||
>
|
||||
<polyline points="34,2 6,106" />
|
||||
<polyline points="34,2 60,106" />
|
||||
<polyline points="100,2 100,106" />
|
||||
<polyline points="74,54 100,54" />
|
||||
<polyline points="130,2 184,2 184,33 130,33 130,64 184,64" />
|
||||
<polyline points="210,2 210,64" />
|
||||
<polyline points="142,76 142,106 210,106" />
|
||||
<polyline points="248,6 336,6" />
|
||||
<polyline points="292,6 254,76" />
|
||||
<polyline points="292,6 330,76" />
|
||||
<polyline points="248,106 336,106" />
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.brand-logo {
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,79 @@
|
||||
<script setup lang="ts">
|
||||
// 알약형 하위 탭 — 통합/시즌, 솔로/파티/클랜, 인기/최신/클립 처럼 섹션 안에서 갈래를 고를 때 쓴다.
|
||||
import type { RankTabOption } from '@/types/home'
|
||||
|
||||
interface Props {
|
||||
options: RankTabOption[]
|
||||
/** 선택된 옵션 값 (v-model) */
|
||||
modelValue: string
|
||||
/** 탭 묶음의 용도 — 보조기기가 읽는다 */
|
||||
label: string
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: string): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="pill-tabs"
|
||||
role="tablist"
|
||||
:aria-label="label"
|
||||
>
|
||||
<button
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
role="tab"
|
||||
class="pill-tabs__item"
|
||||
:class="{ 'pill-tabs__item--on': option.value === modelValue }"
|
||||
:aria-selected="option.value === modelValue"
|
||||
@click="emit('update:modelValue', option.value)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pill-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.pill-tabs__item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 1.875rem;
|
||||
padding: 0 0.8125rem;
|
||||
border: none;
|
||||
border-radius: var(--sz-radius-pill);
|
||||
background: var(--sz-surface-card);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--sz-text-sub);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pill-tabs__item--on {
|
||||
background: var(--sz-text);
|
||||
font-weight: 600;
|
||||
color: var(--sz-text-invert);
|
||||
}
|
||||
|
||||
@media (max-width: 47.9375rem) {
|
||||
.pill-tabs {
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.pill-tabs__item {
|
||||
height: 2rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
// 계급 이미지 · 랭크전 티어 이미지 · 클랜 마크 자리.
|
||||
// 실제 에셋이 없을 때는 회색 타일 위에 약식 표장을 그린다 — 규격을 받으면 이미지로 교체한다.
|
||||
// 랭크전은 한 줄에 계급과 티어가 함께 놓이므로 표장을 갈라 뒀다:
|
||||
// 계급은 막대·별·마름모, 티어는 육각, 클랜은 방패.
|
||||
import AppIcon from '@/components/common/AppIcon.vue'
|
||||
import type { IconName } from '@/components/common/app-icon'
|
||||
import type { RankGradeShape } from '@/types/home'
|
||||
|
||||
interface Props {
|
||||
/** grade = 계급 이미지 자리, tier = 랭크전 티어 자리, clan = 클랜 마크 자리 */
|
||||
variant?: 'grade' | 'tier' | 'clan'
|
||||
/** 계급 자리표시 모양 (variant 가 grade 일 때) */
|
||||
shape?: RankGradeShape
|
||||
/** 실제 이미지 주소. 있으면 자리표시 대신 이미지를 그린다 */
|
||||
imageUrl?: string
|
||||
/** 이미지 설명 — 계급명 · 티어명 · 클랜명 */
|
||||
alt?: string
|
||||
/** 타일 한 변 (rem) */
|
||||
size?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
variant: 'grade',
|
||||
shape: 'bar',
|
||||
size: '1.625rem',
|
||||
imageUrl: undefined,
|
||||
alt: undefined,
|
||||
})
|
||||
|
||||
const gradeIcons: Record<RankGradeShape, IconName> = {
|
||||
bar: 'tierBar',
|
||||
star: 'tierStar',
|
||||
diamond: 'tierDiamond',
|
||||
}
|
||||
|
||||
const iconName = (): IconName => {
|
||||
if (props.variant === 'clan') return 'shield'
|
||||
if (props.variant === 'tier') return 'tierHex'
|
||||
return gradeIcons[props.shape]
|
||||
}
|
||||
|
||||
const iconSize = (): number => (props.variant === 'clan' ? 15 : 14)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="rank-badge"
|
||||
:class="`rank-badge--${variant}`"
|
||||
:style="{ width: size, height: size }"
|
||||
>
|
||||
<img
|
||||
v-if="imageUrl"
|
||||
class="rank-badge__image"
|
||||
:src="imageUrl"
|
||||
:alt="alt ?? ''"
|
||||
>
|
||||
<AppIcon
|
||||
v-else
|
||||
:name="iconName()"
|
||||
:size="iconSize()"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rank-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--sz-radius-tile);
|
||||
color: var(--sz-text-dim);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rank-badge--grade {
|
||||
background: var(--sz-surface-badge);
|
||||
}
|
||||
|
||||
.rank-badge--tier,
|
||||
.rank-badge--clan {
|
||||
background: var(--sz-surface-placeholder);
|
||||
}
|
||||
|
||||
.rank-badge__image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
// 순위 등락 — 상승 ▲n · 하락 ▼n · 변동 없음은 짧은 선.
|
||||
// 색만으로 뜻이 갈리지 않도록 방향 기호와 보조기기용 문구를 함께 둔다.
|
||||
import AppIcon from '@/components/common/AppIcon.vue'
|
||||
import type { RankMovement } from '@/types/home'
|
||||
|
||||
interface Props {
|
||||
movement: RankMovement
|
||||
/** 숫자 크기 (rem) — 표마다 조금씩 다르다 */
|
||||
fontSize?: string
|
||||
/** 삼각형 크기 (px) */
|
||||
iconSize?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
fontSize: '0.8125rem',
|
||||
iconSize: 10,
|
||||
})
|
||||
|
||||
// 보조기기가 읽을 문구 — 화면에는 기호와 숫자만 보인다
|
||||
const description = () => {
|
||||
if (props.movement.direction === 'up') return `${props.movement.amount}계단 상승`
|
||||
if (props.movement.direction === 'down') return `${props.movement.amount}계단 하락`
|
||||
return '변동 없음'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="rank-movement">
|
||||
<span class="sz-sr-only">{{ description() }}</span>
|
||||
|
||||
<template v-if="movement.direction === 'same'">
|
||||
<span
|
||||
class="rank-movement__flat"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<AppIcon
|
||||
:name="movement.direction === 'up' ? 'caretUp' : 'caretDown'"
|
||||
:size="iconSize"
|
||||
:class="`rank-movement__icon rank-movement__icon--${movement.direction}`"
|
||||
/>
|
||||
<span
|
||||
:class="`rank-movement__amount rank-movement__amount--${movement.direction}`"
|
||||
:style="{ fontSize }"
|
||||
aria-hidden="true"
|
||||
>{{ movement.amount }}</span>
|
||||
</template>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rank-movement {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.1875rem;
|
||||
}
|
||||
|
||||
.rank-movement__icon--up {
|
||||
color: var(--sz-up);
|
||||
}
|
||||
|
||||
.rank-movement__icon--down {
|
||||
color: var(--sz-down);
|
||||
}
|
||||
|
||||
.rank-movement__amount--up {
|
||||
color: var(--sz-up);
|
||||
}
|
||||
|
||||
.rank-movement__amount--down {
|
||||
color: var(--sz-down);
|
||||
}
|
||||
|
||||
.rank-movement__flat {
|
||||
width: 0.625rem;
|
||||
height: 1px;
|
||||
background: var(--sz-text-disabled);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
// 섹션 본문의 상태 분기 — 로딩(스켈레톤) · 실패 · 비어 있음 · 정상.
|
||||
// 섹션마다 이 네 상태를 모두 가진다. 한 섹션이 실패해도 그 섹션만 다시 부른다.
|
||||
import SectionStateBlock from '@/components/common/SectionStateBlock.vue'
|
||||
import type { IconName } from '@/components/common/app-icon'
|
||||
import type { SectionStatus } from '@/composables/useSectionResource'
|
||||
|
||||
interface Props {
|
||||
status: SectionStatus
|
||||
/** 실패 문구 — 에러 코드 맵에서 온 문장을 그대로 받는다 */
|
||||
errorMessage?: string
|
||||
/** 비어 있음 문구 — 섹션마다 다르다 */
|
||||
emptyTitle: string
|
||||
emptyDescription?: string
|
||||
emptyLinkLabel?: string
|
||||
emptyIcon?: IconName
|
||||
/** 상태 블록이 채울 높이 — 섹션 본문 높이와 같게 준다 */
|
||||
minHeight?: string
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
errorMessage: '일시적인 시스템 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.',
|
||||
minHeight: '15rem',
|
||||
emptyDescription: undefined,
|
||||
emptyLinkLabel: undefined,
|
||||
emptyIcon: undefined,
|
||||
})
|
||||
|
||||
interface Emits {
|
||||
(e: 'retry'): void
|
||||
(e: 'empty-link'): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 로딩 — 섹션마다 모양이 달라 스켈레톤은 슬롯으로 받는다 -->
|
||||
<div
|
||||
v-if="status === 'idle' || status === 'loading'"
|
||||
aria-busy="true"
|
||||
>
|
||||
<slot name="skeleton" />
|
||||
</div>
|
||||
|
||||
<SectionStateBlock
|
||||
v-else-if="status === 'error'"
|
||||
variant="error"
|
||||
:title="errorMessage"
|
||||
:min-height="minHeight"
|
||||
@retry="emit('retry')"
|
||||
/>
|
||||
|
||||
<SectionStateBlock
|
||||
v-else-if="status === 'empty'"
|
||||
variant="empty"
|
||||
:title="emptyTitle"
|
||||
:description="emptyDescription"
|
||||
:link-label="emptyLinkLabel"
|
||||
:icon="emptyIcon"
|
||||
:min-height="minHeight"
|
||||
@link="emit('empty-link')"
|
||||
/>
|
||||
|
||||
<slot v-else />
|
||||
</template>
|
||||
@@ -0,0 +1,99 @@
|
||||
<script setup lang="ts">
|
||||
// 섹션 머리 — 제목 · 부가 설명 · 오른쪽 링크. 모든 홈 섹션이 같은 모양을 쓴다.
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
/** 제목 옆 부가 설명 (예: 매일 오전 8시 갱신) */
|
||||
note?: string
|
||||
/** 오른쪽 끝 링크 문구 (예: 전체 보기). 없으면 링크를 두지 않는다 */
|
||||
moreLabel?: string
|
||||
/** 오른쪽 끝 회색 보조 문구 (예: 18:20 기준). moreLabel 과 함께 쓰지 않는다 */
|
||||
meta?: string
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
interface Emits {
|
||||
(e: 'more'): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="section-header">
|
||||
<h2 class="section-header__title">
|
||||
{{ title }}
|
||||
</h2>
|
||||
<span
|
||||
v-if="note"
|
||||
class="section-header__note"
|
||||
>{{ note }}</span>
|
||||
|
||||
<span class="section-header__spacer" />
|
||||
|
||||
<span
|
||||
v-if="meta"
|
||||
class="section-header__meta"
|
||||
>{{ meta }}</span>
|
||||
<button
|
||||
v-else-if="moreLabel"
|
||||
type="button"
|
||||
class="section-header__more"
|
||||
@click="emit('more')"
|
||||
>
|
||||
{{ moreLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 0.875rem;
|
||||
}
|
||||
|
||||
.section-header__title {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.035em;
|
||||
}
|
||||
|
||||
.section-header__note {
|
||||
margin-left: 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--sz-text-faint);
|
||||
}
|
||||
|
||||
.section-header__spacer {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.section-header__meta {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--sz-text-faint);
|
||||
}
|
||||
|
||||
.section-header__more {
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--sz-text-sub);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.section-header__more:hover {
|
||||
color: var(--sz-text);
|
||||
}
|
||||
|
||||
@media (max-width: 47.9375rem) {
|
||||
/* 모바일에서는 제목 옆 부가 설명을 접는다 — 자리를 다투지 않게 */
|
||||
.section-header__note {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,155 @@
|
||||
<script setup lang="ts">
|
||||
// 실패 · 비어 있음 블록 — 섹션 본문이 있어야 할 높이를 그대로 채워서
|
||||
// 늦게 도착한 섹션 때문에 페이지가 밀리지 않게 한다.
|
||||
//
|
||||
// 색으로 경고하지 않는다 — 브랜드 오렌지도, 빨강도 쓰지 않는다.
|
||||
import AppIcon from '@/components/common/AppIcon.vue'
|
||||
import type { IconName } from '@/components/common/app-icon'
|
||||
|
||||
interface Props {
|
||||
/** 실패인지 비어 있음인지 */
|
||||
variant: 'error' | 'empty'
|
||||
/** 본문 문구. 실패일 때는 에러 코드 맵의 문장을 그대로 받는다 */
|
||||
title: string
|
||||
/** 보조 문구 — 없으면 두지 않는다 */
|
||||
description?: string
|
||||
/** 비어 있음일 때 붙는 글자 링크 문구 — 갈 곳이 있을 때만 */
|
||||
linkLabel?: string
|
||||
/** 블록 아이콘. 지정하지 않으면 실패는 경고, 비어 있음은 말풍선 */
|
||||
icon?: IconName
|
||||
/** 섹션 본문 높이 (CSS 길이) */
|
||||
minHeight?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
minHeight: '15rem',
|
||||
description: undefined,
|
||||
linkLabel: undefined,
|
||||
icon: undefined,
|
||||
})
|
||||
|
||||
interface Emits {
|
||||
/** 「다시 시도」 */
|
||||
(e: 'retry'): void
|
||||
/** 비어 있음 블록의 글자 링크 */
|
||||
(e: 'link'): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const iconName = (): IconName => props.icon ?? (props.variant === 'error' ? 'alert' : 'message')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="state-block"
|
||||
:style="{ minHeight }"
|
||||
role="status"
|
||||
>
|
||||
<span class="state-block__icon">
|
||||
<AppIcon
|
||||
:name="iconName()"
|
||||
:size="22"
|
||||
/>
|
||||
</span>
|
||||
|
||||
<p class="state-block__title">
|
||||
{{ title }}
|
||||
</p>
|
||||
<p
|
||||
v-if="description"
|
||||
class="state-block__description"
|
||||
>
|
||||
{{ description }}
|
||||
</p>
|
||||
|
||||
<button
|
||||
v-if="variant === 'error'"
|
||||
type="button"
|
||||
class="state-block__retry"
|
||||
@click="emit('retry')"
|
||||
>
|
||||
다시 시도
|
||||
</button>
|
||||
<button
|
||||
v-else-if="linkLabel"
|
||||
type="button"
|
||||
class="state-block__link"
|
||||
@click="emit('link')"
|
||||
>
|
||||
{{ linkLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.state-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
border: 1px solid var(--sz-line);
|
||||
border-radius: var(--sz-radius-md);
|
||||
background: var(--sz-surface-sub);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.state-block__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
border-radius: var(--sz-radius-pill);
|
||||
background: var(--sz-surface-button);
|
||||
color: var(--sz-text-faint);
|
||||
}
|
||||
|
||||
.state-block__title {
|
||||
margin: 0;
|
||||
max-width: 20rem;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.6;
|
||||
color: var(--sz-text-sub);
|
||||
/* 줄바꿈 위치가 어색하지 않게 */
|
||||
word-break: keep-all;
|
||||
}
|
||||
|
||||
.state-block__description {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.6;
|
||||
color: var(--sz-text-faint);
|
||||
word-break: keep-all;
|
||||
}
|
||||
|
||||
.state-block__retry {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 2.25rem;
|
||||
padding: 0 1rem;
|
||||
border: 1px solid var(--sz-line-button);
|
||||
border-radius: var(--sz-radius-pill);
|
||||
background: var(--sz-surface);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--sz-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.state-block__link {
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--sz-brand-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.state-block__link:hover {
|
||||
color: var(--sz-brand-text-hover);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
// 밑줄형 세그먼트 — 갈래 자체를 고르는 상위 선택.
|
||||
// 알약형 하위 탭(PillTabs)과 층이 다르다는 것을 모양으로 구분하려고 밑줄형을 쓴다.
|
||||
import type { RankTabOption } from '@/types/home'
|
||||
|
||||
interface Props {
|
||||
options: RankTabOption[]
|
||||
modelValue: string
|
||||
label: string
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: string): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="segmented-tabs"
|
||||
role="tablist"
|
||||
:aria-label="label"
|
||||
>
|
||||
<button
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
role="tab"
|
||||
class="segmented-tabs__item"
|
||||
:class="{ 'segmented-tabs__item--on': option.value === modelValue }"
|
||||
:aria-selected="option.value === modelValue"
|
||||
@click="emit('update:modelValue', option.value)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.segmented-tabs {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: 1fr;
|
||||
border-bottom: 1px solid var(--sz-line-strong);
|
||||
}
|
||||
|
||||
.segmented-tabs__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 2.875rem;
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
color: var(--sz-text-faint);
|
||||
box-shadow: inset 0 -2px 0 transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.segmented-tabs__item--on {
|
||||
font-weight: 700;
|
||||
color: var(--sz-text);
|
||||
box-shadow: inset 0 -2px 0 var(--sz-text);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
// 스켈레톤 한 덩어리 — 크기와 라운드만 자리마다 다르게 준다.
|
||||
// 움직임을 줄이도록 설정한 기기에서는 쓸어가는 효과를 끈다.
|
||||
|
||||
interface Props {
|
||||
/** 너비 — CSS 길이 문자열 (예: 100%, 5rem) */
|
||||
width?: string
|
||||
/** 높이 — CSS 길이 문자열 */
|
||||
height?: string
|
||||
/** 라운드 — CSS 길이 문자열 */
|
||||
radius?: string
|
||||
/** 가로세로 비율 (예: 720 / 248). 지정하면 height 대신 쓴다 */
|
||||
ratio?: string
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
width: '100%',
|
||||
height: '0.875rem',
|
||||
radius: '0.375rem',
|
||||
ratio: undefined,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="skeleton-box"
|
||||
:style="{ width, height: ratio ? undefined : height, borderRadius: radius, aspectRatio: ratio }"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.skeleton-box {
|
||||
display: block;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
background: var(--sz-surface-placeholder);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.skeleton-box::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
transform: translateX(-100%);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 255, 255, 0) 0%,
|
||||
rgba(255, 255, 255, 0.75) 50%,
|
||||
rgba(255, 255, 255, 0) 100%
|
||||
);
|
||||
animation: skeleton-sweep 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes skeleton-sweep {
|
||||
to {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.skeleton-box::after {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* 아이콘 정의 — 아트보드에 쓰인 선 아이콘·채움 아이콘의 좌표를 한곳에 모았다.
|
||||
* 색은 그리는 쪽에서 currentColor 로 받는다.
|
||||
*/
|
||||
|
||||
type ShapeTag = 'path' | 'circle' | 'rect'
|
||||
|
||||
export interface IconShape {
|
||||
tag: ShapeTag
|
||||
d?: string
|
||||
cx?: number
|
||||
cy?: number
|
||||
r?: number
|
||||
x?: number
|
||||
y?: number
|
||||
width?: number
|
||||
height?: number
|
||||
rx?: number
|
||||
}
|
||||
|
||||
export interface IconDefinition {
|
||||
viewBox: string
|
||||
/** 선 아이콘인지 채움 아이콘인지 */
|
||||
mode: 'stroke' | 'fill'
|
||||
strokeWidth?: number
|
||||
shapes: IconShape[]
|
||||
}
|
||||
|
||||
// path 하나를 짧게 적기 위한 도우미
|
||||
const p = (d: string): IconShape => ({ tag: 'path', d })
|
||||
|
||||
export const icons = {
|
||||
search: {
|
||||
viewBox: '0 0 24 24',
|
||||
mode: 'stroke',
|
||||
strokeWidth: 2.2,
|
||||
shapes: [{ tag: 'circle', cx: 11, cy: 11, r: 6.5 }, p('M19.5 19.5L16 16')],
|
||||
},
|
||||
menu: {
|
||||
viewBox: '0 0 24 24',
|
||||
mode: 'stroke',
|
||||
strokeWidth: 2,
|
||||
shapes: [p('M4 7h16'), p('M4 12h16'), p('M4 17h16')],
|
||||
},
|
||||
close: {
|
||||
viewBox: '0 0 24 24',
|
||||
mode: 'stroke',
|
||||
strokeWidth: 2,
|
||||
shapes: [p('M6 6l12 12'), p('M18 6L6 18')],
|
||||
},
|
||||
chevronLeft: {
|
||||
viewBox: '0 0 24 24',
|
||||
mode: 'stroke',
|
||||
strokeWidth: 2.3,
|
||||
shapes: [p('M14.5 5l-7 7 7 7')],
|
||||
},
|
||||
chevronRight: {
|
||||
viewBox: '0 0 24 24',
|
||||
mode: 'stroke',
|
||||
strokeWidth: 2.3,
|
||||
shapes: [p('M9.5 5l7 7-7 7')],
|
||||
},
|
||||
album: {
|
||||
viewBox: '0 0 24 24',
|
||||
mode: 'stroke',
|
||||
strokeWidth: 2,
|
||||
shapes: [
|
||||
{ tag: 'rect', x: 8, y: 3, width: 13, height: 13, rx: 3 },
|
||||
p('M16 19.5A2.5 2.5 0 0113.5 22H6a3 3 0 01-3-3V9.5'),
|
||||
],
|
||||
},
|
||||
alert: {
|
||||
viewBox: '0 0 24 24',
|
||||
mode: 'stroke',
|
||||
strokeWidth: 1.8,
|
||||
shapes: [{ tag: 'circle', cx: 12, cy: 12, r: 8.5 }, p('M12 7.5v5'), p('M12 16.2h.01')],
|
||||
},
|
||||
message: {
|
||||
viewBox: '0 0 24 24',
|
||||
mode: 'stroke',
|
||||
strokeWidth: 1.8,
|
||||
shapes: [p('M20 14.5a3 3 0 01-3 3H9.5L5 20.5V6a3 3 0 013-3h9a3 3 0 013 3z')],
|
||||
},
|
||||
shield: {
|
||||
viewBox: '0 0 24 24',
|
||||
mode: 'stroke',
|
||||
strokeWidth: 1.8,
|
||||
shapes: [p('M12 3.5l7 2.8v5.2c0 3.8-2.8 7.2-7 9-4.2-1.8-7-5.2-7-9V6.3z')],
|
||||
},
|
||||
chart: {
|
||||
viewBox: '0 0 24 24',
|
||||
mode: 'stroke',
|
||||
strokeWidth: 1.8,
|
||||
shapes: [p('M4 19.5h16'), p('M7.5 16.5v-4'), p('M12 16.5v-8'), p('M16.5 16.5v-5.5')],
|
||||
},
|
||||
video: {
|
||||
viewBox: '0 0 24 24',
|
||||
mode: 'stroke',
|
||||
strokeWidth: 1.8,
|
||||
shapes: [
|
||||
{ tag: 'rect', x: 3.5, y: 5, width: 17, height: 12.5, rx: 3 },
|
||||
p('M10.5 9.2l4.8 3-4.8 3z'),
|
||||
],
|
||||
},
|
||||
document: {
|
||||
viewBox: '0 0 24 24',
|
||||
mode: 'stroke',
|
||||
strokeWidth: 1.8,
|
||||
shapes: [p('M13.5 3H7.5a2 2 0 00-2 2v14a2 2 0 002 2h9a2 2 0 002-2V8z'), p('M13.5 3v5h5')],
|
||||
},
|
||||
flag: {
|
||||
viewBox: '0 0 24 24',
|
||||
mode: 'stroke',
|
||||
strokeWidth: 1.8,
|
||||
shapes: [p('M4.5 4.5v15'), p('M4.5 5.5h12l-2.4 3.4 2.4 3.4h-12')],
|
||||
},
|
||||
// 계급 자리표시 — 실제 계급 이미지 규격을 받으면 교체한다
|
||||
tierBar: {
|
||||
viewBox: '0 0 24 24',
|
||||
mode: 'stroke',
|
||||
strokeWidth: 2.4,
|
||||
shapes: [p('M5 10l7 4 7-4'), p('M5 15l7 4 7-4')],
|
||||
},
|
||||
tierStar: {
|
||||
viewBox: '0 0 24 24',
|
||||
mode: 'fill',
|
||||
shapes: [p('M12 4l2.3 4.9 5.4.7-3.9 3.8 1 5.3L12 16.2 7.2 18.7l1-5.3L4.3 9.6l5.4-.7z')],
|
||||
},
|
||||
tierDiamond: {
|
||||
viewBox: '0 0 24 24',
|
||||
mode: 'fill',
|
||||
shapes: [p('M12 4.5l5.5 7.5L12 19.5 6.5 12z')],
|
||||
},
|
||||
// 랭크전 티어 자리표시 — 한 줄에 계급과 나란히 놓이므로 표장을 갈라 둔다
|
||||
// (계급은 막대·별·마름모, 티어는 육각, 클랜은 방패)
|
||||
tierHex: {
|
||||
viewBox: '0 0 24 24',
|
||||
mode: 'stroke',
|
||||
strokeWidth: 1.8,
|
||||
shapes: [p('M12 3l7.5 4.3v9.4L12 21l-7.5-4.3V7.3z'), p('M9 13.2l3-2.6 3 2.6')],
|
||||
},
|
||||
play: {
|
||||
viewBox: '0 0 24 24',
|
||||
mode: 'fill',
|
||||
shapes: [p('M8 5.5l11 6.5-11 6.5z')],
|
||||
},
|
||||
// 등락 삼각형 — 표에서만 쓰는 작은 아이콘이라 12 격자를 따로 쓴다
|
||||
caretUp: {
|
||||
viewBox: '0 0 12 12',
|
||||
mode: 'fill',
|
||||
shapes: [p('M6 2l4 7H2z')],
|
||||
},
|
||||
caretDown: {
|
||||
viewBox: '0 0 12 12',
|
||||
mode: 'fill',
|
||||
shapes: [p('M6 10L2 3h8z')],
|
||||
},
|
||||
} satisfies Record<string, IconDefinition>
|
||||
|
||||
export type IconName = keyof typeof icons
|
||||
@@ -0,0 +1,253 @@
|
||||
<script setup lang="ts">
|
||||
// 커뮤니티 — 정사각 카드 넷. 사진 · 여러 장 · 글 · 클립 네 종류가 한 격자에 섞인다.
|
||||
import { ref } from 'vue'
|
||||
import AppIcon from '@/components/common/AppIcon.vue'
|
||||
import AvatarCircle from '@/components/common/AvatarCircle.vue'
|
||||
import PillTabs from '@/components/common/PillTabs.vue'
|
||||
import SectionBoundary from '@/components/common/SectionBoundary.vue'
|
||||
import SectionHeader from '@/components/common/SectionHeader.vue'
|
||||
import SkeletonBox from '@/components/common/SkeletonBox.vue'
|
||||
import type { SectionResource } from '@/composables/useSectionResource'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
import type { CommunityPost, RankTabOption } from '@/types/home'
|
||||
|
||||
interface Props {
|
||||
resource: SectionResource<CommunityPost[]>
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
const tabOptions: RankTabOption[] = [
|
||||
{ value: 'popular', label: '인기' },
|
||||
{ value: 'latest', label: '최신' },
|
||||
{ value: 'clip', label: '클립' },
|
||||
]
|
||||
|
||||
const selectedTab = ref('popular')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<SectionHeader
|
||||
title="커뮤니티"
|
||||
more-label="전체 보기"
|
||||
/>
|
||||
|
||||
<PillTabs
|
||||
v-model="selectedTab"
|
||||
:options="tabOptions"
|
||||
label="커뮤니티 정렬"
|
||||
/>
|
||||
|
||||
<SectionBoundary
|
||||
class="community__body"
|
||||
:status="resource.status.value"
|
||||
:error-message="resource.errorMessage.value"
|
||||
empty-title="아직 올라온 글이 없어요"
|
||||
empty-link-label="첫 글 쓰러 가기"
|
||||
min-height="24rem"
|
||||
@retry="resource.reload"
|
||||
>
|
||||
<template #skeleton>
|
||||
<div class="community__grid">
|
||||
<SkeletonBox
|
||||
v-for="index in 4"
|
||||
:key="index"
|
||||
ratio="1 / 1"
|
||||
:radius="'var(--sz-radius-post)'"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="community__grid">
|
||||
<a
|
||||
v-for="post in resource.data.value ?? []"
|
||||
:key="post.id"
|
||||
class="community__card"
|
||||
:class="`community__card--${post.kind}`"
|
||||
:style="{ background: post.placeholderColor }"
|
||||
href="#"
|
||||
>
|
||||
<!-- 사진이 없는 글은 본문이 그대로 카드가 된다 -->
|
||||
<p
|
||||
v-if="post.kind === 'text'"
|
||||
class="community__excerpt"
|
||||
>
|
||||
{{ post.excerpt }}
|
||||
</p>
|
||||
|
||||
<img
|
||||
v-else-if="post.thumbnailUrl"
|
||||
class="community__thumb"
|
||||
:src="post.thumbnailUrl"
|
||||
:alt="post.excerpt ?? ''"
|
||||
>
|
||||
|
||||
<!-- 여러 장 표시 -->
|
||||
<span
|
||||
v-if="post.kind === 'album'"
|
||||
class="community__album"
|
||||
>
|
||||
<AppIcon
|
||||
name="album"
|
||||
:size="17"
|
||||
/>
|
||||
</span>
|
||||
|
||||
<!-- 클립 재생 표시 -->
|
||||
<span
|
||||
v-if="post.kind === 'clip'"
|
||||
class="community__play"
|
||||
>
|
||||
<AppIcon
|
||||
name="play"
|
||||
:size="17"
|
||||
/>
|
||||
</span>
|
||||
|
||||
<div class="community__author">
|
||||
<AvatarCircle
|
||||
size="1.25rem"
|
||||
:color="post.authorAvatarColor"
|
||||
:image-url="post.authorAvatarUrl"
|
||||
/>
|
||||
<span class="community__nickname">{{ post.authorNickname }}</span>
|
||||
<span class="community__count">{{ formatCount(post.reactionCount) }}</span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</SectionBoundary>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.community__body {
|
||||
margin-top: 0.875rem;
|
||||
}
|
||||
|
||||
.community__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.community__card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
aspect-ratio: 1 / 1;
|
||||
border-radius: var(--sz-radius-post);
|
||||
color: inherit;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.community__thumb {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.community__excerpt {
|
||||
margin: 0;
|
||||
padding: 1rem 1rem 0;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.6;
|
||||
font-weight: 500;
|
||||
color: var(--sz-text);
|
||||
word-break: keep-all;
|
||||
}
|
||||
|
||||
.community__album {
|
||||
position: absolute;
|
||||
top: 0.625rem;
|
||||
right: 0.625rem;
|
||||
color: var(--sz-text-invert);
|
||||
}
|
||||
|
||||
.community__play {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
border-radius: var(--sz-radius-pill);
|
||||
background: var(--sz-scrim-strong);
|
||||
color: var(--sz-text-invert);
|
||||
}
|
||||
|
||||
.community__author {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4375rem;
|
||||
margin-top: auto;
|
||||
height: 2.625rem;
|
||||
padding: 0 0.75rem;
|
||||
/* 사진 위에서는 어두운 띠 위에, 글 카드에서는 바탕 위에 그대로 */
|
||||
background: var(--sz-scrim);
|
||||
color: var(--sz-text-invert);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.community__card--text .community__author {
|
||||
height: auto;
|
||||
padding: 0 1rem 1rem;
|
||||
background: none;
|
||||
color: var(--sz-text);
|
||||
}
|
||||
|
||||
.community__nickname {
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.community__count {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.community__card--text .community__count {
|
||||
color: var(--sz-text-sub);
|
||||
}
|
||||
|
||||
@media (max-width: 47.9375rem) {
|
||||
.community__grid {
|
||||
gap: 0.625rem;
|
||||
}
|
||||
|
||||
.community__card {
|
||||
border-radius: var(--sz-radius-md);
|
||||
}
|
||||
|
||||
.community__excerpt {
|
||||
padding: 0.875rem 0.875rem 0;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.community__author {
|
||||
height: 2.375rem;
|
||||
padding: 0 0.625rem;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
|
||||
.community__card--text .community__author {
|
||||
padding: 0 0.875rem 0.875rem;
|
||||
}
|
||||
|
||||
.community__play {
|
||||
width: 2.375rem;
|
||||
height: 2.375rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,342 @@
|
||||
<script setup lang="ts">
|
||||
// 이벤트 — 가로형 배너(720:248)를 한 장씩 넘겨 본다.
|
||||
// 데스크톱은 화살표로, 모바일은 옆으로 밀어서 넘긴다(스크롤 스냅).
|
||||
import { ref } from 'vue'
|
||||
import AppIcon from '@/components/common/AppIcon.vue'
|
||||
import SectionBoundary from '@/components/common/SectionBoundary.vue'
|
||||
import SectionHeader from '@/components/common/SectionHeader.vue'
|
||||
import SkeletonBox from '@/components/common/SkeletonBox.vue'
|
||||
import type { SectionResource } from '@/composables/useSectionResource'
|
||||
import type { EventBanner } from '@/types/home'
|
||||
|
||||
interface Props {
|
||||
resource: SectionResource<EventBanner[]>
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const trackRef = ref<HTMLElement | null>(null)
|
||||
const activeIndex = ref(0)
|
||||
|
||||
// 스크롤 위치로 지금 보고 있는 배너를 알아낸다 — 밀어서 넘겼을 때도 점이 따라온다
|
||||
function syncActiveIndex() {
|
||||
const track = trackRef.value
|
||||
if (!track || track.clientWidth === 0) return
|
||||
activeIndex.value = Math.round(track.scrollLeft / track.clientWidth)
|
||||
}
|
||||
|
||||
function goTo(index: number) {
|
||||
const track = trackRef.value
|
||||
const banners = props.resource.data.value
|
||||
if (!track || !banners) return
|
||||
|
||||
const clamped = Math.min(Math.max(index, 0), banners.length - 1)
|
||||
track.scrollTo({ left: clamped * track.clientWidth, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
const currentBanner = () => props.resource.data.value?.[activeIndex.value]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="event-section">
|
||||
<SectionHeader
|
||||
title="이벤트"
|
||||
more-label="전체 보기"
|
||||
/>
|
||||
|
||||
<SectionBoundary
|
||||
:status="resource.status.value"
|
||||
:error-message="resource.errorMessage.value"
|
||||
empty-title="진행 중인 이벤트가 없어요"
|
||||
empty-description="새 이벤트가 열리면 알려 드릴게요"
|
||||
empty-icon="flag"
|
||||
min-height="20rem"
|
||||
@retry="resource.reload"
|
||||
>
|
||||
<template #skeleton>
|
||||
<SkeletonBox
|
||||
ratio="720 / 248"
|
||||
:radius="'var(--sz-radius-xl)'"
|
||||
/>
|
||||
<div class="event-section__meta">
|
||||
<SkeletonBox
|
||||
width="5rem"
|
||||
height="0.875rem"
|
||||
/>
|
||||
<SkeletonBox
|
||||
width="12rem"
|
||||
height="1rem"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="event-section__banners">
|
||||
<div
|
||||
ref="trackRef"
|
||||
class="event-section__track"
|
||||
@scroll.passive="syncActiveIndex"
|
||||
>
|
||||
<a
|
||||
v-for="banner in resource.data.value ?? []"
|
||||
:key="banner.id"
|
||||
class="event-section__slide"
|
||||
href="#"
|
||||
>
|
||||
<img
|
||||
v-if="banner.imageUrl"
|
||||
class="event-section__image"
|
||||
:src="banner.imageUrl"
|
||||
:alt="banner.title"
|
||||
>
|
||||
<span
|
||||
v-else
|
||||
class="event-section__image event-section__image--placeholder"
|
||||
:style="{ background: banner.placeholderColor }"
|
||||
role="img"
|
||||
:aria-label="banner.title"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="event-section__meta">
|
||||
<span class="event-section__category">{{ currentBanner()?.category }}</span>
|
||||
<span class="event-section__divider" />
|
||||
<span class="event-section__title">{{ currentBanner()?.title }}</span>
|
||||
<span class="event-section__period">{{ currentBanner()?.period }}</span>
|
||||
|
||||
<span class="event-section__spacer" />
|
||||
|
||||
<div
|
||||
class="event-section__dots"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span
|
||||
v-for="(banner, index) in resource.data.value ?? []"
|
||||
:key="banner.id"
|
||||
class="event-section__dot"
|
||||
:class="{ 'event-section__dot--on': index === activeIndex }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span class="event-section__counter">
|
||||
{{ activeIndex + 1 }} / {{ (resource.data.value ?? []).length }}
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="event-section__arrow"
|
||||
aria-label="이전 이벤트"
|
||||
:disabled="activeIndex === 0"
|
||||
@click="goTo(activeIndex - 1)"
|
||||
>
|
||||
<AppIcon
|
||||
name="chevronLeft"
|
||||
:size="14"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="event-section__arrow"
|
||||
aria-label="다음 이벤트"
|
||||
:disabled="activeIndex >= (resource.data.value ?? []).length - 1"
|
||||
@click="goTo(activeIndex + 1)"
|
||||
>
|
||||
<AppIcon
|
||||
name="chevronRight"
|
||||
:size="14"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</SectionBoundary>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.event-section__banners {
|
||||
border: 1px solid var(--sz-line);
|
||||
border-radius: var(--sz-radius-xl);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.event-section__track {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
scroll-snap-type: x mandatory;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.event-section__track::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.event-section__slide {
|
||||
flex: 0 0 100%;
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
|
||||
.event-section__image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 720 / 248;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.event-section__image--placeholder {
|
||||
background: var(--sz-surface-placeholder);
|
||||
}
|
||||
|
||||
.event-section__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
height: 3.5rem;
|
||||
}
|
||||
|
||||
.event-section__category {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--sz-text-faint);
|
||||
}
|
||||
|
||||
.event-section__divider {
|
||||
flex-shrink: 0;
|
||||
width: 1px;
|
||||
height: 0.75rem;
|
||||
background: var(--sz-line-strong);
|
||||
}
|
||||
|
||||
.event-section__title {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.event-section__period {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--sz-text-faint);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.event-section__spacer {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.event-section__dots {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.event-section__dot {
|
||||
width: 0.3125rem;
|
||||
height: 0.3125rem;
|
||||
border-radius: var(--sz-radius-pill);
|
||||
background: #d6dae0;
|
||||
}
|
||||
|
||||
.event-section__dot--on {
|
||||
width: 1.375rem;
|
||||
background: var(--sz-text);
|
||||
}
|
||||
|
||||
.event-section__counter {
|
||||
margin: 0 0.25rem 0 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--sz-text-faint);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.event-section__arrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 2.125rem;
|
||||
height: 2.125rem;
|
||||
border: 1px solid var(--sz-line-strong);
|
||||
border-radius: var(--sz-radius-pill);
|
||||
background: var(--sz-surface);
|
||||
color: var(--sz-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.event-section__arrow:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ---------- 태블릿 — 화살표와 장수 표시를 접는다 ---------- */
|
||||
@media (max-width: 74.9375rem) {
|
||||
.event-section__banners {
|
||||
border-radius: var(--sz-radius-lg);
|
||||
}
|
||||
|
||||
.event-section__meta {
|
||||
height: 3.25rem;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
|
||||
.event-section__counter,
|
||||
.event-section__arrow {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 모바일 — 제목과 점을 한 줄, 분류·기간을 아랫줄 ---------- */
|
||||
@media (max-width: 47.9375rem) {
|
||||
.event-section__banners {
|
||||
border-radius: var(--sz-radius-md);
|
||||
}
|
||||
|
||||
.event-section__meta {
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
height: auto;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.event-section__title {
|
||||
order: 1;
|
||||
flex-grow: 1;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.event-section__dots {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.event-section__dot {
|
||||
width: 0.25rem;
|
||||
height: 0.25rem;
|
||||
}
|
||||
|
||||
.event-section__dot--on {
|
||||
width: 1rem;
|
||||
}
|
||||
|
||||
.event-section__category {
|
||||
order: 3;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.event-section__divider {
|
||||
order: 4;
|
||||
height: 0.625rem;
|
||||
}
|
||||
|
||||
.event-section__period {
|
||||
order: 5;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.event-section__spacer {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,227 @@
|
||||
<script setup lang="ts">
|
||||
// 게임 랭킹 — 계급 · 랭크전 · 클랜 세 갈래.
|
||||
// 데스크톱은 한 칸에 하나씩 세 갈래를 나란히 두고,
|
||||
// 태블릿·모바일은 폭이 모자라 갈래 세그먼트로 하나씩 고른다.
|
||||
import { computed, ref } from 'vue'
|
||||
import PillTabs from '@/components/common/PillTabs.vue'
|
||||
import SectionBoundary from '@/components/common/SectionBoundary.vue'
|
||||
import SectionHeader from '@/components/common/SectionHeader.vue'
|
||||
import SegmentedTabs from '@/components/common/SegmentedTabs.vue'
|
||||
import RankPagination from '@/components/home/RankPagination.vue'
|
||||
import RankingTable from '@/components/home/RankingTable.vue'
|
||||
import { toClanRows, toUserRows } from '@/components/home/ranking-table'
|
||||
import { BREAKPOINT, useMediaQuery } from '@/composables/useMediaQuery'
|
||||
import type { SectionResource } from '@/composables/useSectionResource'
|
||||
import type { GameRanking, GameRankBranch, RankTabOption } from '@/types/home'
|
||||
|
||||
interface Props {
|
||||
resource: SectionResource<GameRanking>
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const UPDATE_NOTE = '매일 오전 8시 갱신'
|
||||
|
||||
const branchOptions: RankTabOption[] = [
|
||||
{ value: 'tier', label: '계급' },
|
||||
{ value: 'ranked', label: '랭크전' },
|
||||
{ value: 'clan', label: '클랜' },
|
||||
]
|
||||
|
||||
// 갈래마다 하위 탭이 다르다
|
||||
const subTabOptions: Record<GameRankBranch, RankTabOption[]> = {
|
||||
tier: [
|
||||
{ value: 'total', label: '통합' },
|
||||
{ value: 'season', label: '시즌' },
|
||||
],
|
||||
ranked: [
|
||||
{ value: 'solo', label: '솔로' },
|
||||
{ value: 'party', label: '파티' },
|
||||
{ value: 'clan', label: '클랜' },
|
||||
],
|
||||
clan: [
|
||||
{ value: 'official', label: '공식' },
|
||||
{ value: 'normal', label: '일반' },
|
||||
],
|
||||
}
|
||||
|
||||
const branchTitle: Record<GameRankBranch, string> = {
|
||||
tier: '계급 랭킹',
|
||||
ranked: '랭크전 랭킹',
|
||||
clan: '클랜 랭킹',
|
||||
}
|
||||
|
||||
// 갈래별로 고른 하위 탭을 따로 기억한다 — 갈래를 옮겨도 되돌아왔을 때 그대로다
|
||||
const selectedSubTab = ref<Record<GameRankBranch, string>>({
|
||||
tier: 'total',
|
||||
ranked: 'solo',
|
||||
clan: 'official',
|
||||
})
|
||||
|
||||
// 태블릿 이하에서만 갈래를 하나씩 고른다
|
||||
const isSegmented = useMediaQuery(BREAKPOINT.tabletDown)
|
||||
const selectedBranch = ref<GameRankBranch>('tier')
|
||||
|
||||
const page = ref(1)
|
||||
|
||||
// 표에 그릴 줄로 옮긴다 — 유저 랭킹과 클랜 랭킹의 모양을 하나로 맞춘다
|
||||
const rowsOf = (branch: GameRankBranch) => {
|
||||
const data = props.resource.data.value
|
||||
if (!data) return []
|
||||
if (branch === 'clan') return toClanRows(data.clan)
|
||||
return toUserRows(branch === 'tier' ? data.tier : data.ranked)
|
||||
}
|
||||
|
||||
const visibleBranches = computed<GameRankBranch[]>(() =>
|
||||
isSegmented.value ? [selectedBranch.value] : ['tier', 'ranked', 'clan'],
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<SectionHeader
|
||||
title="게임 랭킹"
|
||||
:note="UPDATE_NOTE"
|
||||
more-label="전체 보기"
|
||||
/>
|
||||
|
||||
<SectionBoundary
|
||||
:status="resource.status.value"
|
||||
:error-message="resource.errorMessage.value"
|
||||
empty-title="이번 시즌 랭킹이 아직 열리지 않았어요"
|
||||
empty-link-label="지난 시즌 보기"
|
||||
empty-icon="chart"
|
||||
min-height="30rem"
|
||||
@retry="resource.reload"
|
||||
>
|
||||
<template #skeleton>
|
||||
<!-- 표의 뼈대는 그대로 두고 값만 회색으로 — 도착했을 때 자리가 튀지 않는다 -->
|
||||
<div class="game-rank__branches">
|
||||
<div
|
||||
v-for="branch in visibleBranches"
|
||||
:key="branch"
|
||||
class="game-rank__branch"
|
||||
>
|
||||
<div class="game-rank__branch-title">
|
||||
{{ branchTitle[branch] }}
|
||||
</div>
|
||||
<PillTabs
|
||||
v-model="selectedSubTab[branch]"
|
||||
class="game-rank__sub-tabs"
|
||||
:options="subTabOptions[branch]"
|
||||
:label="`${branchTitle[branch]} 하위 탭`"
|
||||
/>
|
||||
<RankingTable
|
||||
class="game-rank__table"
|
||||
:rows="[]"
|
||||
:label-head="branch === 'clan' ? '클랜' : '유저'"
|
||||
:show-rp="branch === 'ranked'"
|
||||
loading
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 태블릿 이하 — 갈래를 하나씩 고른다 -->
|
||||
<SegmentedTabs
|
||||
v-if="isSegmented"
|
||||
class="game-rank__segments"
|
||||
:model-value="selectedBranch"
|
||||
:options="branchOptions"
|
||||
label="게임 랭킹 갈래"
|
||||
@update:model-value="selectedBranch = $event as GameRankBranch"
|
||||
/>
|
||||
|
||||
<div class="game-rank__branches">
|
||||
<div
|
||||
v-for="branch in visibleBranches"
|
||||
:key="branch"
|
||||
class="game-rank__branch"
|
||||
>
|
||||
<div
|
||||
v-if="!isSegmented"
|
||||
class="game-rank__branch-title"
|
||||
>
|
||||
{{ branchTitle[branch] }}
|
||||
</div>
|
||||
|
||||
<div class="game-rank__tab-row">
|
||||
<PillTabs
|
||||
v-model="selectedSubTab[branch]"
|
||||
class="game-rank__sub-tabs"
|
||||
:options="subTabOptions[branch]"
|
||||
:label="`${branchTitle[branch]} 하위 탭`"
|
||||
/>
|
||||
<span
|
||||
v-if="isSegmented"
|
||||
class="game-rank__note"
|
||||
>{{ UPDATE_NOTE }}</span>
|
||||
</div>
|
||||
|
||||
<RankingTable
|
||||
class="game-rank__table"
|
||||
:rows="rowsOf(branch)"
|
||||
:label-head="branch === 'clan' ? '클랜' : '유저'"
|
||||
:show-rp="branch === 'ranked'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RankPagination
|
||||
v-model:page="page"
|
||||
:page-size="resource.data.value?.pageSize ?? 10"
|
||||
:total-count="resource.data.value?.totalCount ?? 0"
|
||||
/>
|
||||
</SectionBoundary>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.game-rank__branches {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.game-rank__branch-title {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.game-rank__tab-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 0.625rem;
|
||||
}
|
||||
|
||||
.game-rank__note {
|
||||
margin-left: auto;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--sz-text-faint);
|
||||
}
|
||||
|
||||
.game-rank__table {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.game-rank__segments {
|
||||
margin-bottom: 0.875rem;
|
||||
}
|
||||
|
||||
/* 태블릿 이하 — 한 갈래만 보이므로 한 열로 */
|
||||
@media (max-width: 74.9375rem) {
|
||||
.game-rank__branches {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.game-rank__tab-row {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 47.9375rem) {
|
||||
.game-rank__note {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,127 @@
|
||||
<script setup lang="ts">
|
||||
// 소식 목록 — 서든어택 소식과 서린즈 소식이 같은 컴포넌트를 쓴다.
|
||||
// 「신규」는 옅은 배경 뱃지 대신 브랜드 글자색만으로 표시한다.
|
||||
import { ref } from 'vue'
|
||||
import PillTabs from '@/components/common/PillTabs.vue'
|
||||
import SectionBoundary from '@/components/common/SectionBoundary.vue'
|
||||
import SectionHeader from '@/components/common/SectionHeader.vue'
|
||||
import SkeletonBox from '@/components/common/SkeletonBox.vue'
|
||||
import type { SectionResource } from '@/composables/useSectionResource'
|
||||
import type { NewsItem, RankTabOption } from '@/types/home'
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
resource: SectionResource<NewsItem[]>
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
const tabOptions: RankTabOption[] = [
|
||||
{ value: 'notice', label: '공지사항' },
|
||||
{ value: 'update', label: '업데이트' },
|
||||
]
|
||||
|
||||
const selectedTab = ref('notice')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<SectionHeader
|
||||
:title="title"
|
||||
more-label="더 보기"
|
||||
/>
|
||||
|
||||
<PillTabs
|
||||
v-model="selectedTab"
|
||||
:options="tabOptions"
|
||||
:label="`${title} 분류`"
|
||||
/>
|
||||
|
||||
<SectionBoundary
|
||||
class="news__body"
|
||||
:status="resource.status.value"
|
||||
:error-message="resource.errorMessage.value"
|
||||
empty-title="등록된 소식이 없어요"
|
||||
empty-icon="document"
|
||||
min-height="18rem"
|
||||
@retry="resource.reload"
|
||||
>
|
||||
<template #skeleton>
|
||||
<div
|
||||
v-for="index in 6"
|
||||
:key="index"
|
||||
class="news__row"
|
||||
>
|
||||
<SkeletonBox
|
||||
width="60%"
|
||||
height="0.875rem"
|
||||
/>
|
||||
<SkeletonBox
|
||||
width="2.25rem"
|
||||
height="0.8125rem"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<a
|
||||
v-for="item in resource.data.value ?? []"
|
||||
:key="item.id"
|
||||
class="news__row"
|
||||
href="#"
|
||||
>
|
||||
<span class="news__title">{{ item.title }}</span>
|
||||
<span
|
||||
v-if="item.isNew"
|
||||
class="news__new"
|
||||
>신규</span>
|
||||
<span class="news__date">{{ item.date }}</span>
|
||||
</a>
|
||||
</SectionBoundary>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.news__body {
|
||||
margin-top: 0.75rem;
|
||||
border-top: 1px solid var(--sz-line-strong);
|
||||
}
|
||||
|
||||
.news__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
height: 2.875rem;
|
||||
border-bottom: 1px solid var(--sz-line-row);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.news__title {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.news__new {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
color: var(--sz-brand-text);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.news__date {
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
padding-left: 0.625rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--sz-text-faint);
|
||||
}
|
||||
|
||||
@media (max-width: 47.9375rem) {
|
||||
.news__row {
|
||||
height: 3.125rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,201 @@
|
||||
<script setup lang="ts">
|
||||
// 순위 구간 이동 — 세 랭킹이 같은 구간을 함께 본다.
|
||||
import { computed } from 'vue'
|
||||
import AppIcon from '@/components/common/AppIcon.vue'
|
||||
|
||||
interface Props {
|
||||
/** 지금 보고 있는 구간 (1부터) */
|
||||
page: number
|
||||
/** 한 구간에 보여 주는 순위 수 */
|
||||
pageSize: number
|
||||
/** 전체 순위 수 */
|
||||
totalCount: number
|
||||
/** 한 번에 늘어놓을 구간 번호 개수 */
|
||||
visibleCount?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
visibleCount: 5,
|
||||
})
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:page', page: number): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const lastPage = computed(() => Math.max(1, Math.ceil(props.totalCount / props.pageSize)))
|
||||
|
||||
// 앞쪽 몇 개를 늘어놓고, 사이를 줄임표로 접은 뒤 마지막 구간을 붙인다
|
||||
const pages = computed<(number | 'gap')[]>(() => {
|
||||
const total = lastPage.value
|
||||
if (total <= props.visibleCount + 1) {
|
||||
return Array.from({ length: total }, (_, index) => index + 1)
|
||||
}
|
||||
|
||||
const start = Math.min(Math.max(1, props.page - 2), total - props.visibleCount)
|
||||
const head = Array.from({ length: props.visibleCount }, (_, index) => start + index)
|
||||
return [...head, 'gap', total]
|
||||
})
|
||||
|
||||
const rangeLabel = computed(() => {
|
||||
const from = (props.page - 1) * props.pageSize + 1
|
||||
const to = Math.min(props.page * props.pageSize, props.totalCount)
|
||||
return `${from} ~ ${to}위 · 전체 ${props.totalCount}위`
|
||||
})
|
||||
|
||||
function move(page: number) {
|
||||
if (page < 1 || page > lastPage.value || page === props.page) return
|
||||
emit('update:page', page)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rank-pagination">
|
||||
<span class="rank-pagination__range">{{ rangeLabel }}</span>
|
||||
<span class="rank-pagination__spacer" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="rank-pagination__arrow"
|
||||
aria-label="이전 구간"
|
||||
:disabled="page === 1"
|
||||
@click="move(page - 1)"
|
||||
>
|
||||
<AppIcon
|
||||
name="chevronLeft"
|
||||
:size="14"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div class="rank-pagination__pages">
|
||||
<template
|
||||
v-for="(item, index) in pages"
|
||||
:key="`${item}-${index}`"
|
||||
>
|
||||
<span
|
||||
v-if="item === 'gap'"
|
||||
class="rank-pagination__gap"
|
||||
aria-hidden="true"
|
||||
>…</span>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="rank-pagination__page"
|
||||
:class="{ 'rank-pagination__page--on': item === page }"
|
||||
:aria-current="item === page ? 'page' : undefined"
|
||||
@click="move(item)"
|
||||
>
|
||||
{{ item }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="rank-pagination__arrow"
|
||||
aria-label="다음 구간"
|
||||
:disabled="page === lastPage"
|
||||
@click="move(page + 1)"
|
||||
>
|
||||
<AppIcon
|
||||
name="chevronRight"
|
||||
:size="14"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rank-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1.25rem;
|
||||
border-top: 1px solid var(--sz-line);
|
||||
}
|
||||
|
||||
.rank-pagination__range {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--sz-text-faint);
|
||||
}
|
||||
|
||||
.rank-pagination__spacer {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.rank-pagination__arrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 2.125rem;
|
||||
height: 2.125rem;
|
||||
border: 1px solid var(--sz-line-strong);
|
||||
border-radius: var(--sz-radius-pill);
|
||||
background: var(--sz-surface);
|
||||
color: var(--sz-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.rank-pagination__arrow:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.rank-pagination__pages {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.rank-pagination__page {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 2.125rem;
|
||||
height: 2.125rem;
|
||||
padding: 0 0.5rem;
|
||||
border: none;
|
||||
border-radius: var(--sz-radius-pill);
|
||||
background: none;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--sz-text-sub);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.rank-pagination__page--on {
|
||||
background: var(--sz-text);
|
||||
font-weight: 700;
|
||||
color: var(--sz-text-invert);
|
||||
}
|
||||
|
||||
.rank-pagination__gap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 1.625rem;
|
||||
height: 2.125rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--sz-text-faint);
|
||||
}
|
||||
|
||||
@media (max-width: 47.9375rem) {
|
||||
/* 좁은 폭에서는 구간 문구를 윗줄로 올린다 */
|
||||
.rank-pagination {
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.rank-pagination__range {
|
||||
flex-basis: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.rank-pagination__spacer {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,250 @@
|
||||
<script setup lang="ts">
|
||||
// 랭킹 표 — 계급 · 랭크전 · 클랜 세 갈래가 같은 표를 쓴다.
|
||||
// 승률 · K/D · 헤드샷 · 판수는 두지 않는다. 순위와 등락, 랭크전의 RP 만 남긴다.
|
||||
import RankBadge from '@/components/common/RankBadge.vue'
|
||||
import RankMovementCell from '@/components/common/RankMovementCell.vue'
|
||||
import SkeletonBox from '@/components/common/SkeletonBox.vue'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
import type { RankingRow } from '@/components/home/ranking-table'
|
||||
|
||||
interface Props {
|
||||
rows: RankingRow[]
|
||||
/** 이름 열의 머리말 — 유저 / 클랜 */
|
||||
labelHead: string
|
||||
/** RP 열을 둘지 — 랭크전 랭킹에만 둔다 */
|
||||
showRp?: boolean
|
||||
/** 표 대신 스켈레톤을 그린다 */
|
||||
loading?: boolean
|
||||
/** 스켈레톤 줄 수 */
|
||||
skeletonRows?: number
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
showRp: false,
|
||||
loading: false,
|
||||
skeletonRows: 10,
|
||||
})
|
||||
|
||||
// 1~3위만 진하게
|
||||
const isTop = (rank: number) => rank <= 3
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="ranking-table"
|
||||
:class="{ 'ranking-table--rp': showRp }"
|
||||
>
|
||||
<div class="ranking-table__head">
|
||||
<span class="ranking-table__th">#</span>
|
||||
<span />
|
||||
<span class="ranking-table__th">{{ labelHead }}</span>
|
||||
<span
|
||||
v-if="showRp"
|
||||
class="ranking-table__th ranking-table__th--right"
|
||||
>RP</span>
|
||||
<span class="ranking-table__th ranking-table__th--right">등락</span>
|
||||
</div>
|
||||
|
||||
<template v-if="loading">
|
||||
<div
|
||||
v-for="index in skeletonRows"
|
||||
:key="index"
|
||||
class="ranking-table__row"
|
||||
>
|
||||
<SkeletonBox
|
||||
width="0.75rem"
|
||||
height="0.875rem"
|
||||
/>
|
||||
<SkeletonBox
|
||||
width="1.625rem"
|
||||
height="1.625rem"
|
||||
:radius="'var(--sz-radius-tile)'"
|
||||
/>
|
||||
<SkeletonBox
|
||||
width="60%"
|
||||
height="0.875rem"
|
||||
/>
|
||||
<span
|
||||
v-if="showRp"
|
||||
class="ranking-table__score"
|
||||
>
|
||||
<SkeletonBox
|
||||
width="1.625rem"
|
||||
height="1.625rem"
|
||||
:radius="'var(--sz-radius-tile)'"
|
||||
/>
|
||||
<span class="ranking-table__rp">
|
||||
<SkeletonBox
|
||||
width="2.125rem"
|
||||
height="0.875rem"
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
<SkeletonBox
|
||||
width="1.5rem"
|
||||
height="0.875rem"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<a
|
||||
v-for="row in rows"
|
||||
:key="row.rank"
|
||||
class="ranking-table__row"
|
||||
href="#"
|
||||
>
|
||||
<span
|
||||
class="ranking-table__rank"
|
||||
:class="{ 'ranking-table__rank--top': isTop(row.rank) }"
|
||||
>{{ row.rank }}</span>
|
||||
<RankBadge
|
||||
:variant="row.badgeVariant"
|
||||
:shape="row.badgeShape"
|
||||
:image-url="row.badgeImageUrl"
|
||||
:alt="row.label"
|
||||
:size="row.badgeVariant === 'clan' ? '1.75rem' : '1.625rem'"
|
||||
/>
|
||||
<span class="ranking-table__label">{{ row.label }}</span>
|
||||
<!-- 티어와 RP 는 한 몸으로 읽혀야 해서 한 칸에 붙여 둔다 -->
|
||||
<span
|
||||
v-if="showRp"
|
||||
class="ranking-table__score"
|
||||
>
|
||||
<RankBadge
|
||||
variant="tier"
|
||||
:image-url="row.tierImageUrl"
|
||||
:alt="`${row.label} 티어`"
|
||||
size="1.625rem"
|
||||
/>
|
||||
<span class="ranking-table__rp">
|
||||
{{ row.rp === undefined ? '-' : formatCount(row.rp) }}
|
||||
</span>
|
||||
</span>
|
||||
<RankMovementCell
|
||||
:movement="row.movement"
|
||||
font-size="0.75rem"
|
||||
:icon-size="9"
|
||||
/>
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ranking-table__head,
|
||||
.ranking-table__row {
|
||||
display: grid;
|
||||
grid-template-columns: 1.125rem 1.625rem minmax(0, 1fr) 2.375rem;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* 랭크전 랭킹 — 티어와 RP 가 한 칸(4.5rem)을 나눠 쓴다.
|
||||
표 전체 칸 간격 0.5rem 을 그대로 두면 티어가 닉네임과 RP 중간에 떠 보인다. */
|
||||
.ranking-table--rp .ranking-table__head,
|
||||
.ranking-table--rp .ranking-table__row {
|
||||
grid-template-columns: 1.125rem 1.625rem minmax(0, 1fr) 4.5rem 2.125rem;
|
||||
}
|
||||
|
||||
.ranking-table__head {
|
||||
height: 2rem;
|
||||
border-bottom: 1px solid var(--sz-line-strong);
|
||||
}
|
||||
|
||||
.ranking-table__th {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--sz-text-faint);
|
||||
}
|
||||
|
||||
.ranking-table__th--right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.ranking-table__row {
|
||||
height: var(--sz-row-h);
|
||||
border-bottom: 1px solid var(--sz-line-row);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.ranking-table__row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.ranking-table__rank {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--sz-text-faint);
|
||||
}
|
||||
|
||||
.ranking-table__rank--top {
|
||||
font-weight: 700;
|
||||
color: var(--sz-text);
|
||||
}
|
||||
|
||||
.ranking-table__label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 티어 타일 + RP 한 칸 */
|
||||
.ranking-table__score {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
/* RP 는 폭을 고정해 두어 자릿수가 달라져도 티어가 세로로 줄맞춤된다 */
|
||||
.ranking-table__rp {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
width: 2.625rem;
|
||||
text-align: right;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ---------- 태블릿 · 모바일 — 한 갈래만 넓게 보이므로 열을 키운다 ---------- */
|
||||
@media (max-width: 74.9375rem) {
|
||||
.ranking-table__head,
|
||||
.ranking-table__row {
|
||||
grid-template-columns: 1.5rem 1.75rem minmax(0, 1fr) 3.75rem;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.ranking-table--rp .ranking-table__head,
|
||||
.ranking-table--rp .ranking-table__row {
|
||||
grid-template-columns: 1.5rem 1.75rem minmax(0, 1fr) 6rem 3.75rem;
|
||||
}
|
||||
|
||||
.ranking-table__rp {
|
||||
width: 4rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 47.9375rem) {
|
||||
.ranking-table__head,
|
||||
.ranking-table__row {
|
||||
grid-template-columns: 1.375rem 1.75rem minmax(0, 1fr) 2.75rem;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
|
||||
.ranking-table--rp .ranking-table__head,
|
||||
.ranking-table--rp .ranking-table__row {
|
||||
grid-template-columns: 1.375rem 1.75rem minmax(0, 1fr) 5.25rem 2.75rem;
|
||||
}
|
||||
|
||||
.ranking-table__rp {
|
||||
width: 3.25rem;
|
||||
}
|
||||
|
||||
.ranking-table__row {
|
||||
height: var(--sz-row-h-mobile);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,154 @@
|
||||
<script setup lang="ts">
|
||||
// 검색 랭킹 — 서린즈에서 많이 찾아본 닉네임. 승률·K/D 같은 지표는 두지 않는다.
|
||||
import { computed } from 'vue'
|
||||
import RankMovementCell from '@/components/common/RankMovementCell.vue'
|
||||
import SectionBoundary from '@/components/common/SectionBoundary.vue'
|
||||
import SectionHeader from '@/components/common/SectionHeader.vue'
|
||||
import SkeletonBox from '@/components/common/SkeletonBox.vue'
|
||||
import type { SectionResource } from '@/composables/useSectionResource'
|
||||
import { formatClockTime } from '@/shared/utils/format'
|
||||
import type { SearchRankEntry } from '@/types/home'
|
||||
|
||||
interface Props {
|
||||
resource: SectionResource<SearchRankEntry[]>
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
// 집계 기준 시각 — 지금은 불러온 시각을 쓴다.
|
||||
// 서버가 집계 시각을 내려 주면 그 값으로 바꾼다.
|
||||
const baseTime = computed(() =>
|
||||
props.resource.status.value === 'ready' ? `${formatClockTime(new Date())} 기준` : '',
|
||||
)
|
||||
|
||||
// 1~3위만 진하게 — 그 아래는 흐리게 두어 상위권이 먼저 읽히게 한다
|
||||
const isTop = (rank: number) => rank <= 3
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<SectionHeader
|
||||
title="검색 랭킹"
|
||||
:meta="baseTime"
|
||||
/>
|
||||
|
||||
<SectionBoundary
|
||||
:status="resource.status.value"
|
||||
:error-message="resource.errorMessage.value"
|
||||
empty-title="아직 집계된 검색이 없어요"
|
||||
empty-description="검색이 쌓이면 순위가 나타납니다"
|
||||
empty-icon="chart"
|
||||
min-height="26rem"
|
||||
@retry="resource.reload"
|
||||
>
|
||||
<template #skeleton>
|
||||
<div class="search-rank__head">
|
||||
<span class="search-rank__th">순위</span>
|
||||
<span class="search-rank__th">닉네임</span>
|
||||
<span class="search-rank__th search-rank__th--right">변동</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="index in 9"
|
||||
:key="index"
|
||||
class="search-rank__row"
|
||||
>
|
||||
<SkeletonBox
|
||||
width="0.875rem"
|
||||
height="0.875rem"
|
||||
/>
|
||||
<SkeletonBox
|
||||
width="45%"
|
||||
height="0.875rem"
|
||||
/>
|
||||
<SkeletonBox
|
||||
width="1.5rem"
|
||||
height="0.875rem"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="search-rank__head">
|
||||
<span class="search-rank__th">순위</span>
|
||||
<span class="search-rank__th">닉네임</span>
|
||||
<span class="search-rank__th search-rank__th--right">변동</span>
|
||||
</div>
|
||||
<a
|
||||
v-for="entry in resource.data.value ?? []"
|
||||
:key="entry.rank"
|
||||
class="search-rank__row"
|
||||
href="#"
|
||||
>
|
||||
<span
|
||||
class="search-rank__rank"
|
||||
:class="{ 'search-rank__rank--top': isTop(entry.rank) }"
|
||||
>{{ entry.rank }}</span>
|
||||
<span class="search-rank__nickname">{{ entry.nickname }}</span>
|
||||
<RankMovementCell :movement="entry.movement" />
|
||||
</a>
|
||||
</SectionBoundary>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.search-rank__head,
|
||||
.search-rank__row {
|
||||
display: grid;
|
||||
grid-template-columns: 1.625rem minmax(0, 1fr) 3.25rem;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
|
||||
.search-rank__head {
|
||||
height: 2.125rem;
|
||||
border-bottom: 1px solid var(--sz-line-strong);
|
||||
}
|
||||
|
||||
.search-rank__th {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--sz-text-faint);
|
||||
}
|
||||
|
||||
.search-rank__th--right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.search-rank__row {
|
||||
height: 2.625rem;
|
||||
border-bottom: 1px solid var(--sz-line-row);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.search-rank__row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.search-rank__rank {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--sz-text-faint);
|
||||
}
|
||||
|
||||
.search-rank__rank--top {
|
||||
font-weight: 700;
|
||||
color: var(--sz-text);
|
||||
}
|
||||
|
||||
.search-rank__nickname {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
@media (max-width: 47.9375rem) {
|
||||
/* 모바일은 손가락이 닿는 높이로 키운다 */
|
||||
.search-rank__head {
|
||||
height: 2.125rem;
|
||||
}
|
||||
|
||||
.search-rank__row {
|
||||
height: 3rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,258 @@
|
||||
<script setup lang="ts">
|
||||
// 지금 방송 중 — 맨 위 하나는 크게, 나머지는 가로 목록으로.
|
||||
import { computed } from 'vue'
|
||||
import AvatarCircle from '@/components/common/AvatarCircle.vue'
|
||||
import SectionBoundary from '@/components/common/SectionBoundary.vue'
|
||||
import SectionHeader from '@/components/common/SectionHeader.vue'
|
||||
import SkeletonBox from '@/components/common/SkeletonBox.vue'
|
||||
import type { SectionResource } from '@/composables/useSectionResource'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
import type { StreamChannel } from '@/types/home'
|
||||
|
||||
interface Props {
|
||||
resource: SectionResource<StreamChannel[]>
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const featured = computed(() => props.resource.data.value?.[0])
|
||||
const rest = computed(() => props.resource.data.value?.slice(1) ?? [])
|
||||
|
||||
const viewerLabel = (channel: StreamChannel) =>
|
||||
`${channel.streamerName} · ${formatCount(channel.viewerCount)}명`
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<SectionHeader
|
||||
title="지금 방송 중"
|
||||
more-label="전체 보기"
|
||||
/>
|
||||
|
||||
<SectionBoundary
|
||||
:status="resource.status.value"
|
||||
:error-message="resource.errorMessage.value"
|
||||
empty-title="지금 방송 중인 채널이 없어요"
|
||||
empty-description="방송이 시작되면 여기에 표시됩니다"
|
||||
empty-icon="video"
|
||||
min-height="24rem"
|
||||
@retry="resource.reload"
|
||||
>
|
||||
<template #skeleton>
|
||||
<div class="streaming__layout">
|
||||
<div>
|
||||
<SkeletonBox
|
||||
ratio="16 / 9"
|
||||
:radius="'var(--sz-radius-md)'"
|
||||
/>
|
||||
<SkeletonBox
|
||||
class="streaming__skeleton-line"
|
||||
width="60%"
|
||||
height="1rem"
|
||||
/>
|
||||
<SkeletonBox
|
||||
class="streaming__skeleton-line"
|
||||
width="40%"
|
||||
height="0.8125rem"
|
||||
/>
|
||||
</div>
|
||||
<div class="streaming__list">
|
||||
<div
|
||||
v-for="index in 3"
|
||||
:key="index"
|
||||
class="streaming__item"
|
||||
>
|
||||
<SkeletonBox
|
||||
width="5.75rem"
|
||||
height="3.375rem"
|
||||
:radius="'var(--sz-radius-thumb)'"
|
||||
/>
|
||||
<div class="streaming__item-body">
|
||||
<SkeletonBox
|
||||
width="70%"
|
||||
height="0.875rem"
|
||||
/>
|
||||
<SkeletonBox
|
||||
class="streaming__skeleton-line"
|
||||
width="45%"
|
||||
height="0.75rem"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="streaming__layout">
|
||||
<a
|
||||
v-if="featured"
|
||||
class="streaming__feature"
|
||||
href="#"
|
||||
>
|
||||
<img
|
||||
v-if="featured.thumbnailUrl"
|
||||
class="streaming__feature-thumb"
|
||||
:src="featured.thumbnailUrl"
|
||||
:alt="featured.title"
|
||||
>
|
||||
<span
|
||||
v-else
|
||||
class="streaming__feature-thumb"
|
||||
:style="{ background: featured.placeholderColor }"
|
||||
/>
|
||||
<div class="streaming__feature-title">{{ featured.title }}</div>
|
||||
<div class="streaming__meta">
|
||||
<AvatarCircle
|
||||
size="1.25rem"
|
||||
:color="featured.streamerAvatarColor"
|
||||
:image-url="featured.streamerAvatarUrl"
|
||||
/>
|
||||
<span class="streaming__meta-text">{{ viewerLabel(featured) }}</span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div class="streaming__list">
|
||||
<a
|
||||
v-for="channel in rest"
|
||||
:key="channel.id"
|
||||
class="streaming__item"
|
||||
href="#"
|
||||
>
|
||||
<img
|
||||
v-if="channel.thumbnailUrl"
|
||||
class="streaming__item-thumb"
|
||||
:src="channel.thumbnailUrl"
|
||||
:alt="channel.title"
|
||||
>
|
||||
<span
|
||||
v-else
|
||||
class="streaming__item-thumb"
|
||||
:style="{ background: channel.placeholderColor }"
|
||||
/>
|
||||
<div class="streaming__item-body">
|
||||
<div class="streaming__item-title">{{ channel.title }}</div>
|
||||
<div class="streaming__meta streaming__meta--small">
|
||||
<AvatarCircle
|
||||
size="1.125rem"
|
||||
:color="channel.streamerAvatarColor"
|
||||
:image-url="channel.streamerAvatarUrl"
|
||||
/>
|
||||
<span class="streaming__meta-text">{{ viewerLabel(channel) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</SectionBoundary>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.streaming__feature {
|
||||
display: block;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.streaming__feature-thumb {
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
border-radius: var(--sz-radius-md);
|
||||
background: var(--sz-surface-placeholder);
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.streaming__feature-title {
|
||||
margin-top: 0.75rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.streaming__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4375rem;
|
||||
margin-top: 0.3125rem;
|
||||
}
|
||||
|
||||
.streaming__meta-text {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--sz-text-faint);
|
||||
}
|
||||
|
||||
.streaming__meta--small {
|
||||
margin-top: 0.25rem;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.streaming__meta--small .streaming__meta-text {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.streaming__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
.streaming__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.streaming__item-thumb {
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
width: 5.75rem;
|
||||
height: 3.375rem;
|
||||
border-radius: var(--sz-radius-thumb);
|
||||
background: var(--sz-surface-placeholder);
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.streaming__item-body {
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.streaming__item-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.streaming__skeleton-line {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* 태블릿 — 섹션이 두 칸을 차지하므로 큰 방송과 목록을 나란히 둔다 */
|
||||
@media (min-width: 48rem) and (max-width: 74.9375rem) {
|
||||
.streaming__layout {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.streaming__list {
|
||||
justify-content: center;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.streaming__item-thumb {
|
||||
width: 6rem;
|
||||
height: 3.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { toClanRows, toUserRows } from '@/components/home/ranking-table'
|
||||
import type { ClanRankEntry, UserRankEntry } from '@/types/home'
|
||||
|
||||
describe('랭킹 표 줄 변환', () => {
|
||||
it('유저 랭킹은 계급 · 티어 자리표시와 RP 를 그대로 옮긴다', () => {
|
||||
const entries: UserRankEntry[] = [
|
||||
{
|
||||
rank: 1,
|
||||
nickname: '헤드샷장인',
|
||||
gradeShape: 'star',
|
||||
tierImageUrl: '/images/tiers/diamond.png',
|
||||
rp: 2486,
|
||||
movement: { direction: 'up', amount: 1 },
|
||||
},
|
||||
]
|
||||
|
||||
expect(toUserRows(entries)).toEqual([
|
||||
{
|
||||
rank: 1,
|
||||
label: '헤드샷장인',
|
||||
badgeVariant: 'grade',
|
||||
badgeShape: 'star',
|
||||
badgeImageUrl: undefined,
|
||||
tierImageUrl: '/images/tiers/diamond.png',
|
||||
rp: 2486,
|
||||
movement: { direction: 'up', amount: 1 },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('계급 랭킹처럼 티어 이미지가 없으면 그 자리는 비워 둔다', () => {
|
||||
const entries: UserRankEntry[] = [
|
||||
{
|
||||
rank: 1,
|
||||
nickname: '달빛조각사',
|
||||
gradeShape: 'bar',
|
||||
movement: { direction: 'same', amount: 0 },
|
||||
},
|
||||
]
|
||||
|
||||
const [row] = toUserRows(entries)
|
||||
|
||||
expect(row?.tierImageUrl).toBeUndefined()
|
||||
expect(row?.rp).toBeUndefined()
|
||||
})
|
||||
|
||||
it('클랜 랭킹은 클랜명을 이름 자리에 두고 RP 를 두지 않는다', () => {
|
||||
const entries: ClanRankEntry[] = [
|
||||
{ rank: 3, clanName: '야간부대', movement: { direction: 'down', amount: 1 } },
|
||||
]
|
||||
|
||||
const [row] = toClanRows(entries)
|
||||
|
||||
expect(row?.label).toBe('야간부대')
|
||||
expect(row?.badgeVariant).toBe('clan')
|
||||
expect(row?.rp).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* 랭킹 표 한 줄의 화면용 모양.
|
||||
*
|
||||
* 유저 랭킹(계급·랭크전)과 클랜 랭킹은 담는 값이 다르지만 표의 생김새는 같다.
|
||||
* 두 도메인 타입을 이 모양으로 옮겨서 표 컴포넌트 하나로 그린다.
|
||||
*/
|
||||
import type { ClanRankEntry, RankGradeShape, RankMovement, UserRankEntry } from '@/types/home'
|
||||
|
||||
export interface RankingRow {
|
||||
rank: number
|
||||
/** 닉네임 또는 클랜명 */
|
||||
label: string
|
||||
/** 이름 앞 타일이 계급 이미지 자리인지 클랜 마크 자리인지 */
|
||||
badgeVariant: 'grade' | 'clan'
|
||||
/** 계급 자리표시 모양 — 유저 랭킹에만 있다 */
|
||||
badgeShape?: RankGradeShape
|
||||
/** 계급 이미지 또는 클랜 마크 */
|
||||
badgeImageUrl?: string
|
||||
/** 랭크전 티어 이미지 — RP 와 한 칸에 붙는다 */
|
||||
tierImageUrl?: string
|
||||
/** 랭크전 랭킹에만 있는 RP 점수 */
|
||||
rp?: number
|
||||
movement: RankMovement
|
||||
}
|
||||
|
||||
export function toUserRows(entries: UserRankEntry[]): RankingRow[] {
|
||||
return entries.map((entry) => ({
|
||||
rank: entry.rank,
|
||||
label: entry.nickname,
|
||||
badgeVariant: 'grade',
|
||||
badgeShape: entry.gradeShape,
|
||||
badgeImageUrl: entry.gradeImageUrl,
|
||||
tierImageUrl: entry.tierImageUrl,
|
||||
rp: entry.rp,
|
||||
movement: entry.movement,
|
||||
}))
|
||||
}
|
||||
|
||||
export function toClanRows(entries: ClanRankEntry[]): RankingRow[] {
|
||||
return entries.map((entry) => ({
|
||||
rank: entry.rank,
|
||||
label: entry.clanName,
|
||||
badgeVariant: 'clan',
|
||||
badgeImageUrl: entry.markImageUrl,
|
||||
movement: entry.movement,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<script setup lang="ts">
|
||||
// 전역 푸터 — 서비스 성격을 밝히는 한 줄까지 포함한다.
|
||||
|
||||
interface FooterLink {
|
||||
label: string
|
||||
to?: string
|
||||
}
|
||||
|
||||
const links: FooterLink[] = [
|
||||
{ label: '이용약관' },
|
||||
{ label: '개인정보 처리방침' },
|
||||
{ label: '문의하기' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer class="app-footer">
|
||||
<span class="app-footer__brand">서린즈</span>
|
||||
<a
|
||||
v-for="link in links"
|
||||
:key="link.label"
|
||||
class="app-footer__link"
|
||||
:href="link.to ?? '#'"
|
||||
>{{ link.label }}</a>
|
||||
|
||||
<span class="app-footer__spacer" />
|
||||
<span class="app-footer__note">넥슨 공개 API 기반 · 서든어택 비공식 서비스</span>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
padding: 2rem var(--sz-page-pad) 3rem;
|
||||
border-top: 1px solid var(--sz-line);
|
||||
font-size: 0.875rem;
|
||||
color: var(--sz-text-faint);
|
||||
}
|
||||
|
||||
.app-footer__brand {
|
||||
font-weight: 600;
|
||||
color: var(--sz-text-sub);
|
||||
}
|
||||
|
||||
.app-footer__link {
|
||||
color: var(--sz-text-faint);
|
||||
}
|
||||
|
||||
.app-footer__link:hover {
|
||||
color: var(--sz-text-sub);
|
||||
}
|
||||
|
||||
.app-footer__spacer {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 74.9375rem) {
|
||||
.app-footer {
|
||||
padding: 2rem var(--sz-page-pad-tablet) 2.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 47.9375rem) {
|
||||
/* 좁은 폭에서는 한 줄에 다 들어가지 않으므로 줄을 바꾼다 */
|
||||
.app-footer {
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem 1rem;
|
||||
padding: 1.5rem var(--sz-page-pad-mobile) 2.5rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.app-footer__spacer {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,358 @@
|
||||
<script setup lang="ts">
|
||||
// 전역 헤더 — 스크롤해도 위에 붙어 있다.
|
||||
// 검색 아이콘은 처음부터 두지 않고 히어로 검색창이 화면 밖으로 나간 뒤에 나타난다
|
||||
// (맨 위에서는 바로 아래 큰 검색창이 있어 같은 것이 둘이 되기 때문).
|
||||
import { ref, watch } from 'vue'
|
||||
import AppIcon from '@/components/common/AppIcon.vue'
|
||||
import BrandLogo from '@/components/common/BrandLogo.vue'
|
||||
|
||||
interface NavItem {
|
||||
label: string
|
||||
/** 라우트가 생기면 to 를 채운다 — 지금은 홈만 있다 */
|
||||
to?: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** 히어로 검색창이 화면 밖으로 나갔는지 — 검색 아이콘과 그림자를 함께 켠다 */
|
||||
searchVisible?: boolean
|
||||
/** 지금 보고 있는 메뉴 */
|
||||
currentLabel?: string
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
searchVisible: false,
|
||||
currentLabel: '홈',
|
||||
})
|
||||
|
||||
interface Emits {
|
||||
/** 헤더 검색 아이콘 — 검색 시트를 연다 */
|
||||
(e: 'open-search'): void
|
||||
(e: 'login'): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ label: '홈', to: '/' },
|
||||
{ label: '전적 검색' },
|
||||
{ label: '랭킹' },
|
||||
{ label: '커뮤니티' },
|
||||
{ label: '스트리밍' },
|
||||
{ label: '소식' },
|
||||
{ label: '이벤트' },
|
||||
]
|
||||
|
||||
// 모바일 메뉴 서랍
|
||||
const isDrawerOpen = ref(false)
|
||||
|
||||
watch(isDrawerOpen, (open) => {
|
||||
// 서랍이 열려 있는 동안에는 뒤 페이지가 따라 움직이지 않게 한다
|
||||
document.body.style.overflow = open ? 'hidden' : ''
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header
|
||||
class="app-header"
|
||||
:class="{ 'app-header--floating': searchVisible }"
|
||||
>
|
||||
<RouterLink
|
||||
class="app-header__logo"
|
||||
to="/"
|
||||
aria-label="서린즈 홈"
|
||||
>
|
||||
<BrandLogo :width="61" />
|
||||
</RouterLink>
|
||||
|
||||
<nav
|
||||
class="app-header__nav"
|
||||
aria-label="주 메뉴"
|
||||
>
|
||||
<RouterLink
|
||||
v-for="item in navItems"
|
||||
:key="item.label"
|
||||
class="app-header__nav-item"
|
||||
:class="{ 'app-header__nav-item--on': item.label === currentLabel }"
|
||||
:to="item.to ?? '/'"
|
||||
>
|
||||
{{ item.label }}
|
||||
</RouterLink>
|
||||
</nav>
|
||||
|
||||
<span class="app-header__spacer" />
|
||||
|
||||
<button
|
||||
v-show="searchVisible"
|
||||
type="button"
|
||||
class="app-header__icon-button"
|
||||
aria-label="전적 검색 열기"
|
||||
@click="emit('open-search')"
|
||||
>
|
||||
<AppIcon
|
||||
name="search"
|
||||
:size="21"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="app-header__login"
|
||||
@click="emit('login')"
|
||||
>
|
||||
로그인
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="app-header__icon-button app-header__menu"
|
||||
aria-label="메뉴 열기"
|
||||
:aria-expanded="isDrawerOpen"
|
||||
@click="isDrawerOpen = true"
|
||||
>
|
||||
<AppIcon
|
||||
name="menu"
|
||||
:size="22"
|
||||
/>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- 모바일 메뉴 서랍 -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="isDrawerOpen"
|
||||
class="app-drawer"
|
||||
>
|
||||
<div
|
||||
class="app-drawer__scrim"
|
||||
@click="isDrawerOpen = false"
|
||||
/>
|
||||
<div
|
||||
class="app-drawer__panel"
|
||||
role="dialog"
|
||||
aria-label="메뉴"
|
||||
>
|
||||
<div class="app-drawer__head">
|
||||
<BrandLogo :width="55" />
|
||||
<span class="app-header__spacer" />
|
||||
<button
|
||||
type="button"
|
||||
class="app-header__icon-button"
|
||||
aria-label="메뉴 닫기"
|
||||
@click="isDrawerOpen = false"
|
||||
>
|
||||
<AppIcon
|
||||
name="close"
|
||||
:size="22"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav
|
||||
class="app-drawer__nav"
|
||||
aria-label="주 메뉴"
|
||||
>
|
||||
<RouterLink
|
||||
v-for="item in navItems"
|
||||
:key="item.label"
|
||||
class="app-drawer__nav-item"
|
||||
:class="{ 'app-drawer__nav-item--on': item.label === currentLabel }"
|
||||
:to="item.to ?? '/'"
|
||||
@click="isDrawerOpen = false"
|
||||
>
|
||||
{{ item.label }}
|
||||
</RouterLink>
|
||||
</nav>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="app-drawer__login"
|
||||
@click="emit('login')"
|
||||
>
|
||||
로그인
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.75rem;
|
||||
height: 4.5rem;
|
||||
padding: 0 var(--sz-page-pad);
|
||||
background: var(--sz-surface);
|
||||
border-bottom: 1px solid var(--sz-line);
|
||||
}
|
||||
|
||||
/* 스크롤된 상태임을 옅은 그림자로 알려 준다 */
|
||||
.app-header--floating {
|
||||
box-shadow: var(--sz-shadow-header);
|
||||
}
|
||||
|
||||
.app-header__logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--sz-text);
|
||||
}
|
||||
|
||||
.app-header__nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
margin-left: 0.75rem;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.app-header__nav-item {
|
||||
font-weight: 500;
|
||||
color: var(--sz-text-sub);
|
||||
}
|
||||
|
||||
.app-header__nav-item:hover {
|
||||
color: var(--sz-text);
|
||||
}
|
||||
|
||||
.app-header__nav-item--on {
|
||||
font-weight: 700;
|
||||
color: var(--sz-text);
|
||||
}
|
||||
|
||||
.app-header__spacer {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.app-header__icon-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--sz-tap-min);
|
||||
height: var(--sz-tap-min);
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--sz-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.app-header__login {
|
||||
height: 2.625rem;
|
||||
padding: 0 1.25rem;
|
||||
border: none;
|
||||
border-radius: var(--sz-radius-pill);
|
||||
background: var(--sz-surface-button);
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: var(--sz-text);
|
||||
letter-spacing: -0.015em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 메뉴 버튼은 모바일에서만 */
|
||||
.app-header__menu {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ---------- 태블릿 ---------- */
|
||||
@media (max-width: 74.9375rem) {
|
||||
.app-header {
|
||||
gap: 1.25rem;
|
||||
height: 4rem;
|
||||
padding: 0 var(--sz-page-pad-tablet);
|
||||
}
|
||||
|
||||
.app-header__nav {
|
||||
gap: 1.0625rem;
|
||||
margin-left: 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 모바일 ---------- */
|
||||
@media (max-width: 47.9375rem) {
|
||||
.app-header {
|
||||
gap: 0.625rem;
|
||||
height: 3.75rem;
|
||||
padding: 0 var(--sz-page-pad-mobile);
|
||||
}
|
||||
|
||||
.app-header__nav,
|
||||
.app-header__login {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-header__menu {
|
||||
display: inline-flex;
|
||||
/* 아이콘 광학 정렬 — 오른쪽 여백에 맞춘다 */
|
||||
margin-right: -0.625rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 메뉴 서랍 ---------- */
|
||||
.app-drawer {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
.app-drawer__scrim {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--sz-scrim);
|
||||
}
|
||||
|
||||
.app-drawer__panel {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(20rem, 82vw);
|
||||
height: 100%;
|
||||
padding: 0 var(--sz-page-pad-mobile) 1.5rem;
|
||||
background: var(--sz-surface);
|
||||
}
|
||||
|
||||
.app-drawer__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 3.75rem;
|
||||
color: var(--sz-text);
|
||||
}
|
||||
|
||||
.app-drawer__nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.app-drawer__nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 3.25rem;
|
||||
border-bottom: 1px solid var(--sz-line-row);
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
color: var(--sz-text-sub);
|
||||
}
|
||||
|
||||
.app-drawer__nav-item--on {
|
||||
font-weight: 700;
|
||||
color: var(--sz-text);
|
||||
}
|
||||
|
||||
.app-drawer__login {
|
||||
height: 3rem;
|
||||
margin-top: auto;
|
||||
border: none;
|
||||
border-radius: var(--sz-radius-pill);
|
||||
background: var(--sz-surface-button);
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: var(--sz-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,307 @@
|
||||
<script setup lang="ts">
|
||||
// 검색 히어로 — 화면 맨 위. 옅은 색면으로 아래 그리드와 층을 나눈다.
|
||||
// 검색은 이 서비스의 첫 동작이라 헤더에 넣지 않고 여기에 크게 둔다.
|
||||
import { ref } from 'vue'
|
||||
import AppIcon from '@/components/common/AppIcon.vue'
|
||||
import AvatarCircle from '@/components/common/AvatarCircle.vue'
|
||||
import type { RecentSearchItem } from '@/types/home'
|
||||
|
||||
interface Props {
|
||||
recentSearches: RecentSearchItem[]
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
interface Emits {
|
||||
/** 닉네임 검색 실행 */
|
||||
(e: 'search', nickname: string): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const keyword = ref('')
|
||||
|
||||
function submit() {
|
||||
const trimmed = keyword.value.trim()
|
||||
if (!trimmed) return
|
||||
emit('search', trimmed)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="search-hero">
|
||||
<div class="search-hero__inner">
|
||||
<h1 class="search-hero__title">
|
||||
누구의 전적이 궁금하세요?
|
||||
</h1>
|
||||
<p class="search-hero__lead">
|
||||
닉네임만 알려주시면 최근 경기 흐름부터 자주 쓰는 무기까지 정리해 드릴게요.
|
||||
</p>
|
||||
|
||||
<form
|
||||
class="search-hero__form"
|
||||
role="search"
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
<span class="search-hero__icon">
|
||||
<AppIcon
|
||||
name="search"
|
||||
:size="21"
|
||||
/>
|
||||
</span>
|
||||
<label
|
||||
class="sz-sr-only"
|
||||
for="hero-search"
|
||||
>닉네임</label>
|
||||
<input
|
||||
id="hero-search"
|
||||
v-model="keyword"
|
||||
class="search-hero__input"
|
||||
type="search"
|
||||
autocomplete="off"
|
||||
placeholder="닉네임을 입력해 보세요"
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
class="search-hero__submit"
|
||||
>
|
||||
찾아보기
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="search-hero__recent">
|
||||
<span class="search-hero__recent-label">최근에 찾아봤어요</span>
|
||||
<div class="search-hero__chips">
|
||||
<button
|
||||
v-for="item in recentSearches"
|
||||
:key="item.nickname"
|
||||
type="button"
|
||||
class="search-hero__chip"
|
||||
@click="emit('search', item.nickname)"
|
||||
>
|
||||
<AvatarCircle
|
||||
size="1.5rem"
|
||||
:color="item.avatarColor"
|
||||
:image-url="item.avatarUrl"
|
||||
/>
|
||||
{{ item.nickname }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.search-hero {
|
||||
background: var(--sz-surface-sub);
|
||||
border-bottom: 1px solid var(--sz-line);
|
||||
}
|
||||
|
||||
.search-hero__inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 3.25rem var(--sz-page-pad) 2.875rem;
|
||||
}
|
||||
|
||||
.search-hero__title {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
/* 한글이 낱자로 끊기지 않게 띄어쓰기에서만 줄을 바꾼다 */
|
||||
word-break: keep-all;
|
||||
font-size: 2.125rem;
|
||||
line-height: 1.28;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.045em;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.search-hero__lead {
|
||||
margin: 0.75rem 0 0;
|
||||
text-align: center;
|
||||
word-break: keep-all;
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.65;
|
||||
color: var(--sz-text-sub);
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.search-hero__form {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.875rem;
|
||||
width: 100%;
|
||||
max-width: 43.75rem;
|
||||
height: 4.125rem;
|
||||
margin-top: 1.625rem;
|
||||
padding: 0 0.5rem 0 1.375rem;
|
||||
background: var(--sz-surface);
|
||||
border: 1px solid var(--sz-line-strong);
|
||||
border-radius: var(--sz-radius-pill);
|
||||
box-shadow: var(--sz-shadow-hero);
|
||||
}
|
||||
|
||||
.search-hero__icon {
|
||||
display: flex;
|
||||
color: var(--sz-text-faint);
|
||||
}
|
||||
|
||||
.search-hero__input {
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
font-family: inherit;
|
||||
font-size: 1.0625rem;
|
||||
letter-spacing: inherit;
|
||||
color: var(--sz-text);
|
||||
}
|
||||
|
||||
.search-hero__input::placeholder {
|
||||
color: var(--sz-text-faint);
|
||||
}
|
||||
|
||||
.search-hero__input:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.search-hero__submit {
|
||||
flex-shrink: 0;
|
||||
height: 3.125rem;
|
||||
padding: 0 1.75rem;
|
||||
border: none;
|
||||
border-radius: var(--sz-radius-pill);
|
||||
background: var(--sz-brand);
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--sz-text-invert);
|
||||
letter-spacing: -0.02em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.search-hero__submit:hover,
|
||||
.search-hero__submit:active {
|
||||
background: var(--sz-brand-press);
|
||||
}
|
||||
|
||||
.search-hero__recent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
max-width: 43.75rem;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
.search-hero__recent-label {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--sz-text-faint);
|
||||
}
|
||||
|
||||
.search-hero__chips {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
/* 가로 스크롤 막대는 숨기고 밀어서 보게 한다 */
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.search-hero__chips::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.search-hero__chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
height: 2.25rem;
|
||||
padding: 0 0.875rem 0 0.375rem;
|
||||
border: 1px solid var(--sz-line);
|
||||
border-radius: var(--sz-radius-pill);
|
||||
background: var(--sz-surface);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--sz-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.search-hero__chip:hover {
|
||||
border-color: var(--sz-line-strong);
|
||||
}
|
||||
|
||||
/* ---------- 태블릿 ---------- */
|
||||
@media (max-width: 74.9375rem) {
|
||||
.search-hero__inner {
|
||||
padding: 2.75rem var(--sz-page-pad-tablet) 2.375rem;
|
||||
}
|
||||
|
||||
.search-hero__title {
|
||||
font-size: 1.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 모바일 ---------- */
|
||||
@media (max-width: 47.9375rem) {
|
||||
.search-hero__inner {
|
||||
align-items: stretch;
|
||||
padding: 2rem var(--sz-page-pad-mobile) 1.625rem;
|
||||
}
|
||||
|
||||
.search-hero__title {
|
||||
font-size: 1.625rem;
|
||||
line-height: 1.32;
|
||||
}
|
||||
|
||||
.search-hero__lead {
|
||||
margin-top: 0.625rem;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.search-hero__form {
|
||||
height: 3.5rem;
|
||||
margin-top: 1.25rem;
|
||||
padding: 0 0.375rem 0 0.875rem;
|
||||
gap: 0.5rem;
|
||||
box-shadow: var(--sz-shadow-hero-mobile);
|
||||
}
|
||||
|
||||
.search-hero__input {
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.search-hero__submit {
|
||||
height: 2.75rem;
|
||||
padding: 0 1.125rem;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
/* 라벨을 칩 위로 올려 좁은 폭에서도 칩이 온전히 보이게 한다 */
|
||||
.search-hero__recent {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.search-hero__recent-label {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.search-hero__chips {
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.search-hero__chip {
|
||||
height: 2.25rem;
|
||||
padding: 0 0.75rem 0 0.3125rem;
|
||||
gap: 0.4375rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,411 @@
|
||||
<script setup lang="ts">
|
||||
// 헤더 검색 시트 — 헤더의 돋보기를 누르면 열린다.
|
||||
// 데스크톱은 640px 카드, 모바일은 전체 화면(반쯤 덮으면 키보드가 올라왔을 때 남는 높이가 너무 작다).
|
||||
// 아직 아무것도 치지 않았을 때는 최근 검색과 「지금 많이 찾는」 다섯을 보여 준다 —
|
||||
// 홈의 검색 랭킹과 같은 데이터라 새로 만들 것이 없고, 빈 시트를 마주하지 않게 한다.
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import AppIcon from '@/components/common/AppIcon.vue'
|
||||
import AvatarCircle from '@/components/common/AvatarCircle.vue'
|
||||
import RankMovementCell from '@/components/common/RankMovementCell.vue'
|
||||
import type { RecentSearchItem, SearchRankEntry } from '@/types/home'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
recentSearches: RecentSearchItem[]
|
||||
/** 지금 많이 찾는 — 홈 검색 랭킹과 같은 데이터 */
|
||||
popular: SearchRankEntry[]
|
||||
/** 「지금 많이 찾는」 에 보여 줄 개수 */
|
||||
popularLimit?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
popularLimit: 5,
|
||||
})
|
||||
|
||||
interface Emits {
|
||||
(e: 'close'): void
|
||||
(e: 'search', nickname: string): void
|
||||
/** 최근 검색 한 줄 지우기 */
|
||||
(e: 'remove-recent', nickname: string): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const keyword = ref('')
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
// 열리면 바로 칠 수 있는 상태로 둔다
|
||||
watch(
|
||||
() => props.open,
|
||||
async (open) => {
|
||||
document.body.style.overflow = open ? 'hidden' : ''
|
||||
if (!open) {
|
||||
keyword.value = ''
|
||||
return
|
||||
}
|
||||
await nextTick()
|
||||
inputRef.value?.focus()
|
||||
},
|
||||
)
|
||||
|
||||
// ESC 로 닫는다 — 시트 안 어디에 초점이 있어도 동작하도록 창 단위로 듣는다
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (props.open && event.key === 'Escape') emit('close')
|
||||
}
|
||||
|
||||
onMounted(() => window.addEventListener('keydown', handleKeydown))
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', handleKeydown)
|
||||
document.body.style.overflow = ''
|
||||
})
|
||||
|
||||
function submit() {
|
||||
const trimmed = keyword.value.trim()
|
||||
if (!trimmed) return
|
||||
emit('search', trimmed)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="open"
|
||||
class="search-sheet"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="전적 검색"
|
||||
>
|
||||
<!-- 바깥을 누르면 닫힌다 -->
|
||||
<div
|
||||
class="search-sheet__scrim"
|
||||
@click="emit('close')"
|
||||
/>
|
||||
|
||||
<div class="search-sheet__card">
|
||||
<form
|
||||
class="search-sheet__field"
|
||||
role="search"
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
<div class="search-sheet__field-box">
|
||||
<span class="search-sheet__field-icon">
|
||||
<AppIcon
|
||||
name="search"
|
||||
:size="21"
|
||||
/>
|
||||
</span>
|
||||
<label
|
||||
class="sz-sr-only"
|
||||
for="sheet-search"
|
||||
>닉네임</label>
|
||||
<input
|
||||
id="sheet-search"
|
||||
ref="inputRef"
|
||||
v-model="keyword"
|
||||
class="search-sheet__input"
|
||||
type="search"
|
||||
autocomplete="off"
|
||||
placeholder="닉네임을 입력해 보세요"
|
||||
>
|
||||
</div>
|
||||
<!-- 데스크톱은 ESC, 모바일은 「취소」 로 닫는다 -->
|
||||
<span class="search-sheet__esc">ESC</span>
|
||||
<button
|
||||
type="button"
|
||||
class="search-sheet__cancel"
|
||||
@click="emit('close')"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="search-sheet__body">
|
||||
<p
|
||||
v-if="recentSearches.length > 0"
|
||||
class="search-sheet__label"
|
||||
>
|
||||
최근에 찾아봤어요
|
||||
</p>
|
||||
<div
|
||||
v-for="item in recentSearches"
|
||||
:key="item.nickname"
|
||||
class="search-sheet__recent"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="search-sheet__recent-main"
|
||||
@click="emit('search', item.nickname)"
|
||||
>
|
||||
<AvatarCircle
|
||||
size="1.625rem"
|
||||
:color="item.avatarColor"
|
||||
:image-url="item.avatarUrl"
|
||||
/>
|
||||
<span class="search-sheet__nickname">{{ item.nickname }}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="search-sheet__remove"
|
||||
:aria-label="`${item.nickname} 검색 기록 지우기`"
|
||||
@click="emit('remove-recent', item.nickname)"
|
||||
>
|
||||
<AppIcon
|
||||
name="close"
|
||||
:size="15"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="popular.length > 0"
|
||||
class="search-sheet__label"
|
||||
>
|
||||
지금 많이 찾는
|
||||
</p>
|
||||
<button
|
||||
v-for="entry in popular.slice(0, popularLimit)"
|
||||
:key="entry.rank"
|
||||
type="button"
|
||||
class="search-sheet__popular"
|
||||
@click="emit('search', entry.nickname)"
|
||||
>
|
||||
<span class="search-sheet__rank">{{ entry.rank }}</span>
|
||||
<span class="search-sheet__nickname">{{ entry.nickname }}</span>
|
||||
<RankMovementCell :movement="entry.movement" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.search-sheet {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.search-sheet__scrim {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(20, 22, 26, 0.45);
|
||||
}
|
||||
|
||||
.search-sheet__card {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 3.5rem;
|
||||
transform: translateX(-50%);
|
||||
width: min(40rem, calc(100vw - 2.5rem));
|
||||
max-height: calc(100vh - 7rem);
|
||||
border-radius: var(--sz-radius-lg);
|
||||
background: var(--sz-surface);
|
||||
box-shadow: 0 24px 60px rgba(20, 22, 26, 0.28);
|
||||
overflow: hidden auto;
|
||||
}
|
||||
|
||||
.search-sheet__field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
height: 4rem;
|
||||
padding: 0 1.25rem;
|
||||
border-bottom: 1px solid var(--sz-line);
|
||||
}
|
||||
|
||||
.search-sheet__field-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.search-sheet__field-icon {
|
||||
display: flex;
|
||||
color: var(--sz-text-faint);
|
||||
}
|
||||
|
||||
.search-sheet__input {
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
font-family: inherit;
|
||||
font-size: 1.0625rem;
|
||||
letter-spacing: inherit;
|
||||
color: var(--sz-text);
|
||||
}
|
||||
|
||||
.search-sheet__input::placeholder {
|
||||
color: var(--sz-text-faint);
|
||||
}
|
||||
|
||||
.search-sheet__input:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.search-sheet__esc {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
height: 1.5rem;
|
||||
padding: 0 0.4375rem;
|
||||
border: 1px solid var(--sz-line-strong);
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
color: var(--sz-text-faint);
|
||||
}
|
||||
|
||||
.search-sheet__cancel {
|
||||
display: none;
|
||||
flex-shrink: 0;
|
||||
padding: 0 0.375rem;
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: var(--sz-text-sub);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.search-sheet__body {
|
||||
padding: 0.25rem 1.25rem 1.125rem;
|
||||
}
|
||||
|
||||
.search-sheet__label {
|
||||
margin: 0.875rem 0 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--sz-text-faint);
|
||||
}
|
||||
|
||||
.search-sheet__recent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 2.875rem;
|
||||
}
|
||||
|
||||
.search-sheet__recent-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.search-sheet__nickname {
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
color: var(--sz-text);
|
||||
}
|
||||
|
||||
.search-sheet__remove {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--sz-text-disabled);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.search-sheet__remove:hover {
|
||||
color: var(--sz-text-sub);
|
||||
}
|
||||
|
||||
.search-sheet__popular {
|
||||
display: grid;
|
||||
grid-template-columns: 1.25rem minmax(0, 1fr) 2.75rem;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
width: 100%;
|
||||
height: 2.75rem;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.search-sheet__rank {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.search-sheet__popular .search-sheet__nickname {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* ---------- 모바일 — 전체 화면 ---------- */
|
||||
@media (max-width: 47.9375rem) {
|
||||
.search-sheet__scrim {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.search-sheet__card {
|
||||
left: 0;
|
||||
top: 0;
|
||||
transform: none;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: none;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.search-sheet__field {
|
||||
height: 3.75rem;
|
||||
padding: 0 0.75rem 0 1rem;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
|
||||
/* 입력칸을 알약 바탕으로 감싸 헤더의 검색 자리처럼 보이게 한다 */
|
||||
.search-sheet__field-box {
|
||||
gap: 0.5rem;
|
||||
height: 2.625rem;
|
||||
padding: 0 0.875rem;
|
||||
border-radius: var(--sz-radius-pill);
|
||||
background: var(--sz-surface-button);
|
||||
}
|
||||
|
||||
.search-sheet__input {
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.search-sheet__esc {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.search-sheet__cancel {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.search-sheet__recent {
|
||||
height: 3rem;
|
||||
}
|
||||
|
||||
/* 모바일에서는 지우기 버튼을 44px 로 키운다 */
|
||||
.search-sheet__remove {
|
||||
width: var(--sz-tap-min);
|
||||
height: var(--sz-tap-min);
|
||||
margin-right: -0.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolveErrorMessage } from '@/composables/useApi'
|
||||
|
||||
// 에러 코드 ↔ 메시지는 세 플랫폼이 공유하는 계약이라 분기를 직접 검증한다
|
||||
describe('resolveErrorMessage', () => {
|
||||
it('서버가 내려준 에러 코드의 문장을 쓴다', () => {
|
||||
const error = { response: { status: 400, data: { success: false, error: { code: 'AUTH_003', message: '' } } } }
|
||||
|
||||
expect(resolveErrorMessage(error)).toBe('해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다.')
|
||||
})
|
||||
|
||||
it('에러 코드가 없으면 HTTP 상태로 코드를 정한다', () => {
|
||||
expect(resolveErrorMessage({ response: { status: 401 } })).toBe(
|
||||
'로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요.',
|
||||
)
|
||||
expect(resolveErrorMessage({ response: { status: 422 } })).toBe(
|
||||
'입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)',
|
||||
)
|
||||
expect(resolveErrorMessage({ response: { status: 404 } })).toBe(
|
||||
'요청하신 정보나 페이지를 찾을 수 없습니다.',
|
||||
)
|
||||
})
|
||||
|
||||
it('모르는 코드나 응답이 없는 에러는 SYS_001 문장으로 떨어진다', () => {
|
||||
const unknownCode = { response: { status: 500, data: { success: false, error: { code: 'NOPE_999', message: '' } } } }
|
||||
const fallbackMessage = '일시적인 시스템 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.'
|
||||
|
||||
expect(resolveErrorMessage(unknownCode)).toBe(fallbackMessage)
|
||||
expect(resolveErrorMessage(new Error('네트워크 끊김'))).toBe(fallbackMessage)
|
||||
expect(resolveErrorMessage(undefined)).toBe(fallbackMessage)
|
||||
})
|
||||
})
|
||||
@@ -14,6 +14,14 @@ export interface StandardResponse<T = unknown> {
|
||||
}
|
||||
}
|
||||
|
||||
// 요청 단위 확장 옵션 — 섹션 안에서 실패를 직접 그리는 화면(홈 등)이 전역 알림을 끄는 데 쓴다
|
||||
declare module 'axios' {
|
||||
export interface AxiosRequestConfig {
|
||||
/** true 면 전역 알림을 띄우지 않는다 — 호출한 쪽에서 문구를 직접 보여 줄 때 */
|
||||
silentError?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
// 에러 코드 사전 — CLAUDE.md / 백엔드 HttpExceptionFilter 와 1:1 매핑
|
||||
// SYS_001 은 폴백 메시지로 항상 존재함을 타입으로 보장 (noUncheckedIndexedAccess 대응)
|
||||
const ErrorCodeLexicon: Record<string, string> & { SYS_001: string } = {
|
||||
@@ -31,6 +39,41 @@ const showErrorUI = (message: string) => {
|
||||
window.alert(message)
|
||||
}
|
||||
|
||||
// 에러 코드 → 한국어 문장. 모르는 코드는 SYS_001 문장으로 떨어진다
|
||||
const messageOf = (code: string) => ErrorCodeLexicon[code] || ErrorCodeLexicon.SYS_001
|
||||
|
||||
/**
|
||||
* HTTP 상태 코드를 에러 코드로 옮긴다.
|
||||
* 서버가 error.code 를 내려 주면 그 값이 우선한다.
|
||||
*/
|
||||
const codeFromStatus = (status: number): string => {
|
||||
if (status === 401) return 'AUTH_001'
|
||||
if (status === 403) return 'AUTH_003'
|
||||
if (status === 400 || status === 422) return 'VAL_001'
|
||||
if (status === 404) return 'RES_001'
|
||||
return 'SYS_001'
|
||||
}
|
||||
|
||||
/**
|
||||
* resolveErrorMessage — 잡은 에러에서 사용자에게 보여 줄 문장을 꺼낸다.
|
||||
*
|
||||
* 화면에서 실패 문구를 새로 짓지 않기 위한 통로다. 실패 블록·폼 오류 등
|
||||
* 알림 대신 화면 안에 문구를 그리는 자리에서 쓴다.
|
||||
*/
|
||||
export function resolveErrorMessage(error: unknown): string {
|
||||
const candidate = error as
|
||||
| { code?: string; response?: { status?: number; data?: StandardResponse } }
|
||||
| undefined
|
||||
|
||||
const serverCode = candidate?.response?.data?.error?.code || candidate?.code
|
||||
if (typeof serverCode === 'string' && serverCode in ErrorCodeLexicon) {
|
||||
return messageOf(serverCode)
|
||||
}
|
||||
|
||||
const status = candidate?.response?.status
|
||||
return messageOf(typeof status === 'number' ? codeFromStatus(status) : 'SYS_001')
|
||||
}
|
||||
|
||||
// 중앙 Axios 인스턴스
|
||||
const api: AxiosInstance = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
|
||||
@@ -57,27 +100,18 @@ api.interceptors.response.use(
|
||||
}
|
||||
// HTTP 200 이지만 비즈니스 로직상 실패인 경우
|
||||
const errCode = payload.error?.code || 'BIZ_001'
|
||||
showErrorUI(ErrorCodeLexicon[errCode] || ErrorCodeLexicon.SYS_001)
|
||||
if (!response.config?.silentError) {
|
||||
showErrorUI(messageOf(errCode))
|
||||
}
|
||||
return Promise.reject(payload.error)
|
||||
}
|
||||
return payload as never
|
||||
},
|
||||
(error) => {
|
||||
let errCode = 'SYS_001'
|
||||
|
||||
if (error?.response) {
|
||||
const status: number = error.response.status
|
||||
const payload = error.response.data as StandardResponse | undefined
|
||||
|
||||
if (payload?.error?.code) {
|
||||
errCode = payload.error.code
|
||||
} else if (status === 401) errCode = 'AUTH_001'
|
||||
else if (status === 403) errCode = 'AUTH_003'
|
||||
else if (status === 400 || status === 422) errCode = 'VAL_001'
|
||||
else if (status === 404) errCode = 'RES_001'
|
||||
// silentError 요청은 호출한 쪽이 화면 안에서 직접 문구를 그린다
|
||||
if (!error?.config?.silentError) {
|
||||
showErrorUI(resolveErrorMessage(error))
|
||||
}
|
||||
|
||||
showErrorUI(ErrorCodeLexicon[errCode] || ErrorCodeLexicon.SYS_001)
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useSectionResource, type SectionResource } from '@/composables/useSectionResource'
|
||||
import * as fixtures from '@/mocks/home.fixtures'
|
||||
import type {
|
||||
CommunityPost,
|
||||
EventBanner,
|
||||
GameRanking,
|
||||
NewsItem,
|
||||
RecentSearchItem,
|
||||
SearchRankEntry,
|
||||
StreamChannel,
|
||||
} from '@/types/home'
|
||||
|
||||
/**
|
||||
* 홈 화면 데이터 — 섹션마다 따로 부른다.
|
||||
*
|
||||
* 아직 백엔드에 홈 API 가 없어서 임시 데이터를 돌려준다.
|
||||
* 연동할 때는 각 로더의 본문을 아래 한 줄로 바꾸면 된다 (주석의 예시 참고).
|
||||
* `silentError` 를 켜는 이유는, 실패를 전역 알림 대신 섹션 안의 실패 블록으로 보여 주기 때문이다.
|
||||
*/
|
||||
|
||||
// 임시 데이터를 비동기로 흉내 낸다 — 로딩 상태(스켈레톤)를 실제로 확인하기 위한 짧은 지연
|
||||
const MOCK_DELAY_MS = 400
|
||||
|
||||
function mock<T>(value: T): Promise<T> {
|
||||
return new Promise((resolve) => {
|
||||
window.setTimeout(() => resolve(value), MOCK_DELAY_MS)
|
||||
})
|
||||
}
|
||||
|
||||
/** 이벤트 배너 */
|
||||
export function useEventBanners(): SectionResource<EventBanner[]> {
|
||||
// 연동 시: return api.get<never, EventBanner[]>('/home/events', { silentError: true })
|
||||
return useSectionResource(() => mock(fixtures.eventBanners))
|
||||
}
|
||||
|
||||
/** 검색 랭킹 */
|
||||
export function useSearchRanking(): SectionResource<SearchRankEntry[]> {
|
||||
// 연동 시: return api.get<never, SearchRankEntry[]>('/home/search-ranking', { silentError: true })
|
||||
return useSectionResource(() => mock(fixtures.searchRanking))
|
||||
}
|
||||
|
||||
/** 게임 랭킹 — 계급 · 랭크전 · 클랜 세 갈래를 한 번에 받는다 */
|
||||
export function useGameRanking(): SectionResource<GameRanking> {
|
||||
// 연동 시: return api.get<never, GameRanking>('/home/game-ranking', { silentError: true })
|
||||
return useSectionResource(() => mock(fixtures.gameRanking), {
|
||||
// 세 갈래가 모두 비어 있을 때만 «비어 있음» 으로 본다
|
||||
isEmpty: (data) => data.tier.length === 0 && data.ranked.length === 0 && data.clan.length === 0,
|
||||
})
|
||||
}
|
||||
|
||||
/** 커뮤니티 인기 글 */
|
||||
export function useCommunityPosts(): SectionResource<CommunityPost[]> {
|
||||
// 연동 시: return api.get<never, CommunityPost[]>('/home/community', { silentError: true })
|
||||
return useSectionResource(() => mock(fixtures.communityPosts))
|
||||
}
|
||||
|
||||
/** 서든어택 소식 */
|
||||
export function useGameNews(): SectionResource<NewsItem[]> {
|
||||
// 연동 시: return api.get<never, NewsItem[]>('/home/news/game', { silentError: true })
|
||||
return useSectionResource(() => mock(fixtures.gameNews))
|
||||
}
|
||||
|
||||
/** 서린즈 소식 */
|
||||
export function useServiceNews(): SectionResource<NewsItem[]> {
|
||||
// 연동 시: return api.get<never, NewsItem[]>('/home/news/service', { silentError: true })
|
||||
return useSectionResource(() => mock(fixtures.serviceNews))
|
||||
}
|
||||
|
||||
/** 지금 방송 중인 채널 */
|
||||
export function useLiveStreams(): SectionResource<StreamChannel[]> {
|
||||
// 연동 시: return api.get<never, StreamChannel[]>('/home/streams', { silentError: true })
|
||||
return useSectionResource(() => mock(fixtures.liveStreams))
|
||||
}
|
||||
|
||||
/**
|
||||
* 최근 검색 기록 — 서버가 아니라 브라우저에 남는 값이라 섹션 상태를 두지 않는다.
|
||||
* 실제 저장소(localStorage) 연결은 검색 기능을 붙일 때 함께 처리한다.
|
||||
*/
|
||||
export function useRecentSearches(): RecentSearchItem[] {
|
||||
return fixtures.recentSearches
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { onBeforeUnmount, readonly, ref, type Ref } from 'vue'
|
||||
|
||||
/**
|
||||
* useMediaQuery — 미디어 쿼리 일치 여부를 반응형으로 돌려준다.
|
||||
*
|
||||
* CSS 로 해결되는 배치는 CSS 에서 처리하고,
|
||||
* 이 컴포저블은 «구조 자체가 달라지는» 경우(예: 게임 랭킹을 세 개 나란히 vs 하나씩)에만 쓴다.
|
||||
*/
|
||||
export function useMediaQuery(query: string): Readonly<Ref<boolean>> {
|
||||
const matches = ref(false)
|
||||
|
||||
// 서버 렌더링·테스트 환경에서는 matchMedia 가 없을 수 있다
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
|
||||
return readonly(matches)
|
||||
}
|
||||
|
||||
const mediaQuery = window.matchMedia(query)
|
||||
matches.value = mediaQuery.matches
|
||||
|
||||
const handleChange = (event: MediaQueryListEvent) => {
|
||||
matches.value = event.matches
|
||||
}
|
||||
|
||||
mediaQuery.addEventListener('change', handleChange)
|
||||
onBeforeUnmount(() => mediaQuery.removeEventListener('change', handleChange))
|
||||
|
||||
return readonly(matches)
|
||||
}
|
||||
|
||||
/** 서린즈 화면 분기점 — 아트보드 1440 / 768 / 390 에 맞춘 값 */
|
||||
export const BREAKPOINT = {
|
||||
/** 태블릿 이하 — 게임 랭킹을 갈래 세그먼트로 하나씩 보여 준다 */
|
||||
tabletDown: '(max-width: 74.9375rem)',
|
||||
/** 모바일 — 한 열 배치 */
|
||||
mobileDown: '(max-width: 47.9375rem)',
|
||||
} as const
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { useSectionResource } from '@/composables/useSectionResource'
|
||||
|
||||
describe('useSectionResource', () => {
|
||||
it('불러오기에 성공하면 ready 상태가 되고 데이터를 담는다', async () => {
|
||||
const resource = useSectionResource(() => Promise.resolve([1, 2, 3]))
|
||||
|
||||
await resource.load()
|
||||
|
||||
expect(resource.status.value).toBe('ready')
|
||||
expect(resource.data.value).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
it('빈 배열이면 empty 상태가 된다', async () => {
|
||||
const resource = useSectionResource<number[]>(() => Promise.resolve([]))
|
||||
|
||||
await resource.load()
|
||||
|
||||
expect(resource.status.value).toBe('empty')
|
||||
})
|
||||
|
||||
it('isEmpty 를 넘기면 그 판정으로 empty 를 정한다', async () => {
|
||||
const resource = useSectionResource(() => Promise.resolve({ items: [] }), {
|
||||
isEmpty: (data) => data.items.length === 0,
|
||||
})
|
||||
|
||||
await resource.load()
|
||||
|
||||
expect(resource.status.value).toBe('empty')
|
||||
})
|
||||
|
||||
it('실패하면 error 상태가 되고 에러 코드 맵의 문장을 담는다', async () => {
|
||||
const resource = useSectionResource(() => Promise.reject({ response: { status: 404 } }))
|
||||
|
||||
await resource.load()
|
||||
|
||||
expect(resource.status.value).toBe('error')
|
||||
expect(resource.errorMessage.value).toBe('요청하신 정보나 페이지를 찾을 수 없습니다.')
|
||||
expect(resource.data.value).toBeNull()
|
||||
})
|
||||
|
||||
it('다시 시도하면 실패했던 섹션이 다시 채워진다', async () => {
|
||||
let shouldFail = true
|
||||
const resource = useSectionResource(() =>
|
||||
shouldFail ? Promise.reject(new Error('일시 실패')) : Promise.resolve(['가']),
|
||||
)
|
||||
|
||||
await resource.load()
|
||||
expect(resource.status.value).toBe('error')
|
||||
|
||||
shouldFail = false
|
||||
await resource.reload()
|
||||
|
||||
expect(resource.status.value).toBe('ready')
|
||||
expect(resource.errorMessage.value).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
import { ref, shallowRef, type Ref, type ShallowRef } from 'vue'
|
||||
import { resolveErrorMessage } from '@/composables/useApi'
|
||||
|
||||
/**
|
||||
* 섹션 하나의 상태 — 홈은 섹션마다 따로 채우기 때문에
|
||||
* 한 섹션이 실패하거나 비어도 나머지는 그대로 보인다.
|
||||
*/
|
||||
export type SectionStatus = 'idle' | 'loading' | 'ready' | 'empty' | 'error'
|
||||
|
||||
export interface SectionResource<T> {
|
||||
data: ShallowRef<T | null>
|
||||
status: Ref<SectionStatus>
|
||||
/** 실패했을 때 화면에 그대로 쓸 문구 — 에러 코드 맵에서 온다 */
|
||||
errorMessage: Ref<string>
|
||||
/** 처음 불러오기 */
|
||||
load: () => Promise<void>
|
||||
/** 「다시 시도」 — 실패 블록의 버튼이 부른다 */
|
||||
reload: () => Promise<void>
|
||||
}
|
||||
|
||||
interface Options<T> {
|
||||
/** 결과가 «비어 있음» 인지 판단한다. 기본값은 빈 배열 판정 */
|
||||
isEmpty?: (data: T) => boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* useSectionResource — 홈 섹션 하나를 불러오고 로딩 · 성공 · 비어 있음 · 실패 네 상태를 관리한다.
|
||||
*
|
||||
* 실패 문구는 여기서 새로 짓지 않고 에러 코드 맵(useApi)의 문장을 그대로 쓴다.
|
||||
*/
|
||||
export function useSectionResource<T>(
|
||||
loader: () => Promise<T>,
|
||||
options: Options<T> = {},
|
||||
): SectionResource<T> {
|
||||
const data = shallowRef<T | null>(null)
|
||||
const status = ref<SectionStatus>('idle')
|
||||
const errorMessage = ref('')
|
||||
|
||||
const isEmpty = options.isEmpty ?? ((value: T) => Array.isArray(value) && value.length === 0)
|
||||
|
||||
async function load() {
|
||||
status.value = 'loading'
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const result = await loader()
|
||||
data.value = result
|
||||
status.value = isEmpty(result) ? 'empty' : 'ready'
|
||||
} catch (error: unknown) {
|
||||
data.value = null
|
||||
errorMessage.value = resolveErrorMessage(error)
|
||||
status.value = 'error'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data,
|
||||
status,
|
||||
errorMessage,
|
||||
load,
|
||||
// 다시 시도도 처음 불러오기와 같은 흐름을 탄다
|
||||
reload: load,
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
// 전역 스타일(디자인 토큰 + 기본값) 을 앱 코드보다 먼저 읽는다
|
||||
import '@/assets/styles/base.css'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* 홈 화면 임시 데이터 — .design/surinz 아트보드에 그려진 값을 그대로 옮긴 것.
|
||||
*
|
||||
* 백엔드에 홈 API 가 아직 없어서 화면을 먼저 세운다.
|
||||
* 실제 연동은 `composables/useHome.ts` 의 로더 한 줄만 바꾸면 되고,
|
||||
* 그때 이 파일은 지우거나 테스트 픽스처로만 남긴다.
|
||||
*/
|
||||
import type {
|
||||
ClanRankEntry,
|
||||
CommunityPost,
|
||||
EventBanner,
|
||||
GameRanking,
|
||||
NewsItem,
|
||||
RecentSearchItem,
|
||||
SearchRankEntry,
|
||||
StreamChannel,
|
||||
UserRankEntry,
|
||||
} from '@/types/home'
|
||||
|
||||
// 등락 값을 짧게 만드는 도우미 — 픽스처를 읽기 쉽게 하려는 용도
|
||||
const up = (amount: number) => ({ direction: 'up' as const, amount })
|
||||
const down = (amount: number) => ({ direction: 'down' as const, amount })
|
||||
const same = () => ({ direction: 'same' as const, amount: 0 })
|
||||
|
||||
export const recentSearches: RecentSearchItem[] = [
|
||||
{ nickname: '달빛조각사', avatarColor: '#DDE4EE' },
|
||||
{ nickname: '칼든햄스터', avatarColor: '#E5E0F0' },
|
||||
{ nickname: '새벽두시', avatarColor: '#E7E3D8' },
|
||||
{ nickname: '무명연대장', avatarColor: '#D8E6E2' },
|
||||
]
|
||||
|
||||
export const eventBanners: EventBanner[] = [
|
||||
{
|
||||
id: 'ev-1',
|
||||
category: '시즌 이벤트',
|
||||
title: '서든 페스티벌 같이 갈래?',
|
||||
period: '9월 1일 ~ 9월 30일',
|
||||
imageUrl: '/images/events/season-festival.jpg',
|
||||
},
|
||||
{
|
||||
id: 'ev-2',
|
||||
category: '출석 이벤트',
|
||||
title: '매일 접속하고 보급 상자 받아 가기',
|
||||
period: '9월 1일 ~ 9월 14일',
|
||||
placeholderColor: '#E4E8ED',
|
||||
},
|
||||
{
|
||||
id: 'ev-3',
|
||||
category: '클랜 이벤트',
|
||||
title: '클랜전 정기 리그 참가 모집',
|
||||
period: '9월 5일 ~ 9월 21일',
|
||||
placeholderColor: '#EAE6E0',
|
||||
},
|
||||
{
|
||||
id: 'ev-4',
|
||||
category: '신규 유저',
|
||||
title: '처음 오셨나요? 첫 주 지원 꾸러미',
|
||||
period: '상시',
|
||||
placeholderColor: '#E2E9E6',
|
||||
},
|
||||
{
|
||||
id: 'ev-5',
|
||||
category: '업데이트',
|
||||
title: '신규 맵 컨테이너 야드 체험 주간',
|
||||
period: '9월 8일 ~ 9월 15일',
|
||||
placeholderColor: '#E8E4EC',
|
||||
},
|
||||
]
|
||||
|
||||
export const searchRanking: SearchRankEntry[] = [
|
||||
{ rank: 1, nickname: '달빛조각사', movement: up(2) },
|
||||
{ rank: 2, nickname: '칼든햄스터', movement: down(1) },
|
||||
{ rank: 3, nickname: '새벽두시', movement: up(4) },
|
||||
{ rank: 4, nickname: '무명연대장', movement: same() },
|
||||
{ rank: 5, nickname: '한밤의저격수', movement: up(3) },
|
||||
{ rank: 6, nickname: '조용한총잡이', movement: down(2) },
|
||||
{ rank: 7, nickname: '야간부대장', movement: same() },
|
||||
{ rank: 8, nickname: '헤드샷장인', movement: up(5) },
|
||||
{ rank: 9, nickname: '삼보급단골', movement: same() },
|
||||
]
|
||||
|
||||
const tierRanking: UserRankEntry[] = [
|
||||
{ rank: 1, nickname: '달빛조각사', gradeShape: 'bar', movement: up(2) },
|
||||
{ rank: 2, nickname: '칼든햄스터', gradeShape: 'star', movement: down(1) },
|
||||
{ rank: 3, nickname: '새벽두시', gradeShape: 'bar', movement: up(4) },
|
||||
{ rank: 4, nickname: '무명연대장', gradeShape: 'diamond', movement: same() },
|
||||
{ rank: 5, nickname: '한밤의저격수', gradeShape: 'diamond', movement: up(3) },
|
||||
{ rank: 6, nickname: '조용한총잡이', gradeShape: 'bar', movement: down(2) },
|
||||
{ rank: 7, nickname: '야간부대장', gradeShape: 'diamond', movement: same() },
|
||||
{ rank: 8, nickname: '헤드샷장인', gradeShape: 'diamond', movement: up(5) },
|
||||
{ rank: 9, nickname: '삼보급단골', gradeShape: 'bar', movement: down(3) },
|
||||
{ rank: 10, nickname: '웨어하우스지박령', gradeShape: 'star', movement: up(1) },
|
||||
]
|
||||
|
||||
const rankedRanking: UserRankEntry[] = [
|
||||
{ rank: 1, nickname: '헤드샷장인', gradeShape: 'star', rp: 2486, movement: up(1) },
|
||||
{ rank: 2, nickname: '달빛조각사', gradeShape: 'bar', rp: 2451, movement: down(1) },
|
||||
{ rank: 3, nickname: '삼보급단골', gradeShape: 'diamond', rp: 2398, movement: up(6) },
|
||||
{ rank: 4, nickname: '칼든햄스터', gradeShape: 'star', rp: 2344, movement: same() },
|
||||
{ rank: 5, nickname: '웨어하우스지박령', gradeShape: 'diamond', rp: 2301, movement: up(2) },
|
||||
{ rank: 6, nickname: '새벽두시', gradeShape: 'bar', rp: 2276, movement: down(3) },
|
||||
{ rank: 7, nickname: '조용한총잡이', gradeShape: 'diamond', rp: 2240, movement: same() },
|
||||
{ rank: 8, nickname: '한밤의저격수', gradeShape: 'diamond', rp: 2205, movement: up(4) },
|
||||
{ rank: 9, nickname: '야간부대장', gradeShape: 'bar', rp: 2181, movement: down(2) },
|
||||
{ rank: 10, nickname: '무명연대장', gradeShape: 'diamond', rp: 2154, movement: up(1) },
|
||||
]
|
||||
|
||||
const clanRanking: ClanRankEntry[] = [
|
||||
{ rank: 1, clanName: '무명연대', movement: same() },
|
||||
{ rank: 2, clanName: '새벽클랜', movement: up(2) },
|
||||
{ rank: 3, clanName: '야간부대', movement: down(1) },
|
||||
{ rank: 4, clanName: '정예사격단', movement: up(3) },
|
||||
{ rank: 5, clanName: '삼보급수호대', movement: same() },
|
||||
{ rank: 6, clanName: '헤드샷연구소', movement: up(4) },
|
||||
{ rank: 7, clanName: '크로스카운터', movement: down(2) },
|
||||
{ rank: 8, clanName: '웨어하우스단', movement: same() },
|
||||
{ rank: 9, clanName: '새벽정찰대', movement: up(1) },
|
||||
{ rank: 10, clanName: '삼보급기동대', movement: down(3) },
|
||||
]
|
||||
|
||||
export const gameRanking: GameRanking = {
|
||||
tier: tierRanking,
|
||||
ranked: rankedRanking,
|
||||
clan: clanRanking,
|
||||
totalCount: 100,
|
||||
pageSize: 10,
|
||||
}
|
||||
|
||||
export const communityPosts: CommunityPost[] = [
|
||||
{
|
||||
id: 'cp-1',
|
||||
kind: 'photo',
|
||||
placeholderColor: '#E4E8ED',
|
||||
authorNickname: '달빛조각사',
|
||||
authorAvatarColor: '#C9D2DD',
|
||||
reactionCount: 312,
|
||||
},
|
||||
{
|
||||
id: 'cp-2',
|
||||
kind: 'album',
|
||||
placeholderColor: '#EAE6E0',
|
||||
authorNickname: '칼든햄스터',
|
||||
authorAvatarColor: '#D8D2C8',
|
||||
reactionCount: 274,
|
||||
},
|
||||
{
|
||||
id: 'cp-3',
|
||||
kind: 'text',
|
||||
excerpt: '폭파 삼보급 A 롱각, 다들 어디 서세요?',
|
||||
placeholderColor: '#F5F6F8',
|
||||
authorNickname: '새벽두시',
|
||||
authorAvatarColor: '#DDE4EE',
|
||||
reactionCount: 198,
|
||||
},
|
||||
{
|
||||
id: 'cp-4',
|
||||
kind: 'clip',
|
||||
placeholderColor: '#E8E4EC',
|
||||
authorNickname: '무명연대장',
|
||||
authorAvatarColor: '#D5CEDC',
|
||||
reactionCount: 163,
|
||||
},
|
||||
]
|
||||
|
||||
export const gameNews: NewsItem[] = [
|
||||
{ id: 'gn-1', title: '9월 정기점검 안내', date: '09.02', isNew: true },
|
||||
{ id: 'gn-2', title: '비매너 이용자 제재 결과', date: '09.01', isNew: true },
|
||||
{ id: 'gn-3', title: '가을 시즌 랭크전 일정', date: '08.29', isNew: false },
|
||||
{ id: 'gn-4', title: '신규 맵 컨테이너 야드 추가', date: '08.28', isNew: false },
|
||||
{ id: 'gn-5', title: 'AK-47 반동 수치 조정', date: '08.26', isNew: false },
|
||||
{ id: 'gn-6', title: '클랜전 매칭 로직 개선', date: '08.22', isNew: false },
|
||||
]
|
||||
|
||||
export const serviceNews: NewsItem[] = [
|
||||
{ id: 'sn-1', title: '맵별 승률 통계를 열었어요', date: '09.03', isNew: true },
|
||||
{ id: 'sn-2', title: '전적 갱신이 두 배 빨라졌어요', date: '09.01', isNew: true },
|
||||
{ id: 'sn-3', title: '즐겨찾기 알림 기능 추가', date: '08.30', isNew: false },
|
||||
{ id: 'sn-4', title: '8월 서버 점검 결과 보고', date: '08.27', isNew: false },
|
||||
{ id: 'sn-5', title: '커뮤니티 이용 규칙 정리', date: '08.24', isNew: false },
|
||||
{ id: 'sn-6', title: '모바일 화면을 다듬었어요', date: '08.20', isNew: false },
|
||||
]
|
||||
|
||||
export const liveStreams: StreamChannel[] = [
|
||||
{
|
||||
id: 'st-1',
|
||||
title: '삼보급 스나 연습',
|
||||
streamerName: '달빛조각사',
|
||||
viewerCount: 1204,
|
||||
placeholderColor: '#E4E8ED',
|
||||
streamerAvatarColor: '#DDE4EE',
|
||||
},
|
||||
{
|
||||
id: 'st-2',
|
||||
title: '클랜전 정기 리그 중계',
|
||||
streamerName: '무명연대',
|
||||
viewerCount: 862,
|
||||
placeholderColor: '#EAE6E0',
|
||||
streamerAvatarColor: '#D8E6E2',
|
||||
},
|
||||
{
|
||||
id: 'st-3',
|
||||
title: '초보 탈출 폭파미션 강의',
|
||||
streamerName: '새벽두시',
|
||||
viewerCount: 517,
|
||||
placeholderColor: '#E2E9E6',
|
||||
streamerAvatarColor: '#E7E3D8',
|
||||
},
|
||||
{
|
||||
id: 'st-4',
|
||||
title: '시청자 참여 한 판',
|
||||
streamerName: '한밤의저격수',
|
||||
viewerCount: 341,
|
||||
placeholderColor: '#E8E4EC',
|
||||
streamerAvatarColor: '#E5E0F0',
|
||||
},
|
||||
]
|
||||
+225
-17
@@ -1,27 +1,235 @@
|
||||
<script setup lang="ts">
|
||||
// 홈 페이지 — 템플릿 기본 진입점
|
||||
import { ref } from 'vue'
|
||||
// 서린즈 홈 — 최상단 검색 히어로 + 밀도 그리드.
|
||||
//
|
||||
// 배치는 폭에 따라 세 가지다.
|
||||
// · 데스크톱(1200~) : 3열. 이벤트와 게임 랭킹이 두 칸씩 차지한다
|
||||
// · 태블릿(768~1199): 2열. 커뮤니티를 검색 랭킹 옆으로 올려 빈 칸을 없앤다
|
||||
// · 모바일(~767) : 1열
|
||||
//
|
||||
// 섹션은 각자 따로 채운다 — 한 섹션이 실패하거나 비어도 나머지는 그대로 보인다.
|
||||
import { onMounted, onBeforeUnmount, ref } from 'vue'
|
||||
import AppFooter from '@/components/layout/AppFooter.vue'
|
||||
import AppHeader from '@/components/layout/AppHeader.vue'
|
||||
import SearchHero from '@/components/layout/SearchHero.vue'
|
||||
import SearchSheet from '@/components/layout/SearchSheet.vue'
|
||||
import CommunitySection from '@/components/home/CommunitySection.vue'
|
||||
import EventSection from '@/components/home/EventSection.vue'
|
||||
import GameRankingSection from '@/components/home/GameRankingSection.vue'
|
||||
import NewsSection from '@/components/home/NewsSection.vue'
|
||||
import SearchRankingSection from '@/components/home/SearchRankingSection.vue'
|
||||
import StreamingSection from '@/components/home/StreamingSection.vue'
|
||||
import {
|
||||
useCommunityPosts,
|
||||
useEventBanners,
|
||||
useGameNews,
|
||||
useGameRanking,
|
||||
useLiveStreams,
|
||||
useRecentSearches,
|
||||
useSearchRanking,
|
||||
useServiceNews,
|
||||
} from '@/composables/useHome'
|
||||
|
||||
const message = ref<string>('You did it!')
|
||||
const events = useEventBanners()
|
||||
const searchRanking = useSearchRanking()
|
||||
const gameRanking = useGameRanking()
|
||||
const community = useCommunityPosts()
|
||||
const gameNews = useGameNews()
|
||||
const serviceNews = useServiceNews()
|
||||
const streams = useLiveStreams()
|
||||
|
||||
const recentSearches = ref(useRecentSearches())
|
||||
|
||||
// 헤더 검색 시트
|
||||
const isSearchSheetOpen = ref(false)
|
||||
|
||||
// 히어로 검색창이 화면 밖으로 나갔는지 — 헤더의 돋보기와 그림자를 함께 켠다
|
||||
const heroRef = ref<InstanceType<typeof SearchHero> | null>(null)
|
||||
const isHeroOut = ref(false)
|
||||
let heroObserver: IntersectionObserver | null = null
|
||||
|
||||
function handleSearch(nickname: string) {
|
||||
isSearchSheetOpen.value = false
|
||||
// 전적 검색 화면이 생기면 router.push({ name: 'record', params: { nickname } }) 로 바꾼다
|
||||
window.console.info('전적 검색:', nickname)
|
||||
}
|
||||
|
||||
function removeRecentSearch(nickname: string) {
|
||||
recentSearches.value = recentSearches.value.filter((item) => item.nickname !== nickname)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 섹션마다 따로 부른다 — 하나가 늦어도 나머지는 먼저 그려진다
|
||||
events.load()
|
||||
searchRanking.load()
|
||||
gameRanking.load()
|
||||
community.load()
|
||||
gameNews.load()
|
||||
serviceNews.load()
|
||||
streams.load()
|
||||
|
||||
const heroElement = heroRef.value?.$el as HTMLElement | undefined
|
||||
if (!heroElement || typeof IntersectionObserver === 'undefined') return
|
||||
|
||||
heroObserver = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
isHeroOut.value = entry !== undefined && !entry.isIntersecting
|
||||
},
|
||||
// 헤더 높이만큼 위를 잘라내고 판단한다
|
||||
{ rootMargin: '-72px 0px 0px 0px' },
|
||||
)
|
||||
heroObserver.observe(heroElement)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => heroObserver?.disconnect())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="home">
|
||||
<h1>{{ message }}</h1>
|
||||
<p>
|
||||
Visit
|
||||
<a
|
||||
href="https://vuejs.org/"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>vuejs.org</a>
|
||||
to read the documentation
|
||||
</p>
|
||||
</main>
|
||||
<div class="home">
|
||||
<AppHeader
|
||||
:search-visible="isHeroOut"
|
||||
current-label="홈"
|
||||
@open-search="isSearchSheetOpen = true"
|
||||
/>
|
||||
|
||||
<SearchHero
|
||||
ref="heroRef"
|
||||
:recent-searches="recentSearches"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
|
||||
<main class="home__grid">
|
||||
<EventSection
|
||||
class="home__cell home__cell--wide home__cell--event"
|
||||
:resource="events"
|
||||
/>
|
||||
<SearchRankingSection
|
||||
class="home__cell home__cell--search-rank"
|
||||
:resource="searchRanking"
|
||||
/>
|
||||
<GameRankingSection
|
||||
class="home__cell home__cell--wide home__cell--game-rank"
|
||||
:resource="gameRanking"
|
||||
/>
|
||||
<CommunitySection
|
||||
class="home__cell home__cell--community"
|
||||
:resource="community"
|
||||
/>
|
||||
<NewsSection
|
||||
class="home__cell home__cell--game-news"
|
||||
title="서든어택 소식"
|
||||
:resource="gameNews"
|
||||
/>
|
||||
<NewsSection
|
||||
class="home__cell home__cell--service-news"
|
||||
title="서린즈 소식"
|
||||
:resource="serviceNews"
|
||||
/>
|
||||
<StreamingSection
|
||||
class="home__cell home__cell--streaming"
|
||||
:resource="streams"
|
||||
/>
|
||||
</main>
|
||||
|
||||
<AppFooter />
|
||||
|
||||
<SearchSheet
|
||||
:open="isSearchSheetOpen"
|
||||
:recent-searches="recentSearches"
|
||||
:popular="searchRanking.data.value ?? []"
|
||||
@close="isSearchSheetOpen = false"
|
||||
@search="handleSearch"
|
||||
@remove-recent="removeRecentSearch"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.home {
|
||||
padding: 2rem;
|
||||
/* ---------- 데스크톱 — 균등 3열 ---------- */
|
||||
.home__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: var(--sz-section-gap) 2rem;
|
||||
padding: 2.25rem var(--sz-page-pad) 3.5rem;
|
||||
}
|
||||
|
||||
.home__cell--wide {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
/* ---------- 태블릿 — 2열. 커뮤니티를 검색 랭킹 옆으로 올려 빈 칸을 없앤다 ---------- */
|
||||
@media (max-width: 74.9375rem) {
|
||||
.home__grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 2.25rem 1.5rem;
|
||||
padding: 1.75rem var(--sz-page-pad-tablet) 3rem;
|
||||
}
|
||||
|
||||
.home__cell--event {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.home__cell--search-rank {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.home__cell--community {
|
||||
order: 3;
|
||||
}
|
||||
|
||||
.home__cell--game-rank {
|
||||
order: 4;
|
||||
}
|
||||
|
||||
.home__cell--game-news {
|
||||
order: 5;
|
||||
}
|
||||
|
||||
.home__cell--service-news {
|
||||
order: 6;
|
||||
}
|
||||
|
||||
/* 방송은 두 칸을 써서 큰 방송과 목록을 나란히 둔다 */
|
||||
.home__cell--streaming {
|
||||
order: 7;
|
||||
grid-column: span 2;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 모바일 — 한 열. 순서는 데스크톱과 같다 ---------- */
|
||||
@media (max-width: 47.9375rem) {
|
||||
.home__grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sz-section-gap-mobile);
|
||||
padding: 1.5rem var(--sz-page-pad-mobile) 2.5rem;
|
||||
}
|
||||
|
||||
.home__cell--event {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.home__cell--search-rank {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.home__cell--game-rank {
|
||||
order: 3;
|
||||
}
|
||||
|
||||
.home__cell--community {
|
||||
order: 4;
|
||||
}
|
||||
|
||||
.home__cell--game-news {
|
||||
order: 5;
|
||||
}
|
||||
|
||||
.home__cell--service-news {
|
||||
order: 6;
|
||||
}
|
||||
|
||||
.home__cell--streaming {
|
||||
order: 7;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -18,3 +18,21 @@ export function formatDate(input: Date | string): string {
|
||||
const dd = String(date.getDate()).padStart(2, '0')
|
||||
return `${yyyy}-${mm}-${dd}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Date 를 'HH:mm' 형식으로 변환 (예: 랭킹 집계 기준 시각)
|
||||
*/
|
||||
export function formatClockTime(input: Date | string): string {
|
||||
const date = typeof input === 'string' ? new Date(input) : input
|
||||
if (Number.isNaN(date.getTime())) return ''
|
||||
const hh = String(date.getHours()).padStart(2, '0')
|
||||
const mm = String(date.getMinutes()).padStart(2, '0')
|
||||
return `${hh}:${mm}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 숫자에 천 단위 구분을 넣는다 (예: 1204 → "1,204")
|
||||
*/
|
||||
export function formatCount(value: number): string {
|
||||
return value.toLocaleString('ko-KR')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* 홈 화면 도메인 타입 — .design/surinz 홈 아트보드(Main · Tablet · Mobile)의 화면 요소를 그대로 옮긴 것.
|
||||
* 백엔드 응답 스키마가 확정되면 이 파일만 맞추면 된다.
|
||||
*/
|
||||
|
||||
/** 등락 방향 — 상승 / 하락 / 변동 없음 */
|
||||
export type MovementDirection = 'up' | 'down' | 'same'
|
||||
|
||||
/** 순위 등락 — 변동 없음이면 amount 는 0 */
|
||||
export interface RankMovement {
|
||||
direction: MovementDirection
|
||||
amount: number
|
||||
}
|
||||
|
||||
/** 계급 자리표시 종류 — 실제 계급 이미지가 없을 때 그리는 약식 표장 */
|
||||
export type RankGradeShape = 'bar' | 'star' | 'diamond'
|
||||
|
||||
/** 이벤트 배너 */
|
||||
export interface EventBanner {
|
||||
id: string
|
||||
/** 이벤트 분류 (예: 시즌 이벤트) */
|
||||
category: string
|
||||
title: string
|
||||
/** 노출 기간 문구 */
|
||||
period: string
|
||||
/** 배너 이미지. 없으면 자리표시 색을 쓴다 */
|
||||
imageUrl?: string
|
||||
/** 이미지가 없을 때 채울 자리표시 색 */
|
||||
placeholderColor?: string
|
||||
}
|
||||
|
||||
/** 검색 랭킹 한 줄 */
|
||||
export interface SearchRankEntry {
|
||||
rank: number
|
||||
nickname: string
|
||||
movement: RankMovement
|
||||
}
|
||||
|
||||
/** 계급 · 랭크전 랭킹 한 줄 */
|
||||
export interface UserRankEntry {
|
||||
rank: number
|
||||
nickname: string
|
||||
/** 계급 이미지. 없으면 gradeShape 로 약식 표장을 그린다 */
|
||||
gradeImageUrl?: string
|
||||
gradeShape: RankGradeShape
|
||||
/**
|
||||
* 랭크전 티어 이미지 — 랭크전 랭킹에만 있다 (RP 와 한 칸에 붙는다).
|
||||
* 없으면 육각 자리표시를 그린다.
|
||||
*/
|
||||
tierImageUrl?: string
|
||||
/** 랭크전 랭킹에만 있는 RP 점수 */
|
||||
rp?: number
|
||||
movement: RankMovement
|
||||
}
|
||||
|
||||
/** 클랜 랭킹 한 줄 */
|
||||
export interface ClanRankEntry {
|
||||
rank: number
|
||||
clanName: string
|
||||
/** 클랜 마크 이미지. 없으면 방패 자리표시를 그린다 */
|
||||
markImageUrl?: string
|
||||
movement: RankMovement
|
||||
}
|
||||
|
||||
/** 게임 랭킹 갈래 — 세 갈래를 나란히 두거나(데스크톱) 하나씩 고른다(태블릿·모바일) */
|
||||
export type GameRankBranch = 'tier' | 'ranked' | 'clan'
|
||||
|
||||
/** 갈래 안의 하위 탭 (예: 통합 / 시즌) */
|
||||
export interface RankTabOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
/** 게임 랭킹 묶음 — 세 갈래가 같은 순위 구간을 함께 본다 */
|
||||
export interface GameRanking {
|
||||
tier: UserRankEntry[]
|
||||
ranked: UserRankEntry[]
|
||||
clan: ClanRankEntry[]
|
||||
/** 전체 순위 수 — 구간 이동(페이지네이션) 계산에 쓴다 */
|
||||
totalCount: number
|
||||
/** 한 구간에 보여 주는 순위 수 */
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
/** 커뮤니티 글 종류 — 사진 / 글 / 클립(영상) / 여러 장 */
|
||||
export type CommunityPostKind = 'photo' | 'text' | 'clip' | 'album'
|
||||
|
||||
/** 커뮤니티 글 */
|
||||
export interface CommunityPost {
|
||||
id: string
|
||||
kind: CommunityPostKind
|
||||
/** 글 종류일 때 카드에 그대로 보이는 본문 */
|
||||
excerpt?: string
|
||||
thumbnailUrl?: string
|
||||
/** 썸네일이 없을 때 채울 자리표시 색 */
|
||||
placeholderColor: string
|
||||
authorNickname: string
|
||||
authorAvatarUrl?: string
|
||||
/** 프로필 이미지가 없을 때 채울 자리표시 색 */
|
||||
authorAvatarColor: string
|
||||
reactionCount: number
|
||||
}
|
||||
|
||||
/** 소식 한 줄 (서든어택 · 서린즈 공통) */
|
||||
export interface NewsItem {
|
||||
id: string
|
||||
title: string
|
||||
/** 노출용 날짜 문구 (MM.DD) */
|
||||
date: string
|
||||
isNew: boolean
|
||||
}
|
||||
|
||||
/** 방송 중인 채널 */
|
||||
export interface StreamChannel {
|
||||
id: string
|
||||
title: string
|
||||
streamerName: string
|
||||
viewerCount: number
|
||||
thumbnailUrl?: string
|
||||
placeholderColor: string
|
||||
streamerAvatarUrl?: string
|
||||
streamerAvatarColor: string
|
||||
}
|
||||
|
||||
/** 최근 검색 기록 칩 */
|
||||
export interface RecentSearchItem {
|
||||
nickname: string
|
||||
avatarUrl?: string
|
||||
avatarColor: string
|
||||
}
|
||||
Reference in New Issue
Block a user