first commit

This commit is contained in:
2026-06-11 18:19:26 +09:00
commit eaa30192cf
291 changed files with 47278 additions and 0 deletions
+48
View File
@@ -0,0 +1,48 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
.eslintcache
# Cypress
/cypress/videos/
/cypress/screenshots/
# Vitest
__screenshots__/
# Vite
*.timestamp-*-*.mjs
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
.env.development
.env.production
+5
View File
@@ -0,0 +1,5 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"semi": false,
"singleQuote": true
}
+7
View File
@@ -0,0 +1,7 @@
{
"recommendations": [
"Vue.volar",
"vitest.explorer",
"oxc.oxc-vscode"
]
}
+26
View File
@@ -0,0 +1,26 @@
FROM node:20-alpine AS build-stage
WORKDIR /app
# 호스트의 npm 11 이 작성한 package-lock.json 형식과 호환되도록 npm 을 11 로 업그레이드
RUN npm install -g npm@11
COPY package*.json ./
RUN npm ci
COPY . .
ARG BUILD_MODE=production
ARG VITE_API_BASE_URL
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
RUN npm run build-only -- --mode ${BUILD_MODE}
FROM nginx:stable-alpine AS production-stage
# 보안 헤더 포함된 nginx 설정 적용
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build-stage /app/dist /usr/share/nginx/html
# 비-root 실행은 nginx-unprivileged 이미지 도입 시 활성화 (현재는 80 포트 바인드 필요)
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+48
View File
@@ -0,0 +1,48 @@
# frontend
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
## Recommended Browser Setup
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
- Firefox:
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
## Type Support for `.vue` Imports in TS
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
## Customize configuration
See [Vite Configuration Reference](https://vite.dev/config/).
## Project Setup
```sh
npm install
```
### Compile and Hot-Reload for Development
```sh
npm run dev
```
### Type-Check, Compile and Minify for Production
```sh
npm run build
```
### Run Unit Tests with [Vitest](https://vitest.dev/)
```sh
npm run test:unit
```
+10
View File
@@ -0,0 +1,10 @@
/// <reference types="vite/client" />
// Vite 환경변수 타입 정의 — VITE_ prefix 만 클라이언트 번들에 포함됨
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
+17
View File
@@ -0,0 +1,17 @@
import pluginVue from 'eslint-plugin-vue'
import vueTsEslintConfig from '@vue/eslint-config-typescript'
// Flat config — Vue 3 + TypeScript 표준 권장 룰
export default [
...pluginVue.configs['flat/recommended'],
...vueTsEslintConfig(),
{
rules: {
// 컴포넌트 단어 수 강제 해제 (HomePage 등 단일 컴포넌트도 허용)
'vue/multi-word-component-names': 'off',
},
},
{
ignores: ['dist/**', 'node_modules/**', 'coverage/**'],
},
]
+27
View File
@@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<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="서린즈 — 서든어택 전적검색 서비스" />
<!-- 본문 폰트: Pretendard / 숫자·라벨 폰트: Chakra Petch -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Chakra+Petch:wght@500;600;700&display=swap"
rel="stylesheet"
/>
<link
rel="stylesheet"
as="style"
crossorigin
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css"
/>
<title>서린즈 · 서든어택 전적검색</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+48
View File
@@ -0,0 +1,48 @@
server {
listen 80;
server_name localhost;
# ----------------------------------------
# 보안 헤더
# ----------------------------------------
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
# ----------------------------------------
# 압축 (Gzip)
# ----------------------------------------
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/javascript application/javascript application/json application/xml image/svg+xml;
# ----------------------------------------
# SPA 라우팅
# ----------------------------------------
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
# 정적 자원 캐싱 (SPA 빌드 산출물은 해시 파일명이라 장기 캐싱 가능)
location ~* \.(?:js|css|woff2?|ttf|svg|png|jpg|jpeg|gif|ico)$ {
expires 30d;
add_header Cache-Control "public, immutable";
}
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
# 숨김 파일 접근 차단
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
}
+7065
View File
File diff suppressed because it is too large Load Diff
+45
View File
@@ -0,0 +1,45 @@
{
"name": "frontend",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"test:unit": "vitest",
"build-only": "vite build",
"type-check": "vue-tsc --build",
"lint": "eslint . --fix",
"format": "oxfmt src/"
},
"dependencies": {
"axios": "^1.14.0",
"pinia": "^3.0.4",
"vue": "^3.5.31",
"vue-router": "^4.4.5"
},
"devDependencies": {
"@tsconfig/node24": "^24.0.4",
"@types/jsdom": "^28.0.1",
"@types/node": "^24.12.0",
"@vitejs/plugin-vue": "^6.0.5",
"@vitejs/plugin-vue-jsx": "^5.1.5",
"@vue/eslint-config-typescript": "^14.2.0",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.9.1",
"eslint": "^9.18.0",
"eslint-plugin-vue": "^9.32.0",
"jsdom": "^29.0.1",
"npm-run-all2": "^8.0.4",
"oxfmt": "^0.42.0",
"typescript": "~6.0.0",
"vite": "^8.0.3",
"vite-plugin-vue-devtools": "^8.1.1",
"vitest": "^4.1.2",
"vue-tsc": "^3.2.6"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+36
View File
@@ -0,0 +1,36 @@
<script setup lang="ts">
// 앱 셸 — 상단 유틸바 + GNB + 라우터 뷰 + 푸터 + 전역 모달(문의/로그인)
import { RouterView } from 'vue-router'
import TopBar from '@/shared/components/layout/TopBar.vue'
import AppNav from '@/shared/components/layout/AppNav.vue'
import AppFooter from '@/shared/components/layout/AppFooter.vue'
import FeedbackWidget from '@/shared/components/FeedbackWidget.vue'
import LoginModal from '@/shared/components/LoginModal.vue'
</script>
<template>
<div class="shell">
<TopBar />
<AppNav />
<main class="shell__main">
<RouterView />
</main>
<AppFooter />
<!-- 전역 오버레이 -->
<FeedbackWidget />
<LoginModal />
</div>
</template>
<style scoped>
.shell {
display: flex;
flex-direction: column;
min-height: 100vh;
background: var(--bg2);
}
.shell__main {
flex: 1;
}
</style>
+93
View File
@@ -0,0 +1,93 @@
import axios, {
type AxiosInstance,
type AxiosResponse,
type InternalAxiosRequestConfig,
} from 'axios'
// 표준 응답 포맷 타입 (Global Response Wrapper) — 백엔드 TransformInterceptor와 동일 구조
export interface StandardResponse<T = unknown> {
success: boolean
data?: T
error?: {
code: string
message: string
}
}
// 에러 코드 사전 — CLAUDE.md / 백엔드 HttpExceptionFilter 와 1:1 매핑
const ErrorCodeLexicon: Record<string, string> = {
SYS_001: '일시적인 시스템 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.',
AUTH_001: '로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요.',
AUTH_002: '안전을 위해 로그아웃 되었습니다. 다시 로그인해 주세요.',
AUTH_003: '해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다.',
VAL_001: '입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)',
RES_001: '요청하신 정보나 페이지를 찾을 수 없습니다.',
BIZ_001: '요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요.',
}
// 사용자 노출용 알림 — Toast 라이브러리 도입 시 이 함수만 교체하면 됨
const showErrorUI = (message: string) => {
window.alert(message)
}
// 중앙 Axios 인스턴스
const api: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
timeout: 10000,
// HttpOnly/Secure 쿠키 인증 시 자격 증명을 함께 전송
withCredentials: true,
})
// 요청 인터셉터 — CSRF, 추가 헤더 등 필요 시 확장
api.interceptors.request.use(
(config: InternalAxiosRequestConfig) => config,
(error: unknown) => Promise.reject(error),
)
// 응답 인터셉터 — Global Response Wrapper 언래핑 + 에러코드 → 메시지 매핑
api.interceptors.response.use(
(response: AxiosResponse<StandardResponse>) => {
const payload = response.data
if (payload && typeof payload.success === 'boolean') {
if (payload.success) {
// 성공 시 data 만 언래핑하여 반환
return payload.data as never
}
// HTTP 200 이지만 비즈니스 로직상 실패인 경우
const errCode = payload.error?.code || 'BIZ_001'
showErrorUI(ErrorCodeLexicon[errCode] ?? ErrorCodeLexicon.SYS_001 ?? '일시적인 시스템 오류가 발생했습니다.')
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'
}
showErrorUI(ErrorCodeLexicon[errCode] ?? ErrorCodeLexicon.SYS_001 ?? '일시적인 시스템 오류가 발생했습니다.')
return Promise.reject(error)
},
)
/**
* useApi — 컴포넌트에서 axios 인스턴스를 가져올 때 사용하는 composable
* 직접 import 대신 useApi() 사용을 권장 (테스트 시 모킹 용이)
*/
export function useApi() {
return api
}
export default api
+35
View File
@@ -0,0 +1,35 @@
import { ref } from 'vue'
import { useApi } from './useApi'
// 진행중 이벤트 (백엔드 /api/events/ongoing 크롤링 결과)
export interface OngoingEvent {
title: string
sub: string
image: string
period: string
link: string
dday: string
tag: string
}
export function useEvents() {
const api = useApi()
const loading = ref<boolean>(false)
const error = ref<string | null>(null)
async function getOngoing(): Promise<OngoingEvent[]> {
loading.value = true
error.value = null
try {
// 인터셉터에서 success/data 언래핑 후 배열만 반환
return (await api.get<OngoingEvent[]>('/events/ongoing')) as unknown as OngoingEvent[]
} catch (e) {
error.value = (e as Error)?.message ?? 'unknown error'
return []
} finally {
loading.value = false
}
}
return { loading, error, getOngoing }
}
+31
View File
@@ -0,0 +1,31 @@
import { ref } from 'vue'
import { useApi } from './useApi'
// 도메인별 API Composable 샘플 — 실제 도메인이 추가되면 이 패턴을 복제
export interface User {
id: number
email: string
name: string
}
export function useUser() {
const api = useApi()
const loading = ref<boolean>(false)
const error = ref<string | null>(null)
async function getUser(id: number): Promise<User | null> {
loading.value = true
error.value = null
try {
// 인터셉터에서 success/data 언래핑 후 데이터만 반환
return (await api.get<User>(`/users/${id}`)) as unknown as User
} catch (e) {
error.value = (e as Error)?.message ?? 'unknown error'
return null
} finally {
loading.value = false
}
}
return { loading, error, getUser }
}
+18
View File
@@ -0,0 +1,18 @@
import './shared/styles/base.css'
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import { useThemeStore } from './stores/theme.store'
const app = createApp(App)
app.use(createPinia())
app.use(router)
// 저장된 포인트 컬러 팔레트 복원 후 마운트
useThemeStore().init()
app.mount('#app')
+337
View File
@@ -0,0 +1,337 @@
<script setup lang="ts">
// 클랜 홈 — 배너 + 스탯 스트립 + 소개 + 2단 섹션. sa-clan.jsx ClanHome 포팅.
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { SA } from '@/shared/data/sa'
import { ClanDB } from '@/shared/data/clan'
import AppIcon from '@/shared/components/AppIcon.vue'
import ImageSlot from '@/shared/components/ImageSlot.vue'
import ClanEmblem from '@/shared/components/ClanEmblem.vue'
import ClanNotices from '@/pages/clan/ClanNotices.vue'
import ClanFeed from '@/pages/clan/ClanFeed.vue'
import ClanGuestbook from '@/pages/clan/ClanGuestbook.vue'
import ClanSchedule from '@/pages/clan/ClanSchedule.vue'
import ClanActivity from '@/pages/clan/ClanActivity.vue'
import ClanWeeklyMatches from '@/pages/clan/ClanWeeklyMatches.vue'
import ClanMembers from '@/pages/clan/ClanMembers.vue'
const route = useRoute()
const router = useRouter()
const clan = computed(() => ClanDB(route.params.name as string))
const stats = computed(() => [
{ label: '클랜 랭킹', value: `${clan.value.rank}` },
{ label: '클랜원', value: `${clan.value.totalMembers}` },
{ label: '창설일', value: clan.value.founded },
{
label: '클랜 경험치',
value: `${SA.fmt(clan.value.expTotal)} EXP`,
sub: `${SA.fmt(clan.value.expWeek)} 이번 주`,
},
])
function back() {
router.back()
}
</script>
<template>
<div class="clan">
<!-- 히어로 배너 -->
<div class="clan__hero">
<div class="clan__banner">
<ImageSlot placeholder="클랜 대표 배너 이미지를 끌어다 놓으세요" />
</div>
<div class="clan__scrim" />
<div class="clan__topbar">
<button
class="clan__close"
@click="back"
>
<AppIcon
name="chevL"
:size="15"
stroke="#fff"
/> 닫기
</button>
</div>
<div class="clan__identity">
<div class="clan__emblem">
<ClanEmblem
:cfg="clan.emblem"
:size="104"
/>
</div>
<div class="clan__title">
<div class="clan__name-line">
<h1 class="clan__name">
{{ clan.name }}
</h1>
<span
v-if="clan.isMine"
class="clan__mine"
> 클랜</span>
</div>
<div class="clan__slogan">
"{{ clan.slogan }}"
</div>
</div>
<div
v-if="!clan.isMine"
class="clan__join-wrap"
>
<button class="clan__join">
가입 신청
</button>
</div>
</div>
</div>
<!-- 스탯 스트립 -->
<div class="clan__statstrip">
<div class="clan__statstrip-inner">
<template
v-for="(s, i) in stats"
:key="s.label"
>
<span
v-if="i > 0"
class="clan__statsep"
/>
<div class="clan__stat">
<div class="clan__stat-label">
{{ s.label }}
</div>
<div class="clan__stat-value num">
{{ s.value }}
</div>
<div
v-if="s.sub"
class="clan__stat-sub num"
>
{{ s.sub }}
</div>
</div>
</template>
</div>
</div>
<!-- 본문 -->
<div class="clan__body">
<div class="clan__intro">
<div class="clan__intro-label">
클랜 소개
</div>
<p class="clan__intro-text">
{{ clan.intro }}
</p>
</div>
<div class="clan__grid">
<div class="clan__col">
<ClanNotices :clan="clan" />
<ClanFeed :clan="clan" />
<ClanGuestbook :clan="clan" />
</div>
<div class="clan__col">
<ClanSchedule :clan="clan" />
<ClanActivity :clan="clan" />
<ClanWeeklyMatches :clan="clan" />
<ClanMembers :clan="clan" />
</div>
</div>
</div>
</div>
</template>
<style scoped>
.clan__hero {
position: relative;
background: var(--dark2);
}
.clan__banner {
height: 210px;
}
.clan__scrim {
position: absolute;
inset: 0;
background: linear-gradient(
180deg,
color-mix(in srgb, var(--point) 15%, transparent) 0%,
rgba(20, 22, 26, 0.34) 46%,
rgba(20, 22, 26, 0.9) 100%
);
pointer-events: none;
}
.clan__topbar {
position: absolute;
top: 14px;
left: 0;
right: 0;
max-width: var(--maxw);
margin: 0 auto;
padding: 0 28px;
display: flex;
justify-content: flex-end;
pointer-events: none;
}
.clan__close {
pointer-events: auto;
display: flex;
align-items: center;
gap: 5px;
padding: 7px 13px;
border-radius: 6px;
border: none;
background: rgba(0, 0, 0, 0.42);
color: #fff;
cursor: pointer;
font-weight: 600;
font-size: 0.78125rem;
font-family: var(--font-body);
}
.clan__identity {
position: absolute;
left: 0;
right: 0;
bottom: 0;
max-width: var(--maxw);
margin: 0 auto;
padding: 0 28px 20px;
display: flex;
align-items: flex-end;
gap: 20px;
}
.clan__emblem {
flex-shrink: 0;
}
.clan__title {
flex: 1;
min-width: 0;
padding-bottom: 4px;
}
.clan__name-line {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.clan__name {
margin: 0;
font-size: 1.875rem;
font-weight: 800;
color: #fff;
letter-spacing: -0.6px;
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.4);
}
.clan__mine {
padding: 3px 10px;
border-radius: var(--r-sm);
background: var(--point);
color: var(--point-ink);
font-weight: 700;
font-size: 0.71875rem;
}
.clan__slogan {
margin-top: 7px;
font-size: 0.90625rem;
color: rgba(255, 255, 255, 0.9);
font-weight: 500;
font-style: italic;
}
.clan__join-wrap {
flex-shrink: 0;
padding-bottom: 4px;
}
.clan__join {
padding: 10px 20px;
border-radius: var(--r-md);
border: none;
cursor: pointer;
background: var(--point);
color: var(--point-ink);
font-weight: 700;
font-size: 0.84375rem;
font-family: var(--font-body);
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.25);
}
.clan__statstrip {
background: var(--surf);
border-bottom: 1px solid var(--line2);
}
.clan__statstrip-inner {
max-width: var(--maxw);
margin: 0 auto;
padding: 0 28px;
display: flex;
align-items: stretch;
}
.clan__statsep {
width: 1px;
background: var(--line);
}
.clan__stat {
flex: 1;
text-align: center;
padding: 12px 6px;
display: flex;
flex-direction: column;
justify-content: center;
min-height: 60px;
}
.clan__stat-label {
font-size: 0.65625rem;
color: var(--t3);
font-weight: 600;
margin-bottom: 5px;
}
.clan__stat-value {
font-weight: 700;
font-size: 0.9375rem;
color: var(--ink);
line-height: 1.1;
}
.clan__stat-sub {
font-size: 0.625rem;
color: var(--point);
font-weight: 700;
margin-top: 3px;
}
.clan__body {
max-width: var(--maxw);
margin: 0 auto;
padding: 22px 28px 44px;
}
.clan__intro {
background: var(--surf);
border: 1px solid var(--line);
border-radius: 6px;
padding: 16px 18px;
margin-bottom: 18px;
border-left: 4px solid var(--point);
}
.clan__intro-label {
font-size: 0.71875rem;
font-weight: 700;
color: var(--t3);
margin-bottom: 7px;
}
.clan__intro-text {
margin: 0;
font-size: 0.84375rem;
line-height: 1.65;
color: #3a4049;
}
.clan__grid {
display: grid;
grid-template-columns: 1fr 322px;
gap: 18px;
align-items: start;
}
.clan__col {
display: flex;
flex-direction: column;
gap: 18px;
min-width: 0;
}
</style>
+695
View File
@@ -0,0 +1,695 @@
<script setup lang="ts">
// 클랜 모집 광장 — 히어로 + 필터 + 카드 그리드 + 상세/등록 모달. sa-clan-plaza.jsx ClanPlaza 포팅.
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { SA } from '@/shared/data/sa'
import { buildRecruits, RECRUIT_TYPES, type Recruit } from '@/shared/data/clan'
import AppIcon from '@/shared/components/AppIcon.vue'
import ClanEmblem from '@/shared/components/ClanEmblem.vue'
import RecruitCard from '@/pages/clan/RecruitCard.vue'
import RecruitDetail from '@/pages/clan/RecruitDetail.vue'
import RecruitCreate from '@/pages/clan/RecruitCreate.vue'
const router = useRouter()
const all = buildRecruits()
const PER_PAGE = 8
const rtype = ref('전체')
const showFilter = ref(false)
const fAge = ref('')
const fVoice = ref('전체')
const fNick = ref('전체')
const fDual = ref('전체')
const fFriend = ref('전체')
const detail = ref<Recruit | null>(null)
const creating = ref(false)
const page = ref(1)
const myClanName = SA.me.clan || '불곰부대'
const myPost = computed(() => all.find((c) => c.name === myClanName))
const myActive = ref<boolean>(
(() => {
try {
return localStorage.getItem('sa-my-recruit-active') !== '0'
} catch {
return true
}
})(),
)
function toggleMy() {
myActive.value = !myActive.value
try {
localStorage.setItem('sa-my-recruit-active', myActive.value ? '1' : '0')
} catch {
// 무시
}
page.value = 1
}
const filtered = computed(() => {
let list = [...all]
if (rtype.value !== '전체') list = list.filter((c) => c.recruitTypes.includes(rtype.value))
if (fAge.value) {
const a = parseInt(fAge.value, 10)
if (!Number.isNaN(a)) {
list = list.filter(
(c) =>
(c.conditions.ageMin == null || a >= c.conditions.ageMin) &&
(c.conditions.ageMax == null || a <= c.conditions.ageMax),
)
}
}
if (fVoice.value !== '전체') list = list.filter((c) => (fVoice.value === '필수' ? c.conditions.voice : !c.conditions.voice))
if (fNick.value !== '전체') list = list.filter((c) => (fNick.value === '불필요' ? !c.conditions.nickChange : c.conditions.nickChange))
if (fDual.value !== '전체') list = list.filter((c) => (fDual.value === '허용' ? !c.conditions.noDualClan : c.conditions.noDualClan))
if (fFriend.value !== '전체') list = list.filter((c) => (fFriend.value === '가능' ? c.conditions.friendJoin : !c.conditions.friendJoin))
if (!myActive.value) list = list.filter((c) => c.name !== myClanName)
return list.sort((a, b) => b.hot - a.hot)
})
const totalPages = computed(() => Math.max(1, Math.ceil(filtered.value.length / PER_PAGE)))
const curPage = computed(() => Math.min(page.value, totalPages.value))
const pageList = computed(() => filtered.value.slice((curPage.value - 1) * PER_PAGE, curPage.value * PER_PAGE))
const totalOpenings = all.reduce((s, c) => s + c.openings, 0)
const detailFilters: { lab: string; model: typeof fVoice; opts: string[] }[] = [
{ lab: '보이스 채팅', model: fVoice, opts: ['전체', '필수', '자율'] },
{ lab: '닉네임 변경', model: fNick, opts: ['전체', '불필요', '필요'] },
{ lab: '이중클랜·부계', model: fDual, opts: ['전체', '허용', '금지'] },
{ lab: '지인 동반', model: fFriend, opts: ['전체', '가능', '불가'] },
]
const activeFilters = computed(
() =>
(fAge.value ? 1 : 0) +
(fVoice.value !== '전체' ? 1 : 0) +
(fNick.value !== '전체' ? 1 : 0) +
(fDual.value !== '전체' ? 1 : 0) +
(fFriend.value !== '전체' ? 1 : 0),
)
function resetFilters() {
fAge.value = ''
fVoice.value = '전체'
fNick.value = '전체'
fDual.value = '전체'
fFriend.value = '전체'
page.value = 1
}
function setType(v: string) {
rtype.value = v
page.value = 1
}
function goPage(n: number) {
page.value = Math.max(1, Math.min(totalPages.value, n))
}
function goMyClan() {
router.push({ name: 'clan', params: { name: myClanName } })
}
</script>
<template>
<div class="plaza">
<!-- 히어로 -->
<div class="plaza__hero">
<div class="plaza__hero-inner">
<div class="plaza__kicker num">
CLAN RECRUIT PLAZA
</div>
<h1 class="plaza__title">
클랜 모집 광장
</h1>
<p class="plaza__lead">
지금 <b class="num">{{ all.length }}</b> 클랜이 <b class="num">{{ totalOpenings }}</b>명의 식구를 찾고 있어요.
</p>
<div class="plaza__hero-row">
<div class="plaza__search">
<span class="plaza__search-icon"><AppIcon
name="search"
:size="17"
stroke="rgba(255,255,255,.55)"
/></span>
<input
class="plaza__search-input"
placeholder="클랜명 · 분위기 · 포지션으로 검색"
>
</div>
<button
class="plaza__myclan"
@click="goMyClan"
>
클랜 가기
</button>
</div>
</div>
</div>
<!-- 필터 -->
<div class="plaza__filterbar">
<div class="plaza__filterbar-inner">
<div class="plaza__types">
<button
v-for="v in ['전체', ...RECRUIT_TYPES]"
:key="v"
class="plaza__type"
:class="{ 'plaza__type--on': v === rtype }"
@click="setType(v)"
>
{{ v }}
</button>
</div>
<div class="plaza__filteractions">
<span class="plaza__count">{{ filtered.length }} 클랜</span>
<button
class="plaza__detailbtn"
:class="{ 'plaza__detailbtn--on': showFilter, 'plaza__detailbtn--active': !showFilter && activeFilters }"
@click="showFilter = !showFilter"
>
상세 필터<span
v-if="activeFilters"
class="plaza__fcount num"
>{{ activeFilters }}</span>
</button>
<button
class="plaza__createbtn"
@click="creating = true"
>
모집글 등록
</button>
</div>
</div>
<div
v-if="showFilter"
class="plaza__detailrow"
>
<div class="plaza__detailrow-inner">
<div class="plaza__ffield">
<div class="plaza__flabel">
나이
</div>
<div class="plaza__age">
<input
v-model="fAge"
type="number"
min="1"
max="99"
placeholder="나이"
class="plaza__age-input"
@input="page = 1"
>
<span class="plaza__age-unit"> 가입 가능</span>
</div>
</div>
<div
v-for="f in detailFilters"
:key="f.lab"
class="plaza__ffield"
>
<div class="plaza__flabel">
{{ f.lab }}
</div>
<div class="plaza__fopts">
<button
v-for="o in f.opts"
:key="o"
class="plaza__fopt"
:class="{ 'plaza__fopt--on': f.model.value === o }"
@click="f.model.value = o; page = 1"
>
{{ o }}
</button>
</div>
</div>
<button
v-if="activeFilters"
class="plaza__reset"
@click="resetFilters"
>
초기화
</button>
</div>
</div>
</div>
<!-- 모집글 관리 -->
<div
v-if="myPost"
class="plaza__mywrap"
>
<div
class="plaza__my"
:class="{ 'plaza__my--off': !myActive }"
>
<ClanEmblem
:cfg="myPost.emblem"
:size="26"
/>
<span class="plaza__my-name">{{ myClanName }}</span>
<span
class="plaza__my-status"
:class="{ 'plaza__my-status--on': myActive }"
>
모집글 · {{ myActive ? '게시 중' : '비활성화됨' }}
</span>
<span
v-if="!myActive"
class="plaza__my-hint"
>목록에 노출되지 않아요.</span>
<div class="plaza__my-actions">
<button
v-if="myActive"
class="plaza__my-preview"
@click="detail = myPost"
>
미리보기
</button>
<button
class="plaza__my-toggle"
:class="{ 'plaza__my-toggle--on': myActive }"
@click="toggleMy"
>
{{ myActive ? '비활성화' : '다시 활성화' }}
</button>
</div>
</div>
</div>
<!-- 카드 그리드 -->
<div class="plaza__grid-wrap">
<div class="plaza__grid">
<RecruitCard
v-for="rc in pageList"
:key="rc.name"
:rc="rc"
@detail="detail = $event"
/>
</div>
<div
v-if="totalPages > 1"
class="plaza__pager"
>
<button
class="plaza__pgnav"
:disabled="curPage === 1"
@click="goPage(curPage - 1)"
>
<AppIcon
name="chevL"
:size="15"
stroke="var(--t2)"
/>
</button>
<button
v-for="n in totalPages"
:key="n"
class="plaza__pgnum num"
:class="{ 'plaza__pgnum--on': n === curPage }"
@click="goPage(n)"
>
{{ n }}
</button>
<button
class="plaza__pgnav"
:disabled="curPage === totalPages"
@click="goPage(curPage + 1)"
>
<AppIcon
name="chevR"
:size="15"
stroke="var(--t2)"
/>
</button>
</div>
</div>
<RecruitDetail
v-if="detail"
:rc="detail"
@close="detail = null"
/>
<RecruitCreate
v-if="creating"
@close="creating = false"
/>
</div>
</template>
<style scoped>
.plaza__hero {
background: var(--dark-grad);
color: #fff;
}
.plaza__hero-inner {
max-width: var(--maxw);
margin: 0 auto;
padding: 30px 28px 26px;
}
.plaza__kicker {
font-size: 0.6875rem;
font-weight: 700;
letter-spacing: 2.5px;
color: rgba(255, 255, 255, 0.55);
margin-bottom: 9px;
}
.plaza__title {
margin: 0;
font-size: 1.875rem;
font-weight: 800;
letter-spacing: -0.6px;
}
.plaza__lead {
margin: 8px 0 0;
font-size: 0.84375rem;
color: rgba(255, 255, 255, 0.7);
}
.plaza__lead b {
color: #fff;
}
.plaza__hero-row {
margin-top: 20px;
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.plaza__search {
position: relative;
flex: 1;
min-width: 240px;
max-width: 480px;
}
.plaza__search-icon {
position: absolute;
left: 13px;
top: 50%;
transform: translateY(-50%);
display: flex;
}
.plaza__search-input {
width: 100%;
height: 42px;
padding: 0 14px 0 40px;
border-radius: var(--r-lg);
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.1);
color: #fff;
font-size: 0.875rem;
outline: none;
font-family: var(--font-body);
}
.plaza__search-input::placeholder {
color: rgba(255, 255, 255, 0.5);
}
.plaza__myclan {
height: 42px;
padding: 0 20px;
border-radius: var(--r-lg);
border: 1px solid rgba(255, 255, 255, 0.25);
background: rgba(255, 255, 255, 0.1);
color: #fff;
font-weight: 700;
font-size: 0.84375rem;
cursor: pointer;
font-family: var(--font-body);
flex-shrink: 0;
margin-left: auto;
}
.plaza__filterbar {
background: var(--surf);
border-bottom: 1px solid var(--line2);
position: sticky;
top: 0;
z-index: 5;
}
.plaza__filterbar-inner {
max-width: var(--maxw);
margin: 0 auto;
padding: 12px 28px;
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.plaza__types {
display: flex;
gap: 7px;
flex-wrap: wrap;
}
.plaza__type {
padding: 6px 12px;
border-radius: var(--r-pill);
border: 1px solid var(--line2);
cursor: pointer;
font-weight: 700;
font-size: 0.75rem;
background: var(--surf);
color: var(--t2);
font-family: var(--font-body);
}
.plaza__type--on {
border-color: var(--point);
background: var(--point);
color: var(--point-ink);
}
.plaza__filteractions {
margin-left: auto;
display: flex;
align-items: center;
gap: 10px;
}
.plaza__count {
font-size: 0.75rem;
color: var(--t3);
font-weight: 600;
}
.plaza__detailbtn {
display: flex;
align-items: center;
gap: 6px;
padding: 7px 12px;
border-radius: 6px;
cursor: pointer;
font-weight: 700;
font-size: 0.78125rem;
border: 1px solid var(--line2);
background: var(--surf);
color: var(--ink);
font-family: var(--font-body);
}
.plaza__detailbtn--on {
border-color: var(--point);
background: var(--point);
color: var(--point-ink);
}
.plaza__detailbtn--active {
border-color: var(--point);
color: var(--point);
}
.plaza__fcount {
font-size: 0.6875rem;
font-weight: 700;
background: var(--point-soft);
border-radius: 9px;
padding: 0 6px;
}
.plaza__createbtn {
padding: 8px 14px;
border-radius: 6px;
border: none;
background: var(--point);
color: var(--point-ink);
font-weight: 700;
font-size: 0.78125rem;
cursor: pointer;
font-family: var(--font-body);
}
.plaza__detailrow {
border-top: 1px solid var(--line);
background: #fafbfc;
}
.plaza__detailrow-inner {
max-width: var(--maxw);
margin: 0 auto;
padding: 12px 28px;
display: flex;
align-items: flex-end;
gap: 18px;
flex-wrap: wrap;
}
.plaza__flabel {
font-size: 0.6875rem;
font-weight: 700;
color: var(--t3);
margin-bottom: 6px;
}
.plaza__age {
display: flex;
align-items: center;
gap: 6px;
}
.plaza__age-input {
width: 64px;
padding: 7px 9px;
border: 1px solid var(--line2);
border-radius: 6px;
font-size: 0.78125rem;
font-family: var(--font-body);
font-weight: 600;
color: var(--ink);
outline: none;
box-sizing: border-box;
background: var(--surf);
}
.plaza__age-unit {
font-size: 0.71875rem;
color: var(--t3);
font-weight: 600;
}
.plaza__fopts {
display: flex;
gap: 4px;
}
.plaza__fopt {
padding: 6px 11px;
border-radius: 6px;
cursor: pointer;
font-weight: 700;
font-size: 0.71875rem;
border: 1px solid var(--line2);
background: var(--surf);
color: var(--t2);
font-family: var(--font-body);
}
.plaza__fopt--on {
border-color: var(--point);
background: var(--point);
color: var(--point-ink);
}
.plaza__reset {
margin-left: auto;
padding: 7px 12px;
border-radius: 6px;
border: none;
background: transparent;
color: var(--t2);
font-weight: 700;
font-size: 0.75rem;
cursor: pointer;
font-family: var(--font-body);
text-decoration: underline;
}
.plaza__mywrap {
max-width: var(--maxw);
margin: 0 auto;
padding: 16px 28px 0;
}
.plaza__my {
display: flex;
align-items: center;
gap: 12px;
background: var(--surf);
border: 1px solid var(--line);
border-left: 3px solid var(--point);
border-radius: var(--r-md);
padding: 11px 16px;
flex-wrap: wrap;
}
.plaza__my--off {
border-left-color: #c4c8ce;
}
.plaza__my-name {
font-size: 0.84375rem;
font-weight: 800;
color: var(--ink);
}
.plaza__my-status {
font-size: 0.75rem;
font-weight: 700;
color: var(--t3);
}
.plaza__my-status--on {
color: var(--point);
}
.plaza__my-hint {
font-size: 0.71875rem;
color: var(--t3);
}
.plaza__my-actions {
margin-left: auto;
display: flex;
align-items: center;
gap: 8px;
}
.plaza__my-preview {
padding: 7px 13px;
border-radius: 6px;
border: 1px solid var(--line2);
background: var(--surf);
color: #3a4049;
font-weight: 700;
font-size: 0.75rem;
cursor: pointer;
font-family: var(--font-body);
}
.plaza__my-toggle {
padding: 7px 13px;
border-radius: 6px;
border: none;
background: var(--point);
color: var(--point-ink);
font-weight: 700;
font-size: 0.75rem;
cursor: pointer;
font-family: var(--font-body);
}
.plaza__my-toggle--on {
background: var(--bg2);
color: var(--t2);
}
.plaza__grid-wrap {
max-width: var(--maxw);
margin: 0 auto;
padding: 20px 28px 46px;
}
.plaza__grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
.plaza__pager {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
margin-top: 26px;
}
.plaza__pgnav {
width: 32px;
height: 32px;
border-radius: 6px;
border: 1px solid var(--line2);
background: var(--surf);
cursor: pointer;
display: grid;
place-items: center;
}
.plaza__pgnav:disabled {
opacity: 0.4;
cursor: default;
}
.plaza__pgnum {
min-width: 32px;
height: 32px;
padding: 0 6px;
border-radius: 6px;
cursor: pointer;
font-weight: 700;
font-size: 0.8125rem;
border: 1px solid var(--line2);
background: var(--surf);
color: var(--t2);
}
.plaza__pgnum--on {
border: none;
background: var(--point);
color: var(--point-ink);
}
</style>
+562
View File
@@ -0,0 +1,562 @@
<script setup lang="ts">
// 커뮤니티 — 게시판 목록 + 검색/필터 + 상세/글쓰기 + 사이드. sa-community.jsx CommunityScreen 포팅.
import { computed, ref } from 'vue'
import { SA } from '@/shared/data/sa'
import {
CM_BOARDS,
CM_NOTICES,
CM_PARTY_TYPES,
CM_POSTS,
cmBody,
type CmPost,
} from '@/shared/data/community'
import AppIcon from '@/shared/components/AppIcon.vue'
import CmRow from '@/pages/community/CmRow.vue'
import CmSidePanel from '@/pages/community/CmSidePanel.vue'
import CmPostDetail from '@/pages/community/CmPostDetail.vue'
import CmWrite from '@/pages/community/CmWrite.vue'
const per = ref(15)
const board = ref('전체')
const ptypeF = ref('전체')
const voiceF = ref('전체')
const q = ref('')
const qScope = ref('제목')
const page = ref(1)
const view = ref<CmPost | null>(null)
const writing = ref(false)
const extra = ref<CmPost[]>([]) // 새로 쓴 글
const allPosts = computed(() => [...extra.value, ...CM_POSTS])
const filtered = computed(() => {
let list = board.value === '전체' ? allPosts.value : allPosts.value.filter((p) => p.board === board.value)
if (board.value === '파티 모집') {
if (ptypeF.value !== '전체') list = list.filter((p) => p.party && p.party.ptype === ptypeF.value)
if (voiceF.value !== '전체') list = list.filter((p) => p.party && (voiceF.value === '필수' ? p.party.voice : !p.party.voice))
}
if (q.value.trim()) {
const k = q.value.trim()
list = list.filter((p) =>
qScope.value === '제목'
? p.title.includes(k)
: qScope.value === '작성자'
? p.author.includes(k)
: cmBody(p).includes(k),
)
}
return list
})
const totalPages = computed(() => Math.max(1, Math.ceil(filtered.value.length / per.value)))
const cur = computed(() => Math.min(page.value, totalPages.value))
const rows = computed(() => filtered.value.slice((cur.value - 1) * per.value, cur.value * per.value))
const hot = [...CM_POSTS].sort((a, b) => b.views - a.views).slice(0, 5)
const best = [...CM_POSTS].sort((a, b) => b.up - a.up).slice(0, 5)
function setBoard(b: string) {
board.value = b
page.value = 1
if (b !== '파티 모집') {
ptypeF.value = '전체'
voiceF.value = '전체'
}
}
function goPage(n: number) {
page.value = Math.max(1, Math.min(totalPages.value, n))
}
function startWrite() {
writing.value = true
view.value = null
}
function onSubmit(p: Omit<CmPost, 'id'>) {
const np: CmPost = { ...p, id: 100000 + extra.value.length }
extra.value = [np, ...extra.value]
writing.value = false
board.value = p.board
page.value = 1
view.value = np
}
function hotView(p: CmPost) {
view.value = p
writing.value = false
}
</script>
<template>
<div class="cm">
<div class="cm__inner">
<!-- 타이틀 + 검색 -->
<div class="cm__head">
<div>
<div class="cm__kicker num">
COMMUNITY
</div>
<h1 class="cm__title">
커뮤니티
</h1>
</div>
<div class="cm__search-wrap">
<select
v-model="qScope"
class="cm__scope"
@change="page = 1"
>
<option
v-for="s in ['제목', '작성자', '내용']"
:key="s"
:value="s"
>
{{ s }}
</option>
</select>
<div class="cm__search">
<span class="cm__search-icon"><AppIcon
name="search"
:size="15"
stroke="var(--t3)"
/></span>
<input
v-model="q"
class="cm__search-input"
:placeholder="`${qScope} 검색`"
@input="page = 1"
>
</div>
</div>
<button
class="cm__write"
@click="startWrite"
>
글쓰기
</button>
</div>
<div class="cm__grid">
<!-- 본문 -->
<CmWrite
v-if="writing"
:initial-board="board"
@cancel="writing = false"
@submit="onSubmit"
/>
<CmPostDetail
v-else-if="view"
:p="view"
@back="view = null"
/>
<div
v-else
class="cm__board"
>
<!-- 게시판 -->
<div class="cm__tabs">
<button
v-for="b in ['전체', ...CM_BOARDS]"
:key="b"
class="cm__tab"
:class="{ 'cm__tab--on': board === b }"
@click="setBoard(b)"
>
{{ b }}
</button>
<span class="cm__tabcount">{{ filtered.length }} </span>
<select
v-model.number="per"
class="cm__per"
@change="page = 1"
>
<option
v-for="n in [15, 30, 50]"
:key="n"
:value="n"
>
{{ n }}개씩
</option>
</select>
</div>
<!-- 파티 모집 필터 -->
<div
v-if="board === '파티 모집'"
class="cm__pfilter"
>
<span class="cm__pflabel">모집 유형</span>
<div class="cm__pchips">
<button
v-for="t in ['전체', ...CM_PARTY_TYPES]"
:key="t"
class="cm__pchip"
:class="{ 'cm__pchip--on': ptypeF === t }"
@click="ptypeF = t; page = 1"
>
{{ t }}
</button>
</div>
<span class="cm__pdivider" />
<span class="cm__pflabel">보이스</span>
<div class="cm__pchips">
<button
v-for="t in ['전체', '필수', '자율']"
:key="t"
class="cm__pchip"
:class="{ 'cm__pchip--on': voiceF === t }"
@click="voiceF = t; page = 1"
>
{{ t }}
</button>
</div>
</div>
<!-- 공지 -->
<div
v-for="(n, i) in CM_NOTICES"
:key="i"
class="cm__notice"
>
<span class="cm__notice-tag">공지</span>
<span class="cm__notice-title">{{ n.title }}</span>
<span class="cm__notice-time">{{ n.time }}</span>
</div>
<!-- 목록 -->
<CmRow
v-for="(p, i) in rows"
:key="p.id"
:p="p"
:zebra="i % 2 === 1"
@open="hotView"
/>
<div
v-if="rows.length === 0"
class="cm__empty"
>
검색 결과가 없어요.
</div>
<!-- 페이지네이션 -->
<div
v-if="totalPages > 1"
class="cm__pager"
>
<button
class="cm__pgnav"
:disabled="cur === 1"
@click="goPage(cur - 1)"
>
<AppIcon
name="chevL"
:size="14"
stroke="var(--t2)"
/>
</button>
<button
v-for="n in totalPages"
:key="n"
class="cm__pgnum num"
:class="{ 'cm__pgnum--on': n === cur }"
@click="goPage(n)"
>
{{ n }}
</button>
<button
class="cm__pgnav"
:disabled="cur === totalPages"
@click="goPage(cur + 1)"
>
<AppIcon
name="chevR"
:size="14"
stroke="var(--t2)"
/>
</button>
</div>
</div>
<!-- 사이드 -->
<div class="cm__side">
<CmSidePanel
title="인기글"
en="HOT"
:posts="hot"
:value-of="(p) => `조회 ${SA.fmt(p.views)}`"
@open="hotView"
/>
<CmSidePanel
title="주간 베스트"
en="WEEKLY BEST"
:posts="best"
:value-of="(p) => `추천 ${p.up}`"
@open="hotView"
/>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.cm__inner {
max-width: var(--maxw);
margin: 0 auto;
padding: 24px 28px 46px;
}
.cm__head {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.cm__kicker {
font-size: 0.65625rem;
font-weight: 700;
letter-spacing: 2.2px;
color: var(--t3);
}
.cm__title {
margin: 3px 0 0;
font-size: 1.5rem;
font-weight: 800;
color: var(--ink);
letter-spacing: -0.4px;
}
.cm__search-wrap {
margin-left: auto;
display: flex;
align-items: center;
gap: 8px;
}
.cm__scope {
font-family: var(--font-body);
font-size: 0.78125rem;
font-weight: 700;
color: var(--ink);
border: 1px solid var(--line2);
border-radius: var(--r-md);
padding: 9px 10px;
background: var(--surf);
cursor: pointer;
}
.cm__search {
position: relative;
width: 250px;
}
.cm__search-icon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
display: flex;
}
.cm__search-input {
width: 100%;
height: 38px;
padding: 0 12px 0 36px;
border-radius: var(--r-md);
border: 1px solid var(--line2);
background: var(--surf);
font-size: 0.8125rem;
outline: none;
font-family: var(--font-body);
color: var(--ink);
box-sizing: border-box;
}
.cm__write {
padding: 10px 18px;
border-radius: var(--r-md);
border: none;
background: var(--point);
color: var(--point-ink);
font-weight: 700;
font-size: 0.8125rem;
cursor: pointer;
font-family: var(--font-body);
}
.cm__grid {
display: grid;
grid-template-columns: 1fr 340px;
gap: 18px;
align-items: start;
}
.cm__board {
background: var(--surf);
border: 1px solid var(--line);
border-radius: var(--r-lg);
overflow: hidden;
}
.cm__tabs {
display: flex;
gap: 6px;
padding: 12px 14px;
border-bottom: 1px solid var(--line);
overflow-x: auto;
white-space: nowrap;
align-items: center;
}
.cm__tab {
flex-shrink: 0;
padding: 6px 14px;
border-radius: var(--r-pill);
cursor: pointer;
font-weight: 700;
font-size: 0.78125rem;
border: 1px solid var(--line2);
background: var(--surf);
color: var(--t2);
font-family: var(--font-body);
}
.cm__tab--on {
border-color: var(--point);
background: var(--point);
color: var(--point-ink);
}
.cm__tabcount {
margin-left: auto;
font-size: 0.71875rem;
color: var(--t3);
font-weight: 600;
flex-shrink: 0;
}
.cm__per {
flex-shrink: 0;
font-family: var(--font-body);
font-size: 0.71875rem;
font-weight: 700;
color: var(--ink);
border: 1px solid var(--line2);
border-radius: var(--r-sm);
padding: 5px 7px;
background: var(--surf);
cursor: pointer;
}
.cm__pfilter {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-bottom: 1px solid var(--line);
background: #fafbfc;
flex-wrap: wrap;
}
.cm__pflabel {
font-size: 0.6875rem;
font-weight: 700;
color: var(--t3);
flex-shrink: 0;
}
.cm__pchips {
display: flex;
gap: 5px;
flex-wrap: wrap;
}
.cm__pchip {
padding: 5px 11px;
border-radius: var(--r-pill);
cursor: pointer;
font-weight: 700;
font-size: 0.71875rem;
border: 1px solid var(--line2);
background: var(--surf);
color: var(--t2);
font-family: var(--font-body);
}
.cm__pchip--on {
border-color: var(--point);
background: var(--point);
color: var(--point-ink);
}
.cm__pdivider {
width: 1px;
height: 16px;
background: var(--line2);
flex-shrink: 0;
}
.cm__notice {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 14px;
background: #f4f6f3;
border-bottom: 1px solid #e6e9e4;
cursor: pointer;
}
.cm__notice-tag {
flex-shrink: 0;
font-size: 0.65625rem;
font-weight: 800;
padding: 1px 7px;
border-radius: var(--r-xs);
background: var(--point);
color: var(--point-ink);
min-width: 44px;
text-align: center;
box-sizing: border-box;
}
.cm__notice-title {
flex: 1;
min-width: 0;
font-size: 0.84375rem;
color: var(--ink);
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cm__notice-time {
flex-shrink: 0;
font-size: 0.71875rem;
color: var(--t3);
font-weight: 600;
}
.cm__empty {
padding: 36px 0;
text-align: center;
font-size: 0.8125rem;
color: var(--t3);
}
.cm__pager {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 14px 0;
}
.cm__pgnav {
width: 30px;
height: 30px;
border-radius: 6px;
border: 1px solid var(--line2);
background: var(--surf);
cursor: pointer;
display: grid;
place-items: center;
}
.cm__pgnav:disabled {
opacity: 0.4;
cursor: default;
}
.cm__pgnum {
min-width: 30px;
height: 30px;
padding: 0 6px;
border-radius: 6px;
cursor: pointer;
font-weight: 700;
font-size: 0.78125rem;
border: 1px solid var(--line2);
background: var(--surf);
color: var(--t2);
}
.cm__pgnum--on {
border: none;
background: var(--point);
color: var(--point-ink);
}
.cm__side {
display: flex;
flex-direction: column;
gap: 18px;
}
</style>
+65
View File
@@ -0,0 +1,65 @@
<script setup lang="ts">
// 홈 페이지 — 히어로 + 이벤트 + 좌(콘텐츠)/우(사이드) 2단 그리드. sa-home.jsx Home 포팅.
import HomeHero from '@/pages/home/HomeHero.vue'
import EventStrip from '@/pages/home/EventStrip.vue'
import StreamLive from '@/pages/home/StreamLive.vue'
import HotStreak from '@/pages/home/HotStreak.vue'
import RecruitPosters from '@/pages/home/RecruitPosters.vue'
import SaNews from '@/pages/home/SaNews.vue'
import CommunityPreview from '@/pages/home/CommunityPreview.vue'
import MeCard from '@/pages/home/MeCard.vue'
import FavoritesPanel from '@/pages/home/FavoritesPanel.vue'
import SeasonCard from '@/pages/home/SeasonCard.vue'
import SaRanking from '@/pages/home/SaRanking.vue'
import SearchRank from '@/pages/home/SearchRank.vue'
import LiveStats from '@/pages/home/LiveStats.vue'
</script>
<template>
<div class="home">
<HomeHero />
<div class="home__inner">
<EventStrip />
<div class="home__grid">
<div class="home__col">
<StreamLive />
<HotStreak />
<RecruitPosters />
<SaNews />
<CommunityPreview />
</div>
<div class="home__col">
<MeCard />
<FavoritesPanel />
<SeasonCard />
<SaRanking />
<SearchRank />
<LiveStats />
</div>
</div>
</div>
</div>
</template>
<style scoped>
.home {
color: var(--ink);
}
.home__inner {
max-width: var(--maxw);
margin: 0 auto;
padding: 20px 28px 40px;
}
.home__grid {
display: grid;
grid-template-columns: 1fr 348px;
gap: 18px;
align-items: start;
}
.home__col {
display: flex;
flex-direction: column;
gap: 18px;
min-width: 0;
}
</style>
+23
View File
@@ -0,0 +1,23 @@
<script setup lang="ts">
// 404 페이지 — 라우팅 매칭 실패 시 노출
</script>
<template>
<main
class="not-found"
role="alert"
>
<h1>404</h1>
<p>요청하신 정보나 페이지를 찾을 없습니다.</p>
<RouterLink to="/">
홈으로 돌아가기
</RouterLink>
</main>
</template>
<style scoped>
.not-found {
padding: 2rem;
text-align: center;
}
</style>
+155
View File
@@ -0,0 +1,155 @@
<script setup lang="ts">
// 전적검색(프로필) 페이지 — 검색 서브헤더 + 요약 + 전적/통계 탭. sa-profile.jsx ProfileScreen 포팅.
import { computed, ref } from 'vue'
import { useRoute } from 'vue-router'
import { SA, findPlayer } from '@/shared/data/sa'
import type { Player } from '@/shared/data/types'
import SearchBar from '@/shared/components/SearchBar.vue'
import ProfileSummary from '@/pages/profile/ProfileSummary.vue'
import RankedSeasons from '@/pages/profile/RankedSeasons.vue'
import ClanRecords from '@/pages/profile/ClanRecords.vue'
import MatchHistory from '@/pages/profile/MatchHistory.vue'
import RecentTrend from '@/pages/profile/RecentTrend.vue'
import Companions from '@/pages/profile/Companions.vue'
import TrendChart from '@/pages/profile/TrendChart.vue'
import ModeRecords from '@/pages/profile/ModeRecords.vue'
import MapRecords from '@/pages/profile/MapRecords.vue'
const route = useRoute()
// 라우트 파라미터로 유저 조회. 없으면 로그인 유저(나) 폴백.
const p = computed<Player>(() => {
const name = route.params.name as string
return findPlayer(name) ?? SA.me
})
const tab = ref('전적')
</script>
<template>
<div class="pf">
<!-- 검색 서브헤더 -->
<div class="pf__subhead">
<div class="pf__subhead-inner">
<div class="pf__search">
<SearchBar placeholder="다른 유저의 닉네임을 검색하세요" />
</div>
</div>
</div>
<div class="pf__body">
<ProfileSummary :p="p" />
<!-- 서브탭 -->
<div class="pf__tabs">
<button
v-for="t in ['전적', '통계']"
:key="t"
class="pf__tab"
:class="{ 'pf__tab--on': tab === t }"
@click="tab = t"
>
{{ t }}
<span
v-if="tab === t"
class="pf__tab-line"
:style="{ background: p.rank.color }"
/>
</button>
</div>
<div
v-if="tab === '전적'"
class="pf__grid"
>
<div class="pf__col">
<RankedSeasons :p="p" />
<ClanRecords :p="p" />
<MatchHistory :p="p" />
</div>
<div class="pf__col pf__col--side">
<RecentTrend :p="p" />
<Companions :p="p" />
</div>
</div>
<div
v-else
class="pf__stack"
>
<TrendChart :p="p" />
<ModeRecords :p="p" />
<MapRecords :p="p" />
</div>
</div>
</div>
</template>
<style scoped>
.pf__subhead {
background: var(--surf);
border-bottom: 1px solid var(--line2);
}
.pf__subhead-inner {
max-width: var(--maxw);
margin: 0 auto;
padding: 11px 28px;
display: flex;
align-items: center;
gap: 14px;
}
.pf__search {
flex: 1;
max-width: 520px;
}
.pf__body {
max-width: var(--maxw);
width: 100%;
margin: 0 auto;
padding: 24px 28px 44px;
}
.pf__tabs {
display: flex;
gap: 4px;
border-bottom: 1px solid var(--line2);
margin-bottom: 18px;
}
.pf__tab {
position: relative;
padding: 12px 22px;
border: none;
background: transparent;
cursor: pointer;
font-weight: 700;
font-size: 0.90625rem;
color: var(--t3);
font-family: var(--font-body);
}
.pf__tab--on {
color: var(--ink);
}
.pf__tab-line {
position: absolute;
left: 12px;
right: 12px;
bottom: -1px;
height: 3px;
border-radius: 2px;
}
.pf__grid {
display: grid;
grid-template-columns: 1fr 340px;
gap: 18px;
align-items: start;
}
.pf__col {
display: flex;
flex-direction: column;
gap: 18px;
min-width: 0;
}
.pf__stack {
display: flex;
flex-direction: column;
gap: 18px;
}
</style>
+75
View File
@@ -0,0 +1,75 @@
<script setup lang="ts">
// 주간 활동 — 요일별 접속률 막대. sa-clan-sections.jsx ClanActivity 포팅.
import { computed } from 'vue'
import type { Clan } from '@/shared/data/clan'
import UiPanel from '@/shared/components/UiPanel.vue'
interface Props {
clan: Clan
}
const props = defineProps<Props>()
const days = ['월', '화', '수', '목', '금', '토', '일']
const max = computed(() => Math.max(...props.clan.attendance))
</script>
<template>
<UiPanel
title="주간 활동"
en="ACTIVITY"
body-pad="14px 14px 12px"
>
<template #right>
<span class="ca__cap">접속률</span>
</template>
<div class="ca__chart">
<div
v-for="(v, i) in clan.attendance"
:key="i"
class="ca__col"
>
<span class="ca__val num">{{ v }}%</span>
<div
class="ca__bar"
:style="{ height: (v / max) * 58 + 'px', opacity: 0.55 + 0.45 * (v / max) }"
/>
<span class="ca__day">{{ days[i] }}</span>
</div>
</div>
</UiPanel>
</template>
<style scoped>
.ca__cap {
font-size: 0.6875rem;
color: var(--t3);
font-weight: 600;
}
.ca__chart {
display: flex;
align-items: flex-end;
gap: 7px;
height: 86px;
}
.ca__col {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
}
.ca__val {
font-size: 0.625rem;
color: var(--t3);
font-weight: 600;
}
.ca__bar {
width: 100%;
background: var(--point);
border-radius: 3px 3px 0 0;
}
.ca__day {
font-size: 0.65625rem;
color: var(--t3);
}
</style>
+190
View File
@@ -0,0 +1,190 @@
<script setup lang="ts">
// 클랜 피드 — 작성(내 클랜) + 페이지네이션. sa-clan-sections.jsx ClanFeed 포팅.
import { computed, ref, watch } from 'vue'
import { SA } from '@/shared/data/sa'
import type { Clan, ClanFeedItem } from '@/shared/data/clan'
import UiPanel from '@/shared/components/UiPanel.vue'
import AppIcon from '@/shared/components/AppIcon.vue'
import ClanPager from './ClanPager.vue'
interface Props {
clan: Clan
}
const props = defineProps<Props>()
const PER = 3
const items = ref<ClanFeedItem[]>([...props.clan.feed])
const draft = ref('')
const page = ref(1)
watch(
() => props.clan.name,
() => {
items.value = [...props.clan.feed]
draft.value = ''
page.value = 1
},
)
const total = computed(() => Math.max(1, Math.ceil(items.value.length / PER)))
const cur = computed(() => Math.min(page.value, total.value))
const rows = computed(() => items.value.slice((cur.value - 1) * PER, cur.value * PER))
function post() {
const text = draft.value.trim()
if (!text) return
items.value = [{ author: SA.me.name, role: '일반', time: '방금 전', text, likes: 0 }, ...items.value]
draft.value = ''
page.value = 1
}
</script>
<template>
<UiPanel
title="클랜 피드"
en="FEED"
body-pad="6px 14px 12px"
>
<div
v-if="clan.isMine"
class="cf__compose"
>
<div class="cf__avatar">
{{ SA.me.name.slice(0, 1) }}
</div>
<input
v-model="draft"
class="cf__input"
placeholder="클랜원들에게 소식을 남겨보세요"
@keydown.enter="post"
>
<button
class="cf__post"
:disabled="!draft.trim()"
@click="post"
>
작성
</button>
</div>
<div
v-for="(f, i) in rows"
:key="(cur - 1) * PER + i"
class="cf__item"
:class="{ 'cf__item--line': i < rows.length - 1 }"
>
<div class="cf__avatar">
{{ f.author.slice(0, 1) }}
</div>
<div class="cf__body">
<div class="cf__meta">
<span class="cf__author">{{ f.author }}</span>
<span class="cf__time">{{ f.time }}</span>
</div>
<div class="cf__text">
{{ f.text }}
</div>
<div class="cf__likes">
<AppIcon
name="heart"
:size="13"
stroke="var(--t3)"
/><span>{{ f.likes }}</span>
</div>
</div>
</div>
<ClanPager
:page="cur"
:total="total"
@page="(n) => (page = n)"
/>
</UiPanel>
</template>
<style scoped>
.cf__compose {
display: flex;
gap: 8px;
padding: 10px 0 12px;
border-bottom: 1px solid var(--line);
}
.cf__avatar {
width: 34px;
height: 34px;
border-radius: 50%;
background: var(--point-soft);
color: var(--point);
display: grid;
place-items: center;
font-weight: 800;
font-size: 0.875rem;
flex-shrink: 0;
}
.cf__input {
flex: 1;
min-width: 0;
border: 1px solid var(--line2);
border-radius: 6px;
padding: 9px 12px;
font-size: 0.8125rem;
font-family: var(--font-body);
outline: none;
}
.cf__post {
padding: 0 16px;
border-radius: 6px;
border: none;
cursor: pointer;
background: var(--point);
color: var(--point-ink);
font-weight: 700;
font-size: 0.78125rem;
font-family: var(--font-body);
flex-shrink: 0;
}
.cf__post:disabled {
background: #c4c8ce;
cursor: not-allowed;
}
.cf__item {
display: flex;
gap: 11px;
padding: 11px 0;
}
.cf__item--line {
border-bottom: 1px solid #f1f2f4;
}
.cf__body {
flex: 1;
min-width: 0;
}
.cf__meta {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 3px;
}
.cf__author {
font-size: 0.8125rem;
font-weight: 700;
color: var(--ink);
}
.cf__time {
margin-left: auto;
font-size: 0.6875rem;
color: var(--t3);
}
.cf__text {
font-size: 0.84375rem;
color: #3a4049;
line-height: 1.5;
}
.cf__likes {
margin-top: 6px;
display: flex;
align-items: center;
gap: 5px;
color: var(--t3);
font-size: 0.71875rem;
font-weight: 600;
}
</style>
+281
View File
@@ -0,0 +1,281 @@
<script setup lang="ts">
// 방명록 — 방문자 글 + 클랜원 답글. sa-clan-sections.jsx ClanGuestbook 포팅.
import { computed, ref, watch } from 'vue'
import { SA } from '@/shared/data/sa'
import type { Clan, ClanGuestItem } from '@/shared/data/clan'
import UiPanel from '@/shared/components/UiPanel.vue'
import ClanPager from './ClanPager.vue'
interface Props {
clan: Clan
}
const props = defineProps<Props>()
const PER = 4
const items = ref<ClanGuestItem[]>([...props.clan.guestbook])
const draft = ref('')
const page = ref(1)
const replyAt = ref(-1)
const replyDraft = ref('')
watch(
() => props.clan.name,
() => {
items.value = [...props.clan.guestbook]
draft.value = ''
page.value = 1
replyAt.value = -1
replyDraft.value = ''
},
)
const total = computed(() => Math.max(1, Math.ceil(items.value.length / PER)))
const cur = computed(() => Math.min(page.value, total.value))
const rows = computed(() =>
items.value.slice((cur.value - 1) * PER, cur.value * PER).map((g, i) => ({ g, gi: (cur.value - 1) * PER + i })),
)
function post() {
const text = draft.value.trim()
if (!text) return
items.value = [{ author: SA.me.name, text, time: '방금 전', replies: [] }, ...items.value]
draft.value = ''
page.value = 1
}
function toggleReply(gi: number) {
replyAt.value = replyAt.value === gi ? -1 : gi
replyDraft.value = ''
}
function postReply(gi: number) {
const text = replyDraft.value.trim()
if (!text) return
items.value = items.value.map((g, idx) =>
idx === gi ? { ...g, replies: [...g.replies, { author: SA.me.name, text, time: '방금 전' }] } : g,
)
replyDraft.value = ''
replyAt.value = -1
}
</script>
<template>
<UiPanel
title="방명록"
en="GUESTBOOK"
body-pad="10px 14px 14px"
>
<template #right>
<span
v-if="clan.isMine"
class="gb__hint"
>클랜원은 답글만 있어요</span>
</template>
<div
v-if="!clan.isMine"
class="gb__compose"
>
<input
v-model="draft"
class="gb__input"
placeholder="따뜻한 한마디를 남겨보세요"
@keydown.enter="post"
>
<button
class="gb__post"
:disabled="!draft.trim()"
@click="post"
>
등록
</button>
</div>
<div
v-for="{ g, gi } in rows"
:key="gi"
class="gb__item"
>
<div class="gb__avatar">
{{ g.author.slice(0, 1) }}
</div>
<div class="gb__body">
<div class="gb__meta">
<span class="gb__author">{{ g.author }}</span>
<span class="gb__time">{{ g.time }}</span>
<button
v-if="clan.isMine"
class="gb__reply-btn"
@click="toggleReply(gi)"
>
{{ replyAt === gi ? '취소' : '답글' }}
</button>
</div>
<div class="gb__text">
{{ g.text }}
</div>
<div
v-for="(rp, j) in g.replies"
:key="j"
class="gb__reply"
>
<div class="gb__avatar gb__avatar--pt">
{{ rp.author.slice(0, 1) }}
</div>
<div class="gb__body">
<div class="gb__meta">
<span class="gb__author">{{ rp.author }}</span>
<span class="gb__badge">클랜원</span>
<span class="gb__time">{{ rp.time }}</span>
</div>
<div class="gb__text">
{{ rp.text }}
</div>
</div>
</div>
<div
v-if="replyAt === gi"
class="gb__reply gb__reply-input"
>
<input
v-model="replyDraft"
class="gb__input"
placeholder="답글을 남겨보세요"
@keydown.enter="postReply(gi)"
>
<button
class="gb__post gb__post--sm"
:disabled="!replyDraft.trim()"
@click="postReply(gi)"
>
등록
</button>
</div>
</div>
</div>
<ClanPager
:page="cur"
:total="total"
@page="(n) => (page = n)"
/>
</UiPanel>
</template>
<style scoped>
.gb__hint {
font-size: 0.6875rem;
color: var(--t3);
font-weight: 600;
}
.gb__compose {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.gb__input {
flex: 1;
min-width: 0;
border: 1px solid var(--line2);
border-radius: 6px;
padding: 9px 12px;
font-size: 0.8125rem;
font-family: var(--font-body);
outline: none;
}
.gb__post {
padding: 0 16px;
border-radius: 6px;
border: none;
background: var(--point);
color: var(--point-ink);
font-weight: 700;
font-size: 0.8125rem;
cursor: pointer;
font-family: var(--font-body);
flex-shrink: 0;
}
.gb__post--sm {
padding: 0 13px;
font-size: 0.71875rem;
}
.gb__post:disabled {
background: #c4c8ce;
cursor: not-allowed;
}
.gb__item {
display: flex;
gap: 9px;
padding: 9px 0;
border-top: 1px solid #f1f2f4;
}
.gb__avatar {
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--bg2);
color: #7a8089;
display: grid;
place-items: center;
font-weight: 700;
font-size: 0.75rem;
flex-shrink: 0;
}
.gb__avatar--pt {
background: var(--point-soft);
color: var(--point);
font-weight: 800;
}
.gb__body {
flex: 1;
min-width: 0;
}
.gb__meta {
display: flex;
align-items: baseline;
gap: 7px;
}
.gb__author {
font-size: 0.78125rem;
font-weight: 700;
color: var(--ink);
}
.gb__time {
font-size: 0.6875rem;
color: var(--t3);
}
.gb__badge {
font-size: 0.59375rem;
font-weight: 700;
color: var(--point);
border: 1px solid color-mix(in srgb, var(--point) 55%, transparent);
border-radius: 3px;
padding: 0 4px;
}
.gb__reply-btn {
margin-left: auto;
border: none;
background: transparent;
cursor: pointer;
font-weight: 700;
font-size: 0.6875rem;
color: var(--point);
padding: 0;
flex-shrink: 0;
font-family: var(--font-body);
}
.gb__text {
font-size: 0.8125rem;
color: #3a4049;
margin-top: 2px;
}
.gb__reply {
display: flex;
gap: 9px;
margin-top: 13px;
padding-left: 10px;
border-left: 2px solid color-mix(in srgb, var(--point) 33%, transparent);
}
.gb__reply-input {
align-items: center;
}
</style>
+132
View File
@@ -0,0 +1,132 @@
<script setup lang="ts">
// 클랜원 — 역할별 그룹 + 기여도. sa-clan-sections.jsx ClanMembers 포팅.
import { useRouter } from 'vue-router'
import { SA } from '@/shared/data/sa'
import type { Clan } from '@/shared/data/clan'
import UiPanel from '@/shared/components/UiPanel.vue'
import RankBadge from '@/shared/components/RankBadge.vue'
interface Props {
clan: Clan
}
const props = defineProps<Props>()
const router = useRouter()
const groups = ['클랜장', '운영진', '정예', '일반']
function byRole(role: string) {
return props.clan.members.filter((m) => m.role === role)
}
function openMember(name: string) {
if (SA.allPlayers.find((p) => p.name === name)) {
router.push({ name: 'profile', params: { name } })
}
}
</script>
<template>
<UiPanel
title="클랜원"
en="MEMBERS"
body-pad="6px 0 6px"
>
<template #right>
<span class="cmb__count">{{ clan.totalMembers }} / {{ clan.memberMax }}</span>
</template>
<template
v-for="g in groups"
:key="g"
>
<div v-if="byRole(g).length">
<div class="cmb__group">
<span class="cmb__role">{{ g }} · {{ byRole(g).length }}</span>
<span class="cmb__contrib-h">기여도</span>
</div>
<button
v-for="(m, i) in byRole(g)"
:key="m.name + i"
class="cmb__row"
@click="openMember(m.name)"
>
<RankBadge
v-if="m.rank"
:rank="m.rank"
:size="26"
/>
<span class="cmb__name">{{ m.name }}</span>
<span class="cmb__contrib">
<span class="num cmb__contrib-v">{{ SA.fmt(m.contrib) }}</span>
<span class="cmb__contrib-u">P</span>
</span>
</button>
</div>
</template>
</UiPanel>
</template>
<style scoped>
.cmb__count {
font-size: 0.6875rem;
color: var(--t3);
font-weight: 600;
}
.cmb__group {
display: flex;
align-items: baseline;
justify-content: space-between;
padding: 8px 16px 4px;
}
.cmb__role {
font-size: 0.65625rem;
font-weight: 700;
color: var(--t3);
letter-spacing: 0.3px;
}
.cmb__contrib-h {
font-size: 0.59375rem;
font-weight: 600;
color: var(--t3);
}
.cmb__row {
display: flex;
align-items: center;
gap: 9px;
width: 100%;
border: none;
background: transparent;
padding: 8px 16px;
cursor: pointer;
text-align: left;
font-family: var(--font-body);
}
.cmb__row:hover {
background: #f6f7f8;
}
.cmb__name {
flex: 1;
min-width: 0;
font-size: 0.8125rem;
font-weight: 600;
color: var(--ink);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cmb__contrib {
display: inline-flex;
align-items: baseline;
gap: 3px;
flex-shrink: 0;
}
.cmb__contrib-v {
font-size: 0.78125rem;
color: var(--point);
font-weight: 700;
}
.cmb__contrib-u {
font-size: 0.59375rem;
color: var(--t3);
font-weight: 600;
}
</style>
+84
View File
@@ -0,0 +1,84 @@
<script setup lang="ts">
// 클랜 공지 — 페이지네이션. sa-clan-sections.jsx ClanNotices 포팅.
import { computed, ref } from 'vue'
import type { Clan } from '@/shared/data/clan'
import UiPanel from '@/shared/components/UiPanel.vue'
import ClanPager from './ClanPager.vue'
interface Props {
clan: Clan
}
const props = defineProps<Props>()
const PER = 4
const page = ref(1)
const total = computed(() => Math.max(1, Math.ceil(props.clan.notices.length / PER)))
const cur = computed(() => Math.min(page.value, total.value))
const rows = computed(() => props.clan.notices.slice((cur.value - 1) * PER, cur.value * PER))
</script>
<template>
<UiPanel
title="클랜 공지"
en="NOTICE"
body-pad="2px 14px 6px"
>
<div
v-for="(n, i) in rows"
:key="i"
class="cn__row"
:class="{ 'cn__row--line': i < rows.length - 1 }"
>
<span
v-if="n.pin"
class="cn__pin"
>고정</span>
<span
class="cn__title"
:class="{ 'cn__title--pin': n.pin }"
>{{ n.title }}</span>
<span class="cn__meta">{{ n.author }} · {{ n.time }}</span>
</div>
<ClanPager
:page="cur"
:total="total"
@page="(n) => (page = n)"
/>
</UiPanel>
</template>
<style scoped>
.cn__row {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 2px;
cursor: pointer;
}
.cn__row--line {
border-bottom: 1px solid var(--line3);
}
.cn__pin {
font-size: 0.625rem;
font-weight: 800;
color: var(--point);
flex-shrink: 0;
}
.cn__title {
flex: 1;
font-size: 0.84375rem;
color: var(--t-table);
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cn__title--pin {
font-weight: 700;
}
.cn__meta {
font-size: 0.6875rem;
color: var(--t3);
flex-shrink: 0;
}
</style>
+95
View File
@@ -0,0 +1,95 @@
<script setup lang="ts">
// 클랜 섹션 미니 페이저. sa-clan-sections.jsx ClanPager 포팅.
import AppIcon from '@/shared/components/AppIcon.vue'
interface Props {
page: number
total: number
}
const props = defineProps<Props>()
const emit = defineEmits<{ (e: 'page', n: number): void }>()
function go(n: number) {
emit('page', Math.max(1, Math.min(props.total, n)))
}
</script>
<template>
<div
v-if="total > 1"
class="cpg"
>
<button
class="cpg__nav"
:disabled="page === 1"
@click="go(page - 1)"
>
<AppIcon
name="chevL"
:size="13"
stroke="var(--t2)"
/>
</button>
<button
v-for="n in total"
:key="n"
class="cpg__num num"
:class="{ 'cpg__num--on': n === page }"
@click="go(n)"
>
{{ n }}
</button>
<button
class="cpg__nav"
:disabled="page === total"
@click="go(page + 1)"
>
<AppIcon
name="chevR"
:size="13"
stroke="var(--t2)"
/>
</button>
</div>
</template>
<style scoped>
.cpg {
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
padding: 11px 0 5px;
}
.cpg__nav {
width: 26px;
height: 26px;
border-radius: var(--r-sm);
border: 1px solid var(--line2);
background: var(--surf);
cursor: pointer;
display: grid;
place-items: center;
}
.cpg__nav:disabled {
opacity: 0.4;
cursor: default;
}
.cpg__num {
min-width: 26px;
height: 26px;
padding: 0 5px;
border-radius: var(--r-sm);
cursor: pointer;
font-weight: 700;
font-size: 0.71875rem;
border: 1px solid var(--line2);
background: var(--surf);
color: var(--t2);
}
.cpg__num--on {
border: none;
background: var(--point);
color: var(--point-ink);
}
</style>
+405
View File
@@ -0,0 +1,405 @@
<script setup lang="ts">
// 클랜 일정 — 주간 정기전 + 등록/삭제(내 클랜). sa-clan-sections.jsx ClanSchedule 포팅.
import { computed, ref, watch } from 'vue'
import type { Clan, ClanScheduleItem } from '@/shared/data/clan'
import UiPanel from '@/shared/components/UiPanel.vue'
interface Props {
clan: Clan
}
const props = defineProps<Props>()
const items = ref<ClanScheduleItem[]>([...props.clan.schedule])
const open = ref(false)
const day = ref('월')
const time = ref('21:00')
const title = ref('')
watch(
() => props.clan.name,
() => {
items.value = [...props.clan.schedule]
open.value = false
},
)
const onItems = computed(() => items.value.filter((s) => s.on))
const dayHasSchedule = computed(() => items.value.find((s) => s.day === day.value && s.on))
function register() {
const name = title.value.trim()
if (!name) return
items.value = items.value.map((s) =>
s.day === day.value ? { ...s, on: true, time: time.value, title: name } : s,
)
title.value = ''
open.value = false
}
function remove(d: string) {
items.value = items.value.map((s) =>
s.day === d ? { ...s, on: false, time: '—', title: '휴식' } : s,
)
}
</script>
<template>
<UiPanel
title="클랜 일정"
en="SCHEDULE"
body-pad="12px 14px 14px"
>
<template #right>
<button
v-if="clan.isMine"
class="cs__add"
@click="open = true"
>
+ 일정 등록
</button>
</template>
<div class="cs__week">
<div
v-for="s in items"
:key="s.day"
class="cs__day"
:class="{ 'cs__day--on': s.on }"
>
<div class="cs__dname">
{{ s.day }}
</div>
<div class="cs__dtime num">
{{ s.time }}
</div>
</div>
</div>
<div class="cs__list">
<div
v-for="s in onItems"
:key="s.day"
class="cs__row"
>
<span class="cs__rd">{{ s.day }}</span>
<span class="cs__rt num">{{ s.time }}</span>
<span class="cs__rtitle">{{ s.title }}</span>
<button
v-if="clan.isMine"
class="cs__del"
title="일정 삭제"
@click="remove(s.day)"
>
×
</button>
</div>
<div
v-if="!onItems.length"
class="cs__empty"
>
등록된 일정이 없어요.
</div>
</div>
<!-- 등록 모달 -->
<div
v-if="open"
class="cs__overlay"
@click="open = false"
>
<div
class="cs__modal"
@click.stop
>
<div class="cs__mhead">
<span class="cs__mtitle">일정 등록</span>
<button
class="cs__close"
@click="open = false"
>
×
</button>
</div>
<div class="cs__mbody">
<div class="cs__label">
요일
</div>
<div class="cs__days">
<button
v-for="s in items"
:key="s.day"
class="cs__daybtn"
:class="{ 'cs__daybtn--on': day === s.day, 'cs__daybtn--has': s.on }"
@click="day = s.day"
>
{{ s.day }}
</button>
</div>
<div
v-if="dayHasSchedule"
class="cs__warn"
>
이미 일정이 있는 요일이에요. 등록하면 덮어써요.
</div>
<div class="cs__fields">
<div class="cs__field cs__field--time">
<div class="cs__label">
시간
</div>
<input
v-model="time"
type="time"
class="cs__input"
>
</div>
<div class="cs__field">
<div class="cs__label">
일정명
</div>
<input
v-model="title"
class="cs__input"
placeholder="예: 정기전, 내전 데이"
@keydown.enter="register"
>
</div>
</div>
<div class="cs__actions">
<button
class="cs__cancel"
@click="open = false"
>
취소
</button>
<button
class="cs__submit"
:disabled="!title.trim()"
@click="register"
>
등록하기
</button>
</div>
</div>
</div>
</div>
</UiPanel>
</template>
<style scoped>
.cs__add {
border: none;
background: transparent;
cursor: pointer;
font-weight: 700;
font-size: 0.75rem;
color: var(--point);
padding: 0;
font-family: var(--font-body);
}
.cs__week {
display: flex;
gap: 5px;
}
.cs__day {
flex: 1;
text-align: center;
padding: 9px 2px;
border-radius: 6px;
background: #f4f5f7;
color: var(--t3);
}
.cs__day--on {
background: var(--point);
color: var(--point-ink);
}
.cs__dname {
font-size: 0.75rem;
font-weight: 800;
}
.cs__dtime {
font-size: 0.59375rem;
margin-top: 2px;
opacity: 0.9;
}
.cs__list {
margin-top: 10px;
display: flex;
flex-direction: column;
gap: 6px;
}
.cs__row {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.78125rem;
}
.cs__rd {
font-weight: 800;
color: var(--point);
width: 16px;
}
.cs__rt {
color: var(--t2);
width: 44px;
}
.cs__rtitle {
color: var(--ink);
font-weight: 600;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cs__del {
border: none;
background: transparent;
cursor: pointer;
color: var(--t3);
font-size: 0.875rem;
line-height: 1;
padding: 0 2px;
flex-shrink: 0;
}
.cs__empty {
font-size: 0.75rem;
color: var(--t3);
text-align: center;
padding: 6px 0;
}
.cs__overlay {
position: fixed;
inset: 0;
z-index: 260;
background: rgba(20, 22, 26, 0.55);
backdrop-filter: blur(2px);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
animation: sgFade 0.18s ease;
}
.cs__modal {
width: 380px;
max-width: 100%;
background: var(--surf);
border-radius: var(--r-xl);
overflow: hidden;
box-shadow: var(--sh-modal);
}
.cs__mhead {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
border-bottom: 1px solid var(--line);
}
.cs__mtitle {
font-size: 0.9375rem;
font-weight: 800;
color: var(--ink);
}
.cs__close {
width: 28px;
height: 28px;
border-radius: 6px;
border: none;
background: var(--bg2);
color: var(--t2);
cursor: pointer;
font-size: 0.9375rem;
}
.cs__mbody {
padding: 16px 18px;
}
.cs__label {
font-size: 0.75rem;
font-weight: 700;
color: var(--t2);
margin-bottom: 7px;
}
.cs__days {
display: flex;
gap: 5px;
margin-bottom: 14px;
}
.cs__daybtn {
flex: 1;
padding: 8px 0;
border-radius: 6px;
cursor: pointer;
font-weight: 800;
font-size: 0.78125rem;
border: 1px solid var(--line2);
background: var(--surf);
color: var(--t2);
font-family: var(--font-body);
}
.cs__daybtn--has {
color: var(--point);
}
.cs__daybtn--on {
border-color: var(--point);
background: var(--point);
color: var(--point-ink);
}
.cs__warn {
font-size: 0.71875rem;
color: var(--t3);
margin: -8px 0 12px;
}
.cs__fields {
display: flex;
gap: 10px;
margin-bottom: 14px;
}
.cs__field {
flex: 1;
}
.cs__field--time {
width: 120px;
flex: none;
}
.cs__input {
width: 100%;
border: 1px solid var(--line2);
border-radius: 6px;
padding: 9px 11px;
font-size: 0.8125rem;
font-family: var(--font-body);
font-weight: 600;
color: var(--ink);
outline: none;
box-sizing: border-box;
}
.cs__actions {
display: flex;
gap: 8px;
}
.cs__cancel {
flex: 1;
padding: 11px 0;
border-radius: var(--r-md);
border: 1px solid var(--line2);
background: var(--surf);
color: #3a4049;
font-weight: 700;
font-size: 0.84375rem;
cursor: pointer;
font-family: var(--font-body);
}
.cs__submit {
flex: 1.4;
padding: 11px 0;
border-radius: var(--r-md);
border: none;
background: var(--point);
color: var(--point-ink);
font-weight: 700;
font-size: 0.84375rem;
cursor: pointer;
font-family: var(--font-body);
}
.cs__submit:disabled {
background: #c4c8ce;
cursor: not-allowed;
}
</style>
@@ -0,0 +1,141 @@
<script setup lang="ts">
// 주간 매치 — 요일별 모드 스택 막대 + 합계. sa-clan-sections.jsx ClanWeeklyMatches 포팅.
import { computed } from 'vue'
import type { Clan, ClanActivityDay } from '@/shared/data/clan'
import UiPanel from '@/shared/components/UiPanel.vue'
interface Props {
clan: Clan
}
const props = defineProps<Props>()
const days = ['월', '화', '수', '목', '금', '토', '일']
type CatKey = keyof ClanActivityDay
const cats: { key: CatKey; label: string; color: string }[] = [
{ key: 'clanWar', label: '클랜전', color: 'var(--point)' },
{ key: 'clanRanked', label: '클랜 랭크전', color: '#6b7585' },
{ key: 'soloRanked', label: '솔로 랭크전', color: '#99a1ae' },
{ key: 'partyRanked', label: '파티 랭크전', color: '#cdd2d9' },
]
function sum(a: ClanActivityDay) {
return cats.reduce((s, c) => s + a[c.key], 0)
}
const max = computed(() => Math.max(...props.clan.activity.map(sum)))
const totals = computed(() =>
cats.map((c) => ({
label: c.label,
color: c.color,
n: props.clan.activity.reduce((s, a) => s + a[c.key], 0),
})),
)
</script>
<template>
<UiPanel
title="주간 매치"
en="MATCHES"
body-pad="14px 14px 12px"
>
<template #right>
<span class="cw__cap">모드별 판수</span>
</template>
<div class="cw__chart">
<div
v-for="(a, i) in clan.activity"
:key="i"
class="cw__col"
>
<span class="cw__val num">{{ sum(a) }}</span>
<div
class="cw__stack"
:style="{ height: (sum(a) / max) * 62 + 'px' }"
>
<div
v-for="c in cats"
:key="c.key"
:title="`${c.label} ${a[c.key]}판`"
:style="{ height: (a[c.key] / sum(a)) * 100 + '%', background: c.color }"
/>
</div>
<span class="cw__day">{{ days[i] }}</span>
</div>
</div>
<div class="cw__legend">
<span
v-for="t in totals"
:key="t.label"
class="cw__leg"
>
<span
class="cw__dot"
:style="{ background: t.color }"
/>
{{ t.label }} <b class="num cw__legn">{{ t.n }}</b>
</span>
</div>
</UiPanel>
</template>
<style scoped>
.cw__cap {
font-size: 0.6875rem;
color: var(--t3);
font-weight: 600;
}
.cw__chart {
display: flex;
align-items: flex-end;
gap: 7px;
height: 96px;
}
.cw__col {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
}
.cw__val {
font-size: 0.625rem;
color: var(--t3);
font-weight: 600;
}
.cw__stack {
width: 100%;
display: flex;
flex-direction: column-reverse;
border-radius: 3px 3px 0 0;
overflow: hidden;
}
.cw__day {
font-size: 0.65625rem;
color: var(--t3);
}
.cw__legend {
margin-top: 11px;
padding-top: 10px;
border-top: 1px solid var(--line);
display: grid;
grid-template-columns: 1fr 1fr;
gap: 7px 10px;
}
.cw__leg {
display: flex;
align-items: center;
gap: 5px;
font-size: 0.71875rem;
color: var(--t2);
font-weight: 600;
}
.cw__dot {
width: 8px;
height: 8px;
border-radius: 2px;
flex-shrink: 0;
}
.cw__legn {
color: var(--ink);
margin-left: auto;
}
</style>
+30
View File
@@ -0,0 +1,30 @@
<script setup lang="ts">
// 문의 채널 글리프 — 디스코드/오픈카톡. sa-clan-plaza.jsx ContactGlyph 포팅.
interface Props {
type: string
}
defineProps<Props>()
</script>
<template>
<svg
v-if="type === '디스코드'"
width="19"
height="19"
viewBox="0 0 24 24"
fill="#fff"
aria-hidden="true"
>
<path d="M20.3 4.4A19.8 19.8 0 0 0 15.4 3l-.25.5a14.6 14.6 0 0 1 4.3 1.4c-2-1-4.2-1.5-6.45-1.5s-4.45.5-6.45 1.5A14.6 14.6 0 0 1 10.85 3.5L10.6 3a19.8 19.8 0 0 0-4.9 1.4C2.6 9 1.75 13.4 2.15 17.75A19.9 19.9 0 0 0 8.2 20.8l.5-.85a13 13 0 0 1-2-1l.5-.36a14.2 14.2 0 0 0 12.1 0l.5.36c-.62.4-1.3.74-2 1l.5.85a19.9 19.9 0 0 0 6.05-3.05c.5-5-0.86-9.36-3.85-13.35ZM9 15.3c-1 0-1.8-.9-1.8-2s.8-2 1.8-2 1.82.9 1.8 2c0 1.1-.8 2-1.8 2Zm6 0c-1 0-1.8-.9-1.8-2s.8-2 1.8-2 1.82.9 1.8 2c0 1.1-.8 2-1.8 2Z" />
</svg>
<svg
v-else
width="19"
height="19"
viewBox="0 0 24 24"
fill="#191600"
aria-hidden="true"
>
<path d="M12 4C7 4 3 7.1 3 11c0 2.5 1.7 4.7 4.2 5.9-.18.65-.66 2.35-.76 2.72 0 0-.02.13.07.18.08.05.2 0 .2 0 .26-.04 2.96-1.96 3.5-2.34.58.08 1.18.13 1.79.13 5 0 9-3.1 9-7C21 7.1 17 4 12 4Z" />
</svg>
</template>
+263
View File
@@ -0,0 +1,263 @@
<script setup lang="ts">
// 모집 카드 — 포스터 + 클랜 정보 + 모집 현황 막대. sa-clan-plaza.jsx RecruitCard 포팅.
import { computed } from 'vue'
import { SA } from '@/shared/data/sa'
import { recruitAgeLabel, type Recruit } from '@/shared/data/clan'
import ImageSlot from '@/shared/components/ImageSlot.vue'
import ClanEmblem from '@/shared/components/ClanEmblem.vue'
interface Props {
rc: Recruit
}
const props = defineProps<Props>()
const emit = defineEmits<{ (e: 'detail', rc: Recruit): void }>()
const fill = computed(() => Math.round((props.rc.cur / props.rc.max) * 100))
const isMine = computed(() => props.rc.name === (SA.me.clan || ''))
const danger = computed(() => props.rc.openings <= 3)
const condBadges = computed(() => {
const cd = props.rc.conditions
const age = recruitAgeLabel(cd)
return [age !== '전연령' ? age : null, cd.voice ? '보이스 채팅 필수' : null].filter(Boolean) as string[]
})
</script>
<template>
<div
class="rcard"
:class="{ 'rcard--mine': isMine }"
@click="emit('detail', rc)"
>
<div class="rcard__poster">
<ImageSlot placeholder="홍보 포스터" />
</div>
<div class="rcard__main">
<div class="rcard__head">
<ClanEmblem
:cfg="rc.emblem"
:size="30"
/>
<span class="rcard__name">{{ rc.name }}</span>
<span
v-if="isMine"
class="rcard__mine"
> 모집글</span>
</div>
<div class="rcard__tags">
<span
v-for="t in rc.recruitTypes"
:key="t"
class="rcard__type"
>{{ t }}</span>
<span
v-for="b in condBadges"
:key="b"
class="rcard__cond"
>{{ b }}</span>
</div>
<p class="rcard__intro">
{{ rc.intro }}
</p>
<div class="rcard__stats">
<span><b class="rcard__rank num">{{ rc.rank }}</b></span>
<span class="rcard__dot">·</span>
<span>승률 <b class="num">{{ rc.weeklyWr }}%</b></span>
<span class="rcard__dot">·</span>
<span class="num">{{ rc.record.matches }} <b class="rcard__w">{{ rc.record.w }}</b> <b class="rcard__l">{{ rc.record.l }}</b></span>
</div>
<div class="rcard__progress">
<div class="rcard__progress-head">
<span class="rcard__progress-label">클랜원</span>
<span class="num rcard__progress-val">
{{ rc.cur }}<span class="rcard__progress-max">/{{ rc.max }}</span>
<span
class="rcard__openings"
:class="{ 'rcard__openings--danger': danger }"
>· {{ rc.openings }}자리</span>
</span>
</div>
<div class="rcard__bar">
<div
class="rcard__fill"
:class="{ 'rcard__fill--danger': danger }"
:style="{ width: fill + '%' }"
/>
</div>
</div>
<button
class="rcard__join"
@click.stop
>
가입 신청
</button>
</div>
</div>
</template>
<style scoped>
.rcard {
background: var(--surf);
border: 1px solid var(--line);
border-radius: var(--r-lg);
overflow: hidden;
display: flex;
cursor: pointer;
box-shadow: var(--sh-card);
transition: box-shadow 0.15s;
}
.rcard:hover {
box-shadow: var(--sh-hover);
}
.rcard--mine {
border-color: var(--point);
}
.rcard__poster {
width: 150px;
flex-shrink: 0;
align-self: stretch;
border-right: 1px solid var(--line);
background: #f4f5f7;
}
.rcard__main {
flex: 1;
min-width: 0;
padding: 13px 15px;
display: flex;
flex-direction: column;
}
.rcard__head {
display: flex;
align-items: center;
gap: 9px;
}
.rcard__name {
font-size: 0.96875rem;
font-weight: 800;
color: var(--ink);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rcard__mine {
flex-shrink: 0;
font-size: 0.59375rem;
font-weight: 700;
color: var(--point);
border: 1px solid var(--point);
border-radius: 3px;
padding: 1px 6px;
}
.rcard__tags {
margin-top: 7px;
display: flex;
gap: 5px;
flex-wrap: wrap;
align-items: center;
}
.rcard__type {
font-size: 0.65625rem;
font-weight: 700;
color: var(--ink);
border: 1px solid var(--line2);
background: var(--surf);
padding: 2px 8px;
border-radius: var(--r-xs);
}
.rcard__cond {
font-size: 0.65625rem;
font-weight: 600;
color: var(--t2);
background: var(--bg2);
padding: 2px 8px;
border-radius: var(--r-xs);
}
.rcard__intro {
margin: 8px 0 0;
font-size: 0.75rem;
line-height: 1.5;
color: #4a505a;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.rcard__stats {
margin-top: auto;
padding-top: 10px;
display: flex;
align-items: center;
gap: 9px;
font-size: 0.71875rem;
color: var(--t2);
flex-wrap: wrap;
}
.rcard__rank {
color: var(--point);
font-weight: 700;
}
.rcard__dot {
color: var(--line2);
}
.rcard__w {
color: var(--point);
}
.rcard__l {
color: var(--negative);
}
.rcard__progress {
margin-top: 10px;
}
.rcard__progress-head {
display: flex;
justify-content: space-between;
align-items: baseline;
font-size: 0.6875rem;
margin-bottom: 4px;
}
.rcard__progress-label {
color: var(--t3);
font-weight: 600;
}
.rcard__progress-val {
font-weight: 700;
color: var(--ink);
}
.rcard__progress-max {
color: var(--t3);
}
.rcard__openings {
color: var(--point);
font-weight: 700;
}
.rcard__openings--danger {
color: var(--negative);
}
.rcard__bar {
height: 6px;
background: #d4d8de;
border-radius: 4px;
overflow: hidden;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.05);
}
.rcard__fill {
height: 100%;
background: var(--point);
border-radius: 4px;
}
.rcard__fill--danger {
background: var(--negative);
}
.rcard__join {
margin-top: 11px;
padding: 8px 0;
border-radius: 6px;
border: none;
background: var(--point);
color: var(--point-ink);
font-weight: 700;
font-size: 0.78125rem;
cursor: pointer;
font-family: var(--font-body);
}
</style>
+490
View File
@@ -0,0 +1,490 @@
<script setup lang="ts">
// 모집글 등록 모달 — 포스터/유형/조건/안내/문의채널. sa-clan-plaza.jsx RecruitCreate 포팅(데모).
import { computed, ref } from 'vue'
import { SA } from '@/shared/data/sa'
import { ClanDB, RECRUIT_TYPES } from '@/shared/data/clan'
import ImageSlot from '@/shared/components/ImageSlot.vue'
import ClanEmblem from '@/shared/components/ClanEmblem.vue'
import ContactGlyph from './ContactGlyph.vue'
const emit = defineEmits<{ (e: 'close'): void }>()
const myClanName = SA.me.clan || '불곰부대'
const clan = ClanDB(myClanName)
const types = ref<string[]>([])
const ageMin = ref('')
const ageMax = ref('')
const conds = ref<string[]>(['보이스 채팅 필수'])
const intro = ref('')
const dc = ref(false)
const dcLink = ref('')
const kakao = ref(false)
const kakaoLink = ref('')
const CONDS = ['보이스 채팅 필수', '닉네임 변경 필요', '이중클랜·부계 금지', '지인 동반 가입 가능']
const valid = computed(() => intro.value.trim().length > 0 && types.value.length > 0)
function toggleType(v: string) {
types.value = types.value.includes(v) ? types.value.filter((x) => x !== v) : [...types.value, v]
}
function toggleCond(v: string) {
conds.value = conds.value.includes(v) ? conds.value.filter((x) => x !== v) : [...conds.value, v]
}
</script>
<template>
<div
class="rcr__overlay"
@click="emit('close')"
>
<div
class="rcr__modal"
@click.stop
>
<div class="rcr__head">
<div>
<div class="rcr__title">
모집글 등록
</div>
<div class="rcr__sub">
랭킹 · 승률 · 전적은 클랜 데이터에서 자동으로 표시됩니다.
</div>
</div>
<button
class="rcr__close"
@click="emit('close')"
>
×
</button>
</div>
<div class="rcr__content">
<div class="rcr__poster">
<div class="rcr__poster-slot">
<ImageSlot placeholder="홍보 포스터 (3:4) 업로드" />
</div>
<div class="rcr__poster-note">
권장 비율 3:4 · 클랜을 가장 보여줄 포스터를 올려보세요.
</div>
</div>
<div class="rcr__form">
<div class="rcr__group">
<div class="rcr__label">
클랜
</div>
<div class="rcr__clan">
<ClanEmblem
:cfg="clan.emblem"
:size="28"
/>
<span class="rcr__clan-name">{{ myClanName }}</span>
<span class="rcr__clan-rank num">{{ clan.rank }}</span>
</div>
</div>
<div class="rcr__group">
<div class="rcr__label">
모집 유형 <span class="rcr__hint">(어떤 유저를 찾나요?)</span>
</div>
<div class="rcr__chips">
<button
v-for="v in RECRUIT_TYPES"
:key="v"
class="rcr__chip"
:class="{ 'rcr__chip--on': types.includes(v) }"
@click="toggleType(v)"
>
{{ v }}
</button>
</div>
</div>
<div class="rcr__group">
<div class="rcr__label">
연령 조건 <span class="rcr__hint">(비워두면 전연령)</span>
</div>
<div class="rcr__age">
<input
v-model="ageMin"
type="number"
min="1"
max="99"
placeholder="제한 없음"
class="rcr__age-input"
>
<span class="rcr__age-unit"> 이상</span>
<span class="rcr__age-tilde">~</span>
<input
v-model="ageMax"
type="number"
min="1"
max="99"
placeholder="제한 없음"
class="rcr__age-input"
>
<span class="rcr__age-unit"> 이하</span>
</div>
</div>
<div class="rcr__group">
<div class="rcr__label">
가입 조건 <span class="rcr__hint">(해당하는 항목 선택)</span>
</div>
<div class="rcr__chips">
<button
v-for="v in CONDS"
:key="v"
class="rcr__chip"
:class="{ 'rcr__chip--on': conds.includes(v) }"
@click="toggleCond(v)"
>
{{ v }}
</button>
</div>
</div>
<div class="rcr__group">
<div class="rcr__label">
모집 안내
</div>
<textarea
v-model="intro"
rows="5"
class="rcr__textarea"
placeholder="클랜 분위기와 매너 기준, 활동 시간대, 클랜 목표(정기전·내전·티어 올리기 등)를 자유롭게 적어주세요."
/>
</div>
<div class="rcr__group">
<div class="rcr__label">
문의 채널 <span class="rcr__hint">(선택)</span>
</div>
<div class="rcr__channels">
<div class="rcr__channel">
<button
class="rcr__ch-btn"
:class="{ 'rcr__ch-btn--on rcr__ch-btn--dc': dc }"
@click="dc = !dc"
>
<span class="rcr__ch-glyph rcr__ch-glyph--dc"><ContactGlyph type="디스코드" /></span>
디스코드
</button>
<input
v-model="dcLink"
:disabled="!dc"
placeholder="https://discord.gg/..."
class="rcr__ch-input"
>
</div>
<div class="rcr__channel">
<button
class="rcr__ch-btn"
:class="{ 'rcr__ch-btn--on rcr__ch-btn--kakao': kakao }"
@click="kakao = !kakao"
>
<span class="rcr__ch-glyph rcr__ch-glyph--kakao"><ContactGlyph type="오픈카톡" /></span>
오픈카톡
</button>
<input
v-model="kakaoLink"
:disabled="!kakao"
placeholder="https://open.kakao.com/..."
class="rcr__ch-input"
>
</div>
</div>
</div>
</div>
</div>
<div class="rcr__footer">
<button
class="rcr__cancel"
@click="emit('close')"
>
취소
</button>
<button
class="rcr__submit"
:disabled="!valid"
@click="emit('close')"
>
모집글 등록하기
</button>
</div>
</div>
</div>
</template>
<style scoped>
.rcr__overlay {
position: fixed;
inset: 0;
z-index: 250;
background: rgba(20, 22, 26, 0.62);
backdrop-filter: blur(3px);
display: flex;
align-items: center;
justify-content: center;
padding: 32px 20px;
overflow-y: auto;
animation: sgFade 0.2s ease;
}
.rcr__modal {
width: 760px;
max-width: 100%;
margin: auto;
background: var(--surf);
border-radius: var(--r-xl);
overflow: hidden;
box-shadow: var(--sh-modal);
}
.rcr__head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 15px 20px;
border-bottom: 1px solid var(--line);
}
.rcr__title {
font-size: 1.03125rem;
font-weight: 800;
color: var(--ink);
}
.rcr__sub {
font-size: 0.71875rem;
color: var(--t3);
margin-top: 2px;
}
.rcr__close {
width: 30px;
height: 30px;
border-radius: 6px;
border: none;
background: var(--bg2);
color: var(--t2);
cursor: pointer;
font-size: 1rem;
}
.rcr__content {
display: flex;
align-items: stretch;
}
.rcr__poster {
width: 300px;
flex-shrink: 0;
background: #f4f5f7;
border-right: 1px solid var(--line);
display: flex;
flex-direction: column;
}
.rcr__poster-slot {
height: 400px;
}
.rcr__poster-note {
padding: 11px 16px;
color: var(--t3);
font-size: 0.6875rem;
line-height: 1.5;
}
.rcr__form {
flex: 1;
min-width: 0;
padding: 18px 22px 20px;
max-height: 520px;
overflow-y: auto;
}
.rcr__group {
margin-bottom: 16px;
}
.rcr__label {
font-size: 0.75rem;
font-weight: 700;
color: var(--t2);
margin-bottom: 7px;
}
.rcr__hint {
color: var(--t3);
font-weight: 600;
}
.rcr__clan {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 12px;
border: 1px solid var(--line);
border-radius: var(--r-md);
background: #fafbfc;
}
.rcr__clan-name {
font-size: 0.875rem;
font-weight: 700;
color: var(--ink);
}
.rcr__clan-rank {
margin-left: auto;
font-size: 0.71875rem;
color: var(--t3);
}
.rcr__chips {
display: flex;
gap: 7px;
flex-wrap: wrap;
}
.rcr__chip {
padding: 6px 12px;
border-radius: var(--r-sm);
border: 1px solid var(--line2);
cursor: pointer;
font-weight: 700;
font-size: 0.75rem;
background: var(--surf);
color: var(--t2);
font-family: var(--font-body);
}
.rcr__chip--on {
border-color: var(--ink);
background: var(--ink);
color: #fff;
}
.rcr__age {
display: flex;
align-items: center;
gap: 8px;
}
.rcr__age-input {
width: 90px;
padding: 9px 11px;
border: 1px solid var(--line2);
border-radius: var(--r-md);
font-size: 0.8125rem;
font-family: var(--font-body);
font-weight: 600;
color: var(--ink);
outline: none;
box-sizing: border-box;
}
.rcr__age-unit {
font-size: 0.78125rem;
font-weight: 600;
color: var(--t2);
flex-shrink: 0;
}
.rcr__age-tilde {
color: var(--line2);
}
.rcr__textarea {
width: 100%;
resize: vertical;
padding: 11px 12px;
border: 1px solid var(--line2);
border-radius: var(--r-md);
font-size: 0.84375rem;
line-height: 1.6;
font-family: var(--font-body);
color: #3a4049;
outline: none;
box-sizing: border-box;
}
.rcr__channels {
display: flex;
flex-direction: column;
gap: 9px;
}
.rcr__channel {
display: flex;
align-items: center;
gap: 9px;
}
.rcr__ch-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 9px 12px;
min-width: 124px;
border-radius: var(--r-md);
cursor: pointer;
font-weight: 700;
font-size: 0.8125rem;
font-family: var(--font-body);
flex-shrink: 0;
border: 1px solid var(--line2);
background: var(--surf);
color: var(--t3);
}
.rcr__ch-btn--dc {
border: 2px solid var(--discord);
background: color-mix(in srgb, var(--discord) 8%, transparent);
color: var(--ink);
}
.rcr__ch-btn--kakao {
border: 2px solid var(--kakao);
background: color-mix(in srgb, var(--kakao) 12%, transparent);
color: var(--ink);
}
.rcr__ch-glyph {
width: 22px;
height: 22px;
border-radius: 5px;
display: grid;
place-items: center;
}
.rcr__ch-glyph--dc {
background: var(--discord);
}
.rcr__ch-glyph--kakao {
background: var(--kakao);
}
.rcr__ch-input {
flex: 1;
min-width: 0;
padding: 9px 11px;
border: 1px solid var(--line2);
border-radius: var(--r-md);
font-size: 0.78125rem;
font-family: var(--font-body);
color: var(--ink);
outline: none;
box-sizing: border-box;
}
.rcr__ch-input:disabled {
background: #f4f5f7;
}
.rcr__footer {
display: flex;
gap: 10px;
padding: 14px 20px;
border-top: 1px solid var(--line);
background: #fafbfc;
}
.rcr__cancel {
flex: 1;
padding: 12px 0;
border-radius: var(--r-md);
border: 1px solid var(--line2);
background: var(--surf);
color: #3a4049;
font-weight: 700;
font-size: 0.875rem;
cursor: pointer;
font-family: var(--font-body);
}
.rcr__submit {
flex: 2;
padding: 12px 0;
border-radius: var(--r-md);
border: none;
background: var(--point);
color: var(--point-ink);
font-weight: 700;
font-size: 0.875rem;
cursor: pointer;
font-family: var(--font-body);
}
.rcr__submit:disabled {
background: #c4c8ce;
cursor: not-allowed;
}
</style>
+367
View File
@@ -0,0 +1,367 @@
<script setup lang="ts">
// 모집 상세 모달 — 포스터 + 통계 + 가입 조건 + 액션. sa-clan-plaza.jsx RecruitDetail 포팅.
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { recruitAgeLabel, type Recruit } from '@/shared/data/clan'
import ImageSlot from '@/shared/components/ImageSlot.vue'
import ClanEmblem from '@/shared/components/ClanEmblem.vue'
import ContactGlyph from './ContactGlyph.vue'
interface Props {
rc: Recruit
}
const props = defineProps<Props>()
const emit = defineEmits<{ (e: 'close'): void }>()
const router = useRouter()
const cond = computed(() => {
const cd = props.rc.conditions
return [
['연령', recruitAgeLabel(cd)],
['보이스 채팅', cd.voice ? '필수' : '자율'],
['닉네임 변경', cd.nickChange ? '필요' : '불필요'],
['이중클랜·부계정', cd.noDualClan ? '금지' : '허용'],
['지인 동반 가입', cd.friendJoin ? '가능' : '불가'],
]
})
function openClan() {
emit('close')
router.push({ name: 'clan', params: { name: props.rc.name } })
}
</script>
<template>
<div
class="rd__overlay"
@click="emit('close')"
>
<div
class="rd__modal"
@click.stop
>
<div class="rd__head">
<div class="rd__head-left">
<ClanEmblem
:cfg="rc.emblem"
:size="34"
/>
<span class="rd__name">{{ rc.name }}</span>
<span class="rd__ago">모집글 · {{ rc.createdAgo }} </span>
</div>
<button
class="rd__close"
@click="emit('close')"
>
×
</button>
</div>
<div class="rd__content">
<div class="rd__poster">
<ImageSlot placeholder="클랜 홍보 포스터 (3:4)를 끌어다 놓으세요" />
</div>
<div class="rd__main">
<div class="rd__stats">
<div class="rd__stat">
<div class="rd__stat-l">
랭킹
</div>
<div
class="rd__stat-v num"
style="color: var(--point)"
>
{{ rc.rank }}
</div>
</div>
<div class="rd__stat rd__stat--alt">
<div class="rd__stat-l">
승률
</div>
<div class="rd__stat-v num">
{{ rc.weeklyWr }}%
</div>
</div>
<div class="rd__stat rd__stat--wide">
<div class="rd__stat-l">
전적
</div>
<div class="rd__stat-v num">
{{ rc.record.matches }} {{ rc.record.w }} {{ rc.record.l }}
</div>
</div>
</div>
<div class="rd__label">
모집 안내
</div>
<p class="rd__intro">
{{ rc.intro }}
</p>
<div class="rd__types">
<span
v-for="t in rc.recruitTypes"
:key="t"
class="rd__type"
>{{ t }}</span>
</div>
<div class="rd__label">
가입 조건
</div>
<div class="rd__cond">
<div
v-for="[l, v] in cond"
:key="l"
class="rd__cond-row"
>
<span class="rd__cond-l">{{ l }}</span>
<span class="rd__cond-v">{{ v }}</span>
</div>
</div>
<div class="rd__actions">
<button
class="rd__ghost"
@click="openClan"
>
클랜 구경
</button>
<button class="rd__primary">
가입 신청
</button>
<button
v-for="ct in rc.contacts"
:key="ct.type"
class="rd__contact"
:class="ct.type === '디스코드' ? 'rd__contact--dc' : 'rd__contact--kakao'"
:title="ct.label"
>
<ContactGlyph :type="ct.type" />
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.rd__overlay {
position: fixed;
inset: 0;
z-index: 240;
background: rgba(20, 22, 26, 0.62);
backdrop-filter: blur(3px);
display: flex;
align-items: center;
justify-content: center;
padding: 32px 20px;
overflow-y: auto;
animation: sgFade 0.2s ease;
}
.rd__modal {
width: 860px;
max-width: 100%;
margin: auto;
background: var(--surf);
border-radius: var(--r-xl);
overflow: hidden;
box-shadow: var(--sh-modal);
}
.rd__head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 20px;
border-bottom: 1px solid var(--line);
}
.rd__head-left {
display: flex;
align-items: center;
gap: 11px;
}
.rd__name {
font-size: 1.0625rem;
font-weight: 800;
color: var(--ink);
}
.rd__ago {
font-size: 0.71875rem;
color: var(--t3);
font-weight: 600;
}
.rd__close {
width: 30px;
height: 30px;
border-radius: 6px;
border: none;
background: var(--bg2);
color: var(--t2);
cursor: pointer;
font-size: 1rem;
}
.rd__content {
display: flex;
align-items: stretch;
}
.rd__poster {
width: 360px;
flex-shrink: 0;
height: 480px;
background: #f4f5f7;
border-right: 1px solid var(--line);
}
.rd__main {
flex: 1;
min-width: 0;
padding: 20px 24px 24px;
display: flex;
flex-direction: column;
}
.rd__stats {
display: flex;
border: 1px solid var(--line);
border-radius: var(--r-md);
overflow: hidden;
margin-bottom: 16px;
}
.rd__stat {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 5px;
padding: 11px 4px;
background: #fafbfc;
}
.rd__stat--alt {
background: var(--surf);
border-left: 1px solid var(--line);
}
.rd__stat--wide {
flex: 1.6;
border-left: 1px solid var(--line);
background: var(--surf);
}
.rd__stat-l {
font-size: 0.625rem;
line-height: 1;
color: var(--t3);
font-weight: 600;
}
.rd__stat-v {
font-weight: 700;
font-size: 0.84375rem;
line-height: 1;
white-space: nowrap;
color: var(--ink);
}
.rd__label {
font-size: 0.71875rem;
font-weight: 700;
color: var(--t3);
margin-bottom: 6px;
}
.rd__intro {
margin: 0 0 14px;
font-size: 0.84375rem;
line-height: 1.7;
color: #3a4049;
white-space: pre-line;
}
.rd__types {
margin-top: auto;
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.rd__type {
font-size: 0.71875rem;
font-weight: 700;
color: var(--ink);
border: 1px solid var(--line2);
background: var(--surf);
padding: 3px 10px;
border-radius: var(--r-sm);
}
.rd__cond {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 7px 18px;
margin-bottom: 14px;
padding: 12px 14px;
background: #fafbfc;
border: 1px solid var(--line);
border-radius: var(--r-md);
}
.rd__cond-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.rd__cond-l {
font-size: 0.71875rem;
color: var(--t3);
font-weight: 600;
flex-shrink: 0;
}
.rd__cond-v {
font-size: 0.78125rem;
font-weight: 700;
color: var(--ink);
text-align: right;
}
.rd__actions {
display: flex;
align-items: center;
gap: 9px;
}
.rd__ghost {
flex: 1;
padding: 11px 0;
border-radius: var(--r-md);
border: 1px solid var(--line2);
background: var(--surf);
color: #3a4049;
font-weight: 700;
font-size: 0.84375rem;
cursor: pointer;
font-family: var(--font-body);
}
.rd__primary {
flex: 1.3;
padding: 11px 0;
border-radius: var(--r-md);
border: none;
background: var(--point);
color: var(--point-ink);
font-weight: 700;
font-size: 0.84375rem;
cursor: pointer;
font-family: var(--font-body);
}
.rd__contact {
width: 42px;
height: 42px;
flex-shrink: 0;
display: grid;
place-items: center;
border-radius: var(--r-md);
border: none;
cursor: pointer;
}
.rd__contact--dc {
background: var(--discord);
}
.rd__contact--kakao {
background: var(--kakao);
}
</style>
+57
View File
@@ -0,0 +1,57 @@
<script setup lang="ts">
// 작성자 + 서든어택 인증 마크. sa-community.jsx CmAuthor/CmVerifiedTag 포팅.
import { computed } from 'vue'
import { cmVerified } from '@/shared/data/community'
interface Props {
name: string
size?: number
}
const props = withDefaults(defineProps<Props>(), { size: 13 })
const verified = computed(() => cmVerified(props.name))
</script>
<template>
<span class="cma">
<span class="cma__name"><slot>{{ name }}</slot></span>
<span
v-if="verified"
class="cma__badge"
:style="{ width: size + 'px', height: size + 'px' }"
title="서든어택 계정 인증 유저"
>
<svg
:width="size * 0.68"
:height="size * 0.68"
viewBox="0 0 24 24"
fill="none"
stroke="#fff"
stroke-width="3.4"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M5 12.5l4.5 4.5L19 7.5" /></svg>
</span>
</span>
</template>
<style scoped>
.cma {
display: inline-flex;
align-items: center;
gap: 4px;
min-width: 0;
}
.cma__name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cma__badge {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--point);
flex-shrink: 0;
}
</style>
@@ -0,0 +1,513 @@
<script setup lang="ts">
// 커뮤니티 글 상세 — 본문 + 추천/비추 + 댓글/답글. sa-community.jsx CmPostDetail 포팅.
import { computed, ref, watch } from 'vue'
import { SA } from '@/shared/data/sa'
import { CM_BEST_UP, cmBody, cmComments, cmPartyBadges, type CmComment, type CmPost } from '@/shared/data/community'
import AppIcon from '@/shared/components/AppIcon.vue'
import CmAuthor from './CmAuthor.vue'
interface Props {
p: CmPost
}
const props = defineProps<Props>()
const emit = defineEmits<{ (e: 'back'): void }>()
const body = computed(() => cmBody(props.p))
const comments = ref<CmComment[]>(cmComments(props.p))
const draft = ref('')
const up = ref(props.p.up)
const voted = ref(0) // 0 없음 / 1 추천 / -1 비추
const replyAt = ref(-1)
const replyDraft = ref('')
const cmtVoted = ref<Record<number, boolean>>({})
watch(
() => props.p.id,
() => {
comments.value = cmComments(props.p)
draft.value = ''
up.value = props.p.up
voted.value = 0
cmtVoted.value = {}
replyAt.value = -1
replyDraft.value = ''
},
)
function vote(v: number) {
if (voted.value === v) {
voted.value = 0
up.value -= v
return
}
up.value = up.value - voted.value + v
voted.value = v
}
function post() {
const text = draft.value.trim()
if (!text) return
comments.value = [...comments.value, { author: SA.me.name, text, time: '방금 전', up: 0, replies: [] }]
draft.value = ''
}
function delCmt(i: number) {
comments.value = comments.value.map((c, idx) => (idx === i ? { ...c, deleted: true } : c))
}
function voteCmt(i: number) {
cmtVoted.value = { ...cmtVoted.value, [i]: !cmtVoted.value[i] }
}
function toggleReply(i: number) {
replyAt.value = replyAt.value === i ? -1 : i
replyDraft.value = ''
}
function postReply(ci: number) {
const text = replyDraft.value.trim()
if (!text) return
comments.value = comments.value.map((c, idx) =>
idx === ci ? { ...c, replies: [...c.replies, { author: SA.me.name, text, time: '방금 전' }] } : c,
)
replyDraft.value = ''
replyAt.value = -1
}
</script>
<template>
<div class="pd">
<div class="pd__top">
<button
class="pd__back"
@click="emit('back')"
>
<AppIcon
name="chevL"
:size="13"
stroke="var(--t2)"
/> 목록
</button>
<span class="pd__board">{{ p.board }}</span>
</div>
<div class="pd__header">
<h2 class="pd__title">
{{ p.title }}
</h2>
<div class="pd__meta">
<CmAuthor :name="p.author" />
<span>·</span><span>{{ p.time }}</span>
<span>·</span><span>조회 <b class="num">{{ SA.fmt(p.views) }}</b></span>
<span>·</span><span>추천 <b
class="num"
:class="{ 'pd__best': up >= CM_BEST_UP }"
>{{ up }}</b></span>
</div>
<div
v-if="p.party"
class="pd__party"
>
<span
v-for="b in cmPartyBadges(p.party)"
:key="b"
class="pd__pbadge"
>{{ b }}</span>
</div>
</div>
<div class="pd__body">
{{ body }}
</div>
<div class="pd__vote">
<button
class="pd__votebtn"
:class="{ 'pd__votebtn--up': voted === 1 }"
@click="vote(1)"
>
추천 <span class="num">{{ up }}</span>
</button>
<button
class="pd__votebtn"
:class="{ 'pd__votebtn--down': voted === -1 }"
@click="vote(-1)"
>
비추
</button>
</div>
<div class="pd__comments">
<div class="pd__ctitle">
댓글 <span class="num pd__ccount">{{ comments.length }}</span>
</div>
<div
v-for="(c, i) in comments"
:key="i"
class="pd__comment"
:class="{ 'pd__comment--line': i > 0 }"
>
<div
class="pd__avatar"
:class="{ 'pd__avatar--del': c.deleted }"
>
{{ c.deleted ? '' : c.author.slice(0, 1) }}
</div>
<div class="pd__cbody">
<div
v-if="c.deleted"
class="pd__deleted"
>
삭제된 댓글입니다.
</div>
<template v-else>
<div class="pd__cmeta">
<CmAuthor :name="c.author" />
<span class="pd__ctime">{{ c.time }}</span>
<button
class="pd__caction"
@click="toggleReply(i)"
>
{{ replyAt === i ? '취소' : '답글' }}
</button>
<button
v-if="c.author === SA.me.name"
class="pd__caction pd__caction--del"
@click="delCmt(i)"
>
삭제
</button>
<button
class="pd__clike num"
:class="{ 'pd__clike--on': cmtVoted[i] }"
title="댓글 추천"
@click="voteCmt(i)"
>
{{ c.up + (cmtVoted[i] ? 1 : 0) }}
</button>
</div>
<div class="pd__ctext">
{{ c.text }}
</div>
</template>
<div
v-for="(rp, j) in c.replies"
:key="j"
class="pd__reply"
>
<div class="pd__avatar pd__avatar--pt">
{{ rp.author.slice(0, 1) }}
</div>
<div class="pd__cbody">
<div class="pd__cmeta">
<CmAuthor :name="rp.author" />
<span class="pd__ctime">{{ rp.time }}</span>
</div>
<div class="pd__ctext">
{{ rp.text }}
</div>
</div>
</div>
<div
v-if="replyAt === i"
class="pd__reply pd__reply-input"
>
<input
v-model="replyDraft"
class="pd__input"
placeholder="답글을 남겨보세요"
@keydown.enter="postReply(i)"
>
<button
class="pd__post pd__post--sm"
:disabled="!replyDraft.trim()"
@click="postReply(i)"
>
등록
</button>
</div>
</div>
</div>
<div class="pd__compose">
<input
v-model="draft"
class="pd__input"
placeholder="댓글을 남겨보세요"
@keydown.enter="post"
>
<button
class="pd__post"
:disabled="!draft.trim()"
@click="post"
>
등록
</button>
</div>
</div>
</div>
</template>
<style scoped>
.pd {
background: var(--surf);
border: 1px solid var(--line);
border-radius: var(--r-lg);
overflow: hidden;
}
.pd__top {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
border-bottom: 1px solid var(--line);
}
.pd__back {
display: flex;
align-items: center;
gap: 4px;
border: 1px solid var(--line2);
background: var(--surf);
border-radius: 6px;
padding: 6px 12px;
cursor: pointer;
font-weight: 700;
font-size: 0.75rem;
color: var(--t2);
font-family: var(--font-body);
}
.pd__board {
font-size: 0.65625rem;
font-weight: 700;
padding: 1px 7px;
border-radius: var(--r-xs);
background: var(--surf2);
border: 1px solid var(--line);
color: #5a5f66;
}
.pd__header {
padding: 16px 18px 14px;
border-bottom: 1px solid var(--line);
}
.pd__title {
margin: 0;
font-size: 1.1875rem;
font-weight: 800;
color: var(--ink);
letter-spacing: -0.3px;
line-height: 1.4;
}
.pd__meta {
margin-top: 9px;
display: flex;
align-items: center;
gap: 8px;
font-size: 0.75rem;
color: var(--t3);
flex-wrap: wrap;
}
.pd__meta b {
color: var(--t2);
}
.pd__best {
color: var(--point);
}
.pd__party {
margin-top: 11px;
display: flex;
gap: 6px;
}
.pd__pbadge {
display: inline-flex;
align-items: center;
height: 22px;
font-size: 0.6875rem;
font-weight: 700;
color: var(--t2);
border: 1px solid var(--line2);
border-radius: var(--r-xs);
padding: 0 8px;
}
.pd__body {
padding: 18px 18px 8px;
font-size: 0.875rem;
line-height: 1.8;
color: var(--t-table);
white-space: pre-line;
}
.pd__vote {
display: flex;
justify-content: center;
gap: 8px;
padding: 16px 0 20px;
}
.pd__votebtn {
display: flex;
align-items: center;
gap: 6px;
padding: 9px 18px;
border-radius: var(--r-md);
cursor: pointer;
font-weight: 700;
font-size: 0.8125rem;
border: 1px solid var(--line2);
background: var(--surf);
color: var(--t2);
font-family: var(--font-body);
}
.pd__votebtn--up {
border-color: var(--point);
background: var(--point);
color: var(--point-ink);
}
.pd__votebtn--down {
border-color: var(--hot);
background: var(--hot);
color: #fff;
}
.pd__comments {
border-top: 1px solid var(--line);
padding: 14px 18px 18px;
}
.pd__ctitle {
font-size: 0.8125rem;
font-weight: 800;
color: var(--ink);
margin-bottom: 10px;
}
.pd__ccount {
color: var(--point);
}
.pd__comment {
display: flex;
gap: 9px;
padding: 10px 0;
}
.pd__comment--line {
border-top: 1px solid #f1f2f4;
}
.pd__avatar {
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--bg2);
color: #7a8089;
display: grid;
place-items: center;
font-weight: 700;
font-size: 0.75rem;
flex-shrink: 0;
}
.pd__avatar--del {
opacity: 0.5;
}
.pd__avatar--pt {
background: var(--point-soft);
color: var(--point);
font-weight: 800;
}
.pd__cbody {
flex: 1;
min-width: 0;
}
.pd__deleted {
font-size: 0.8125rem;
color: var(--t3);
font-style: italic;
padding: 5px 0;
}
.pd__cmeta {
display: flex;
align-items: baseline;
gap: 7px;
}
.pd__cmeta :deep(.cma__name) {
font-size: 0.78125rem;
font-weight: 700;
color: var(--ink);
}
.pd__ctime {
font-size: 0.6875rem;
color: var(--t3);
}
.pd__caction {
border: none;
background: transparent;
cursor: pointer;
font-weight: 700;
font-size: 0.6875rem;
color: var(--point);
padding: 0;
font-family: var(--font-body);
}
.pd__caction--del {
color: var(--hot);
}
.pd__clike {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 4px;
border: 1px solid var(--line2);
background: var(--surf);
color: var(--t3);
border-radius: 5px;
padding: 3px 8px;
cursor: pointer;
font-size: 0.65625rem;
font-weight: 700;
}
.pd__clike--on {
border-color: var(--point);
background: var(--point);
color: var(--point-ink);
}
.pd__ctext {
font-size: 0.8125rem;
color: #3a4049;
margin-top: 3px;
line-height: 1.55;
}
.pd__reply {
display: flex;
gap: 9px;
margin-top: 13px;
padding-left: 10px;
border-left: 2px solid color-mix(in srgb, var(--point) 33%, transparent);
}
.pd__reply-input {
align-items: center;
}
.pd__compose {
display: flex;
gap: 8px;
margin-top: 12px;
}
.pd__input {
flex: 1;
min-width: 0;
border: 1px solid var(--line2);
border-radius: 6px;
padding: 10px 12px;
font-size: 0.8125rem;
font-family: var(--font-body);
outline: none;
}
.pd__post {
padding: 0 18px;
border-radius: 6px;
border: none;
background: var(--point);
color: var(--point-ink);
font-weight: 700;
font-size: 0.8125rem;
cursor: pointer;
font-family: var(--font-body);
flex-shrink: 0;
}
.pd__post--sm {
padding: 0 13px;
font-size: 0.71875rem;
}
.pd__post:disabled {
background: #c4c8ce;
cursor: not-allowed;
}
</style>
+186
View File
@@ -0,0 +1,186 @@
<script setup lang="ts">
// 커뮤니티 목록 행. sa-community.jsx CmRow 포팅.
import { SA } from '@/shared/data/sa'
import { CM_BEST_UP, cmPartyBadges, type CmPost } from '@/shared/data/community'
import CmAuthor from './CmAuthor.vue'
interface Props {
p: CmPost
zebra: boolean
}
defineProps<Props>()
const emit = defineEmits<{ (e: 'open', p: CmPost): void }>()
</script>
<template>
<div
class="cmrow"
:class="{ 'cmrow--zebra': zebra }"
@click="emit('open', p)"
>
<span class="cmrow__board">{{ p.board }}</span>
<span class="cmrow__main">
<span class="cmrow__title">{{ p.title }}</span>
<svg
v-if="p.hasImage"
class="cmrow__img"
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="var(--t3)"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-label="사진 포함"
>
<rect
x="3"
y="3"
width="18"
height="18"
rx="2"
/><circle
cx="8.5"
cy="8.5"
r="1.5"
/><path d="M21 15l-5-5L5 21" />
</svg>
<span
v-if="p.cmt > 0"
class="cmrow__cmt num"
>[{{ p.cmt }}]</span>
<span
v-if="p.hot"
class="cmrow__hot num"
>HOT</span>
<span
v-if="p.party"
class="cmrow__party"
>
<span
v-for="b in cmPartyBadges(p.party).slice(0, 3)"
:key="b"
class="cmrow__pbadge"
>{{ b }}</span>
</span>
</span>
<span
class="cmrow__up num"
:class="{ 'cmrow__up--best': p.up >= CM_BEST_UP }"
>추천 {{ p.up }}</span>
<span class="cmrow__author"><CmAuthor :name="p.author" /></span>
<span class="cmrow__time">{{ p.time }}</span>
<span class="cmrow__views num">{{ SA.fmt(p.views) }}</span>
</div>
</template>
<style scoped>
.cmrow {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 14px;
background: var(--surf);
border-bottom: 1px solid var(--bg2);
cursor: pointer;
}
.cmrow--zebra {
background: #fbfbfc;
}
.cmrow__board {
flex-shrink: 0;
font-size: 0.65625rem;
font-weight: 700;
padding: 1px 7px;
border-radius: var(--r-xs);
background: var(--surf2);
border: 1px solid var(--line);
color: #5a5f66;
min-width: 44px;
text-align: center;
}
.cmrow__main {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 6px;
}
.cmrow__title {
min-width: 0;
flex-shrink: 1;
font-size: 0.84375rem;
line-height: 1.3;
color: var(--t-table);
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cmrow__img {
flex-shrink: 0;
}
.cmrow__cmt {
flex-shrink: 0;
font-size: 0.71875rem;
font-weight: 700;
color: var(--point);
}
.cmrow__hot {
flex-shrink: 0;
font-size: 0.59375rem;
font-weight: 800;
color: var(--hot);
}
.cmrow__party {
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 4px;
}
.cmrow__pbadge {
display: inline-flex;
align-items: center;
height: 18px;
font-size: 0.59375rem;
font-weight: 700;
color: var(--t2);
border: 1px solid var(--line2);
border-radius: 3px;
padding: 0 5px;
}
.cmrow__up {
flex-shrink: 0;
font-size: 0.6875rem;
width: 52px;
text-align: right;
color: var(--t3);
}
.cmrow__up--best {
color: var(--point);
font-weight: 700;
}
.cmrow__author {
flex-shrink: 0;
width: 92px;
display: flex;
justify-content: flex-end;
font-size: 0.75rem;
color: var(--t2);
}
.cmrow__time {
flex-shrink: 0;
font-size: 0.6875rem;
color: var(--t3);
width: 56px;
text-align: right;
}
.cmrow__views {
flex-shrink: 0;
font-size: 0.6875rem;
color: var(--t3);
width: 52px;
text-align: right;
}
</style>
@@ -0,0 +1,80 @@
<script setup lang="ts">
// 커뮤니티 사이드 위젯 — 인기글/주간 베스트. sa-community.jsx CmSidePanel 포팅.
import type { CmPost } from '@/shared/data/community'
import UiPanel from '@/shared/components/UiPanel.vue'
interface Props {
title: string
en: string
posts: CmPost[]
valueOf: (p: CmPost) => string
}
defineProps<Props>()
const emit = defineEmits<{ (e: 'open', p: CmPost): void }>()
</script>
<template>
<UiPanel
:title="title"
:en="en"
body-pad="4px 0 6px"
>
<div
v-for="(p, i) in posts"
:key="p.id"
class="cmside__row"
:class="{ 'cmside__row--line': i < posts.length - 1 }"
@click="emit('open', p)"
>
<span
class="cmside__no num"
:class="{ 'cmside__no--top': i < 3 }"
>{{ i + 1 }}</span>
<span class="cmside__title">{{ p.title }}</span>
<span class="cmside__val num">{{ valueOf(p) }}</span>
</div>
</UiPanel>
</template>
<style scoped>
.cmside__row {
display: flex;
align-items: center;
gap: 9px;
padding: 9px 14px;
cursor: pointer;
}
.cmside__row--line {
border-bottom: 1px solid #f1f2f4;
}
.cmside__no {
width: 14px;
text-align: center;
font-weight: 700;
font-size: 0.78125rem;
line-height: 1;
color: var(--t3);
flex-shrink: 0;
}
.cmside__no--top {
color: var(--point);
}
.cmside__title {
flex: 1;
min-width: 0;
font-size: 0.78125rem;
line-height: 1.3;
color: var(--t-table);
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cmside__val {
flex-shrink: 0;
font-size: 0.65625rem;
line-height: 1;
color: var(--t3);
white-space: nowrap;
}
</style>
+367
View File
@@ -0,0 +1,367 @@
<script setup lang="ts">
// 글쓰기 — 일반/파티 모집. sa-community.jsx CmWrite 포팅.
import { computed, ref } from 'vue'
import { SA } from '@/shared/data/sa'
import { CM_BOARDS, CM_PARTY_TYPES, type CmPost } from '@/shared/data/community'
interface Props {
initialBoard: string
}
const props = defineProps<Props>()
const emit = defineEmits<{ (e: 'cancel'): void; (e: 'submit', p: Omit<CmPost, 'id'>): void }>()
const board = ref(props.initialBoard && props.initialBoard !== '전체' ? props.initialBoard : '자유')
const title = ref('')
const body = ref('')
const hasImage = ref(false)
const ptype = ref(CM_PARTY_TYPES[0] as string)
const voice = ref(true)
const fileInput = ref<HTMLInputElement | null>(null)
const isParty = computed(() => board.value === '파티 모집')
const valid = computed(() => title.value.trim().length > 0 && body.value.trim().length > 0)
function onFile(e: Event) {
const t = e.target as HTMLInputElement
if (t.files && t.files.length) hasImage.value = true
t.value = ''
}
function submit() {
if (!valid.value) return
emit('submit', {
board: board.value,
title: title.value.trim(),
author: SA.me.name,
time: '방금 전',
hoursAgo: 0,
views: 1,
cmt: 0,
up: 0,
hot: false,
hasImage: hasImage.value,
body: body.value.trim(),
party: isParty.value ? { ptype: ptype.value, voice: voice.value } : null,
})
}
</script>
<template>
<div class="cw">
<div class="cw__head">
<span class="cw__title">글쓰기</span>
<button
class="cw__list"
@click="emit('cancel')"
>
목록
</button>
</div>
<div class="cw__body">
<div class="cw__group">
<div class="cw__label">
게시판
</div>
<div class="cw__chips">
<button
v-for="b in CM_BOARDS"
:key="b"
class="cw__board"
:class="{ 'cw__board--on': board === b }"
@click="board = b"
>
{{ b }}
</button>
</div>
</div>
<div class="cw__group">
<div class="cw__label">
제목
</div>
<input
v-model="title"
class="cw__input"
:placeholder="isParty ? '예: 저녁 9시 파티 랭크전 같이 하실 분' : '제목을 입력하세요'"
>
</div>
<div
v-if="isParty"
class="cw__group"
>
<div class="cw__label">
모집 유형
</div>
<div class="cw__chips">
<button
v-for="t in CM_PARTY_TYPES"
:key="t"
class="cw__chip"
:class="{ 'cw__chip--on': ptype === t }"
@click="ptype = t"
>
{{ t }}
</button>
</div>
</div>
<div
v-if="isParty"
class="cw__group"
>
<div class="cw__label">
참여 조건
</div>
<button
class="cw__chip"
:class="{ 'cw__chip--on': voice }"
@click="voice = !voice"
>
보이스 채팅 필수
</button>
</div>
<div class="cw__group">
<div class="cw__label">
내용
</div>
<textarea
v-model="body"
rows="10"
class="cw__textarea"
:placeholder="isParty ? '활동 시간대, 분위기, 디스코드/오픈카톡 연락 방법 등을 적어주세요.' : '내용을 입력하세요.'"
/>
</div>
<input
ref="fileInput"
type="file"
accept="image/*"
hidden
@change="onFile"
>
<div class="cw__attach">
<button
class="cw__attach-btn"
@click="fileInput?.click()"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><rect
x="3"
y="3"
width="18"
height="18"
rx="2"
/><circle
cx="8.5"
cy="8.5"
r="1.5"
/><path d="M21 15l-5-5L5 21" /></svg>
사진 첨부
</button>
<span
v-if="hasImage"
class="cw__attached"
>
사진 1 첨부됨
<button
class="cw__attached-x"
@click="hasImage = false"
>×</button>
</span>
</div>
<div class="cw__actions">
<button
class="cw__cancel"
@click="emit('cancel')"
>
취소
</button>
<button
class="cw__submit"
:disabled="!valid"
@click="submit"
>
등록하기
</button>
</div>
</div>
</div>
</template>
<style scoped>
.cw {
background: var(--surf);
border: 1px solid var(--line);
border-radius: var(--r-lg);
overflow: hidden;
}
.cw__head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 13px 16px;
border-bottom: 1px solid var(--line);
}
.cw__title {
font-size: 0.96875rem;
font-weight: 800;
color: var(--ink);
}
.cw__list {
border: 1px solid var(--line2);
background: var(--surf);
border-radius: 6px;
padding: 6px 12px;
cursor: pointer;
font-weight: 700;
font-size: 0.75rem;
color: var(--t2);
font-family: var(--font-body);
}
.cw__body {
padding: 18px 18px 20px;
}
.cw__group {
margin-bottom: 16px;
}
.cw__label {
font-size: 0.75rem;
font-weight: 700;
color: var(--t2);
margin-bottom: 7px;
}
.cw__chips {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.cw__board {
padding: 7px 14px;
border-radius: var(--r-pill);
cursor: pointer;
font-weight: 700;
font-size: 0.78125rem;
border: 1px solid var(--line2);
background: var(--surf);
color: var(--t2);
font-family: var(--font-body);
}
.cw__board--on {
border-color: var(--point);
background: var(--point);
color: var(--point-ink);
}
.cw__chip {
padding: 6px 12px;
border-radius: var(--r-sm);
border: 1px solid var(--line2);
cursor: pointer;
font-weight: 700;
font-size: 0.75rem;
background: var(--surf);
color: var(--t2);
font-family: var(--font-body);
}
.cw__chip--on {
border-color: var(--ink);
background: var(--ink);
color: #fff;
}
.cw__input,
.cw__textarea {
width: 100%;
border: 1px solid var(--line2);
border-radius: var(--r-md);
padding: 10px 12px;
font-size: 0.84375rem;
font-family: var(--font-body);
color: var(--ink);
outline: none;
box-sizing: border-box;
}
.cw__textarea {
resize: vertical;
line-height: 1.7;
}
.cw__attach {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 18px;
}
.cw__attach-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 13px;
border-radius: var(--r-md);
cursor: pointer;
background: var(--surf);
border: 1px dashed var(--line2);
color: var(--t2);
font-weight: 700;
font-size: 0.75rem;
font-family: var(--font-body);
}
.cw__attached {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.75rem;
color: var(--t2);
font-weight: 600;
}
.cw__attached-x {
border: none;
background: transparent;
cursor: pointer;
color: var(--t3);
font-size: 0.875rem;
padding: 0;
}
.cw__actions {
display: flex;
gap: 10px;
border-top: 1px solid var(--line);
padding-top: 16px;
}
.cw__cancel {
flex: 1;
padding: 12px 0;
border-radius: var(--r-md);
border: 1px solid var(--line2);
background: var(--surf);
color: #3a4049;
font-weight: 700;
font-size: 0.875rem;
cursor: pointer;
font-family: var(--font-body);
}
.cw__submit {
flex: 2;
padding: 12px 0;
border-radius: var(--r-md);
border: none;
background: var(--point);
color: var(--point-ink);
font-weight: 700;
font-size: 0.875rem;
cursor: pointer;
font-family: var(--font-body);
}
.cw__submit:disabled {
background: #c4c8ce;
cursor: not-allowed;
}
</style>
@@ -0,0 +1,118 @@
<script setup lang="ts">
// 커뮤니티 미리보기 — 자유+공략 인기글 6개. sa-home.jsx Community 포팅.
import { useRouter } from 'vue-router'
import { SA } from '@/shared/data/sa'
import UiPanel from '@/shared/components/UiPanel.vue'
import AppIcon from '@/shared/components/AppIcon.vue'
const router = useRouter()
const rows = [...(SA.community['자유'] ?? []), ...(SA.community['공략'] ?? [])]
.sort((a, b) => (b.hot ? 1 : 0) - (a.hot ? 1 : 0))
.slice(0, 6)
function goAll() {
router.push('/community')
}
</script>
<template>
<UiPanel
title="커뮤니티"
en="COMMUNITY"
body-pad="2px 14px 6px"
>
<template #right>
<button
class="cm__all"
@click="goAll"
>
전체 보기 <AppIcon
name="chevR"
:size="13"
stroke="var(--t2)"
/>
</button>
</template>
<div
v-for="(p, i) in rows"
:key="i"
class="cm__row"
:class="{ 'cm__row--line': i < rows.length - 1 }"
>
<span class="cm__tag">{{ p.tag }}</span>
<span class="cm__title">{{ p.title }}</span>
<span
v-if="p.hot"
class="cm__hot num"
>HOT</span>
<span class="cm__cmt num">{{ p.cmt }}</span>
<span class="cm__time">{{ p.time }}</span>
</div>
</UiPanel>
</template>
<style scoped>
.cm__all {
display: flex;
align-items: center;
gap: 3px;
border: none;
background: transparent;
cursor: pointer;
font-weight: 700;
font-size: 0.75rem;
color: var(--t2);
padding: 0;
font-family: var(--font-body);
}
.cm__row {
display: flex;
align-items: center;
gap: 11px;
padding: 10px 2px;
cursor: pointer;
}
.cm__row--line {
border-bottom: 1px solid var(--line3);
}
.cm__tag {
flex-shrink: 0;
font-size: 0.65625rem;
font-weight: 700;
padding: 1px 7px;
border-radius: var(--r-xs);
background: var(--surf2);
border: 1px solid var(--line);
color: #5a5f66;
min-width: 32px;
text-align: center;
}
.cm__title {
flex: 1;
font-size: 0.875rem;
color: var(--t-table);
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cm__hot {
font-size: 0.625rem;
font-weight: 800;
color: var(--hot);
}
.cm__cmt {
font-size: 0.75rem;
color: var(--point);
font-weight: 700;
min-width: 26px;
text-align: right;
}
.cm__time {
font-size: 0.6875rem;
color: var(--t3);
min-width: 44px;
text-align: right;
}
</style>
+321
View File
@@ -0,0 +1,321 @@
<script setup lang="ts">
// 진행중인 이벤트 — 4개씩 페이지 캐러셀 + 자동 슬라이드. sa-home.jsx EventStrip 포팅.
// 데이터: 백엔드 크롤링(/api/events/ongoing). 실패 시 더미(SA.events) 폴백.
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { SA } from '@/shared/data/sa'
import { useEvents } from '@/composables/useEvents'
import ImageSlot from '@/shared/components/ImageSlot.vue'
interface StripEvent {
title: string
sub: string
period: string
dday: string
tag: string
image?: string
link?: string
}
const PER = 4
// 초기값은 더미 — 마운트 시 실데이터로 교체
const events = ref<StripEvent[]>(SA.events)
const pages = computed(() => Math.max(1, Math.ceil(events.value.length / PER)))
const page = ref(0)
const hover = ref(false)
const pageList = computed(() =>
Array.from({ length: pages.value }, (_, pi) => ({
pi,
items: events.value.slice(pi * PER, pi * PER + PER).map((e, i) => ({ ...e, gi: pi * PER + i, i })),
})),
)
function isUrgent(dday: string) {
return dday.startsWith('D-') && Number(dday.slice(2)) <= 3
}
function go(d: number) {
page.value = (page.value + d + pages.value) % pages.value
}
function openEvent(e: StripEvent) {
if (e.link) window.open(e.link, '_blank', 'noopener')
}
// 실데이터 로드 (크롤링). 성공 시 교체, 실패/빈 배열이면 더미 유지.
const { getOngoing } = useEvents()
onMounted(async () => {
const list = await getOngoing()
if (list.length) {
events.value = list
page.value = 0
}
})
// 자동 슬라이드 (마우스 호버 시 일시정지)
let timer: number | undefined
function startTimer() {
stopTimer()
if (pages.value <= 1) return
timer = window.setInterval(() => {
if (!hover.value) page.value = (page.value + 1) % pages.value
}, 5000)
}
function stopTimer() {
if (timer) window.clearInterval(timer)
}
watch(hover, (h) => (h ? stopTimer() : startTimer()), { immediate: true })
// 이벤트 개수 변동(실데이터 로드) 시 타이머 재설정
watch(pages, () => startTimer())
onBeforeUnmount(stopTimer)
</script>
<template>
<div
class="es"
@mouseenter="hover = true"
@mouseleave="hover = false"
>
<div class="es__head">
<div class="es__title">
<span class="es__name">진행중인 이벤트</span>
<span class="es__en num">ON-GOING EVENT</span>
</div>
<div class="es__ctrl">
<span class="es__count num">{{ page + 1 }} / {{ pages }}</span>
<button
class="es__arrow"
@click="go(-1)"
>
</button>
<button
class="es__arrow"
@click="go(1)"
>
</button>
</div>
</div>
<div class="es__viewport">
<div
class="es__track"
:style="{ width: pages * 100 + '%', transform: `translateX(-${page * (100 / pages)}%)` }"
>
<div
v-for="pg in pageList"
:key="pg.pi"
class="es__page"
:style="{ width: 100 / pages + '%' }"
>
<div
v-for="e in pg.items"
:key="e.title"
class="es__card"
:class="{ 'es__card--sep': e.i > 0 }"
@click="openEvent(e)"
>
<div class="es__banner">
<img
v-if="e.image"
:src="e.image"
:alt="e.title"
class="es__banner-img"
loading="lazy"
>
<ImageSlot
v-else
:placeholder="`${e.tag} 배너 이미지`"
/>
</div>
<div class="es__body">
<div class="es__meta">
<span class="es__tag">{{ e.tag }}</span>
<span
class="es__dday num"
:class="{ 'es__dday--urgent': isUrgent(e.dday) }"
>{{ e.dday }}</span>
</div>
<div class="es__cardtitle">
{{ e.title }}
</div>
<div class="es__sub">
{{ e.sub }}
</div>
<div class="es__period num">
{{ e.period }}
</div>
</div>
</div>
</div>
</div>
</div>
<div class="es__dots">
<button
v-for="i in pages"
:key="i"
class="es__dot"
:class="{ 'es__dot--on': i - 1 === page }"
@click="page = i - 1"
/>
</div>
</div>
</template>
<style scoped>
.es {
border: 1px solid var(--line2);
border-radius: var(--r-sm);
margin-bottom: 18px;
background: var(--surf);
overflow: hidden;
}
.es__head {
display: flex;
align-items: center;
justify-content: space-between;
background: var(--dark);
color: #fff;
padding: 0 12px 0 14px;
height: 38px;
}
.es__title {
display: flex;
align-items: baseline;
gap: 9px;
}
.es__name {
font-size: 0.90625rem;
font-weight: 800;
letter-spacing: -0.2px;
}
.es__en {
font-size: 0.625rem;
font-weight: 600;
color: rgba(255, 255, 255, 0.5);
letter-spacing: 1.2px;
}
.es__ctrl {
display: flex;
align-items: center;
gap: 6px;
}
.es__count {
font-size: 0.71875rem;
font-weight: 700;
color: rgba(255, 255, 255, 0.7);
}
.es__arrow {
width: 26px;
height: 26px;
border-radius: var(--r-xs);
border: none;
background: rgba(255, 255, 255, 0.14);
color: #fff;
cursor: pointer;
display: grid;
place-items: center;
font-size: 0.8125rem;
}
.es__viewport {
overflow: hidden;
}
.es__track {
display: flex;
transition: transform 0.45s cubic-bezier(0.4, 0, 0.2, 1);
}
.es__page {
display: grid;
grid-template-columns: repeat(4, 1fr);
}
.es__card {
cursor: pointer;
display: flex;
flex-direction: column;
}
.es__card--sep {
border-left: 1px solid var(--line);
}
.es__banner {
width: 100%;
height: 112px;
border-bottom: 1px solid var(--line);
overflow: hidden;
}
.es__banner-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.es__body {
padding: 11px 13px 13px;
display: flex;
flex-direction: column;
flex: 1;
}
.es__meta {
display: flex;
align-items: center;
gap: 7px;
margin-bottom: 8px;
}
.es__tag {
font-size: 0.6875rem;
font-weight: 700;
color: var(--point-ink);
background: var(--point);
padding: 2px 9px;
white-space: nowrap;
flex-shrink: 0;
}
.es__dday {
margin-left: auto;
font-size: 0.71875rem;
font-weight: 700;
color: var(--point);
}
.es__dday--urgent {
color: var(--hot);
}
.es__cardtitle {
font-size: 0.875rem;
font-weight: 700;
color: var(--ink);
line-height: 1.34;
margin-bottom: 5px;
}
.es__sub {
font-size: 0.71875rem;
color: var(--t2);
line-height: 1.45;
margin-bottom: auto;
}
.es__period {
font-size: 0.6875rem;
color: var(--t3);
margin-top: 11px;
padding-top: 9px;
border-top: 1px solid var(--line);
}
.es__dots {
display: flex;
justify-content: center;
gap: 6px;
padding: 11px 0;
border-top: 1px solid var(--line);
}
.es__dot {
width: 7px;
height: 7px;
border-radius: 4px;
border: none;
background: var(--line2);
cursor: pointer;
transition: all 0.3s;
padding: 0;
}
.es__dot--on {
width: 20px;
background: var(--point);
}
</style>
+190
View File
@@ -0,0 +1,190 @@
<script setup lang="ts">
// 즐겨찾기 — 페이지네이션 목록. sa-home.jsx Favorites 포팅.
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { SA } from '@/shared/data/sa'
import type { Player } from '@/shared/data/types'
import UiPanel from '@/shared/components/UiPanel.vue'
import RankBadge from '@/shared/components/RankBadge.vue'
const router = useRouter()
const all = SA.favorites
const PER = 5
const pages = Math.ceil(all.length / PER)
const page = ref(0)
const view = computed(() => all.slice(page.value * PER, page.value * PER + PER))
function open(p: Player) {
router.push({ name: 'profile', params: { name: p.name } })
}
function setPage(i: number) {
page.value = Math.max(0, Math.min(pages - 1, i))
}
</script>
<template>
<UiPanel
title="즐겨찾기"
en="FAVORITES"
body-pad="0"
>
<template #right>
<span class="fv__count">{{ all.length }}</span>
</template>
<div>
<button
v-for="(f, i) in view"
:key="f.name"
class="fv__row"
:class="{ 'fv__row--alt': (page * PER + i) % 2 === 1 }"
@click="open(f)"
>
<RankBadge
:rank="f.rank"
:size="30"
/>
<div class="fv__info">
<div class="fv__line">
<span class="fv__nm">{{ f.name }}</span>
<span
v-if="f.clan"
class="fv__clan"
>{{ f.clan }}</span>
</div>
<div class="fv__sub">
{{ f.rank.name }} · 승률 {{ f.wr }}%
</div>
</div>
<span class="fv__kd num">킬뎃 {{ SA.kdPct(f.kda) }}%</span>
</button>
</div>
<div class="fv__pager">
<button
class="fv__pgbtn"
:disabled="page === 0"
@click="setPage(page - 1)"
>
</button>
<button
v-for="i in pages"
:key="i"
class="fv__pgnum num"
:class="{ 'fv__pgnum--on': i - 1 === page }"
@click="setPage(i - 1)"
>
{{ i }}
</button>
<button
class="fv__pgbtn"
:disabled="page === pages - 1"
@click="setPage(page + 1)"
>
</button>
</div>
</UiPanel>
</template>
<style scoped>
.fv__count {
font-size: 0.6875rem;
color: var(--t3);
font-weight: 600;
}
.fv__row {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
border: none;
background: var(--surf);
padding: 9px 14px;
cursor: pointer;
text-align: left;
border-bottom: 1px solid var(--line3);
font-family: var(--font-body);
}
.fv__row--alt {
background: #f7f8f9;
}
.fv__info {
flex: 1;
min-width: 0;
}
.fv__line {
display: flex;
align-items: center;
gap: 5px;
}
.fv__nm {
font-size: 0.84375rem;
color: var(--ink);
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex-shrink: 0;
max-width: 60%;
}
.fv__clan {
font-size: 0.65625rem;
color: var(--t3);
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fv__sub {
font-size: 0.65625rem;
color: var(--t3);
margin-top: 1px;
}
.fv__kd {
font-weight: 700;
font-size: 0.78125rem;
color: var(--point);
flex-shrink: 0;
}
.fv__pager {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 10px 0;
}
.fv__pgbtn {
width: 26px;
height: 26px;
border-radius: var(--r-xs);
border: 1px solid var(--line2);
background: var(--surf);
color: var(--t2);
cursor: pointer;
font-family: var(--font-num);
font-weight: 700;
font-size: 0.875rem;
}
.fv__pgbtn:disabled {
color: var(--line2);
cursor: default;
}
.fv__pgnum {
width: 26px;
height: 26px;
border-radius: var(--r-xs);
border: 1px solid var(--line2);
background: var(--surf);
color: var(--t2);
cursor: pointer;
font-weight: 700;
font-size: 0.75rem;
}
.fv__pgnum--on {
border-color: var(--point);
background: var(--point);
color: var(--point-ink);
}
</style>
+75
View File
@@ -0,0 +1,75 @@
<script setup lang="ts">
// 홈 히어로 — 검색 밴드 + 환영 문구 + 최근검색 칩
import { SA } from '@/shared/data/sa'
import SearchBar from '@/shared/components/SearchBar.vue'
import RecentChips from '@/shared/components/RecentChips.vue'
const me = SA.me
</script>
<template>
<div class="hero">
<div class="hero__inner">
<div class="hero__center">
<div class="hero__kicker num">
SUDDEN ATTACK RECORD SEARCH
</div>
<h1 class="hero__title">
전적 검색
</h1>
<div class="hero__welcome">
어서와요, <b>{{ me.name }}</b>
</div>
<SearchBar big />
<div class="hero__chips">
<RecentChips />
</div>
</div>
</div>
</div>
</template>
<style scoped>
.hero {
background: var(--surf);
border-bottom: 1px solid var(--line2);
}
.hero__inner {
max-width: var(--maxw);
margin: 0 auto;
padding: 34px 28px 30px;
}
.hero__center {
max-width: 720px;
margin: 0 auto;
text-align: center;
}
.hero__kicker {
font-size: 0.6875rem;
font-weight: 700;
letter-spacing: 3px;
color: var(--point);
margin-bottom: 10px;
}
.hero__title {
margin: 0 0 6px;
font-weight: 800;
font-size: 1.875rem;
line-height: 1.2;
letter-spacing: -1px;
color: var(--ink);
}
.hero__welcome {
font-size: 0.84375rem;
color: var(--t2);
margin-bottom: 18px;
}
.hero__welcome b {
color: var(--point);
}
.hero__chips {
margin-top: 13px;
display: flex;
justify-content: center;
}
</style>
+192
View File
@@ -0,0 +1,192 @@
<script setup lang="ts">
// 폼 좋은 유저 — 연승/승률/킬뎃 탭. sa-home.jsx HotStreak 포팅.
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { SA } from '@/shared/data/sa'
import { wrColor } from '@/shared/utils/stats'
import type { Player } from '@/shared/data/types'
import UiPanel from '@/shared/components/UiPanel.vue'
import SegTabs from '@/shared/components/SegTabs.vue'
import RankBadge from '@/shared/components/RankBadge.vue'
import AppIcon from '@/shared/components/AppIcon.vue'
const router = useRouter()
const tab = ref('연승')
interface Row {
p: Player
streak?: number
}
const rows = computed<Row[]>(() => {
if (tab.value === '연승') {
return SA.hotStreak
.map((e): Row | null => {
const p = SA.allPlayers.find((x) => x.name === e.name)
return p ? { p, streak: e.streak } : null
})
.filter((r): r is Row => r !== null)
}
if (tab.value === '승률') {
return [...SA.allPlayers].sort((a, b) => b.wr - a.wr).slice(0, 6).map((p) => ({ p }))
}
return [...SA.allPlayers].sort((a, b) => b.kda - a.kda).slice(0, 6).map((p) => ({ p }))
})
function open(p: Player) {
router.push({ name: 'profile', params: { name: p.name } })
}
</script>
<template>
<UiPanel
title="폼 좋은 유저"
en="HOT STREAK"
body-pad="12px 14px 14px"
>
<template #right>
<SegTabs
v-model="tab"
:tabs="['연승', '승률', '킬뎃']"
/>
</template>
<div class="hs__grid">
<button
v-for="(row, i) in rows"
:key="row.p.name"
class="hs__card"
@click="open(row.p)"
>
<span
class="hs__rank num"
:class="{ 'hs__rank--top': i < 3 }"
>{{ i + 1 }}</span>
<RankBadge
:rank="row.p.rank"
:size="30"
/>
<div class="hs__info">
<div class="hs__line">
<span class="hs__nm">{{ row.p.name }}</span>
<span
v-if="row.p.clan"
class="hs__clan"
>{{ row.p.clan }}</span>
</div>
<div class="hs__sub">
{{ row.p.rank.name }}
</div>
</div>
<div class="hs__metric">
<span
v-if="tab === '연승'"
class="hs__streak"
>
<AppIcon
name="flame"
:size="14"
stroke="var(--negative)"
/>
<span class="num hs__streakn">{{ row.streak }}</span><span class="hs__streaku">연승</span>
</span>
<span
v-else-if="tab === '승률'"
class="num hs__pct"
:style="{ color: wrColor(row.p.wr) }"
>{{ row.p.wr }}%</span>
<span
v-else
class="num hs__pct hs__pct--pt"
>{{ SA.kdPct(row.p.kda) }}%</span>
</div>
</button>
</div>
</UiPanel>
</template>
<style scoped>
.hs__grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.hs__card {
display: flex;
align-items: center;
gap: 10px;
border: 1px solid var(--line2);
border-radius: var(--r-sm);
background: var(--surf);
padding: 10px 12px;
cursor: pointer;
text-align: left;
font-family: var(--font-body);
}
.hs__rank {
width: 14px;
text-align: center;
font-weight: 700;
font-size: 0.78125rem;
color: var(--t3);
flex-shrink: 0;
}
.hs__rank--top {
color: var(--point);
}
.hs__info {
flex: 1;
min-width: 0;
}
.hs__line {
display: flex;
align-items: center;
gap: 5px;
}
.hs__nm {
font-size: 0.84375rem;
font-weight: 600;
color: var(--ink);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex-shrink: 0;
max-width: 60%;
}
.hs__clan {
font-size: 0.65625rem;
color: var(--t3);
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hs__sub {
font-size: 0.65625rem;
color: var(--t3);
margin-top: 1px;
}
.hs__metric {
flex-shrink: 0;
}
.hs__streak {
display: flex;
align-items: center;
gap: 3px;
color: var(--negative);
}
.hs__streakn {
font-size: 0.875rem;
font-weight: 700;
}
.hs__streaku {
font-size: 0.65625rem;
font-weight: 700;
}
.hs__pct {
font-size: 0.9375rem;
font-weight: 700;
}
.hs__pct--pt {
color: var(--point);
}
</style>
+89
View File
@@ -0,0 +1,89 @@
<script setup lang="ts">
// 오늘의 전장 — 총 매치/모드별/제재. sa-home.jsx LiveStats 포팅.
import { SA } from '@/shared/data/sa'
import UiPanel from '@/shared/components/UiPanel.vue'
const L = SA.liveStats
</script>
<template>
<UiPanel
title="오늘의 전장"
en="TODAY"
body-pad="14px 14px"
>
<div class="ls__head">
<span class="ls__total num">{{ SA.fmt(L.totalMatches) }}</span>
<span class="ls__unit">매치</span>
<span class="ls__delta num"> {{ L.prevDelta }}%</span>
</div>
<div class="ls__list">
<div
v-for="m in L.matchModes"
:key="m.name"
class="ls__row"
>
<span class="ls__name">{{ m.name }}</span>
<span class="ls__val num">{{ SA.fmt(m.count) }}</span>
</div>
<div class="ls__row ls__row--last">
<span class="ls__name">오늘 제재</span>
<span class="ls__val ls__val--ban num">{{ L.bannedToday }}</span>
</div>
</div>
</UiPanel>
</template>
<style scoped>
.ls__head {
display: flex;
align-items: baseline;
gap: 7px;
margin-bottom: 12px;
white-space: nowrap;
}
.ls__total {
font-weight: 700;
font-size: 1.625rem;
color: var(--ink);
letter-spacing: -0.5px;
}
.ls__unit {
font-size: 0.78125rem;
color: var(--t2);
}
.ls__delta {
margin-left: auto;
font-size: 0.75rem;
color: var(--point);
font-weight: 700;
}
.ls__list {
display: flex;
flex-direction: column;
border-top: 1px solid var(--line);
}
.ls__row {
display: flex;
justify-content: space-between;
align-items: baseline;
padding: 8px 0;
border-bottom: 1px solid var(--line3);
}
.ls__row--last {
border-bottom: none;
}
.ls__name {
font-size: 0.78125rem;
color: var(--t2);
}
.ls__val {
font-weight: 600;
font-size: 0.8125rem;
color: var(--ink);
white-space: nowrap;
}
.ls__val--ban {
color: var(--hot);
}
</style>
+175
View File
@@ -0,0 +1,175 @@
<script setup lang="ts">
// 내 전적 요약 — 통합/시즌 토글 + 핵심 지표. sa-home.jsx MeCard 포팅.
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { SA } from '@/shared/data/sa'
import { wrColor } from '@/shared/utils/stats'
import UiPanel from '@/shared/components/UiPanel.vue'
import SegTabs from '@/shared/components/SegTabs.vue'
import RankBadge from '@/shared/components/RankBadge.vue'
const router = useRouter()
const me = SA.me
const scope = ref('통합')
const rankNo = computed(() => (scope.value === '통합' ? me.rankNoTotal : me.rankNoSeason))
const selRank = computed(() => (scope.value === '통합' ? me.rankTotal : me.rankSeason))
const stats = computed(() => [
{ k: '승·패', v: `${me.wins}/${me.losses}`, isWr: false },
{ k: '승률', v: me.wr + '%', isWr: true },
{ k: '킬뎃', v: SA.kdPct(me.kda) + '%', isWr: false },
])
function open() {
router.push({ name: 'profile', params: { name: me.name } })
}
</script>
<template>
<UiPanel
title="내 전적"
en="MY RECORD"
body-pad="14px 14px"
>
<template #right>
<SegTabs
v-model="scope"
:tabs="['통합', '시즌']"
/>
</template>
<div class="me__head">
<RankBadge
:rank="selRank"
:size="46"
/>
<div class="me__id">
<div class="me__name-line">
<span class="me__name">{{ me.name }}</span>
<span class="me__clan">{{ me.clan }}</span>
</div>
<div class="me__rank-line">
<span class="me__rank-label">{{ scope }} 랭킹</span>
<span class="me__rank-no num">{{ SA.fmt(rankNo) }}<span class="me__rank-unit"></span></span>
</div>
</div>
</div>
<div class="me__stats">
<div
v-for="(s, i) in stats"
:key="s.k"
class="me__stat"
:class="{ 'me__stat--alt': i % 2 === 0, 'me__stat--sep': i > 0 }"
>
<div class="me__stat-k">
{{ s.k }}
</div>
<div
class="me__stat-v num"
:style="{ color: s.isWr ? wrColor(me.wr) : 'var(--ink)' }"
>
{{ s.v }}
</div>
</div>
</div>
<button
class="me__btn"
@click="open"
>
전적 자세히 보기
</button>
</UiPanel>
</template>
<style scoped>
.me__head {
display: flex;
align-items: center;
gap: 13px;
}
.me__id {
flex: 1;
min-width: 0;
}
.me__name-line {
display: flex;
align-items: baseline;
gap: 7px;
}
.me__name {
font-size: 1rem;
font-weight: 800;
color: var(--ink);
}
.me__clan {
font-size: 0.6875rem;
color: var(--t3);
font-weight: 600;
}
.me__rank-line {
display: flex;
align-items: center;
gap: 7px;
margin-top: 6px;
}
.me__rank-label {
font-size: 0.71875rem;
color: var(--t3);
font-weight: 600;
}
.me__rank-no {
font-size: 0.9375rem;
font-weight: 700;
color: var(--point);
}
.me__rank-unit {
font-size: 0.6875rem;
color: var(--t3);
font-weight: 500;
margin-left: 1px;
}
.me__stats {
display: flex;
margin-top: 13px;
border: 1px solid var(--line);
border-radius: var(--r-xs);
}
.me__stat {
flex: 1;
text-align: center;
padding: 8px 0;
background: var(--surf);
}
.me__stat--alt {
background: #f7f8f9;
}
.me__stat--sep {
border-left: 1px solid var(--line);
}
.me__stat-k {
font-size: 0.65625rem;
color: var(--t3);
font-weight: 600;
margin-bottom: 3px;
}
.me__stat-v {
font-weight: 700;
font-size: 0.96875rem;
}
.me__btn {
width: 100%;
margin-top: 12px;
padding: 9px 0;
border: 1px solid var(--point);
cursor: pointer;
background: var(--surf);
color: var(--point);
font-weight: 700;
font-size: 0.8125rem;
border-radius: var(--r-xs);
font-family: var(--font-body);
}
</style>
+120
View File
@@ -0,0 +1,120 @@
<script setup lang="ts">
// 모집 중인 클랜 — 세로 포스터 4개. sa-home.jsx RecruitPosters 포팅.
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { buildRecruits } from '@/shared/data/clan'
import UiPanel from '@/shared/components/UiPanel.vue'
import AppIcon from '@/shared/components/AppIcon.vue'
import ImageSlot from '@/shared/components/ImageSlot.vue'
import ClanEmblem from '@/shared/components/ClanEmblem.vue'
const router = useRouter()
const list = computed(() =>
buildRecruits()
.slice()
.sort((a, b) => b.hot - a.hot)
.slice(0, 4),
)
function goPlaza() {
router.push('/clans')
}
function goClan(name: string) {
router.push({ name: 'clan', params: { name } })
}
</script>
<template>
<UiPanel
title="모집 중인 클랜"
en="RECRUIT"
body-pad="14px 14px"
>
<template #right>
<button
class="rp__all"
@click="goPlaza"
>
전체 보기 <AppIcon
name="chevR"
:size="13"
stroke="var(--t2)"
/>
</button>
</template>
<div class="rp__grid">
<div
v-for="rc in list"
:key="rc.name"
class="rp__item"
@click="goClan(rc.name)"
>
<div class="rp__poster">
<ImageSlot placeholder="홍보 포스터" />
</div>
<div class="rp__name">
<ClanEmblem
:cfg="rc.emblem"
:size="20"
/>
<span class="rp__nametxt">{{ rc.name }}</span>
</div>
<div class="rp__sub">
랭킹 {{ rc.rank }} · {{ rc.openings }} 모집
</div>
</div>
</div>
</UiPanel>
</template>
<style scoped>
.rp__all {
display: flex;
align-items: center;
gap: 3px;
border: none;
background: transparent;
cursor: pointer;
font-weight: 700;
font-size: 0.75rem;
color: var(--t2);
padding: 0;
font-family: var(--font-body);
}
.rp__grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
}
.rp__item {
cursor: pointer;
}
.rp__poster {
aspect-ratio: 3 / 4;
border: 1px solid var(--line);
border-radius: var(--r-sm);
overflow: hidden;
background: #f4f5f7;
}
.rp__name {
margin-top: 8px;
display: flex;
align-items: center;
gap: 7px;
}
.rp__nametxt {
font-size: 0.78125rem;
font-weight: 700;
color: var(--ink);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rp__sub {
margin-top: 2px;
font-size: 0.65625rem;
color: var(--t3);
padding-left: 27px;
}
</style>
+64
View File
@@ -0,0 +1,64 @@
<script setup lang="ts">
// SA 소식 — 공지/GM이야기/업데이트 탭. sa-home.jsx SANews 포팅.
import { computed, ref } from 'vue'
import { SA } from '@/shared/data/sa'
import UiPanel from '@/shared/components/UiPanel.vue'
import SegTabs from '@/shared/components/SegTabs.vue'
const tab = ref('공지사항')
const rows = computed(() => SA.news[tab.value] ?? [])
</script>
<template>
<UiPanel
title="SA 소식"
en="NOTICE"
body-pad="2px 14px 6px"
>
<template #right>
<SegTabs
v-model="tab"
:tabs="['공지사항', 'GM이야기', '업데이트']"
/>
</template>
<div
v-for="(r, i) in rows"
:key="i"
class="news__row"
:class="{ 'news__row--line': i < rows.length - 1 }"
>
<span class="news__date num">{{ r.date }}</span>
<span class="news__title">{{ r.title }}</span>
</div>
</UiPanel>
</template>
<style scoped>
.news__row {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 2px;
cursor: pointer;
}
.news__row--line {
border-bottom: 1px solid var(--line3);
}
.news__date {
font-size: 0.78125rem;
color: var(--t3);
font-weight: 600;
width: 42px;
flex-shrink: 0;
}
.news__title {
flex: 1;
font-size: 0.875rem;
color: var(--t-table);
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
+377
View File
@@ -0,0 +1,377 @@
<script setup lang="ts">
// SA 랭킹 — 통합/시즌 계급, 랭크전(솔로/파티/클랜), 공식/일반 클랜. sa-home.jsx SARanking 포팅.
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { SA } from '@/shared/data/sa'
import UiPanel from '@/shared/components/UiPanel.vue'
import RankBadge from '@/shared/components/RankBadge.vue'
const router = useRouter()
const CATS = ['통합 계급', '시즌 계급', '랭크전', '공식 클랜', '클랜']
const cat = ref('통합 계급')
const mode = ref('솔로')
const tcol = computed(() => Object.fromEntries(SA.tiers.map((t) => [t.name, t.color])))
const isClass = computed(() => cat.value === '시즌 계급' || cat.value === '통합 계급')
const isRanked = computed(() => cat.value === '랭크전')
const isRankedUser = computed(() => isRanked.value && mode.value !== '클랜')
const isRankedClan = computed(() => isRanked.value && mode.value === '클랜')
const isClan = computed(() => cat.value === '공식 클랜' || cat.value === '클랜')
const classList = computed(() =>
cat.value === '시즌 계급' ? SA.seasonClassRanking : SA.totalClassRanking,
)
const contentList = computed(() => (mode.value === '솔로' ? SA.soloRpRanking : SA.partyRpRanking))
const clanList = computed(() =>
cat.value === '공식 클랜' ? SA.officialClanRanking : SA.generalClanRanking,
)
function findP(name: string) {
return SA.allPlayers.find((x) => x.name === name)
}
function openPlayer(name: string) {
const p = findP(name)
if (p) router.push({ name: 'profile', params: { name: p.name } })
}
function openClan(name: string) {
router.push({ name: 'clan', params: { name } })
}
</script>
<template>
<UiPanel
title="SA 랭킹"
en="RANKING"
body-pad="11px 0 0"
>
<template #right>
<span class="sr__cap">TOP 10</span>
</template>
<!-- 카테고리 -->
<div class="sr__cats">
<button
v-for="c in CATS"
:key="c"
class="sr__cat"
:class="{ 'sr__cat--on': c === cat }"
@click="cat = c"
>
{{ c }}
</button>
</div>
<!-- 랭크전 하위 토글 -->
<div
v-if="isRanked"
class="sr__modewrap"
>
<div class="sr__mode">
<button
v-for="(t, i) in ['솔로', '파티', '클랜']"
:key="t"
class="sr__mode-btn"
:class="{ 'sr__mode-btn--on': mode === t, 'sr__mode-btn--sep': i > 0 }"
@click="mode = t"
>
{{ t }}
</button>
</div>
</div>
<!-- 계급 랭킹 -->
<template v-if="isClass">
<button
v-for="(e, i) in classList"
:key="e.name"
class="sr__row"
:class="{ 'sr__row--alt': i % 2 === 1 }"
@click="openPlayer(e.name)"
>
<span
class="sr__no num"
:class="{ 'sr__no--top': i < 3 }"
>{{ i + 1 }}</span>
<RankBadge
:rank="findP(e.name)?.rank ?? null"
:size="28"
/>
<div class="sr__info">
<div class="sr__name-line">
<span class="sr__name">{{ e.name }}</span>
<span
v-if="findP(e.name)?.clan"
class="sr__tag"
>{{ findP(e.name)?.clan }}</span>
</div>
<div class="sr__sub">
{{ findP(e.name)?.rank.name }}
</div>
</div>
<span class="sr__val num">{{ SA.fmt(e.score) }}<span class="sr__unit"></span></span>
</button>
</template>
<!-- 랭크전(솔로/파티) -->
<template v-if="isRankedUser">
<button
v-for="(e, i) in contentList"
:key="e.name"
class="sr__row"
:class="{ 'sr__row--alt': i % 2 === 1 }"
@click="openPlayer(e.name)"
>
<span
class="sr__no num"
:class="{ 'sr__no--top': i < 3 }"
>{{ i + 1 }}</span>
<div
class="sr__tier"
:style="{ background: tcol[e.tier] || 'var(--point)' }"
>
{{ e.tier[0] }}
</div>
<div class="sr__info">
<div class="sr__name-line">
<span class="sr__name">{{ e.name }}</span>
<span
v-if="findP(e.name)?.clan"
class="sr__tag"
>{{ findP(e.name)?.clan }}</span>
</div>
<div
class="sr__sub"
:style="{ color: tcol[e.tier], fontWeight: 600 }"
>
{{ e.tier }}
</div>
</div>
<span class="sr__val num">{{ SA.fmt(e.rp) }}<span class="sr__unit">RP</span></span>
</button>
</template>
<!-- 랭크전(클랜) -->
<template v-if="isRankedClan">
<button
v-for="(c, i) in SA.clanRpRanking"
:key="c.name"
class="sr__row"
:class="{ 'sr__row--alt': i % 2 === 1 }"
@click="openClan(c.name)"
>
<span
class="sr__no num"
:class="{ 'sr__no--top': i < 3 }"
>{{ i + 1 }}</span>
<div class="sr__clantag num">
{{ c.tag }}
</div>
<div class="sr__info">
<div class="sr__name">
{{ c.name }}
</div>
<div class="sr__sub">
{{ c.members }}
</div>
</div>
<span class="sr__val num">{{ SA.fmt(c.rp) }}<span class="sr__unit">RP</span></span>
</button>
</template>
<!-- 클랜 랭킹 -->
<template v-if="isClan">
<button
v-for="(c, i) in clanList"
:key="c.name"
class="sr__row"
:class="{ 'sr__row--alt': i % 2 === 1 }"
@click="openClan(c.name)"
>
<span
class="sr__no num"
:class="{ 'sr__no--top': i < 3 }"
>{{ i + 1 }}</span>
<div class="sr__clantag num">
{{ c.tag }}
</div>
<div class="sr__info">
<div class="sr__name-line">
<span class="sr__name">{{ c.name }}</span>
<span
v-if="cat === '공식 클랜'"
class="sr__cert"
>인증</span>
</div>
<div class="sr__sub">
{{ c.members }}
</div>
</div>
<span class="sr__val num">{{ SA.fmt(c.rp) }}<span class="sr__unit">RP</span></span>
</button>
</template>
</UiPanel>
</template>
<style scoped>
.sr__cap {
font-size: 0.6875rem;
color: var(--t3);
font-weight: 600;
}
.sr__cats {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 0 14px 10px;
}
.sr__cat {
padding: 5px 10px;
border-radius: var(--r-xs);
border: 1px solid var(--line2);
cursor: pointer;
font-weight: 700;
font-size: 0.71875rem;
background: var(--surf);
color: var(--t2);
font-family: var(--font-body);
}
.sr__cat--on {
border-color: var(--point);
background: var(--point);
color: var(--point-ink);
}
.sr__modewrap {
padding: 0 14px 11px;
}
.sr__mode {
display: flex;
border: 1px solid var(--line2);
border-radius: var(--r-xs);
overflow: hidden;
background: var(--surf);
}
.sr__mode-btn {
flex: 1;
padding: 6px 0;
border: none;
cursor: pointer;
font-weight: 700;
font-size: 0.75rem;
background: var(--surf);
color: var(--t2);
font-family: var(--font-body);
}
.sr__mode-btn--sep {
border-left: 1px solid var(--line2);
}
.sr__mode-btn--on {
background: var(--point);
color: var(--point-ink);
}
.sr__row {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
border: none;
background: var(--surf);
padding: 8px 14px;
cursor: pointer;
text-align: left;
border-bottom: 1px solid var(--line3);
font-family: var(--font-body);
}
.sr__row--alt {
background: #f7f8f9;
}
.sr__no {
width: 16px;
text-align: center;
font-weight: 700;
font-size: 0.84375rem;
color: var(--t3);
flex-shrink: 0;
}
.sr__no--top {
color: var(--point);
}
.sr__tier {
width: 28px;
height: 28px;
border-radius: var(--r-xs);
display: grid;
place-items: center;
color: #fff;
font-weight: 800;
font-size: 0.78125rem;
flex-shrink: 0;
}
.sr__clantag {
width: 28px;
height: 28px;
border-radius: var(--r-xs);
background: var(--dark);
display: grid;
place-items: center;
font-weight: 700;
font-size: 0.625rem;
color: #fff;
flex-shrink: 0;
}
.sr__info {
flex: 1;
min-width: 0;
}
.sr__name-line {
display: flex;
align-items: center;
gap: 5px;
}
.sr__name {
font-size: 0.84375rem;
color: var(--ink);
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex-shrink: 0;
max-width: 60%;
}
.sr__tag {
font-size: 0.65625rem;
color: var(--t3);
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sr__cert {
font-size: 0.5625rem;
font-weight: 700;
color: var(--point);
border: 1px solid var(--point);
border-radius: 3px;
padding: 0 4px;
line-height: 1.5;
}
.sr__sub {
font-size: 0.65625rem;
color: var(--t3);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sr__val {
font-weight: 700;
font-size: 0.8125rem;
color: var(--ink);
flex-shrink: 0;
white-space: nowrap;
}
.sr__unit {
font-size: 0.625rem;
color: var(--t3);
font-weight: 500;
margin-left: 2px;
}
</style>
+149
View File
@@ -0,0 +1,149 @@
<script setup lang="ts">
// 인기 검색 — 24시간 조회수 랭킹 + 추세(▲▼/NEW). sa-home.jsx SearchRank 포팅.
import { useRouter } from 'vue-router'
import { SA } from '@/shared/data/sa'
import UiPanel from '@/shared/components/UiPanel.vue'
const router = useRouter()
function findP(name: string) {
return SA.allPlayers.find((x) => x.name === name)
}
function open(name: string) {
const p = findP(name)
if (p) router.push({ name: 'profile', params: { name: p.name } })
}
</script>
<template>
<UiPanel
title="인기 검색"
en="TRENDING"
body-pad="4px 0 0"
>
<template #right>
<span class="tr__cap">최근 24시간</span>
</template>
<button
v-for="(s, i) in SA.searchTrending"
:key="s.name"
class="tr__row"
:class="{ 'tr__row--alt': i % 2 === 1 }"
@click="open(s.name)"
>
<span
class="tr__no num"
:class="{ 'tr__no--top': i < 3 }"
>{{ i + 1 }}</span>
<span class="tr__move num">
<template v-if="s.move === 'new'"><span class="tr__new">NEW</span></template>
<template v-else-if="s.move === 0"><span class="tr__flat"></span></template>
<template v-else>
<span :class="(s.move as number) > 0 ? 'tr__up' : 'tr__down'">
{{ (s.move as number) > 0 ? '▲' : '▼' }}{{ Math.abs(s.move as number) }}
</span>
</template>
</span>
<div class="tr__info">
<span class="tr__name">{{ s.name }}</span>
<span
v-if="findP(s.name)?.clan"
class="tr__clan"
>{{ findP(s.name)?.clan }}</span>
</div>
<span class="tr__cnt num">{{ SA.fmt(s.count) }}<span class="tr__cntu"> 조회</span></span>
</button>
</UiPanel>
</template>
<style scoped>
.tr__cap {
font-size: 0.6875rem;
color: var(--t3);
font-weight: 600;
}
.tr__row {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
border: none;
background: var(--surf);
padding: 8px 14px;
cursor: pointer;
text-align: left;
border-bottom: 1px solid var(--line3);
font-family: var(--font-body);
}
.tr__row--alt {
background: #f7f8f9;
}
.tr__no {
width: 16px;
text-align: center;
font-weight: 700;
font-size: 0.84375rem;
color: var(--t3);
}
.tr__no--top {
color: var(--point);
}
.tr__move {
width: 28px;
text-align: center;
flex-shrink: 0;
font-size: 0.6875rem;
font-weight: 700;
}
.tr__new {
color: var(--hot);
font-size: 0.59375rem;
}
.tr__flat {
color: var(--t3);
font-weight: 400;
}
.tr__up {
color: var(--point);
}
.tr__down {
color: var(--negative);
}
.tr__info {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 6px;
}
.tr__name {
font-size: 0.84375rem;
color: var(--ink);
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex-shrink: 0;
max-width: 60%;
}
.tr__clan {
font-size: 0.65625rem;
color: var(--t3);
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tr__cnt {
font-size: 0.75rem;
color: var(--t2);
font-weight: 600;
flex-shrink: 0;
}
.tr__cntu {
font-size: 0.625rem;
color: var(--t3);
font-weight: 500;
}
</style>
+187
View File
@@ -0,0 +1,187 @@
<script setup lang="ts">
// 시즌 현황 — 진행률 + 시즌 맵(5v5/3v3). sa-home.jsx SeasonCard 포팅.
import { computed, ref } from 'vue'
import { SA } from '@/shared/data/sa'
import UiPanel from '@/shared/components/UiPanel.vue'
const s = SA.season
const mapTab = ref('5vs5')
const maps = computed(() => (mapTab.value === '5vs5' ? s.maps5v5 : s.maps3v3))
</script>
<template>
<UiPanel
title="시즌 현황"
en="SEASON"
body-pad="14px 14px"
>
<template #right>
<span class="sc__dday num">{{ s.dday }}</span>
</template>
<div class="sc__head">
<div class="sc__badge num">
S2
</div>
<div class="sc__id">
<div class="sc__name">
{{ s.name }}
</div>
<div class="sc__period">
{{ s.period }} 종료
</div>
</div>
</div>
<div class="sc__bar">
<div
class="sc__fill"
:style="{ width: s.progress + '%' }"
/>
</div>
<div class="sc__progress">
<span>시즌 진행</span><span class="num sc__pct">{{ s.progress }}%</span>
</div>
<div class="sc__maps">
<div class="sc__maps-head">
<div class="sc__maps-label">
시즌
</div>
<div class="sc__toggle">
<button
v-for="(t, i) in ['5vs5', '3vs3']"
:key="t"
class="sc__toggle-btn num"
:class="{ 'sc__toggle-btn--on': mapTab === t, 'sc__toggle-btn--sep': i > 0 }"
@click="mapTab = t"
>
{{ t }}
</button>
</div>
</div>
<div class="sc__maplist">
<span
v-for="m in maps"
:key="m"
class="sc__map"
>{{ m }}</span>
</div>
</div>
</UiPanel>
</template>
<style scoped>
.sc__dday {
font-size: 0.78125rem;
font-weight: 700;
color: var(--point);
}
.sc__head {
display: flex;
align-items: center;
gap: 11px;
margin-bottom: 13px;
}
.sc__badge {
width: 42px;
height: 42px;
border-radius: 6px;
background: var(--dark);
display: grid;
place-items: center;
color: #fff;
font-weight: 700;
font-size: 0.875rem;
flex-shrink: 0;
}
.sc__id {
flex: 1;
min-width: 0;
}
.sc__name {
font-size: 0.875rem;
font-weight: 800;
color: var(--ink);
line-height: 1.25;
}
.sc__period {
font-size: 0.71875rem;
color: var(--t3);
margin-top: 2px;
}
.sc__bar {
height: 7px;
background: #d4d8de;
border-radius: 4px;
overflow: hidden;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.05);
}
.sc__fill {
height: 100%;
background: var(--point);
border-radius: 4px;
}
.sc__progress {
display: flex;
justify-content: space-between;
font-size: 0.6875rem;
color: var(--t3);
margin-top: 5px;
}
.sc__pct {
color: var(--t2);
font-weight: 600;
}
.sc__maps {
margin-top: 14px;
}
.sc__maps-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 7px;
}
.sc__maps-label {
font-size: 0.65625rem;
color: var(--t3);
font-weight: 600;
}
.sc__toggle {
display: flex;
border: 1px solid var(--line2);
border-radius: var(--r-xs);
overflow: hidden;
background: var(--surf);
}
.sc__toggle-btn {
padding: 3px 10px;
border: none;
cursor: pointer;
font-weight: 700;
font-size: 0.65625rem;
background: var(--surf);
color: var(--t2);
}
.sc__toggle-btn--sep {
border-left: 1px solid var(--line2);
}
.sc__toggle-btn--on {
background: var(--point);
color: var(--point-ink);
}
.sc__maplist {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.sc__map {
padding: 4px 10px;
border-radius: var(--r-xs);
background: var(--surf);
border: 1px solid var(--line2);
font-size: 0.71875rem;
color: var(--t2);
font-weight: 500;
}
</style>
+223
View File
@@ -0,0 +1,223 @@
<script setup lang="ts">
// 서든 LIVE — 치지직/숲/유튜브 방송. 플랫폼 필터 + 시청자 합계. sa-home.jsx StreamLive 포팅.
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { SA } from '@/shared/data/sa'
import type { StreamPlatform } from '@/shared/data/types'
import UiPanel from '@/shared/components/UiPanel.vue'
import SegTabs from '@/shared/components/SegTabs.vue'
import ImageSlot from '@/shared/components/ImageSlot.vue'
const router = useRouter()
const tab = ref('전체')
const list = computed(() =>
tab.value === '전체' ? SA.streams : SA.streams.filter((s) => s.platform === tab.value),
)
const totalView = SA.streams.reduce((a, s) => a + s.viewers, 0)
function platform(name: string): StreamPlatform {
return SA.streamPlatforms[name] as StreamPlatform
}
function openStreamer(name: string) {
const p = SA.allPlayers.find((x) => x.name === name)
if (p) router.push({ name: 'profile', params: { name: p.name } })
}
</script>
<template>
<UiPanel
title="서든 LIVE"
en="STREAMING"
body-pad="12px 14px 14px"
>
<template #right>
<SegTabs
v-model="tab"
:tabs="['전체', '치지직', '숲', '유튜브']"
/>
</template>
<div class="sl__summary">
<span class="sl__livetag">
<span class="sl__dot" />LIVE
</span>
<span>{{ list.length }} 방송</span>
<span class="sl__mid">·</span>
<span>시청자 <b class="num">{{ SA.fmt(totalView) }}</b></span>
</div>
<div class="sl__grid">
<button
v-for="(s, i) in list"
:key="s.streamer + i"
class="sl__card"
@click="openStreamer(s.streamer)"
>
<div class="sl__thumb">
<ImageSlot :placeholder="`${s.streamer} 방송 썸네일`" />
<span class="sl__live num"><span class="sl__livedot" />LIVE</span>
<span
class="sl__pf num"
:style="{ background: platform(s.platform).color }"
>{{ platform(s.platform).tag }}</span>
<span class="sl__view num">👁 {{ SA.fmt(s.viewers) }}</span>
</div>
<div class="sl__info">
<div class="sl__title">
{{ s.title }}
</div>
<div class="sl__meta">
<span
class="sl__plabel"
:style="{ color: platform(s.platform).color }"
>{{ platform(s.platform).label }}</span>
<span class="sl__bar" />
<span class="sl__streamer">{{ s.streamer }}</span>
<span class="sl__started">{{ s.started }}</span>
</div>
</div>
</button>
</div>
</UiPanel>
</template>
<style scoped>
.sl__summary {
display: flex;
align-items: center;
gap: 7px;
margin-bottom: 11px;
font-size: 0.71875rem;
color: var(--t2);
}
.sl__livetag {
display: inline-flex;
align-items: center;
gap: 5px;
font-weight: 700;
color: var(--negative);
}
.sl__dot {
width: 7px;
height: 7px;
border-radius: 4px;
background: var(--negative);
display: inline-block;
}
.sl__mid {
color: var(--t3);
}
.sl__summary b {
color: var(--ink);
}
.sl__grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.sl__card {
display: flex;
flex-direction: column;
text-align: left;
border: 1px solid var(--line2);
border-radius: var(--r-sm);
overflow: hidden;
background: var(--surf);
cursor: pointer;
padding: 0;
font-family: var(--font-body);
}
.sl__thumb {
position: relative;
aspect-ratio: 7 / 4;
}
.sl__live {
position: absolute;
top: 7px;
left: 7px;
display: inline-flex;
align-items: center;
gap: 4px;
background: var(--negative);
color: #fff;
font-weight: 700;
font-size: 0.59375rem;
letter-spacing: 0.6px;
padding: 2px 6px;
border-radius: 3px;
}
.sl__livedot {
width: 5px;
height: 5px;
border-radius: 3px;
background: #fff;
display: inline-block;
}
.sl__pf {
position: absolute;
top: 7px;
right: 7px;
color: #fff;
font-weight: 700;
font-size: 0.59375rem;
letter-spacing: 0.5px;
padding: 2px 7px;
border-radius: 3px;
}
.sl__view {
position: absolute;
bottom: 7px;
right: 7px;
background: rgba(20, 22, 28, 0.82);
color: #fff;
font-weight: 700;
font-size: 0.65625rem;
padding: 2px 7px;
border-radius: 3px;
}
.sl__info {
padding: 9px 10px 10px;
}
.sl__title {
font-size: 0.78125rem;
font-weight: 700;
color: var(--ink);
line-height: 1.34;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
min-height: 33px;
}
.sl__meta {
display: flex;
align-items: center;
gap: 6px;
margin-top: 7px;
}
.sl__plabel {
font-size: 0.5625rem;
font-weight: 800;
letter-spacing: 0.3px;
}
.sl__bar {
width: 1px;
height: 9px;
background: var(--line2);
}
.sl__streamer {
font-size: 0.71875rem;
font-weight: 600;
color: var(--t2);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sl__started {
margin-left: auto;
font-size: 0.65625rem;
color: var(--t3);
flex-shrink: 0;
}
</style>
+236
View File
@@ -0,0 +1,236 @@
<script setup lang="ts">
// 클랜전 전적 — 소속 클랜별 행, 클릭 시 활동 기간 펼침. sa-profile-sections.jsx ClanRecords 포팅.
import { computed, ref } from 'vue'
import type { ClanRecord, Player } from '@/shared/data/types'
import UiPanel from '@/shared/components/UiPanel.vue'
import AppIcon from '@/shared/components/AppIcon.vue'
import WrCell from '@/shared/components/WrCell.vue'
interface Props {
p: Player
}
const props = defineProps<Props>()
const CAP = 4
const open = ref(-1)
const showAll = ref(false)
const all = props.p.clanRecords
const list = computed(() => (showAll.value ? all : all.slice(0, CAP)))
function toggle(i: number) {
open.value = open.value === i ? -1 : i
}
function recStr(d: ClanRecord) {
return `${d.matches}${d.w}${d.l}`
}
</script>
<template>
<UiPanel
title="클랜전 전적"
en="CLAN"
body-pad="0"
>
<template #right>
<span class="cr__hint">소속 클랜별 · 클릭 활동 기간</span>
</template>
<div class="rectable-scroll">
<table
class="rectable"
style="min-width: 600px"
>
<thead>
<tr>
<th>클랜</th>
<th class="ta-r">
매치
</th>
<th class="ta-r">
전적
</th>
<th class="ta-r">
승률
</th>
<th class="ta-r">
킬뎃
</th>
</tr>
</thead>
<tbody>
<template
v-for="(c, i) in list"
:key="c.clan + i"
>
<tr
class="cr__row"
:class="{ 'cr__row--open': open === i, 'cr__row--alt': i % 2 === 1 }"
@click="toggle(i)"
>
<td>
<span class="cr__clan">
<AppIcon
name="chevR"
:size="13"
stroke="var(--t3)"
:style="{ transform: open === i ? 'rotate(90deg)' : 'none', transition: 'transform .15s' }"
/>
<span class="cr__tag num">{{ c.tag }}</span>
<span class="cr__name">{{ c.clan }}</span>
<span
v-if="i === 0"
class="cr__cur"
>현재</span>
<span class="cr__joins">가입 {{ c.periods.length }}</span>
</span>
</td>
<td class="ta-r td-num">
{{ c.matches }}
</td>
<td class="ta-r td-rec">
{{ recStr(c) }}
</td>
<td class="ta-r">
<WrCell :wr="c.wr" />
</td>
<td class="ta-r td-kd">
{{ c.kd }}%
</td>
</tr>
<tr
v-if="open === i"
:key="c.clan + i + '-exp'"
>
<td
colspan="5"
class="cr__exp"
>
<div class="cr__exp-head">
활동 기간 · {{ c.periods.length }} 가입
</div>
<div class="cr__periods">
<div
v-for="(pd, j) in c.periods"
:key="j"
class="cr__period"
>
<span class="cr__period-no num">{{ c.periods.length - j }}</span>
<AppIcon
name="clock"
:size="14"
stroke="var(--t3)"
/>
<span class="cr__period-range num">{{ pd.from }} ~ {{ pd.to }}</span>
<span class="cr__period-months"> {{ pd.months }}개월</span>
</div>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<button
v-if="all.length > CAP"
class="rectable-more"
@click="showAll = !showAll"
>
{{ showAll ? '접기 ▲' : `소속했던 클랜 더보기 +${all.length - CAP}` }}
</button>
</UiPanel>
</template>
<style scoped>
.cr__hint {
font-size: 0.6875rem;
color: var(--t3);
font-weight: 600;
}
.cr__row {
border-bottom: 1px solid var(--line3);
cursor: pointer;
}
.cr__row--alt {
background: #fbfbfc;
}
.cr__row--open {
background: var(--surf2);
}
.cr__clan {
display: inline-flex;
align-items: center;
gap: 9px;
}
.cr__tag {
width: 26px;
height: 26px;
border-radius: var(--r-xs);
background: var(--dark);
display: grid;
place-items: center;
font-weight: 700;
font-size: 0.59375rem;
color: #fff;
}
.cr__name {
font-weight: 700;
color: var(--ink);
}
.cr__cur {
font-size: 0.59375rem;
font-weight: 700;
color: var(--point);
border: 1px solid var(--point);
border-radius: 3px;
padding: 0 5px;
}
.cr__joins {
font-size: 0.65625rem;
color: var(--t3);
}
.cr__exp {
padding: 13px 16px 15px;
background: #f7f8f9;
border-bottom: 1px solid var(--line3);
}
.cr__exp-head {
font-size: 0.6875rem;
font-weight: 700;
color: var(--t3);
margin-bottom: 9px;
letter-spacing: 0.2px;
}
.cr__periods {
display: flex;
flex-direction: column;
gap: 6px;
}
.cr__period {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
background: var(--surf);
border: 1px solid var(--line);
border-radius: var(--r-sm);
}
.cr__period-no {
font-weight: 700;
font-size: 0.71875rem;
color: var(--point-ink);
background: var(--point);
border-radius: 3px;
padding: 1px 7px;
flex-shrink: 0;
}
.cr__period-range {
font-size: 0.8125rem;
color: var(--ink);
font-weight: 700;
}
.cr__period-months {
font-size: 0.75rem;
color: var(--t3);
}
</style>
+127
View File
@@ -0,0 +1,127 @@
<script setup lang="ts">
// 자주 함께한 유저 — 함께한 전적/승률. sa-profile-sections.jsx Companions 포팅.
import { useRouter } from 'vue-router'
import { SA } from '@/shared/data/sa'
import { wrColor } from '@/shared/utils/stats'
import type { Player } from '@/shared/data/types'
import UiPanel from '@/shared/components/UiPanel.vue'
import RankBadge from '@/shared/components/RankBadge.vue'
import AppIcon from '@/shared/components/AppIcon.vue'
const router = useRouter()
const friends = SA.friends.slice(0, 4)
function togetherWr(f: Player) {
return Math.round((f.together.w / f.together.games) * 100)
}
function open(f: Player) {
router.push({ name: 'profile', params: { name: f.name } })
}
</script>
<template>
<UiPanel
title="자주 함께한 유저"
en="WITH"
body-pad="6px 8px 8px"
>
<button
v-for="f in friends"
:key="f.name"
class="cp__row"
@click="open(f)"
>
<RankBadge
:rank="f.rank"
:size="30"
/>
<div class="cp__info">
<div class="cp__line">
<span class="cp__nm">{{ f.name }}</span>
<span
v-if="f.clan"
class="cp__clan"
>{{ f.clan }}</span>
</div>
<div class="cp__sub">
함께 {{ f.together.games }}<span class="cp__mid">·</span>
<b class="num cp__w">{{ f.together.w }}</b> <b class="num cp__l">{{ f.together.l }}</b>
<span class="cp__mid">·</span>
<span
class="num cp__wr"
:style="{ color: wrColor(togetherWr(f)) }"
>승률 {{ togetherWr(f) }}%</span>
</div>
</div>
<AppIcon
name="chevR"
:size="15"
stroke="var(--t3)"
/>
</button>
</UiPanel>
</template>
<style scoped>
.cp__row {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
border: none;
background: transparent;
padding: 8px;
border-radius: var(--r-sm);
cursor: pointer;
text-align: left;
font-family: var(--font-body);
}
.cp__row:hover {
background: #f5f6f8;
}
.cp__info {
flex: 1;
min-width: 0;
}
.cp__line {
display: flex;
align-items: center;
gap: 5px;
}
.cp__nm {
font-size: 0.8125rem;
color: var(--ink);
font-weight: 600;
flex-shrink: 0;
max-width: 58%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cp__clan {
font-size: 0.65625rem;
color: var(--t3);
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cp__sub {
font-size: 0.6875rem;
color: var(--t3);
margin-top: 1px;
}
.cp__mid {
color: var(--line2);
margin: 0 5px;
}
.cp__w {
color: var(--point);
}
.cp__l {
color: var(--negative);
}
.cp__wr {
font-weight: 600;
}
</style>
+117
View File
@@ -0,0 +1,117 @@
<script setup lang="ts">
// 맵별 전적 — 매치 많은 순. sa-profile-sections.jsx MapRecords 포팅.
import { computed, ref } from 'vue'
import type { MapRecord, Player } from '@/shared/data/types'
import UiPanel from '@/shared/components/UiPanel.vue'
import WrCell from '@/shared/components/WrCell.vue'
interface Props {
p: Player
}
const props = defineProps<Props>()
const CAP = 8
const showAll = ref(false)
const all = props.p.mapRecords
const list = computed(() => (showAll.value ? all : all.slice(0, CAP)))
function recStr(d: MapRecord) {
return `${d.matches}${d.w}${d.l}`
}
</script>
<template>
<UiPanel
title="맵별 전적"
en="BY MAP"
body-pad="0"
>
<template #right>
<span class="mr__hint">매치 많은 · {{ all.length }} </span>
</template>
<div class="rectable-scroll">
<table
class="rectable"
style="min-width: 560px"
>
<thead>
<tr>
<th style="width: 30px">
#
</th>
<th></th>
<th class="ta-r">
매치
</th>
<th class="ta-r">
전적
</th>
<th class="ta-r">
승률
</th>
<th class="ta-r">
킬뎃
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(m, i) in list"
:key="m.name + i"
:class="{ 'mr__row--alt': i % 2 === 1 }"
>
<td class="ta-c td-num mr__rank">
{{ i + 1 }}
</td>
<td class="td-strong">
{{ m.name }}
</td>
<td class="ta-r td-num">
{{ m.matches }}
</td>
<td class="ta-r td-rec">
{{ recStr(m) }}
</td>
<td class="ta-r">
<WrCell :wr="m.wr" />
</td>
<td class="ta-r td-kd">
{{ m.kd }}%
</td>
</tr>
</tbody>
</table>
</div>
<button
v-if="all.length > CAP"
class="rectable-more"
@click="showAll = !showAll"
>
{{ showAll ? '접기 ▲' : `맵 전체 보기 +${all.length - CAP}` }}
</button>
</UiPanel>
</template>
<style scoped>
.mr__hint {
font-size: 0.6875rem;
color: var(--t3);
font-weight: 600;
}
.rectable tbody tr {
border-bottom: 1px solid var(--line3);
}
.rectable tbody tr:last-child {
border-bottom: none;
}
.mr__row--alt {
background: #fbfbfc;
}
.mr__rank {
font-weight: 700;
font-size: 0.71875rem;
color: var(--t3);
}
</style>
+100
View File
@@ -0,0 +1,100 @@
<script setup lang="ts">
// 매치 기록 — 모드 필터 + 매치 행 목록. sa-profile-sections.jsx MatchHistory 포팅.
import { computed, ref, watch } from 'vue'
import type { Player } from '@/shared/data/types'
import UiPanel from '@/shared/components/UiPanel.vue'
import MatchRow from './MatchRow.vue'
interface Props {
p: Player
}
const props = defineProps<Props>()
const TABS = ['전체', '클랜전', '솔로 랭크전', '파티 랭크전', '클랜 랭크전', '토너먼트']
const tab = ref('전체')
// 다른 유저로 전환 시 탭 초기화
watch(() => props.p.name, () => (tab.value = '전체'))
const ms = computed(() =>
tab.value === '전체' ? props.p.matches : props.p.matches.filter((m) => m.mode === tab.value),
)
const wins = computed(() => ms.value.filter((m) => m.win).length)
</script>
<template>
<UiPanel
title="매치 기록"
en="MATCHES"
body-pad="0"
>
<template #right>
<span class="mh__hint">
{{ ms.length ? `최근 ${ms.length}전 · ${wins}${ms.length - wins}패 · 클릭 시 상세` : '클릭 시 상세' }}
</span>
</template>
<div class="mh__tabs">
<button
v-for="t in TABS"
:key="t"
class="mh__tab"
:class="{ 'mh__tab--on': tab === t }"
@click="tab = t"
>
{{ t }}
</button>
</div>
<MatchRow
v-for="(m, i) in ms"
:key="i"
:m="m"
:last="i === ms.length - 1"
/>
<div
v-if="ms.length === 0"
class="mh__empty"
>
해당 모드의 최근 매치가 없어요.
</div>
</UiPanel>
</template>
<style scoped>
.mh__hint {
font-size: 0.6875rem;
color: var(--t3);
font-weight: 600;
}
.mh__tabs {
display: flex;
gap: 6px;
padding: 11px 14px;
border-bottom: 1px solid var(--line);
overflow-x: auto;
white-space: nowrap;
}
.mh__tab {
flex-shrink: 0;
padding: 6px 13px;
border-radius: var(--r-pill);
cursor: pointer;
font-weight: 700;
font-size: 0.75rem;
border: 1px solid var(--line2);
background: var(--surf);
color: var(--t2);
font-family: var(--font-body);
}
.mh__tab--on {
border-color: var(--point);
background: var(--point);
color: var(--point-ink);
}
.mh__empty {
padding: 26px 0;
text-align: center;
font-size: 0.8125rem;
color: var(--t3);
}
</style>
+164
View File
@@ -0,0 +1,164 @@
<script setup lang="ts">
// 매치 1건 행 — 승/패 + 맵/모드 + KDA, 클릭 시 양 팀 상세. sa-profile-sections.jsx MatchRow 포팅.
import { computed, ref } from 'vue'
import type { MatchRecord } from '@/shared/data/types'
import KdaChip from '@/shared/components/KdaChip.vue'
import AppIcon from '@/shared/components/AppIcon.vue'
import TeamTable from './TeamTable.vue'
interface Props {
m: MatchRecord
last: boolean
}
const props = defineProps<Props>()
const open = ref(false)
const pct = computed(() => Math.round((props.m.k / (props.m.k + props.m.d)) * 100))
const winTeam = computed(() => (props.m.winSide === 'blue' ? props.m.teams.blue : props.m.teams.red))
const loseTeam = computed(() => (props.m.winSide === 'blue' ? props.m.teams.red : props.m.teams.blue))
</script>
<template>
<div
class="mrow"
:class="{ 'mrow--last': last }"
>
<div
class="mrow__head"
:class="{ 'mrow__head--open': open }"
:style="{ borderLeftColor: m.win ? 'var(--point)' : 'var(--negative)' }"
@click="open = !open"
>
<div class="mrow__result">
<span
class="num"
:style="{ color: m.win ? 'var(--point)' : 'var(--negative)' }"
>{{ m.win ? '승' : '패' }}</span>
</div>
<div class="mrow__map">
<div class="mrow__mapname">
{{ m.map }}
</div>
<div class="mrow__mode">
{{ m.mode }}
</div>
</div>
<div class="mrow__kda">
<KdaChip
:k="m.k"
:d="m.d"
:a="m.a"
/>
<span class="mrow__pct">킬뎃 {{ pct }}%</span>
</div>
<div class="mrow__right">
<span class="mrow__ago">{{ m.ago }}</span>
<AppIcon
name="chevR"
:size="15"
stroke="var(--t3)"
:style="{ transform: open ? 'rotate(90deg)' : 'none', transition: 'transform .15s' }"
/>
</div>
</div>
<div
v-if="open"
class="mrow__detail"
>
<div class="mrow__team">
<TeamTable
result="승리"
color="var(--point)"
:players="winTeam"
/>
</div>
<div class="mrow__team">
<TeamTable
result="패배"
color="var(--negative)"
:players="loseTeam"
/>
</div>
</div>
</div>
</template>
<style scoped>
.mrow {
border-bottom: 1px solid #f1f2f4;
}
.mrow--last {
border-bottom: none;
}
.mrow__head {
display: flex;
align-items: center;
gap: 14px;
padding: 11px 16px;
cursor: pointer;
border-left: 3px solid;
}
.mrow__head--open {
background: #f7f8f9;
}
.mrow__result {
width: 40px;
flex-shrink: 0;
}
.mrow__result span {
font-weight: 800;
font-size: 0.8125rem;
}
.mrow__map {
width: 132px;
min-width: 0;
flex-shrink: 0;
}
.mrow__mapname {
font-size: 0.8125rem;
color: var(--ink);
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mrow__mode {
font-size: 0.6875rem;
color: var(--t3);
}
.mrow__kda {
flex: 1;
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.mrow__pct {
font-size: 0.71875rem;
color: var(--t3);
}
.mrow__right {
text-align: right;
flex-shrink: 0;
display: flex;
align-items: center;
gap: 10px;
}
.mrow__ago {
font-size: 0.6875rem;
color: var(--t3);
}
.mrow__detail {
padding: 14px 16px 16px;
background: #f7f8f9;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
.mrow__team {
border: 1px solid var(--line);
border-radius: var(--r-md);
overflow: hidden;
background: var(--surf);
}
</style>
@@ -0,0 +1,83 @@
<script setup lang="ts">
// 모드별 전적. sa-profile-sections.jsx ModeRecords 포팅.
import type { ModeRecord, Player } from '@/shared/data/types'
import UiPanel from '@/shared/components/UiPanel.vue'
import WrCell from '@/shared/components/WrCell.vue'
interface Props {
p: Player
}
const props = defineProps<Props>()
function recStr(d: ModeRecord) {
return `${d.matches}${d.w}${d.l}`
}
</script>
<template>
<UiPanel
title="모드별 전적"
en="BY MODE"
body-pad="0"
>
<div class="rectable-scroll">
<table
class="rectable"
style="min-width: 560px"
>
<thead>
<tr>
<th>모드</th>
<th class="ta-r">
매치
</th>
<th class="ta-r">
전적
</th>
<th class="ta-r">
승률
</th>
<th class="ta-r">
킬뎃
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(m, i) in props.p.modeRecords"
:key="m.name + i"
:class="{ 'md__row--alt': i % 2 === 1 }"
>
<td class="td-strong">
{{ m.name }}
</td>
<td class="ta-r td-num">
{{ m.matches }}
</td>
<td class="ta-r td-rec">
{{ recStr(m) }}
</td>
<td class="ta-r">
<WrCell :wr="m.wr" />
</td>
<td class="ta-r td-kd">
{{ m.kd }}%
</td>
</tr>
</tbody>
</table>
</div>
</UiPanel>
</template>
<style scoped>
.rectable tbody tr {
border-bottom: 1px solid var(--line3);
}
.rectable tbody tr:last-child {
border-bottom: none;
}
.md__row--alt {
background: #fbfbfc;
}
</style>
@@ -0,0 +1,245 @@
<script setup lang="ts">
// 프로필 요약 헤더 — 계급/이름/액션 + 승률·킬뎃 링 + 9종 지표. sa-profile-sections.jsx ProfileSummary 포팅.
import { computed, ref } from 'vue'
import { SA } from '@/shared/data/sa'
import { wrColor } from '@/shared/utils/stats'
import type { Player } from '@/shared/data/types'
import SegTabs from '@/shared/components/SegTabs.vue'
import RankBadge from '@/shared/components/RankBadge.vue'
import WinRing from '@/shared/components/WinRing.vue'
import AppIcon from '@/shared/components/AppIcon.vue'
interface Props {
p: Player
}
const props = defineProps<Props>()
const scope = ref('통합')
const selRank = computed(() => (scope.value === '통합' ? props.p.rankTotal : props.p.rankSeason))
const selRankNo = computed(() => (scope.value === '통합' ? props.p.rankNoTotal : props.p.rankNoSeason))
const stats = computed(() => [
{ lab: '승률', val: props.p.wr + '%', col: wrColor(props.p.wr) },
{ lab: '킬뎃', val: SA.kdPct(props.p.kda) + '%', col: 'var(--point)' },
{ lab: '헤드샷', val: props.p.hs + '%', col: 'var(--ink)' },
{ lab: '라이플', val: props.p.rifle + '%', col: 'var(--ink)' },
{ lab: '스나', val: props.p.sniper + '%', col: 'var(--ink)' },
{ lab: '특수', val: props.p.special + '%', col: 'var(--ink)' },
{ lab: '랭킹', val: SA.fmt(selRankNo.value) + '위', col: 'var(--point)' },
{ lab: '매너', val: props.p.manner + '점', col: 'var(--ink)' },
{ lab: '접속률', val: props.p.connRate + '%', col: 'var(--ink)' },
])
</script>
<template>
<div class="ps">
<div
class="ps__accent"
:style="{ background: p.rank.color }"
/>
<div class="ps__top">
<div class="ps__rank">
<SegTabs
v-model="scope"
:tabs="['통합', '시즌']"
/>
<div class="ps__badge">
<RankBadge
:rank="selRank"
:size="66"
/>
</div>
<div
class="ps__rankname"
:style="{ color: selRank.color }"
>
{{ selRank.name }}
</div>
<div class="ps__rankscope">
{{ scope }} 계급
</div>
</div>
<div class="ps__id">
<div class="ps__nameline">
<h1 class="ps__name">
{{ p.name }}
</h1>
<span
v-if="p.clan"
class="ps__clan"
>{{ p.clan }}</span>
</div>
<div class="ps__actions">
<button class="ps__btn ps__btn--primary">
<AppIcon
name="heart"
:size="14"
stroke="#fff"
/> 즐겨찾기
</button>
<button class="ps__btn ps__btn--ghost">
<AppIcon
name="refresh"
:size="14"
stroke="#3a4049"
/> 전적 갱신
</button>
</div>
</div>
<div class="ps__rings">
<WinRing
:wr="p.wr"
:size="88"
:sw="8"
/>
<WinRing
:wr="SA.kdPct(p.kda)"
:size="88"
:sw="8"
label="킬뎃"
color="var(--point)"
/>
</div>
</div>
<div class="ps__stats">
<div
v-for="(s, i) in stats"
:key="s.lab"
class="ps__stat"
:class="{ 'ps__stat--alt': i % 2 === 0, 'ps__stat--sep': i > 0 }"
>
<div class="ps__stat-lab">
{{ s.lab }}
</div>
<div
class="ps__stat-val num"
:style="{ color: s.col }"
>
{{ s.val }}
</div>
</div>
</div>
</div>
</template>
<style scoped>
.ps {
background: var(--surf);
border: 1px solid var(--line);
border-radius: var(--r-sm);
overflow: hidden;
margin-bottom: 18px;
}
.ps__accent {
height: 4px;
}
.ps__top {
display: flex;
align-items: center;
gap: 24px;
padding: 22px 26px;
flex-wrap: wrap;
}
.ps__rank {
text-align: center;
flex-shrink: 0;
}
.ps__badge {
margin-top: 12px;
display: flex;
justify-content: center;
}
.ps__rankname {
font-size: 0.75rem;
font-weight: 700;
margin-top: 5px;
}
.ps__rankscope {
font-size: 0.625rem;
color: var(--t3);
margin-top: 1px;
}
.ps__id {
flex: 1;
min-width: 220px;
}
.ps__nameline {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.ps__name {
margin: 0;
font-size: 1.75rem;
font-weight: 800;
color: var(--ink);
letter-spacing: -0.5px;
}
.ps__clan {
font-weight: 700;
font-size: 0.875rem;
color: var(--t2);
}
.ps__actions {
margin-top: 13px;
display: flex;
gap: 8px;
}
.ps__btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
border-radius: var(--r-sm);
cursor: pointer;
font-size: 0.78125rem;
font-family: var(--font-body);
}
.ps__btn--primary {
border: none;
background: var(--ink);
color: #fff;
font-weight: 700;
}
.ps__btn--ghost {
background: var(--surf);
border: 1px solid var(--line2);
color: #3a4049;
font-weight: 600;
}
.ps__rings {
display: flex;
gap: 14px;
flex-shrink: 0;
}
.ps__stats {
display: grid;
grid-template-columns: repeat(9, 1fr);
border-top: 1px solid var(--line);
}
.ps__stat {
padding: 12px 6px;
text-align: center;
background: var(--surf);
}
.ps__stat--alt {
background: #fafbfc;
}
.ps__stat--sep {
border-left: 1px solid var(--line);
}
.ps__stat-lab {
font-size: 0.65625rem;
color: var(--t3);
font-weight: 600;
margin-bottom: 5px;
}
.ps__stat-val {
font-weight: 700;
font-size: 0.90625rem;
}
</style>
@@ -0,0 +1,183 @@
<script setup lang="ts">
// 시즌별 랭크전 전적 — 시즌 선택(통산 집계 포함) · 솔로/파티. sa-profile-sections.jsx RankedSeasons 포팅.
import { computed, ref } from 'vue'
import { SA } from '@/shared/data/sa'
import type { Player, RankedRecord } from '@/shared/data/types'
import UiPanel from '@/shared/components/UiPanel.vue'
import WrCell from '@/shared/components/WrCell.vue'
import TierChip from '@/shared/components/TierChip.vue'
interface Props {
p: Player
}
const props = defineProps<Props>()
const seasons = props.p.rankedSeasons
const sel = ref(seasons[0]?.season ?? '통산')
const isAll = computed(() => sel.value === '통산')
const cur = computed(() => seasons.find((s) => s.season === sel.value) ?? seasons[0])
// 통산 집계(매치 가중 평균)
function agg(key: 'solo' | 'party') {
let M = 0, W = 0, L = 0, kdW = 0, hsW = 0
seasons.forEach((s) => {
const d = s[key]
M += d.matches
W += d.w
L += d.l
kdW += d.kd * d.matches
hsW += d.hs * d.matches
})
return {
matches: M, w: W, l: L,
wr: M ? Math.round((W / M) * 100) : 0,
kd: M ? Math.round(kdW / M) : 0,
hs: M ? Math.round(hsW / M) : 0,
tier: '', div: null, points: 0,
} as RankedRecord
}
const rows = computed<[string, RankedRecord][]>(() => {
const c = cur.value
return [
['솔로', isAll.value ? agg('solo') : (c?.solo as RankedRecord)],
['파티', isAll.value ? agg('party') : (c?.party as RankedRecord)],
]
})
function recStr(d: RankedRecord) {
return `${d.matches}${d.w}${d.l}`
}
</script>
<template>
<UiPanel
title="시즌별 랭크전 전적"
en="RANKED"
body-pad="0"
>
<template #right>
<select
v-model="sel"
class="rs__select"
>
<option value="통산">
통산
</option>
<option
v-for="s in seasons"
:key="s.season"
:value="s.season"
>
{{ s.season }}
</option>
</select>
</template>
<div class="rectable-scroll">
<table
class="rectable"
style="min-width: 660px"
>
<thead>
<tr>
<th>구분</th>
<th style="min-width: 150px">
티어
</th>
<th class="ta-r">
티어 점수
</th>
<th class="ta-r">
매치
</th>
<th class="ta-r">
전적
</th>
<th class="ta-r">
승률
</th>
<th class="ta-r">
킬뎃
</th>
</tr>
</thead>
<tbody>
<tr
v-for="([label, d], i) in rows"
:key="label"
:class="{ 'rs__row--alt': i % 2 === 1 }"
>
<td class="td-strong">
{{ label }} 랭크전
</td>
<td>
<span
v-if="isAll"
class="rs__dash"
></span>
<TierChip
v-else
:name="d.tier"
:div="d.div"
/>
</td>
<td class="ta-r td-num">
<span
v-if="isAll"
class="rs__dash"
></span>
<span v-else>{{ SA.fmt(d.points) }}<span class="rs__unit"> </span></span>
</td>
<td class="ta-r td-num">
{{ d.matches }}
</td>
<td class="ta-r td-rec">
{{ recStr(d) }}
</td>
<td class="ta-r">
<WrCell :wr="d.wr" />
</td>
<td class="ta-r td-kd">
{{ d.kd }}%
</td>
</tr>
</tbody>
</table>
</div>
</UiPanel>
</template>
<style scoped>
.rs__select {
font-family: var(--font-body);
font-size: 0.75rem;
font-weight: 700;
color: var(--ink);
border: 1px solid var(--line2);
border-radius: var(--r-xs);
padding: 5px 8px;
background: var(--surf);
cursor: pointer;
}
.rs__row--alt {
background: #fbfbfc;
}
.rs__row--alt + .rs__row--alt {
border-top: 1px solid var(--line3);
}
.rectable tbody tr {
border-bottom: 1px solid var(--line3);
}
.rectable tbody tr:last-child {
border-bottom: none;
}
.rs__dash {
color: var(--t3);
}
.rs__unit {
font-size: 0.625rem;
color: var(--t3);
font-weight: 500;
}
</style>
+110
View File
@@ -0,0 +1,110 @@
<script setup lang="ts">
// 최근 전적 및 동향 — 최근 승률/평균 킬뎃 링 + 최근 경기 흐름. sa-profile-sections.jsx RecentTrend 포팅.
import { computed } from 'vue'
import type { Player } from '@/shared/data/types'
import UiPanel from '@/shared/components/UiPanel.vue'
import WinRing from '@/shared/components/WinRing.vue'
interface Props {
p: Player
}
const props = defineProps<Props>()
const recent = computed(() => props.p.matches.slice(0, 12))
const wins = computed(() => recent.value.filter((m) => m.win).length)
const losses = computed(() => recent.value.length - wins.value)
const wr = computed(() => Math.round((wins.value / recent.value.length) * 100))
const avgKd = computed(() =>
Math.round(
recent.value.reduce((a, m) => a + (m.k / (m.k + m.d)) * 100, 0) / recent.value.length,
),
)
const flow = computed(() => recent.value.slice().reverse())
</script>
<template>
<UiPanel
title="최근 전적 및 동향"
en="RECENT"
body-pad="14px 16px 16px"
>
<div class="rt__rings">
<WinRing
:wr="wr"
:size="72"
:sw="7"
label="최근 승률"
/>
<WinRing
:wr="avgKd"
:size="72"
:sw="7"
label="평균 킬뎃"
color="var(--point)"
/>
</div>
<div class="rt__summary">
최근 {{ recent.length }} <b class="rt__w">{{ wins }}</b> <b class="rt__l">{{ losses }}</b>
</div>
<div class="rt__flow">
<span
v-for="(m, i) in flow"
:key="i"
class="rt__cell num"
:class="m.win ? 'rt__cell--w' : 'rt__cell--l'"
>
{{ m.win ? '승' : '패' }}
</span>
</div>
<div class="rt__caption">
최신 경기 흐름
</div>
</UiPanel>
</template>
<style scoped>
.rt__rings {
display: flex;
gap: 18px;
margin-bottom: 14px;
justify-content: center;
}
.rt__summary {
text-align: center;
font-size: 0.8125rem;
color: var(--t2);
margin-bottom: 12px;
}
.rt__w {
color: var(--point);
}
.rt__l {
color: var(--negative);
}
.rt__flow {
display: flex;
gap: 4px;
}
.rt__cell {
flex: 1;
height: 20px;
border-radius: 3px;
display: grid;
place-items: center;
font-size: 0.59375rem;
font-weight: 800;
color: #fff;
}
.rt__cell--w {
background: rgba(72, 81, 95, 0.9);
background: color-mix(in srgb, var(--point) 90%, transparent);
}
.rt__cell--l {
background: rgba(229, 72, 77, 0.9);
}
.rt__caption {
font-size: 0.65625rem;
color: var(--t3);
margin-top: 7px;
}
</style>
+197
View File
@@ -0,0 +1,197 @@
<script setup lang="ts">
// 매치 상세 팀 테이블 — 승/패 팀 로스터. sa-profile-sections.jsx TeamTable 포팅.
import { SA } from '@/shared/data/sa'
import type { RosterPlayer } from '@/shared/data/types'
import RankBadge from '@/shared/components/RankBadge.vue'
interface Props {
result: string
color: string
players: RosterPlayer[]
}
defineProps<Props>()
function kdPct(pl: RosterPlayer) {
return Math.round((pl.k / (pl.k + pl.d)) * 100)
}
</script>
<template>
<div>
<div
class="tt__head"
:style="{ background: `${color}12` }"
>
<span
class="tt__dot"
:style="{ background: color }"
/>
<span
class="tt__result"
:style="{ color }"
>{{ result }}</span>
<span class="tt__count">5</span>
</div>
<table class="tt__table">
<thead>
<tr>
<th class="tt__th">
플레이어
</th>
<th class="tt__th ta-r">
K / D / A
</th>
<th class="tt__th ta-r">
킬뎃
</th>
<th class="tt__th ta-r">
헤드샷
</th>
<th class="tt__th ta-r">
데미지
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(pl, j) in players"
:key="j"
:style="{ background: pl.isOwner ? `${color}0c` : '#fff' }"
>
<td class="tt__td tt__player">
<span class="tt__pname-wrap">
<RankBadge
:rank="pl.rank"
:size="22"
/>
<span
class="tt__pname"
:style="{ fontWeight: pl.isOwner ? 800 : 600, color: pl.isOwner ? color : 'var(--ink)' }"
>
<span
v-if="pl.isOwner"
class="tt__me"
:style="{ color }"
>ME</span>{{ pl.name }}
</span>
</span>
</td>
<td class="tt__td ta-r num">
<span class="tt__k">{{ pl.k }}</span>
<span class="tt__sep">/</span>
<span class="tt__d">{{ pl.d }}</span>
<span class="tt__sep">/</span>
<span class="tt__a">{{ pl.a }}</span>
</td>
<td class="tt__td ta-r num tt__kd">
{{ kdPct(pl) }}%
</td>
<td class="tt__td ta-r num tt__hs">
{{ pl.hs }}%
</td>
<td class="tt__td ta-r num tt__dmg">
{{ SA.fmt(pl.dmg) }}
</td>
</tr>
</tbody>
</table>
</div>
</template>
<style scoped>
.tt__head {
display: flex;
align-items: center;
gap: 7px;
padding: 8px 12px;
}
.tt__dot {
width: 9px;
height: 9px;
border-radius: 2px;
}
.tt__result {
font-weight: 800;
font-size: 0.78125rem;
}
.tt__count {
font-size: 0.65625rem;
color: var(--t3);
font-weight: 600;
}
.tt__table {
width: 100%;
border-collapse: collapse;
}
.tt__th {
font-size: 0.6875rem;
font-weight: 700;
color: var(--t3);
padding: 7px 8px;
text-align: left;
white-space: nowrap;
border-bottom: 1px solid var(--line);
}
.tt__th:first-child {
padding-left: 12px;
}
.tt__th:last-child {
padding-right: 12px;
}
.ta-r {
text-align: right;
}
.tt__td {
padding: 7px 8px;
font-size: 0.78125rem;
border-bottom: 1px solid #f1f2f4;
}
tbody tr:last-child .tt__td {
border-bottom: none;
}
.tt__player {
padding-left: 12px;
max-width: 150px;
}
.tt__dmg {
padding-right: 12px;
color: var(--t2);
}
.tt__pname-wrap {
display: flex;
align-items: center;
gap: 7px;
min-width: 0;
}
.tt__pname {
font-size: 0.78125rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tt__me {
font-size: 0.59375rem;
font-weight: 800;
margin-right: 4px;
}
.tt__k {
color: var(--ink);
}
.tt__d {
color: var(--negative);
}
.tt__a {
color: var(--point);
}
.tt__sep {
color: var(--t3);
margin: 0 2px;
}
.tt__kd {
font-weight: 600;
color: var(--point);
}
.tt__hs {
color: var(--t2);
}
</style>
+249
View File
@@ -0,0 +1,249 @@
<script setup lang="ts">
// 최근 동향 추세 그래프 — 승률/킬뎃 시계열 (일별/주별). sa-profile-sections.jsx TrendChart 포팅.
import { computed, ref } from 'vue'
import type { Player, TrendPoint } from '@/shared/data/types'
import UiPanel from '@/shared/components/UiPanel.vue'
import SegTabs from '@/shared/components/SegTabs.vue'
interface Props {
p: Player
}
const props = defineProps<Props>()
const period = ref('주별')
const data = computed<TrendPoint[]>(() =>
period.value === '일별' ? props.p.trend.daily : props.p.trend.weekly,
)
const W = 720, H = 250, padL = 38, padR = 16, padT = 18, padB = 34
const iw = W - padL - padR
const ih = H - padT - padB
const n = computed(() => data.value.length)
function x(i: number) {
return padL + (n.value === 1 ? iw / 2 : (i / (n.value - 1)) * iw)
}
function y(v: number) {
return padT + (1 - (v - 20) / (90 - 20)) * ih
}
function path(key: 'wr' | 'kd') {
return data.value.map((d, i) => `${i ? 'L' : 'M'}${x(i).toFixed(1)} ${y(d[key]).toFixed(1)}`).join(' ')
}
const areaWr = computed(() => {
const last = n.value - 1
return `${path('wr')} L${x(last).toFixed(1)} ${(padT + ih).toFixed(1)} L${x(0).toFixed(1)} ${(padT + ih).toFixed(1)} Z`
})
const grid = [20, 40, 60, 80]
const last = computed(() => data.value[n.value - 1] as TrendPoint)
const first = computed(() => data.value[0] as TrendPoint)
const labelStep = computed(() => Math.ceil(n.value / 8))
function delta(key: 'wr' | 'kd') {
return last.value[key] - first.value[key]
}
const xLabels = computed(() =>
data.value
.map((d, i) => ({ d, i }))
.filter(({ i }) => i % labelStep.value === 0 || i === n.value - 1),
)
const points = computed(() => data.value.map((d, i) => ({ d, i, isLast: i === n.value - 1 })))
</script>
<template>
<UiPanel
title="최근 동향"
en="TREND"
body-pad="14px 16px 16px"
>
<template #right>
<SegTabs
v-model="period"
:tabs="['일별', '주별']"
/>
</template>
<div class="tc__legend">
<div class="tc__chip">
<span class="tc__line tc__line--wr" />
<span class="tc__lab">승률</span>
<span class="num tc__val tc__val--wr">{{ last.wr }}%</span>
<span
class="num tc__delta"
:class="delta('wr') >= 0 ? 'tc__delta--up' : 'tc__delta--down'"
>
{{ delta('wr') >= 0 ? '▲' : '▼' }}{{ Math.abs(delta('wr')) }}
</span>
</div>
<div class="tc__chip">
<span class="tc__line tc__line--kd" />
<span class="tc__lab">킬뎃</span>
<span class="num tc__val tc__val--kd">{{ last.kd }}%</span>
<span
class="num tc__delta"
:class="delta('kd') >= 0 ? 'tc__delta--up' : 'tc__delta--down'"
>
{{ delta('kd') >= 0 ? '▲' : '▼' }}{{ Math.abs(delta('kd')) }}
</span>
</div>
<span class="tc__period">{{ period === '일별' ? '최근 14일' : '최근 10주' }} 추이</span>
</div>
<svg
:viewBox="`0 0 ${W} ${H}`"
class="tc__svg"
>
<defs>
<linearGradient
id="wrGrad"
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="0%"
stop-color="var(--accent-blue)"
stop-opacity="0.16"
/>
<stop
offset="100%"
stop-color="var(--accent-blue)"
stop-opacity="0"
/>
</linearGradient>
</defs>
<g
v-for="g in grid"
:key="g"
>
<line
:x1="padL"
:y1="y(g)"
:x2="W - padR"
:y2="y(g)"
stroke="#eceef1"
stroke-width="1"
/>
<text
:x="padL - 8"
:y="y(g) + 3.5"
text-anchor="end"
font-size="10.5"
fill="#949aa3"
font-family="'Chakra Petch',sans-serif"
>{{ g }}</text>
</g>
<text
v-for="{ d, i } in xLabels"
:key="'lab' + i"
:x="x(i)"
:y="H - 12"
text-anchor="middle"
font-size="10.5"
fill="#949aa3"
>{{ d.label }}</text>
<path
:d="areaWr"
fill="url(#wrGrad)"
/>
<path
:d="path('wr')"
fill="none"
stroke="var(--accent-blue)"
stroke-width="2.5"
stroke-linejoin="round"
stroke-linecap="round"
/>
<path
:d="path('kd')"
fill="none"
stroke="var(--point)"
stroke-width="2.5"
stroke-linejoin="round"
stroke-linecap="round"
/>
<g
v-for="{ d, i, isLast } in points"
:key="'p' + i"
>
<circle
:cx="x(i)"
:cy="y(d.wr)"
:r="isLast ? 4 : 2.6"
fill="#fff"
stroke="var(--accent-blue)"
stroke-width="2"
/>
<circle
:cx="x(i)"
:cy="y(d.kd)"
:r="isLast ? 4 : 2.6"
fill="#fff"
stroke="var(--point)"
stroke-width="2"
/>
</g>
</svg>
</UiPanel>
</template>
<style scoped>
.tc__legend {
display: flex;
gap: 20px;
margin-bottom: 10px;
padding-left: 4px;
}
.tc__chip {
display: flex;
align-items: center;
gap: 7px;
}
.tc__line {
width: 11px;
height: 3px;
border-radius: 2px;
}
.tc__line--wr {
background: var(--accent-blue);
}
.tc__line--kd {
background: var(--point);
}
.tc__lab {
font-size: 0.75rem;
color: var(--t2);
font-weight: 600;
}
.tc__val {
font-weight: 700;
font-size: 0.8125rem;
}
.tc__val--wr {
color: var(--accent-blue);
}
.tc__val--kd {
color: var(--point);
}
.tc__delta {
font-weight: 700;
font-size: 0.71875rem;
}
.tc__delta--up {
color: var(--up);
}
.tc__delta--down {
color: var(--negative);
}
.tc__period {
margin-left: auto;
font-size: 0.6875rem;
color: var(--t3);
}
.tc__svg {
width: 100%;
height: auto;
display: block;
}
</style>
+59
View File
@@ -0,0 +1,59 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
// 코드 스플리팅 — 모든 페이지 컴포넌트는 lazy import 처리
const routes: RouteRecordRaw[] = [
{
path: '/',
name: 'home',
component: () => import('@/pages/HomePage.vue'),
meta: { requiresAuth: false },
},
{
path: '/player/:name',
name: 'profile',
component: () => import('@/pages/ProfilePage.vue'),
meta: { requiresAuth: false },
},
{
path: '/community',
name: 'community',
component: () => import('@/pages/CommunityPage.vue'),
meta: { requiresAuth: false },
},
{
path: '/clans',
name: 'clans',
component: () => import('@/pages/ClanPlazaPage.vue'),
meta: { requiresAuth: false },
},
{
path: '/clan/:name',
name: 'clan',
component: () => import('@/pages/ClanHomePage.vue'),
meta: { requiresAuth: false },
},
{
path: '/:pathMatch(.*)*',
name: 'not-found',
component: () => import('@/pages/NotFoundPage.vue'),
},
]
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes,
// 페이지 전환 시 스크롤 최상단 복귀 (뒤로가기는 위치 복원)
scrollBehavior(_to, _from, saved) {
return saved || { top: 0 }
},
})
// 네비게이션 가드 — 인증 분기 (스토어 도입 후 useAuthStore로 교체)
router.beforeEach((to) => {
if (to.meta.requiresAuth) {
// TODO: useAuthStore().isLoggedIn 검사 후 '/login' 으로 리다이렉트
}
return true
})
export default router
@@ -0,0 +1,54 @@
<script setup lang="ts">
// 공용 아이콘 — 단색 라인/필 SVG (sa-shared.jsx Icon 포팅)
import { computed } from 'vue'
interface Props {
name: keyof typeof PATHS
size?: number
stroke?: string
fill?: string
sw?: number
}
const props = withDefaults(defineProps<Props>(), {
size: 18,
stroke: 'currentColor',
fill: 'none',
sw: 1.7,
})
const PATHS = {
search: 'M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zm6 12 4 4',
chevR: 'M9 5l7 7-7 7',
chevL: 'M15 5l-7 7 7 7',
user: 'M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM4 20a8 8 0 0 1 16 0',
users: 'M9 11a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7zM2.5 19a6.5 6.5 0 0 1 13 0M16 4.5a3.5 3.5 0 0 1 0 7M18 13a6.5 6.5 0 0 1 3.5 6',
bolt: 'M13 2L4 14h6l-1 8 9-12h-6z',
refresh: 'M20 11a8 8 0 1 0-.7 4.5M20 5v6h-6',
clock: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18zM12 7v5l3.5 2',
heart: 'M12 20s-7-4.3-7-9.5A3.5 3.5 0 0 1 12 7a3.5 3.5 0 0 1 7 3.5C19 15.7 12 20 12 20z',
gun: 'M3 8h15l-1 4h-3l-1 3h-3l-1-3H5a2 2 0 0 1-2-2V8zM8 12v3',
shieldcheck: 'M12 3l7 3v5c0 4.5-3 7.3-7 8.5-4-1.2-7-4-7-8.5V6l7-3zM9 11.5l2 2 4-4',
flame: 'M12 2c1 3-1.5 4.2-1.5 6.5 0 1.4 1 2.3 1 2.3s.6-1 .6-2.2c1.6 1 3.4 3 3.4 5.6a5.5 5.5 0 1 1-11 0c0-2.3 1.4-4 2.2-5 .3 1 .9 1.6.9 1.6s-.2-2.3 1.2-4.2C10.4 4.6 12 4 12 2z',
palette: 'M12 3a9 9 0 1 0 0 18c1.5 0 2-1 2-2 0-1.3-1-1.5-1-2.5 0-.8.7-1.5 1.5-1.5H17a4 4 0 0 0 4-4c0-3.9-4-6.5-9-6.5zM7.5 12a1 1 0 1 1 0-2 1 1 0 0 1 0 2zm3-4a1 1 0 1 1 0-2 1 1 0 0 1 0 2zm5 0a1 1 0 1 1 0-2 1 1 0 0 1 0 2z',
} as const
// 채워서 그리는 아이콘 (라인 대신 fill 사용)
const FILLED = ['flame', 'bolt', 'heart', 'palette']
const filled = computed(() => FILLED.includes(props.name as string))
</script>
<template>
<svg
:width="size"
:height="size"
viewBox="0 0 24 24"
:fill="filled ? stroke : fill"
:stroke="filled ? 'none' : stroke"
:stroke-width="sw"
stroke-linecap="round"
stroke-linejoin="round"
style="flex-shrink: 0"
>
<path :d="PATHS[name]" />
</svg>
</template>
@@ -0,0 +1,33 @@
<script setup lang="ts">
// 서린즈 로고 — 워드마크. 포인트색은 테마 변수 사용.
interface Props {
size?: number
light?: boolean // 어두운 배경 위에 올릴 때 본문색을 흰색으로
}
const props = withDefaults(defineProps<Props>(), { size: 24, light: false })
</script>
<template>
<div class="logo">
<div
class="word"
:style="{ fontSize: props.size + 'px', color: light ? '#fff' : '#15171c' }"
>
서린즈
</div>
</div>
</template>
<style scoped>
.logo {
display: flex;
align-items: center;
user-select: none;
}
.word {
font-family: var(--font-body);
font-weight: 800;
letter-spacing: -1px;
line-height: 1;
}
</style>
@@ -0,0 +1,50 @@
<script setup lang="ts">
// 어두운 섹션 헤더바 — 제목 + EN 라벨 + (선택) 우측 슬롯. sa-home.jsx BarHead 포팅.
interface Props {
title: string
en?: string
}
withDefaults(defineProps<Props>(), { en: '' })
</script>
<template>
<div class="barhead">
<div class="barhead__title">
<span class="barhead__name">{{ title }}</span>
<span
v-if="en"
class="barhead__en num"
>{{ en }}</span>
</div>
<slot name="right" />
</div>
</template>
<style scoped>
.barhead {
display: flex;
align-items: center;
justify-content: space-between;
background: var(--dark);
color: #fff;
padding: 0 14px;
height: 38px;
border-radius: var(--r-sm) var(--r-sm) 0 0;
}
.barhead__title {
display: flex;
align-items: baseline;
gap: 9px;
}
.barhead__name {
font-size: 0.90625rem;
font-weight: 800;
letter-spacing: -0.2px;
}
.barhead__en {
font-size: 0.625rem;
font-weight: 600;
color: rgba(255, 255, 255, 0.5);
letter-spacing: 1.2px;
}
</style>
@@ -0,0 +1,278 @@
<script setup lang="ts">
// 클랜 엠블럼 렌더러 — 도형(방패/원/육각/마름모) + 심볼 조합. sa-clan-data.jsx Emblem 포팅.
import { computed } from 'vue'
import { EMBLEM_SHAPES, shade, type ClanEmblem } from '@/shared/data/clan'
interface Props {
cfg?: ClanEmblem
size?: number
ring?: boolean
}
const props = withDefaults(defineProps<Props>(), {
cfg: () => ({ shape: 'shield', bg: '#48515f', symbol: 'crosshair', symbolColor: '#ffffff' }),
size: 88,
ring: true,
})
const shape = computed(() => props.cfg?.shape ?? 'shield')
const bg = computed(() => props.cfg?.bg ?? '#48515f')
const symbol = computed(() => props.cfg?.symbol ?? 'crosshair')
const symbolColor = computed(() => props.cfg?.symbolColor ?? '#ffffff')
const gradId = computed(() => `eg-${shape.value}-${bg.value.slice(1)}-${Math.round(props.size)}`)
const shapePath = computed(() => EMBLEM_SHAPES[shape.value] ?? null)
const gradTop = computed(() => shade(bg.value, 26))
const gradBot = computed(() => shade(bg.value, -22))
</script>
<template>
<svg
:width="size"
:height="size"
viewBox="0 0 100 100"
class="emblem"
aria-hidden="true"
>
<defs>
<linearGradient
:id="gradId"
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="0%"
:stop-color="gradTop"
/>
<stop
offset="100%"
:stop-color="gradBot"
/>
</linearGradient>
</defs>
<!-- 도형 배경 -->
<circle
v-if="shape === 'circle'"
cx="50"
cy="50"
r="45"
:fill="`url(#${gradId})`"
:stroke="ring ? '#ffffff' : 'none'"
:stroke-width="ring ? 3 : 0"
stroke-opacity="0.7"
/>
<path
v-else
:d="shapePath || ''"
:fill="`url(#${gradId})`"
:stroke="ring ? '#ffffff' : 'none'"
:stroke-width="ring ? 3 : 0"
stroke-opacity="0.7"
stroke-linejoin="round"
/>
<!-- 심볼 -->
<g
v-if="symbol === 'crosshair'"
fill="none"
:stroke="symbolColor"
stroke-width="5"
stroke-linecap="round"
>
<circle
cx="50"
cy="50"
r="17"
/>
<line
x1="50"
y1="22"
x2="50"
y2="34"
/>
<line
x1="50"
y1="66"
x2="50"
y2="78"
/>
<line
x1="22"
y1="50"
x2="34"
y2="50"
/>
<line
x1="66"
y1="50"
x2="78"
y2="50"
/>
<circle
cx="50"
cy="50"
r="3"
:fill="symbolColor"
stroke="none"
/>
</g>
<path
v-else-if="symbol === 'star'"
:fill="symbolColor"
d="M50 26 L56.5 43 L74 44 L60 55 L65 72 L50 62 L35 72 L40 55 L26 44 L43.5 43 Z"
/>
<g
v-else-if="symbol === 'skull'"
:fill="symbolColor"
>
<path d="M50 28 C36 28 28 38 28 50 C28 58 32 62 36 65 L36 72 L64 72 L64 65 C68 62 72 58 72 50 C72 38 64 28 50 28 Z" />
<circle
cx="41"
cy="50"
r="5"
fill="#fff"
/>
<circle
cx="59"
cy="50"
r="5"
fill="#fff"
/>
<path
d="M47 60 L50 56 L53 60 Z"
fill="#fff"
/>
</g>
<g
v-else-if="symbol === 'wolf'"
:fill="symbolColor"
>
<ellipse
cx="50"
cy="58"
rx="9"
ry="11"
/>
<ellipse
cx="35"
cy="44"
rx="5"
ry="7"
/>
<ellipse
cx="65"
cy="44"
rx="5"
ry="7"
/>
<ellipse
cx="42"
cy="34"
rx="4.5"
ry="6"
/>
<ellipse
cx="58"
cy="34"
rx="4.5"
ry="6"
/>
</g>
<g
v-else-if="symbol === 'sword'"
:stroke="symbolColor"
stroke-width="5"
stroke-linecap="round"
fill="none"
>
<line
x1="30"
y1="30"
x2="68"
y2="68"
/>
<line
x1="70"
y1="30"
x2="32"
y2="68"
/>
<line
x1="26"
y1="40"
x2="40"
y2="26"
/>
<line
x1="60"
y1="74"
x2="74"
y2="60"
/>
</g>
<path
v-else-if="symbol === 'crown'"
:fill="symbolColor"
d="M28 64 L24 36 L37 48 L50 30 L63 48 L76 36 L72 64 Z"
/>
<path
v-else-if="symbol === 'flame'"
:fill="symbolColor"
d="M50 26 C58 38 66 44 66 56 C66 67 58 74 50 74 C42 74 34 67 34 56 C34 50 38 46 42 50 C42 40 46 32 50 26 Z"
/>
<path
v-else-if="symbol === 'bolt'"
:fill="symbolColor"
d="M54 24 L34 54 L47 54 L44 76 L66 44 L52 44 Z"
/>
<g v-else-if="symbol === 'shieldcheck'">
<path
:fill="symbolColor"
d="M50 26 L70 33 V52 C70 64 61 70 50 75 C39 70 30 64 30 52 V33 Z"
/>
<path
d="M42 51 L48 57 L60 43"
fill="none"
stroke="#fff"
stroke-width="5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
<g
v-else-if="symbol === 'anchor'"
fill="none"
:stroke="symbolColor"
stroke-width="5"
stroke-linecap="round"
>
<circle
cx="50"
cy="32"
r="5"
/>
<line
x1="50"
y1="37"
x2="50"
y2="72"
/>
<line
x1="38"
y1="48"
x2="62"
y2="48"
/>
<path d="M32 60 C32 70 42 74 50 74 C58 74 68 70 68 60" />
</g>
</svg>
</template>
<style scoped>
.emblem {
flex-shrink: 0;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.18));
}
</style>
@@ -0,0 +1,463 @@
<script setup lang="ts">
// 문의 위젯 — 우하단 플로팅 버튼 + 문의 모달. ui 스토어(feedbackOpen)와 연동.
import { computed, ref, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { useUiStore } from '@/stores/ui.store'
const ui = useUiStore()
const { feedbackOpen } = storeToRefs(ui)
const TYPES = ['불편 사항', '버그 제보', '기능 제안', '신고', '기타 문의']
const type = ref('불편 사항')
const text = ref('')
const sent = ref(false)
const files = ref<File[]>([])
const fileInput = ref<HTMLInputElement | null>(null)
const canSend = computed(() => text.value.trim().length > 0)
function fmtSize(n: number): string {
return n > 1048576 ? (n / 1048576).toFixed(1) + 'MB' : Math.max(1, Math.round(n / 1024)) + 'KB'
}
function addFiles(list: FileList | null) {
if (!list) return
files.value = [...files.value, ...Array.from(list)].slice(0, 3)
}
function removeFile(i: number) {
files.value = files.value.filter((_, j) => j !== i)
}
function submit() {
if (!canSend.value) return
sent.value = true
setTimeout(() => {
ui.closeFeedback()
text.value = ''
type.value = '불편 사항'
sent.value = false
files.value = []
}, 1400)
}
// 모달이 열릴 때마다 전송 상태 초기화
watch(feedbackOpen, (v) => {
if (v) sent.value = false
})
</script>
<template>
<!-- 문의 모달 (오픈 트리거는 상단바 '문의' 버튼) -->
<div
v-if="feedbackOpen"
class="fw__overlay"
@click="ui.closeFeedback()"
>
<div
class="fw__modal"
@click.stop
>
<div class="fw__head">
<div>
<div class="fw__title">
문의하기
</div>
<div class="fw__subtitle">
불편한 점이나 궁금한 점을 알려주세요.
</div>
</div>
<button
class="fw__close"
@click="ui.closeFeedback()"
>
×
</button>
</div>
<div
v-if="sent"
class="fw__sent"
>
<div class="fw__check">
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="var(--point)"
stroke-width="2.4"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M5 12.5l4.5 4.5L19 7.5" /></svg>
</div>
<div class="fw__sent-t">
문의가 접수되었어요
</div>
<div class="fw__sent-s">
답변은 마이페이지 &gt; 문의 내역에서 확인할 있어요.
</div>
</div>
<div
v-else
class="fw__body"
>
<div class="fw__label">
문의 유형
</div>
<div class="fw__types">
<button
v-for="v in TYPES"
:key="v"
class="fw__type"
:class="{ 'fw__type--on': type === v }"
@click="type = v"
>
{{ v }}
</button>
</div>
<div class="fw__label">
내용
</div>
<textarea
v-model="text"
class="fw__textarea"
rows="5"
placeholder="어떤 점이 불편했는지, 어떤 기능이 궁금한지 자유롭게 적어주세요."
/>
<div class="fw__note">
답변은 로그인한 계정의 <b>마이페이지 &gt; 문의 내역</b>으로 드려요.
</div>
<!-- 파일 첨부 -->
<input
ref="fileInput"
type="file"
multiple
accept="image/*,video/*,.log,.txt"
hidden
@change="(e) => { addFiles((e.target as HTMLInputElement).files); (e.target as HTMLInputElement).value = '' }"
>
<div class="fw__attach">
<button
class="fw__attach-btn"
:disabled="files.length >= 3"
@click="fileInput?.click()"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M21.4 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" /></svg>
파일 첨부 <span class="fw__attach-cnt">({{ files.length }}/3)</span>
</button>
<div
v-if="files.length"
class="fw__files"
>
<div
v-for="(f, i) in files"
:key="i"
class="fw__file"
>
<span class="fw__file-nm">{{ f.name }}</span>
<span class="fw__file-sz">{{ fmtSize(f.size) }}</span>
<button
class="fw__file-x"
@click="removeFile(i)"
>
×
</button>
</div>
</div>
</div>
<button
class="fw__send"
:disabled="!canSend"
@click="submit"
>
보내기
</button>
<div class="fw__divider">
<span class="fw__line" />
<span class="fw__divtxt">계정 없이 이용 중이신가요?</span>
<span class="fw__line" />
</div>
<div class="fw__channels">
<button class="fw__ch fw__ch--discord">
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="#fff"
><path d="M19.6 5.6A17.3 17.3 0 0 0 15.4 4l-.5 1a16 16 0 0 0-5.8 0l-.5-1a17.3 17.3 0 0 0-4.2 1.6C1.7 9.6 1 13.5 1.3 17.3A17.5 17.5 0 0 0 6.6 20l1.1-1.8a11 11 0 0 1-1.7-.9l.4-.3a12.4 12.4 0 0 0 11.2 0l.4.3c-.5.4-1.1.7-1.7.9L17.4 20a17.4 17.4 0 0 0 5.3-2.7c.4-4.4-.7-8.2-3.1-11.7zM8.7 14.9c-1 0-1.9-1-1.9-2.1s.8-2.1 1.9-2.1 1.9 1 1.9 2.1-.9 2.1-1.9 2.1zm6.6 0c-1 0-1.9-1-1.9-2.1s.9-2.1 1.9-2.1 1.9 1 1.9 2.1-.8 2.1-1.9 2.1z" /></svg>
디스코드 문의
</button>
<button class="fw__ch fw__ch--kakao">
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="#191600"
><path d="M12 3C6.5 3 2 6.5 2 10.8c0 2.7 1.8 5.1 4.5 6.5l-1 3.8c-.1.4.3.7.6.5l4.4-2.9c.5.1 1 .1 1.5.1 5.5 0 10-3.5 10-7.9S17.5 3 12 3z" /></svg>
카카오톡 문의
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.fw__overlay {
position: fixed;
inset: 0;
z-index: 300;
background: rgba(20, 22, 26, 0.55);
backdrop-filter: blur(2px);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
animation: sgFade 0.18s ease;
}
.fw__modal {
width: 430px;
max-width: 100%;
background: var(--surf);
border-radius: var(--r-xl);
overflow: hidden;
box-shadow: var(--sh-modal);
}
.fw__head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
border-bottom: 1px solid var(--line);
}
.fw__title {
font-size: 0.9375rem;
font-weight: 800;
color: var(--ink);
}
.fw__subtitle {
font-size: 0.71875rem;
color: var(--t3);
margin-top: 2px;
}
.fw__close {
width: 28px;
height: 28px;
border-radius: 6px;
border: none;
background: var(--bg2);
color: var(--t2);
cursor: pointer;
font-size: 0.9375rem;
}
.fw__sent {
padding: 38px 18px;
text-align: center;
}
.fw__check {
width: 44px;
height: 44px;
border-radius: 50%;
background: var(--point-soft);
display: grid;
place-items: center;
margin: 0 auto 12px;
}
.fw__sent-t {
font-size: 0.90625rem;
font-weight: 800;
color: var(--ink);
}
.fw__sent-s {
font-size: 0.78125rem;
color: var(--t2);
margin-top: 4px;
}
.fw__body {
padding: 16px 18px;
}
.fw__label {
font-size: 0.75rem;
font-weight: 700;
color: var(--t2);
margin-bottom: 7px;
}
.fw__types {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-bottom: 14px;
}
.fw__type {
padding: 6px 12px;
border-radius: var(--r-sm);
cursor: pointer;
font-weight: 700;
font-size: 0.75rem;
border: 1px solid var(--line2);
background: var(--surf);
color: var(--t2);
}
.fw__type--on {
border-color: var(--point);
background: var(--point);
color: var(--point-ink);
}
.fw__textarea {
width: 100%;
resize: vertical;
padding: 10px 12px;
border: 1px solid var(--line2);
border-radius: var(--r-md);
font-size: 0.8125rem;
line-height: 1.6;
font-family: var(--font-body);
color: #3a4049;
outline: none;
box-sizing: border-box;
margin-bottom: 6px;
}
.fw__note {
font-size: 0.71875rem;
color: var(--t3);
margin-bottom: 14px;
}
.fw__note b {
color: var(--t2);
}
.fw__attach {
margin-bottom: 14px;
}
.fw__attach-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 13px;
border-radius: var(--r-md);
cursor: pointer;
background: var(--surf);
border: 1px dashed var(--line2);
color: var(--t2);
font-weight: 700;
font-size: 0.75rem;
font-family: var(--font-body);
}
.fw__attach-btn:disabled {
cursor: not-allowed;
color: var(--t3);
}
.fw__attach-cnt {
font-weight: 600;
color: var(--t3);
}
.fw__files {
display: flex;
flex-direction: column;
gap: 5px;
margin-top: 8px;
}
.fw__file {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
background: #f7f8f9;
border: 1px solid var(--line);
border-radius: 6px;
}
.fw__file-nm {
flex: 1;
min-width: 0;
font-size: 0.75rem;
font-weight: 600;
color: var(--ink);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fw__file-sz {
font-size: 0.65625rem;
color: var(--t3);
flex-shrink: 0;
}
.fw__file-x {
border: none;
background: transparent;
cursor: pointer;
color: var(--t3);
font-size: 0.875rem;
line-height: 1;
padding: 0;
flex-shrink: 0;
}
.fw__send {
width: 100%;
padding: 12px 0;
border-radius: var(--r-md);
border: none;
background: var(--point);
color: var(--point-ink);
font-weight: 700;
font-size: 0.875rem;
cursor: pointer;
font-family: var(--font-body);
}
.fw__send:disabled {
background: #c4c8ce;
cursor: not-allowed;
}
.fw__divider {
display: flex;
align-items: center;
gap: 10px;
margin: 16px 0 11px;
}
.fw__line {
flex: 1;
height: 1px;
background: var(--line);
}
.fw__divtxt {
font-size: 0.6875rem;
color: var(--t3);
font-weight: 600;
}
.fw__channels {
display: flex;
gap: 8px;
}
.fw__ch {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
padding: 10px 0;
border-radius: var(--r-md);
border: none;
cursor: pointer;
font-weight: 700;
font-size: 0.78125rem;
font-family: var(--font-body);
}
.fw__ch--discord {
background: var(--discord);
color: #fff;
}
.fw__ch--kakao {
background: var(--kakao);
color: var(--kakao-ink);
}
</style>
@@ -0,0 +1,142 @@
<script setup lang="ts">
// 이미지 슬롯 — 배너/썸네일/포스터 자리. 클릭 또는 드래그&드롭으로 이미지 지정.
// 원본 image-slot.js 웹컴포넌트를 대체하는 경량 플레이스홀더.
import { ref } from 'vue'
interface Props {
placeholder?: string
rounded?: boolean
}
withDefaults(defineProps<Props>(), { placeholder: '이미지를 끌어다 놓으세요', rounded: false })
const url = ref<string | null>(null)
const over = ref(false)
const input = ref<HTMLInputElement | null>(null)
function setFile(file?: File | null) {
if (!file || !file.type.startsWith('image/')) return
url.value = URL.createObjectURL(file)
}
function onDrop(e: DragEvent) {
over.value = false
setFile(e.dataTransfer?.files?.[0])
}
function onPick(e: Event) {
const target = e.target as HTMLInputElement
setFile(target.files?.[0])
target.value = ''
}
</script>
<template>
<div
class="slot"
:class="{ 'slot--over': over, 'slot--rounded': rounded, 'slot--filled': url }"
role="button"
tabindex="0"
@click="input?.click()"
@keydown.enter="input?.click()"
@dragover.prevent="over = true"
@dragleave="over = false"
@drop.prevent="onDrop"
>
<img
v-if="url"
:src="url"
alt=""
class="slot__img"
>
<div
v-else
class="slot__ph"
>
<svg
width="34"
height="34"
viewBox="0 0 28 28"
fill="none"
aria-hidden="true"
>
<circle
cx="14"
cy="14"
r="9"
fill="none"
stroke="currentColor"
stroke-width="1.6"
/>
<path
d="M14 4v3.5M14 20.5V24M4 14h3.5M20.5 14H24"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
/>
<circle
cx="14"
cy="14"
r="2"
fill="currentColor"
/>
</svg>
<span class="slot__txt">{{ placeholder }}</span>
</div>
<input
ref="input"
type="file"
accept="image/*"
hidden
@change="onPick"
>
</div>
</template>
<style scoped>
.slot {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
background: repeating-linear-gradient(
45deg,
#f4f5f7,
#f4f5f7 10px,
#eef0f2 10px,
#eef0f2 20px
);
color: var(--t3);
cursor: pointer;
overflow: hidden;
transition: box-shadow 0.14s;
}
.slot--rounded {
border-radius: var(--r-sm);
}
.slot--over {
box-shadow: inset 0 0 0 2px var(--point);
color: var(--point);
}
.slot--filled {
background: #000;
}
.slot__img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.slot__ph {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 0.75rem;
text-align: center;
}
.slot__txt {
font-size: 0.6875rem;
font-weight: 600;
line-height: 1.4;
max-width: 12rem;
}
</style>
@@ -0,0 +1,47 @@
<script setup lang="ts">
// K/D/A 칩 — 킬(잉크) / 데스(네거티브) / 어시스트(포인트). sa-shared.jsx KdaChip 포팅.
interface Props {
k: number
d: number
a: number
big?: boolean
}
withDefaults(defineProps<Props>(), { big: false })
</script>
<template>
<span
class="kda num"
:class="{ 'kda--big': big }"
>
<span class="kda__k">{{ k }}</span>
<span class="kda__sep">/</span>
<span class="kda__d">{{ d }}</span>
<span class="kda__sep">/</span>
<span class="kda__a">{{ a }}</span>
</span>
</template>
<style scoped>
.kda {
font-weight: 600;
font-size: 0.8125rem;
letter-spacing: 0.3px;
}
.kda--big {
font-size: 1rem;
}
.kda__k {
color: var(--ink);
}
.kda__d {
color: var(--negative);
}
.kda__a {
color: var(--point);
}
.kda__sep {
color: var(--t3);
margin: 0 3px;
}
</style>
@@ -0,0 +1,216 @@
<script setup lang="ts">
// 로그인 모달 — 소셜 로그인(카카오/디스코드/구글). ui 스토어(loginOpen) 연동. sa-login.jsx 포팅.
import { storeToRefs } from 'pinia'
import { useUiStore } from '@/stores/ui.store'
const ui = useUiStore()
const { loginOpen } = storeToRefs(ui)
</script>
<template>
<div
v-if="loginOpen"
class="lm__overlay"
@click="ui.closeLogin()"
>
<div
class="lm__modal"
@click.stop
>
<div class="lm__head">
<button
class="lm__close"
aria-label="닫기"
@click="ui.closeLogin()"
>
×
</button>
<h2 class="lm__title">
로그인
</h2>
<p class="lm__sub">
소셜 계정으로 간편하게 시작하세요
</p>
</div>
<div class="lm__body">
<button class="lm__social lm__social--kakao">
<span class="lm__mark">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
><path
d="M12 3.5c-5 0-9 3.13-9 7 0 2.5 1.7 4.69 4.27 5.94-.15.53-.78 2.78-.81 2.96 0 0-.02.14.07.2.09.05.2 0 .2 0 .26-.04 3.02-2 3.5-2.33.57.08 1.16.13 1.77.13 5 0 9-3.13 9-7s-4-7-9-7z"
fill="#191600"
/></svg>
</span>
<span class="lm__txt">카카오() 계속하기</span>
</button>
<button class="lm__social lm__social--discord">
<span class="lm__mark">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="#fff"
><path d="M20.317 4.369A19.79 19.79 0 0 0 15.885 3c-.21.375-.45.88-.617 1.28a18.27 18.27 0 0 0-5.535 0C9.565 3.88 9.318 3.375 9.107 3A19.736 19.736 0 0 0 4.677 4.369C1.9 8.46 1.144 12.44 1.522 16.36a19.9 19.9 0 0 0 6.075 3.05c.49-.665.927-1.372 1.304-2.114-.717-.27-1.404-.604-2.05-.99.172-.126.34-.257.502-.39 3.96 1.84 8.245 1.84 12.16 0 .164.135.332.266.5.39-.647.387-1.335.72-2.052.99.378.742.814 1.45 1.304 2.114a19.84 19.84 0 0 0 6.078-3.05c.444-4.55-.758-8.494-3.205-11.99zM8.02 14.09c-1.18 0-2.15-1.085-2.15-2.42 0-1.334.95-2.42 2.15-2.42 1.21 0 2.17 1.096 2.15 2.42 0 1.335-.95 2.42-2.15 2.42zm7.96 0c-1.18 0-2.15-1.085-2.15-2.42 0-1.334.95-2.42 2.15-2.42 1.21 0 2.17 1.096 2.15 2.42 0 1.335-.94 2.42-2.15 2.42z" /></svg>
</span>
<span class="lm__txt">디스코드() 계속하기</span>
</button>
<button class="lm__social lm__social--google">
<span class="lm__mark">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
><path
d="M23.04 12.26c0-.81-.07-1.59-.21-2.34H12v4.43h6.2a5.3 5.3 0 0 1-2.3 3.48v2.89h3.72c2.18-2 3.42-4.96 3.42-8.46z"
fill="#4285F4"
/><path
d="M12 24c3.11 0 5.72-1.03 7.63-2.79l-3.72-2.89c-1.03.69-2.35 1.1-3.91 1.1-3 0-5.55-2.03-6.46-4.76H1.69v2.98A11.99 11.99 0 0 0 12 24z"
fill="#34A853"
/><path
d="M5.54 14.66A7.2 7.2 0 0 1 5.16 12c0-.92.16-1.82.38-2.66V6.36H1.69A11.99 11.99 0 0 0 .43 12c0 1.94.47 3.77 1.26 5.4l3.85-2.74z"
fill="#FBBC05"
/><path
d="M12 4.77c1.69 0 3.21.58 4.4 1.72l3.3-3.3C17.71 1.19 15.1 0 12 0 7.31 0 3.26 2.69 1.69 6.36l3.85 2.98C6.45 6.61 9 4.77 12 4.77z"
fill="#EA4335"
/></svg>
</span>
<span class="lm__txt">구글() 계속하기</span>
</button>
</div>
<div class="lm__foot">
<p>
로그인 <a>이용약관</a> <a>개인정보처리방침</a><br>
동의하는 것으로 간주됩니다.
</p>
</div>
</div>
</div>
</template>
<style scoped>
.lm__overlay {
position: fixed;
inset: 0;
z-index: 400;
background: rgba(20, 22, 26, 0.62);
backdrop-filter: blur(3px);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
animation: sgFade 0.2s ease;
}
.lm__modal {
width: 420px;
max-width: 100%;
background: var(--surf);
border-radius: var(--r-xl);
overflow: hidden;
box-shadow: var(--sh-modal);
}
.lm__head {
position: relative;
background: var(--surf);
padding: 30px 30px 24px;
text-align: center;
border-bottom: 1px solid var(--line);
}
.lm__close {
position: absolute;
top: 14px;
right: 14px;
width: 30px;
height: 30px;
border-radius: 6px;
border: none;
background: var(--bg2);
color: var(--t2);
cursor: pointer;
font-size: 1rem;
line-height: 1;
}
.lm__title {
margin: 0;
color: var(--ink);
font-size: 1.3125rem;
font-weight: 800;
letter-spacing: -0.4px;
}
.lm__sub {
margin: 7px 0 0;
color: var(--t3);
font-size: 0.8125rem;
line-height: 1.5;
}
.lm__body {
padding: 24px 30px 14px;
display: flex;
flex-direction: column;
gap: 11px;
}
.lm__social {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 13px 18px;
border-radius: var(--r-lg);
cursor: pointer;
border: none;
font-family: var(--font-body);
font-weight: 700;
font-size: 0.90625rem;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
transition:
transform 0.16s,
box-shadow 0.16s;
}
.lm__social:hover {
transform: translateY(-1px);
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.14);
}
.lm__social--kakao {
background: var(--kakao);
color: var(--kakao-ink);
}
.lm__social--discord {
background: var(--discord);
color: #fff;
}
.lm__social--google {
background: #fff;
color: #33373d;
border: 1px solid #dadce0;
}
.lm__mark {
display: grid;
place-items: center;
width: 22px;
}
.lm__txt {
flex: 1;
text-align: center;
margin-right: 22px;
}
.lm__foot {
padding: 8px 30px 26px;
text-align: center;
}
.lm__foot p {
margin: 0;
font-size: 0.71875rem;
color: var(--t3);
line-height: 1.6;
}
.lm__foot a {
color: var(--t2);
text-decoration: underline;
cursor: pointer;
}
</style>
@@ -0,0 +1,183 @@
<script setup lang="ts">
// 팔레트 피커 — 사이트 포인트(컨셉) 컬러를 즉시 교체. 선택값은 localStorage 영속.
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { storeToRefs } from 'pinia'
import { useThemeStore } from '@/stores/theme.store'
import AppIcon from './AppIcon.vue'
interface Props {
onDark?: boolean // 어두운 바(TopBar) 위에 놓일 때
}
withDefaults(defineProps<Props>(), { onDark: false })
const theme = useThemeStore()
const { palette, palettes, paletteId } = storeToRefs(theme)
const open = ref(false)
const root = ref<HTMLElement | null>(null)
function choose(id: string) {
theme.setPalette(id)
open.value = false
}
function onDocPointer(e: PointerEvent) {
if (root.value && !root.value.contains(e.target as Node)) open.value = false
}
onMounted(() => document.addEventListener('pointerdown', onDocPointer, true))
onBeforeUnmount(() => document.removeEventListener('pointerdown', onDocPointer, true))
</script>
<template>
<div
ref="root"
class="pp"
>
<button
class="pp__trigger"
:class="{ 'pp__trigger--dark': onDark }"
title="사이트 색상 변경"
@click="open = !open"
>
<span
class="pp__dot"
:style="{ background: palette.point }"
/>
<AppIcon
name="palette"
:size="14"
:stroke="onDark ? 'rgba(255,255,255,.8)' : 'var(--t2)'"
/>
</button>
<div
v-if="open"
class="pp__menu"
>
<div class="pp__head">
<span>사이트 색상</span>
<span class="pp__hint">포인트 컬러를 골라보세요</span>
</div>
<button
v-for="p in palettes"
:key="p.id"
class="pp__item"
:class="{ 'pp__item--on': p.id === paletteId }"
@click="choose(p.id)"
>
<span
class="pp__swatch"
:style="{ background: p.point }"
/>
<span class="pp__meta">
<span class="pp__name">{{ p.name }}</span>
<span class="pp__desc">{{ p.desc }}</span>
</span>
<AppIcon
v-if="p.id === paletteId"
name="shieldcheck"
:size="15"
:stroke="p.point"
/>
</button>
</div>
</div>
</template>
<style scoped>
.pp {
position: relative;
display: flex;
}
.pp__trigger {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 9px;
border-radius: var(--r-pill);
border: 1px solid var(--line2);
background: var(--surf);
cursor: pointer;
}
.pp__trigger--dark {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.18);
}
.pp__dot {
width: 12px;
height: 12px;
border-radius: 50%;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.12);
}
.pp__menu {
position: absolute;
top: calc(100% + 8px);
right: 0;
width: 244px;
background: var(--surf);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--sh-pop);
padding: 7px;
z-index: 320;
animation: sgFade 0.16s ease;
}
.pp__head {
display: flex;
flex-direction: column;
gap: 2px;
padding: 6px 8px 9px;
}
.pp__head > span:first-child {
font-size: 0.8125rem;
font-weight: 800;
color: var(--ink);
}
.pp__hint {
font-size: 0.6875rem;
color: var(--t3);
}
.pp__item {
display: flex;
align-items: center;
gap: 11px;
width: 100%;
border: none;
background: transparent;
padding: 8px 9px;
border-radius: var(--r-md);
cursor: pointer;
text-align: left;
transition: background 0.12s;
}
.pp__item:hover {
background: var(--surf2);
}
.pp__item--on {
background: var(--point-soft);
}
.pp__swatch {
width: 22px;
height: 22px;
border-radius: 6px;
flex-shrink: 0;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.12);
}
.pp__meta {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.pp__name {
font-size: 0.8125rem;
font-weight: 700;
color: var(--ink);
}
.pp__desc {
font-size: 0.6875rem;
color: var(--t3);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
@@ -0,0 +1,155 @@
<script setup lang="ts">
// 계급(랭크) 뱃지 — 방패 + 계급명 + pip(계급 표식). sa-shared.jsx RankBadge 포팅.
import { computed } from 'vue'
import type { Rank } from '@/shared/data/types'
interface Props {
rank: Rank | null
size?: number
showName?: boolean
}
const props = withDefaults(defineProps<Props>(), { size: 44, showName: false })
const s = computed(() => props.size)
// 방패 외곽선 path
const shieldPath = computed(() => {
const size = s.value
return `M${size / 2} ${size * 0.07} L${size * 0.86} ${size * 0.24} V${size * 0.52} c0 ${size * 0.22}-${size * 0.16} ${size * 0.32}-${size * 0.36} ${size * 0.4} C${size * 0.3} ${size * 0.84}-${0} 0 ${size * 0.14} ${size * 0.52} V${size * 0.24} Z`
})
// pip(계급 표식) 1개의 좌표/도형 계산
interface Pip {
i: number
x: number
base: number
}
const pips = computed<Pip[]>(() => {
if (!props.rank) return []
const size = s.value
const cx = size / 2
const base = size * 0.66
const gap = size * 0.13
const total = props.rank.pips
return Array.from({ length: total }, (_, i) => ({
i,
x: cx + (i - (total - 1) / 2) * gap,
base,
}))
})
</script>
<template>
<div
v-if="rank"
class="rb"
:style="{ gap: showName ? '8px' : '0' }"
>
<svg
:width="s"
:height="s"
:viewBox="`0 0 ${s} ${s}`"
style="flex-shrink: 0"
>
<path
:d="shieldPath"
:fill="`${rank.color}16`"
:stroke="rank.color"
stroke-width="1.4"
stroke-linejoin="round"
/>
<text
:x="s / 2"
:y="s * 0.43"
text-anchor="middle"
font-family="'Pretendard',sans-serif"
font-weight="800"
:font-size="s * 0.26"
:fill="rank.color"
>
{{ rank.name }}
</text>
<template
v-for="p in pips"
:key="p.i"
>
<!-- star -->
<path
v-if="rank.shape === 'star'"
d="M0 -2.1 L0.62 -0.65 L2.1 -0.5 L1 0.5 L1.35 2 L0 1.2 L-1.35 2 L-1 0.5 L-2.1 -0.5 L-0.62 -0.65 Z"
:transform="`translate(${p.x} ${p.base}) scale(${s * 0.06})`"
:fill="rank.color"
/>
<!-- flower -->
<circle
v-else-if="rank.shape === 'flower'"
:cx="p.x"
:cy="p.base"
:r="s * 0.05"
:fill="rank.color"
/>
<!-- diamond -->
<rect
v-else-if="rank.shape === 'dia'"
:x="p.x - s * 0.045"
:y="p.base - s * 0.045"
:width="s * 0.09"
:height="s * 0.09"
:fill="rank.color"
:transform="`rotate(45 ${p.x} ${p.base})`"
/>
<!-- chevron -->
<path
v-else-if="rank.shape === 'chev'"
:d="`M ${p.x - s * 0.07} ${p.base + s * 0.05} L ${p.x} ${p.base - s * 0.03} L ${p.x + s * 0.07} ${p.base + s * 0.05}`"
:stroke="rank.color"
:stroke-width="s * 0.035"
fill="none"
/>
<!-- bar (기본) -->
<rect
v-else
:x="p.x - s * 0.05"
:y="p.base - s * 0.025"
:width="s * 0.1"
:height="s * 0.05"
:rx="s * 0.01"
:fill="rank.color"
/>
</template>
</svg>
<div
v-if="showName"
class="rb-name"
>
<div
class="rb-name__t"
:style="{ color: rank.color }"
>
{{ rank.name }}
</div>
<div class="rb-name__g">
{{ rank.group }}
</div>
</div>
</div>
</template>
<style scoped>
.rb {
display: flex;
align-items: center;
}
.rb-name {
line-height: 1.1;
}
.rb-name__t {
font-family: var(--font-num);
font-weight: 600;
font-size: 0.8125rem;
}
.rb-name__g {
font-size: 0.65625rem;
color: var(--t3);
}
</style>
@@ -0,0 +1,86 @@
<script setup lang="ts">
// 최근 검색 칩 — 클릭 시 프로필로 이동. sa-shared.jsx RecentChips 포팅.
import { useRouter } from 'vue-router'
import { SA } from '@/shared/data/sa'
import AppIcon from './AppIcon.vue'
interface Props {
onDark?: boolean
}
withDefaults(defineProps<Props>(), { onDark: false })
const router = useRouter()
const names = SA.recentSearches.slice(0, 6)
function go(name: string) {
router.push({ name: 'profile', params: { name } })
}
</script>
<template>
<div class="chips">
<span
class="chips__label"
:class="{ 'chips__label--dark': onDark }"
>
<AppIcon
name="clock"
:size="13"
/> 최근
</span>
<button
v-for="n in names"
:key="n"
class="chips__btn"
:class="{ 'chips__btn--dark': onDark }"
@click="go(n)"
>
{{ n }}
</button>
</div>
</template>
<style scoped>
.chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.chips__label {
font-size: 0.75rem;
color: var(--t3);
display: flex;
align-items: center;
gap: 5px;
}
.chips__label--dark {
color: rgba(255, 255, 255, 0.55);
}
.chips__btn {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 12px 5px 9px;
border-radius: var(--r-xs);
background: var(--surf);
border: 1px solid var(--line2);
cursor: pointer;
color: #3a4049;
font-size: 0.78125rem;
font-weight: 500;
font-family: var(--font-body);
transition: border-color 0.12s;
}
.chips__btn:hover {
border-color: #15171c;
}
.chips__btn--dark {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.16);
color: #e7eaee;
}
.chips__btn--dark:hover {
border-color: #fff;
}
</style>
@@ -0,0 +1,274 @@
<script setup lang="ts">
// 검색바 — 라이브 추천 드롭다운(최근검색/매칭). 선택 시 프로필 라우트로 이동.
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { SA } from '@/shared/data/sa'
import type { Player } from '@/shared/data/types'
import AppIcon from './AppIcon.vue'
import RankBadge from './RankBadge.vue'
interface Props {
big?: boolean
placeholder?: string
}
withDefaults(defineProps<Props>(), {
big: false,
placeholder: '닉네임으로 전적을 검색하세요',
})
const emit = defineEmits<{ (e: 'pick', player: Player): void }>()
const router = useRouter()
const q = ref('')
const open = ref(false)
const root = ref<HTMLElement | null>(null)
const matches = computed<Player[]>(() => {
const term = q.value.trim().toLowerCase()
if (!term) return []
return SA.allPlayers.filter((p) => p.name.toLowerCase().includes(term)).slice(0, 6)
})
const recents = computed<Player[]>(() =>
SA.recentSearches
.map((n) => SA.allPlayers.find((p) => p.name === n))
.filter((p): p is Player => Boolean(p)),
)
const list = computed(() => (q.value.trim() ? matches.value : recents.value))
function pick(p: Player) {
emit('pick', p)
q.value = ''
open.value = false
router.push({ name: 'profile', params: { name: p.name } })
}
function submit() {
const found = matches.value[0] || SA.allPlayers.find((p) => p.name === q.value.trim())
if (found) pick(found)
else if (q.value.trim()) {
const fallback = SA.allPlayers[1] // 데모: 미존재 시 임의 유저
if (fallback) pick(fallback)
}
}
function onDocPointer(e: PointerEvent) {
if (root.value && !root.value.contains(e.target as Node)) open.value = false
}
onMounted(() => document.addEventListener('pointerdown', onDocPointer, true))
onBeforeUnmount(() => document.removeEventListener('pointerdown', onDocPointer, true))
</script>
<template>
<div
ref="root"
class="sb"
:class="{ 'sb--big': big, 'sb--open': open }"
>
<div class="sb__field">
<div class="sb__icon">
<AppIcon
name="search"
:size="big ? 22 : 18"
:sw="2"
/>
</div>
<input
v-model="q"
class="sb__input"
:placeholder="placeholder"
@focus="open = true"
@input="open = true"
@keydown.enter="submit"
>
<button
class="sb__btn num"
@click="submit"
>
검색
</button>
</div>
<div
v-if="open"
class="sb__pop"
>
<div class="sb__pophead">
{{ q.trim() ? `"${q}" 검색 결과` : '최근 검색한 유저' }}
</div>
<button
v-for="p in list"
:key="p.name"
class="sb__row"
@click="pick(p)"
>
<RankBadge
:rank="p.rank"
:size="32"
/>
<div class="sb__info">
<div class="sb__line">
<span class="sb__nm">{{ p.name }}</span>
<span
v-if="p.clan"
class="sb__clan"
>{{ p.clan }}</span>
</div>
<div class="sb__sub">
{{ p.rank.name }} · Lv.{{ p.level }} · 승률 {{ p.wr }}%
</div>
</div>
<AppIcon
name="chevR"
:size="15"
stroke="var(--t3)"
/>
</button>
<div
v-if="q.trim() && matches.length === 0"
class="sb__empty"
>
일치하는 유저가 없어요. 닉네임을 확인해 주세요.
</div>
</div>
</div>
</template>
<style scoped>
.sb {
position: relative;
width: 100%;
}
.sb__field {
display: flex;
align-items: center;
height: 44px;
background: var(--surf);
border: 1.5px solid var(--line2);
border-radius: var(--r-xs);
box-shadow: var(--sh-card);
transition:
border-color 0.14s,
box-shadow 0.14s;
overflow: hidden;
}
.sb--big .sb__field {
height: 58px;
}
.sb--open .sb__field {
border-color: var(--point);
box-shadow: var(--sh-pop);
}
.sb__icon {
padding-left: 13px;
color: var(--t3);
display: flex;
}
.sb--big .sb__icon {
padding-left: 18px;
}
.sb--open .sb__icon {
color: var(--point);
}
.sb__input {
flex: 1;
border: none;
outline: none;
background: transparent;
color: #15171c;
font-family: var(--font-body);
font-size: 0.90625rem;
padding: 0 11px;
font-weight: 500;
min-width: 0;
}
.sb--big .sb__input {
font-size: 1.0625rem;
padding: 0 16px;
}
.sb__btn {
height: 100%;
border: none;
cursor: pointer;
color: var(--point-ink);
background: var(--point);
font-weight: 700;
font-size: 0.8125rem;
padding: 0 17px;
letter-spacing: 0.5px;
}
.sb--big .sb__btn {
font-size: 0.9375rem;
padding: 0 26px;
}
.sb__pop {
position: absolute;
top: calc(100% + 8px);
left: 0;
right: 0;
background: var(--surf);
border: 1px solid var(--line);
border-radius: var(--r-xs);
box-shadow: 0 16px 40px rgba(20, 22, 28, 0.12);
padding: 7px;
z-index: 40;
overflow: hidden;
text-align: left;
}
.sb__pophead {
font-size: 0.6875rem;
font-weight: 700;
color: var(--t3);
padding: 6px 10px 8px;
letter-spacing: 0.3px;
}
.sb__row {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
border: none;
background: transparent;
padding: 8px 10px;
border-radius: var(--r-xs);
cursor: pointer;
text-align: left;
transition: background 0.12s;
}
.sb__row:hover {
background: #f5f6f8;
}
.sb__info {
flex: 1;
min-width: 0;
}
.sb__line {
display: flex;
align-items: baseline;
gap: 6px;
}
.sb__nm {
color: #15171c;
font-weight: 600;
font-size: 0.875rem;
flex-shrink: 0;
max-width: 62%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sb__clan {
font-size: 0.6875rem;
color: var(--t2);
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sb__sub {
font-size: 0.71875rem;
color: var(--t3);
}
.sb__empty {
padding: 14px 10px;
color: var(--t3);
font-size: 0.8125rem;
}
</style>
@@ -0,0 +1,65 @@
<script setup lang="ts">
// 세그먼트 탭 (각진) — 선택 시 포인트 배경. sa-home.jsx Seg 포팅. v-model 지원.
interface Props {
tabs: string[]
modelValue: string
fill?: boolean // 전체 폭 균등 분할
small?: boolean
}
withDefaults(defineProps<Props>(), { fill: false, small: false })
const emit = defineEmits<{ (e: 'update:modelValue', value: string): void }>()
</script>
<template>
<div
class="seg"
:class="{ 'seg--fill': fill }"
>
<button
v-for="(t, i) in tabs"
:key="t"
class="seg__btn"
:class="{ 'seg__btn--on': modelValue === t, 'seg__btn--sep': i > 0, 'seg__btn--sm': small }"
@click="emit('update:modelValue', t)"
>
{{ t }}
</button>
</div>
</template>
<style scoped>
.seg {
display: flex;
border: 1px solid var(--line2);
border-radius: var(--r-xs);
overflow: hidden;
background: var(--surf);
}
.seg--fill {
width: 100%;
}
.seg--fill .seg__btn {
flex: 1;
}
.seg__btn {
padding: 5px 11px;
border: none;
cursor: pointer;
font-weight: 700;
font-size: 0.75rem;
background: var(--surf);
color: var(--t2);
}
.seg__btn--sm {
padding: 3px 10px;
font-size: 0.65625rem;
font-family: var(--font-num);
}
.seg__btn--sep {
border-left: 1px solid var(--line2);
}
.seg__btn--on {
background: var(--point);
color: var(--point-ink);
}
</style>
@@ -0,0 +1,46 @@
<script setup lang="ts">
// 티어 칩 — 마름모 색 표식 + 티어명(+디비전). sa-profile-sections.jsx TierChipS 포팅.
import { computed } from 'vue'
import { SA } from '@/shared/data/sa'
interface Props {
name: string
div?: number | null
}
const props = withDefaults(defineProps<Props>(), { div: null })
const col = computed(() => {
const t = (SA.tiers || []).find((x) => x.name === props.name)
return t ? t.color : 'var(--point)'
})
</script>
<template>
<span class="tc">
<span
class="tc__mark"
:style="{ background: col }"
/>
<span class="tc__name">{{ name }}{{ div ? ' ' + div : '' }}</span>
</span>
</template>
<style scoped>
.tc {
display: inline-flex;
align-items: center;
gap: 7px;
white-space: nowrap;
}
.tc__mark {
width: 9px;
height: 9px;
border-radius: 2px;
transform: rotate(45deg);
flex-shrink: 0;
}
.tc__name {
font-weight: 700;
color: var(--ink);
}
</style>
@@ -0,0 +1,73 @@
<script setup lang="ts">
// 패널 — 채워진 헤더바(포인트 마커 + 제목 + EN 라벨) + 보더 본문. sa-home.jsx Panel 포팅.
interface Props {
title: string
en?: string
bodyPad?: string // 본문 패딩 (예: '14px 14px')
}
withDefaults(defineProps<Props>(), { en: '', bodyPad: '8px 14px 12px' })
</script>
<template>
<section class="panel">
<div class="panel__head">
<div class="panel__title">
<span class="panel__marker" />
<span class="panel__name">{{ title }}</span>
<span
v-if="en"
class="panel__en num"
>{{ en }}</span>
</div>
<slot name="right" />
</div>
<div
class="panel__body"
:style="{ padding: bodyPad }"
>
<slot />
</div>
</section>
</template>
<style scoped>
.panel {
border: 1px solid var(--line2);
background: var(--surf);
border-radius: var(--r-sm);
}
.panel__head {
display: flex;
align-items: center;
justify-content: space-between;
height: 40px;
padding: 0 12px 0 0;
background: var(--head);
border-bottom: 1px solid var(--line2);
}
.panel__title {
display: flex;
align-items: center;
gap: 9px;
min-width: 0;
}
.panel__marker {
width: 4px;
align-self: stretch;
background: var(--point);
margin: 8px 0 8px 0;
margin-right: 2px;
}
.panel__name {
font-size: 0.90625rem;
font-weight: 800;
color: var(--ink);
letter-spacing: -0.2px;
}
.panel__en {
font-size: 0.625rem;
font-weight: 600;
color: var(--t3);
letter-spacing: 1.2px;
}
</style>
@@ -0,0 +1,94 @@
<script setup lang="ts">
// 승률 링 — 도넛 게이지 + 중앙 수치. sa-shared.jsx WinRing 포팅.
import { computed } from 'vue'
import { wrColor } from '@/shared/utils/stats'
interface Props {
wr: number
size?: number
sw?: number
label?: string
color?: string
}
const props = withDefaults(defineProps<Props>(), {
size: 64,
sw: 6,
label: '승률',
color: '', // 빈 값이면 승률 기반 색상 자동 적용
})
const radius = computed(() => (props.size - props.sw) / 2)
const circ = computed(() => 2 * Math.PI * radius.value)
const col = computed(() => props.color || wrColor(props.wr))
const dashOffset = computed(() => circ.value * (1 - props.wr / 100))
</script>
<template>
<div
class="ring"
:style="{ width: size + 'px', height: size + 'px' }"
>
<svg
:width="size"
:height="size"
style="transform: rotate(-90deg)"
>
<circle
:cx="size / 2"
:cy="size / 2"
:r="radius"
fill="none"
stroke="#eceef1"
:stroke-width="sw"
/>
<circle
:cx="size / 2"
:cy="size / 2"
:r="radius"
fill="none"
:stroke="col"
:stroke-width="sw"
stroke-linecap="round"
:stroke-dasharray="circ"
:stroke-dashoffset="dashOffset"
/>
</svg>
<div class="ring__center">
<div>
<div
class="ring__val num"
:style="{ fontSize: size * 0.28 + 'px', color: col }"
>
{{ wr }}<span :style="{ fontSize: size * 0.16 + 'px' }">%</span>
</div>
<div
class="ring__lab"
:style="{ fontSize: size * 0.13 + 'px' }"
>
{{ label }}
</div>
</div>
</div>
</div>
</template>
<style scoped>
.ring {
position: relative;
}
.ring__center {
position: absolute;
inset: 0;
display: grid;
place-items: center;
text-align: center;
line-height: 1;
}
.ring__val {
font-weight: 700;
}
.ring__lab {
color: var(--t3);
margin-top: 2px;
}
</style>
+58
View File
@@ -0,0 +1,58 @@
<script setup lang="ts">
// 승률 셀 — 가로 막대 + 퍼센트. 표 안에서 승률 표현. sa-profile-sections.jsx WrCell 포팅.
import { computed } from 'vue'
import { wrColor } from '@/shared/utils/stats'
interface Props {
wr: number
width?: number
}
const props = withDefaults(defineProps<Props>(), { width: 46 })
const fill = computed(() => Math.max(0.04, Math.min(1, (props.wr - 35) / (80 - 35))))
const col = computed(() => wrColor(props.wr))
</script>
<template>
<div class="wrc">
<span
class="wrc__bar"
:style="{ width: width + 'px' }"
>
<span
class="wrc__fill"
:style="{ width: fill * 100 + '%', background: col }"
/>
</span>
<span
class="wrc__pct num"
:style="{ color: col }"
>{{ wr }}%</span>
</div>
</template>
<style scoped>
.wrc {
display: inline-flex;
align-items: center;
gap: 8px;
justify-content: flex-end;
}
.wrc__bar {
height: 7px;
background: #d4d8de;
border-radius: 4px;
overflow: hidden;
display: inline-block;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.05);
}
.wrc__fill {
display: block;
height: 100%;
}
.wrc__pct {
font-weight: 700;
min-width: 36px;
text-align: right;
}
</style>
@@ -0,0 +1,59 @@
<script setup lang="ts">
// 푸터 — 로고 + 정책 링크 + 면책 문구. '문의하기'는 피드백 모달 오픈.
import { useUiStore } from '@/stores/ui.store'
import AppLogo from '@/shared/components/AppLogo.vue'
const ui = useUiStore()
const links = ['이용약관', '개인정보처리방침', '공지사항', '문의하기']
</script>
<template>
<footer class="footer">
<div class="footer__inner">
<AppLogo :size="18" />
<div class="footer__links">
<a
v-for="t in links"
:key="t"
@click="t === '문의하기' && ui.openFeedback()"
>{{ t }}</a>
</div>
<div class="footer__copy">
서린즈는 서든어택 전적 조회를 위한 비공식 사이트입니다. 페이지의 데이터는 데모용 예시입니다.<br>
© 2026 서린즈
</div>
</div>
</footer>
</template>
<style scoped>
.footer {
border-top: 1px solid var(--line2);
background: var(--surf);
}
.footer__inner {
max-width: var(--maxw);
margin: 0 auto;
padding: 24px 28px 34px;
display: flex;
align-items: flex-start;
gap: 20px;
}
.footer__links {
display: flex;
gap: 16px;
margin-left: 8px;
}
.footer__links a {
font-size: 0.78125rem;
color: var(--t2);
cursor: pointer;
}
.footer__copy {
margin-left: auto;
text-align: right;
font-size: 0.71875rem;
color: var(--t3);
line-height: 1.7;
}
</style>
@@ -0,0 +1,102 @@
<script setup lang="ts">
// 메인 네비게이션(GNB) — 로고 + 메뉴. 현재 라우트로 활성 탭 판별. (로그인은 상단바로 이동)
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import AppLogo from '@/shared/components/AppLogo.vue'
const route = useRoute()
const router = useRouter()
interface NavItem {
label: string
to: string
// 활성으로 간주할 라우트 name 들
match: string[]
}
const items: NavItem[] = [
{ label: '홈', to: '/', match: ['home'] },
{ label: '전적검색', to: '/', match: ['profile'] },
{ label: '커뮤니티', to: '/community', match: ['community'] },
{ label: '클랜', to: '/clans', match: ['clans', 'clan'] },
]
const activeLabel = computed(() => {
const name = route.name as string
const found = items.find((it) => it.match.includes(name))
return found ? found.label : '홈'
})
function go(to: string) {
router.push(to)
}
</script>
<template>
<div class="nav">
<div class="nav__inner">
<RouterLink
to="/"
class="nav__logo"
>
<AppLogo :size="22" />
</RouterLink>
<nav class="nav__menu">
<button
v-for="it in items"
:key="it.label"
class="nav__item"
:class="{ 'nav__item--on': activeLabel === it.label }"
@click="go(it.to)"
>
{{ it.label }}
</button>
</nav>
</div>
</div>
</template>
<style scoped>
.nav {
background: var(--surf);
border-bottom: 2px solid var(--dark);
position: sticky;
top: 0;
z-index: 30;
}
.nav__inner {
max-width: var(--maxw);
margin: 0 auto;
height: 60px;
display: flex;
align-items: center;
padding: 0 28px;
}
.nav__logo {
display: flex;
text-decoration: none;
}
.nav__menu {
display: flex;
margin-left: 34px;
height: 100%;
}
.nav__item {
display: flex;
align-items: center;
padding: 0 18px;
height: 100%;
font-size: 0.9375rem;
font-weight: 600;
color: #3a4049;
cursor: pointer;
background: transparent;
border: none;
border-bottom: 3px solid transparent;
font-family: var(--font-body);
}
.nav__item--on {
font-weight: 800;
color: var(--point);
border-bottom-color: var(--point);
}
</style>
@@ -0,0 +1,64 @@
<script setup lang="ts">
// 상단 유틸 바 — 어두운 바. 팔레트 피커 + 로그인 + 문의.
import { useUiStore } from '@/stores/ui.store'
import PalettePicker from '@/shared/components/PalettePicker.vue'
const ui = useUiStore()
</script>
<template>
<div class="topbar">
<div class="topbar__inner">
<PalettePicker on-dark />
<span class="topbar__sep" />
<button
class="topbar__link"
@click="ui.openLogin()"
>
로그인
</button>
<span class="topbar__sep" />
<button
class="topbar__link"
@click="ui.openFeedback()"
>
문의
</button>
</div>
</div>
</template>
<style scoped>
.topbar {
height: 30px;
background: var(--dark);
}
.topbar__inner {
max-width: var(--maxw);
margin: 0 auto;
height: 100%;
display: flex;
align-items: center;
justify-content: flex-end;
padding: 0 28px;
}
.topbar__sep {
width: 1px;
height: 11px;
background: rgba(255, 255, 255, 0.18);
margin: 0 12px;
}
.topbar__link {
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.72);
cursor: pointer;
font-weight: 500;
background: none;
border: none;
font-family: var(--font-body);
padding: 0;
}
.topbar__link:hover {
color: #fff;
}
</style>
+74
View File
@@ -0,0 +1,74 @@
/* ────────────────────────────────────────────────────────────
* 서린즈 · 전역 base 스타일
* 리셋 + 폰트 + 공통 애니메이션 + 숫자 유틸 클래스
* ──────────────────────────────────────────────────────────── */
@import './tokens.css';
@import './record-table.css';
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
background: var(--bg2);
}
body {
font-family: var(--font-body);
color: var(--ink);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#app {
min-height: 100vh;
}
button {
font-family: var(--font-body);
}
input::placeholder,
textarea::placeholder {
color: var(--placeholder);
}
a {
color: inherit;
}
/* 숫자·지표·라벨 — Chakra Petch */
.num {
font-family: var(--font-num);
}
/* 스크롤바 숨김(원본 디자인 토큰과 동일) */
::-webkit-scrollbar {
width: 0;
height: 0;
}
/* 공통 페이드 인 애니메이션 */
@keyframes sgFade {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes sgPulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.35;
}
}
@@ -0,0 +1,65 @@
/* ────────────────────────────────────────────────────────────
* 전적 표 공통 스타일 — 프로필/클랜 등 여러 표가 공유하는 유틸 클래스
* (scoped 로 공유 불가하므로 전역 유틸로 둔다)
* ──────────────────────────────────────────────────────────── */
.rectable {
width: 100%;
border-collapse: collapse;
}
.rectable thead tr {
border-bottom: 1px solid var(--line2);
background: #f7f8f9;
}
.rectable th {
font-size: 0.6875rem;
font-weight: 700;
color: var(--t3);
padding: 10px 14px;
text-align: left;
white-space: nowrap;
letter-spacing: 0.2px;
}
.rectable td {
font-size: 0.8125rem;
color: var(--t-table);
padding: 11px 14px;
white-space: nowrap;
vertical-align: middle;
}
.rectable .ta-r {
text-align: right;
}
.rectable .ta-c {
text-align: center;
}
.rectable .td-strong {
font-weight: 700;
color: var(--ink);
}
.rectable .td-rec {
color: var(--t2);
}
.rectable .td-num {
font-family: var(--font-num);
}
.rectable .td-kd {
font-family: var(--font-num);
font-weight: 600;
}
/* 가로 스크롤 래퍼 */
.rectable-scroll {
overflow-x: auto;
}
/* 더보기/접기 버튼 */
.rectable-more {
width: 100%;
padding: 10px 0;
border: none;
border-top: 1px solid var(--line);
background: var(--surf);
cursor: pointer;
font-family: var(--font-body);
font-weight: 700;
font-size: 0.78125rem;
color: var(--t2);
}
+76
View File
@@ -0,0 +1,76 @@
/* ────────────────────────────────────────────────────────────
* 서린즈 · 디자인 토큰 (searchgg 디자인 토큰 기반)
* 밝은 표면 + 포인트 컬러. 숫자=Chakra Petch, 본문=Pretendard.
*
* 포인트 계열(--point / --point-strong / --point-soft / --positive)은
* 테마 스토어(useThemeStore)가 런타임에 :root 에 덮어씁니다.
* 아래 값은 SSR/초기 페인트용 기본값(올리브/슬레이트)입니다.
* ──────────────────────────────────────────────────────────── */
:root {
/* ── 표면 / 배경 ── */
--bg: #ffffff;
--bg2: #eef0f2;
--surf: #ffffff;
--surf2: #f1f3f5;
--head: #f1f3f5; /* 패널 헤더바 */
/* ── 어두운 표면(네비/바헤드/히어로) — 중립색, 테마 영향 없음 ── */
--dark: #23262b;
--dark2: #1c1f24;
--dark-grad: linear-gradient(135deg, #23262b, #2e333b);
/* ── 경계선 ── */
--line: #e4e6e9;
--line2: #d2d6db;
--line3: #eaecef; /* 표 행 구분선 */
/* ── 텍스트 ── */
--ink: #20242b; /* 본문 강조 */
--t2: #5b626c; /* 보조 */
--t3: #949aa3; /* 약한 / 캡션 */
--t-table: #33373d; /* 표 셀 */
--placeholder: #9aa1ab;
/* ── 포인트 / 시맨틱 (테마 스토어가 덮어씀) ── */
--point: #48515f; /* 메인 포인트 */
--point-strong: #3a4049; /* 호버/액티브용 진한 포인트 */
--point-soft: rgba(72, 81, 95, 0.12); /* 포인트 12% */
--point-ink: #ffffff; /* 포인트 배경 위 텍스트 */
--positive: #48515f; /* 승 / 좋은 승률 */
/* ── 고정 시맨틱 — 테마 영향 없음 ── */
--negative: #e5484d; /* 패 / 데스 / 위험 / 마감임박 */
--up: #16a34a; /* 상승 추세 */
--accent-blue: #2f6fed; /* 그래프 승률선 */
--gold: #c0901a; /* 영관 계급 / 챔피언 */
--hot: #b23b3b; /* HOT / 긴급 */
/* ── 외부 채널 ── */
--discord: #5865f2;
--kakao: #fee500;
--kakao-ink: #191600;
--chzzk: #00b6a0;
--soop: #0064ff;
--youtube: #e62117;
/* ── 모양 (라디우스) ── */
--r-xs: 0.25rem; /* 4px 인풋·작은 배지 */
--r-sm: 0.3125rem; /* 5px 기본 카드·패널 */
--r-md: 0.4375rem; /* 7px 버튼·인풋 */
--r-lg: 0.5rem; /* 8px 큰 카드 */
--r-xl: 0.875rem; /* 14px 모달 */
--r-pill: 1.25rem; /* 20px 칩·태그 */
/* ── 그림자 ── */
--sh-card: 0 1px 2px rgba(0, 0, 0, 0.04);
--sh-hover: 0 4px 14px rgba(0, 0, 0, 0.1);
--sh-pop: 0 6px 22px rgba(0, 0, 0, 0.1);
--sh-modal: 0 24px 70px rgba(0, 0, 0, 0.34);
/* ── 레이아웃 ── */
--maxw: 1180px; /* 콘텐츠 최대 폭 */
/* ── 폰트 ── */
--font-body: 'Pretendard', sans-serif;
--font-num: 'Chakra Petch', sans-serif; /* 숫자·지표·라벨·로고 */
}
+46
View File
@@ -0,0 +1,46 @@
// 색상 유틸 — 부수효과 없는 순수 함수만 작성
export interface Rgb {
r: number
g: number
b: number
}
/** '#48515f' / '48515f' → {r,g,b}. 형식 오류 시 검정 반환 */
export function hexToRgb(hex: string): Rgb {
const h = hex.replace('#', '').trim()
const full = h.length === 3
? h.split('').map((c) => c + c).join('')
: h
if (full.length < 6) return { r: 0, g: 0, b: 0 }
return {
r: parseInt(full.slice(0, 2), 16),
g: parseInt(full.slice(2, 4), 16),
b: parseInt(full.slice(4, 6), 16),
}
}
/** 포인트 색을 알파와 함께 rgba 문자열로 (예: point-soft 12%) */
export function hexToRgba(hex: string, alpha: number): string {
const { r, g, b } = hexToRgb(hex)
return `rgba(${r}, ${g}, ${b}, ${alpha})`
}
/** 명도 기반 밝은 색 판별 (텍스트 대비 선택용) */
export function isLight(hex: string): boolean {
const { r, g, b } = hexToRgb(hex)
return 0.299 * r + 0.587 * g + 0.114 * b > 150
}
/** 배경색 위에 올릴 텍스트 색(검정/흰) 결정 */
export function readableInk(hex: string): string {
return isLight(hex) ? '#20242b' : '#ffffff'
}
/** 색을 ratio(0~1)만큼 어둡게 (호버용 진한 포인트 생성) */
export function darken(hex: string, ratio: number): string {
const { r, g, b } = hexToRgb(hex)
const f = (v: number) => Math.max(0, Math.min(255, Math.round(v * (1 - ratio))))
const toHex = (v: number) => f(v).toString(16).padStart(2, '0')
return `#${toHex(r)}${toHex(g)}${toHex(b)}`
}
+20
View File
@@ -0,0 +1,20 @@
// 공통 포맷터 — 부수효과 없는 순수 함수만 작성
/**
* 숫자를 한국 통화 표기 (예: 12345 → "12,345원")
*/
export function formatCurrency(value: number): string {
return `${value.toLocaleString('ko-KR')}`
}
/**
* Date 또는 ISO 문자열을 'YYYY-MM-DD' 형식으로 변환
*/
export function formatDate(input: Date | string): string {
const date = typeof input === 'string' ? new Date(input) : input
if (Number.isNaN(date.getTime())) return ''
const yyyy = date.getFullYear()
const mm = String(date.getMonth() + 1).padStart(2, '0')
const dd = String(date.getDate()).padStart(2, '0')
return `${yyyy}-${mm}-${dd}`
}
+17
View File
@@ -0,0 +1,17 @@
// 전적 관련 순수 유틸
/** 승률 색상 — 50% 이상이면 포인트(긍정), 미만이면 네거티브.
* 테마 교체에 반응하도록 CSS 변수를 반환한다. */
export function wrColor(wr: number): string {
return wr >= 50 ? 'var(--positive)' : 'var(--negative)'
}
/** 킬뎃 비율(%) — kda → 백분율 */
export function kdPct(kda: number): number {
return Math.round((kda / (kda + 1)) * 100)
}
/** 천 단위 콤마 */
export function fmt(n: number): string {
return n.toLocaleString('en-US')
}
+75
View File
@@ -0,0 +1,75 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import { darken, hexToRgba, readableInk } from '@/shared/utils/color'
// ────────────────────────────────────────────────────────────
// 테마(포인트 컬러 팔레트) 스토어
// 사용자가 피커에서 사이트 컨셉 색을 교체 → CSS 변수(--point 계열)를
// :root 에 주입하고 localStorage 에 영속한다.
// 정적 토큰(표면/텍스트/라디우스 등)은 tokens.css 가 담당.
// ────────────────────────────────────────────────────────────
export interface Palette {
id: string
name: string // 표시 이름
point: string // 메인 포인트(hex)
desc: string // 피커 설명
}
// 내장 프리셋 — 기본은 올리브/슬레이트. 추후 자유롭게 추가 가능.
export const PALETTES: Palette[] = [
{ id: 'olive', name: '올리브/슬레이트', point: '#48515f', desc: '차분한 군용 톤 (기본)' },
{ id: 'blue', name: '택티컬 블루', point: '#2f6fed', desc: '시원하고 정보형' },
{ id: 'crimson', name: '크림슨 레드', point: '#d83a40', desc: '강렬한 전투 분위기' },
{ id: 'violet', name: '네온 퍼플', point: '#7a5bb0', desc: '게이밍/스트리밍 톤' },
{ id: 'teal', name: '시안 틸', point: '#0f9b8e', desc: '선명한 e스포츠 톤' },
{ id: 'amber', name: '골드 앰버', point: '#c0901a', desc: '챔피언/프리미엄 톤' },
]
const STORAGE_KEY = 'surinz-theme'
const DEFAULT_ID = 'olive'
export const useThemeStore = defineStore('theme', () => {
const paletteId = ref<string>(DEFAULT_ID)
const palette = computed<Palette>(
() => PALETTES.find((p) => p.id === paletteId.value) ?? (PALETTES[0] as Palette),
)
// :root 에 포인트 계열 CSS 변수 주입
function applyToRoot(p: Palette) {
const root = document.documentElement
root.style.setProperty('--point', p.point)
root.style.setProperty('--point-strong', darken(p.point, 0.16))
root.style.setProperty('--point-soft', hexToRgba(p.point, 0.12))
root.style.setProperty('--point-ink', readableInk(p.point))
root.style.setProperty('--positive', p.point) // 승/긍정은 포인트색을 따른다
root.dataset.theme = p.id
}
// 팔레트 선택 + 영속
function setPalette(id: string) {
if (!PALETTES.some((p) => p.id === id)) return
paletteId.value = id
applyToRoot(palette.value)
try {
localStorage.setItem(STORAGE_KEY, id)
} catch {
// 프라이빗 모드 등 저장 실패는 무시
}
}
// 앱 부팅 시 1회 호출 — 저장값 복원 후 적용
function init() {
let saved: string | null = null
try {
saved = localStorage.getItem(STORAGE_KEY)
} catch {
saved = null
}
paletteId.value = saved && PALETTES.some((p) => p.id === saved) ? saved : DEFAULT_ID
applyToRoot(palette.value)
}
return { paletteId, palette, palettes: PALETTES, setPalette, init }
})
+38
View File
@@ -0,0 +1,38 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
// ────────────────────────────────────────────────────────────
// 전역 UI 상태 — 로그인 모달, 문의(피드백) 모달 등 라우트와 무관한 오버레이
// ────────────────────────────────────────────────────────────
export const useUiStore = defineStore('ui', () => {
const loginOpen = ref<boolean>(false)
const feedbackOpen = ref<boolean>(false)
const palettePickerOpen = ref<boolean>(false)
function openLogin() {
loginOpen.value = true
}
function closeLogin() {
loginOpen.value = false
}
function openFeedback() {
feedbackOpen.value = true
}
function closeFeedback() {
feedbackOpen.value = false
}
function togglePalettePicker(v?: boolean) {
palettePickerOpen.value = v ?? !palettePickerOpen.value
}
return {
loginOpen,
feedbackOpen,
palettePickerOpen,
openLogin,
closeLogin,
openFeedback,
closeFeedback,
togglePalettePicker,
}
})
+19
View File
@@ -0,0 +1,19 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import type { User } from '@/composables/useUser'
// 사용자 도메인 Pinia 스토어 샘플 — 도메인 단위로 파일을 분리하여 관리
export const useUserStore = defineStore('user', () => {
const user = ref<User | null>(null)
const isLoggedIn = computed<boolean>(() => user.value !== null)
function setUser(payload: User | null) {
user.value = payload
}
function clear() {
user.value = null
}
return { user, isLoggedIn, setUser, clear }
})
+18
View File
@@ -0,0 +1,18 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
// Extra safety for array and object lookups, but may have false positives.
"noUncheckedIndexedAccess": true,
// Path mapping for cleaner imports.
"paths": {
"@/*": ["./src/*"]
},
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
// Specified here to keep it out of the root directory.
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.vitest.json"
}
]
}
+27
View File
@@ -0,0 +1,27 @@
// TSConfig for modules that run in Node.js environment via either transpilation or type-stripping.
{
"extends": "@tsconfig/node24/tsconfig.json",
"include": [
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"playwright.config.*",
"eslint.config.*"
],
"compilerOptions": {
// Most tools use transpilation instead of Node.js's native type-stripping.
// Bundler mode provides a smoother developer experience.
"module": "preserve",
"moduleResolution": "bundler",
// Include Node.js types and avoid accidentally including other `@types/*` packages.
"types": ["node"],
// Disable emitting output during `vue-tsc --build`, which is used for type-checking only.
"noEmit": true,
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
// Specified here to keep it out of the root directory.
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
}
}
+19
View File
@@ -0,0 +1,19 @@
{
"extends": "./tsconfig.app.json",
// Override to include only test files and clear exclusions.
// Application code imported in tests is automatically included via module resolution.
"include": ["src/**/__tests__/*", "env.d.ts"],
"exclude": [],
"compilerOptions": {
// Vitest runs in a different environment than the application code.
// Adjust lib and types accordingly.
"lib": [],
"types": ["node", "jsdom"],
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
// Specified here to keep it out of the root directory.
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.vitest.tsbuildinfo"
}
}
+35
View File
@@ -0,0 +1,35 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
import vueDevTools from 'vite-plugin-vue-devtools'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
vueJsx(),
vueDevTools(),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
},
},
server: {
// ngrok 등 외부 도메인 접근 허용 (DNS Rebinding 보호 비활성화)
allowedHosts: true,
// 도커/윈도우 환경 파일 핫 리로딩 강제 활성화
watch: {
usePolling: true,
},
// 개발 중 /api 요청을 NestJS 백엔드(3000)로 프록시 (CORS 회피)
proxy: {
'/api': {
target: process.env.VITE_API_PROXY_TARGET || 'http://localhost:3000',
changeOrigin: true,
},
},
}
})
+14
View File
@@ -0,0 +1,14 @@
import { fileURLToPath } from 'node:url'
import { mergeConfig, defineConfig, configDefaults } from 'vitest/config'
import viteConfig from './vite.config'
export default mergeConfig(
viteConfig,
defineConfig({
test: {
environment: 'jsdom',
exclude: [...configDefaults.exclude, 'e2e/**'],
root: fileURLToPath(new URL('./', import.meta.url)),
},
}),
)