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:
2026-09-05 16:43:13 +09:00
parent 049bc5e2cf
commit e5de79a669
41 changed files with 5156 additions and 34 deletions
+10 -2
View File
@@ -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

+64
View File
@@ -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;
}
+67
View File
@@ -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>
+160
View File
@@ -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>
+32
View File
@@ -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)
})
})
+49 -15
View File
@@ -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)
},
)
+81
View File
@@ -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
}
+36
View File
@@ -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,
}
}
+3
View File
@@ -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'
+216
View File
@@ -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
View File
@@ -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
View File
@@ -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')
}
+130
View File
@@ -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
}