Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fbd596bdd0 |
@@ -1,5 +1,10 @@
|
|||||||
# 의사결정 이력
|
# 의사결정 이력
|
||||||
|
|
||||||
|
## 2026-03-30 v1.2.3
|
||||||
|
- 로그인 유도는 좌측 하단의 단일 버튼이면 충분하므로, 비로그인 상태에서 사이드 상단에 별도 안내 카드를 또 보여주는 구조는 제거하는 편이 더 깔끔하다고 판단했다.
|
||||||
|
- 티어표 편집 화면은 공통 우측 패널의 generic 문맥 카드보다 실제 편집 필드가 우측에 있는 편이 훨씬 중요하므로, 이 화면은 전용 로컬 우측 패널을 두는 쪽으로 정리했다.
|
||||||
|
- 좌측 내비가 이미 라우팅 역할을 하므로, 에디터 우측 패널에서는 “게임 목록으로” 같은 중복 이동 CTA보다 저장과 편집 자체에 집중하는 것이 맞다고 판단했다.
|
||||||
|
|
||||||
## 2026-03-30 v1.2.2
|
## 2026-03-30 v1.2.2
|
||||||
- 우측 패널은 본문 내부 보조 박스가 아니라 별도 컬럼으로 보이는 것이 핵심이므로, 폭을 `320px`로 고정하고 접힘/펼침도 레이아웃 레벨에서 처리하는 편이 맞다고 판단했다.
|
- 우측 패널은 본문 내부 보조 박스가 아니라 별도 컬럼으로 보이는 것이 핵심이므로, 폭을 `320px`로 고정하고 접힘/펼침도 레이아웃 레벨에서 처리하는 편이 맞다고 판단했다.
|
||||||
- 좌측 패널도 시안 기준 인지 폭이 중요하므로 `248px`로 고정하고, 중앙 콘텐츠는 나머지 공간을 유동적으로 쓰게 하는 구조로 정리했다.
|
- 좌측 패널도 시안 기준 인지 폭이 중요하므로 `248px`로 고정하고, 중앙 콘텐츠는 나머지 공간을 유동적으로 쓰게 하는 구조로 정리했다.
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
## `/editor/:gameId/new`, `/editor/:gameId/:tierListId`
|
## `/editor/:gameId/new`, `/editor/:gameId/:tierListId`
|
||||||
- 화면 파일: `frontend/src/views/TierEditorView.vue`
|
- 화면 파일: `frontend/src/views/TierEditorView.vue`
|
||||||
- 역할: 티어 그룹 편집, 티어 행 추가/삭제, 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 티어표 썸네일 선택, 작성 권한 제어, 저장, 공개 여부 설정, 즐겨찾기 토글, PNG 다운로드
|
- 역할: 티어 그룹 편집, 티어 행 추가/삭제, 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 우측 전용 편집 패널에서 티어표 제목/설명/대표 썸네일/공개 여부/저장 제어, 즐겨찾기 토글, PNG 다운로드
|
||||||
- 연동 API: `GET /api/games/:gameId`, `GET /api/tierlists/:id`, `POST /api/tierlists/:id/favorite`, `DELETE /api/tierlists/:id/favorite`, `POST /api/tierlists/thumbnail`, `POST /api/tierlists/custom-items`, `POST /api/tierlists`
|
- 연동 API: `GET /api/games/:gameId`, `GET /api/tierlists/:id`, `POST /api/tierlists/:id/favorite`, `DELETE /api/tierlists/:id/favorite`, `POST /api/tierlists/thumbnail`, `POST /api/tierlists/custom-items`, `POST /api/tierlists`
|
||||||
|
|
||||||
## `/login`
|
## `/login`
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
- 프런트 파비콘은 운영 정적 파일 차단 영향을 줄이기 위해 `index.html`의 인라인 데이터 URL로 제공한다.
|
- 프런트 파비콘은 운영 정적 파일 차단 영향을 줄이기 위해 `index.html`의 인라인 데이터 URL로 제공한다.
|
||||||
- 프런트 앱 셸은 `좌측 내비게이션 / 중앙 워크스페이스 / 우측 컨텍스트 패널` 3단 구조로 재정의되었고, preview 모드에서는 이 셸을 숨기고 콘텐츠만 렌더링한다.
|
- 프런트 앱 셸은 `좌측 내비게이션 / 중앙 워크스페이스 / 우측 컨텍스트 패널` 3단 구조로 재정의되었고, preview 모드에서는 이 셸을 숨기고 콘텐츠만 렌더링한다.
|
||||||
- 좌측 패널은 `248px`, 우측 패널은 `320px` 기준 폭을 사용하며, 우측 패널은 상단 토글 버튼으로 접고 펼칠 수 있다.
|
- 좌측 패널은 `248px`, 우측 패널은 `320px` 기준 폭을 사용하며, 우측 패널은 상단 토글 버튼으로 접고 펼칠 수 있다.
|
||||||
|
- 비로그인 상태의 로그인 유도는 좌측 하단 버튼으로만 노출하고, 좌측 상단 사용자 카드 영역에는 별도 게스트 안내 카드를 렌더링하지 않는다.
|
||||||
|
|
||||||
## 데이터 저장 구조
|
## 데이터 저장 구조
|
||||||
- 메인 데이터베이스: MariaDB `tier_cursor` (기본값)
|
- 메인 데이터베이스: MariaDB `tier_cursor` (기본값)
|
||||||
@@ -30,6 +31,9 @@
|
|||||||
- 현재 화면 문맥에 맞는 설명, 빠른 액션, 계정 상태 같은 보조 정보를 배치한다.
|
- 현재 화면 문맥에 맞는 설명, 빠른 액션, 계정 상태 같은 보조 정보를 배치한다.
|
||||||
- 에디터/관리자 세부 옵션은 후속 단계에서 이 패널로 점진 이관한다.
|
- 에디터/관리자 세부 옵션은 후속 단계에서 이 패널로 점진 이관한다.
|
||||||
- 공통 토글 버튼으로 패널을 접으면 중앙 워크스페이스가 남는 공간을 확장 사용한다.
|
- 공통 토글 버튼으로 패널을 접으면 중앙 워크스페이스가 남는 공간을 확장 사용한다.
|
||||||
|
- 티어표 편집 화면
|
||||||
|
- 공통 우측 패널 대신 전용 로컬 편집 패널을 사용한다.
|
||||||
|
- 제목, 설명, 대표 썸네일, 공개 여부, 저장/삭제/요청 액션을 우측 로컬 패널에 배치한다.
|
||||||
|
|
||||||
## DB 스키마
|
## DB 스키마
|
||||||
- `users`
|
- `users`
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
## 즉시 확인 필요
|
## 즉시 확인 필요
|
||||||
- 피그마 기반 리디자인은 현재 공통 셸과 카드 목록 화면, 포커스 화면 안정화까지만 반영된 상태이므로, 에디터/관리자 우측 옵션 패널을 시안 구조에 맞게 실제 기능 패널로 이관하는 작업이 남아 있다.
|
- 피그마 기반 리디자인은 현재 공통 셸과 카드 목록 화면, 포커스 화면 안정화까지만 반영된 상태이므로, 에디터/관리자 우측 옵션 패널을 시안 구조에 맞게 실제 기능 패널로 이관하는 작업이 남아 있다.
|
||||||
- 공통 우측 패널의 토글과 독립 컬럼 구조는 반영되었지만, 현재는 안내 카드 중심이므로 실제 화면별 기능 컨트롤을 이 패널로 옮기는 작업이 남아 있다.
|
- 공통 우측 패널의 토글과 독립 컬럼 구조는 반영되었지만, 현재는 안내 카드 중심이므로 실제 화면별 기능 컨트롤을 이 패널로 옮기는 작업이 남아 있다.
|
||||||
|
- 티어표 편집 화면은 기본 편집 필드를 우측 로컬 패널로 옮겼지만, 관리자 화면도 같은 방식으로 실제 운영 패널 중심 레이아웃으로 다시 정리할 필요가 있다.
|
||||||
- 머티리얼 아이콘 SVG는 아직 임시 문자/배지 스타일로 대체된 부분이 있으므로, 최종 아이콘 에셋을 받아 반영하는 작업이 필요하다.
|
- 머티리얼 아이콘 SVG는 아직 임시 문자/배지 스타일로 대체된 부분이 있으므로, 최종 아이콘 에셋을 받아 반영하는 작업이 필요하다.
|
||||||
- 미사용 커스텀 이미지 일괄 삭제는 현재 "참조가 없는 항목" 기준이며, 보관 기간 정책 같은 운영 규칙은 아직 없다.
|
- 미사용 커스텀 이미지 일괄 삭제는 현재 "참조가 없는 항목" 기준이며, 보관 기간 정책 같은 운영 규칙은 아직 없다.
|
||||||
- 업로드 이미지는 현재 원본 파일을 그대로 저장하므로, 운영 부담이 커지면 서버 저장 전 리사이즈/압축(예: 긴 변 제한, WebP 변환) 도입이 필요하다.
|
- 업로드 이미지는 현재 원본 파일을 그대로 저장하므로, 운영 부담이 커지면 서버 저장 전 리사이즈/압축(예: 긴 변 제한, WebP 변환) 도입이 필요하다.
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# 업데이트 로그
|
# 업데이트 로그
|
||||||
|
|
||||||
|
## 2026-03-30 v1.2.3
|
||||||
|
- **비로그인 중복 안내 제거**: 좌측 사이드 상단의 별도 로그인 안내 카드를 제거하고, 비로그인 상태에서는 좌측 하단 버튼만 `로그인` 진입점으로 사용하도록 단순화
|
||||||
|
- **에디터 우측 편집 패널 이관**: 티어표 편집 화면의 제목, 설명, 대표 썸네일, 공개 여부, 저장/삭제/요청 액션을 중앙 상단이 아니라 독립 우측 편집 패널로 이동
|
||||||
|
- **공통 우측 패널 예외 처리**: 티어표 편집 화면은 공통 우측 패널 대신 화면 내부 전용 편집 패널을 사용하도록 조정해, generic 안내 카드가 중복 표시되지 않게 정리
|
||||||
|
|
||||||
## 2026-03-30 v1.2.2
|
## 2026-03-30 v1.2.2
|
||||||
- **사이드 패널 폭 고정**: 공통 앱 셸의 좌측 패널 폭을 `248px`, 우측 패널 폭을 `320px` 기준으로 재정의해 피그마 시안과 더 가깝게 맞춤
|
- **사이드 패널 폭 고정**: 공통 앱 셸의 좌측 패널 폭을 `248px`, 우측 패널 폭을 `320px` 기준으로 재정의해 피그마 시안과 더 가깝게 맞춤
|
||||||
- **우측 패널 토글 추가**: 상단 우측 토글 버튼으로 우측 패널을 접고 펼칠 수 있게 하고, 접힐 때는 중앙 작업 영역이 자연스럽게 확장되도록 전환 애니메이션을 추가
|
- **우측 패널 토글 추가**: 상단 우측 토글 버튼으로 우측 패널을 접고 펼칠 수 있게 하고, 접힐 때는 중앙 작업 영역이 자연스럽게 확장되도록 전환 애니메이션을 추가
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const rightRailOpen = ref(true)
|
|||||||
|
|
||||||
const isAdmin = computed(() => !!auth.user?.isAdmin)
|
const isAdmin = computed(() => !!auth.user?.isAdmin)
|
||||||
const isPreviewMode = computed(() => route.query.preview === '1')
|
const isPreviewMode = computed(() => route.query.preview === '1')
|
||||||
|
const usesLocalRightRail = computed(() => ['editEditor', 'newEditor'].includes(String(route.name || '')))
|
||||||
const avatarUrl = computed(() => (auth.user?.avatarSrc ? toApiUrl(auth.user.avatarSrc) : ''))
|
const avatarUrl = computed(() => (auth.user?.avatarSrc ? toApiUrl(auth.user.avatarSrc) : ''))
|
||||||
const accountName = computed(() => {
|
const accountName = computed(() => {
|
||||||
const nickname = (auth.user?.nickname || '').trim()
|
const nickname = (auth.user?.nickname || '').trim()
|
||||||
@@ -198,7 +199,7 @@ async function logout() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="appUserCard">
|
<div v-if="auth.user" class="appUserCard">
|
||||||
<button v-if="auth.user" class="appUserCard__button" type="button" @click.stop="toggleMenu">
|
<button v-if="auth.user" class="appUserCard__button" type="button" @click.stop="toggleMenu">
|
||||||
<img v-if="avatarUrl" :src="avatarUrl" class="appUserCard__avatar" alt="avatar" />
|
<img v-if="avatarUrl" :src="avatarUrl" class="appUserCard__avatar" alt="avatar" />
|
||||||
<div v-else class="appUserCard__avatar appUserCard__avatar--fallback">{{ accountName[0]?.toUpperCase() || 'U' }}</div>
|
<div v-else class="appUserCard__avatar appUserCard__avatar--fallback">{{ accountName[0]?.toUpperCase() || 'U' }}</div>
|
||||||
@@ -207,13 +208,6 @@ async function logout() {
|
|||||||
<div class="appUserCard__email">{{ accountEmail }}</div>
|
<div class="appUserCard__email">{{ accountEmail }}</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<div v-else class="appUserCard__guest" @click="$router.push('/login')">
|
|
||||||
<div class="appUserCard__avatar appUserCard__avatar--fallback">G</div>
|
|
||||||
<div class="appUserCard__meta">
|
|
||||||
<div class="appUserCard__name">로그인 필요</div>
|
|
||||||
<div class="appUserCard__email">개인 메뉴를 사용하려면 로그인하세요.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="menuOpen" class="appUserMenu">
|
<div v-if="menuOpen" class="appUserMenu">
|
||||||
<button class="appUserMenu__item" type="button" @click="goProfile">프로필</button>
|
<button class="appUserMenu__item" type="button" @click="goProfile">프로필</button>
|
||||||
<button class="appUserMenu__item" type="button" @click="logout">로그아웃</button>
|
<button class="appUserMenu__item" type="button" @click="logout">로그아웃</button>
|
||||||
@@ -271,7 +265,12 @@ async function logout() {
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<aside class="rightRail" :class="{ 'rightRail--closed': !rightRailOpen }" :aria-hidden="!rightRailOpen">
|
<aside
|
||||||
|
v-if="!usesLocalRightRail"
|
||||||
|
class="rightRail"
|
||||||
|
:class="{ 'rightRail--closed': !rightRailOpen }"
|
||||||
|
:aria-hidden="!rightRailOpen"
|
||||||
|
>
|
||||||
<div class="rightRail__top">
|
<div class="rightRail__top">
|
||||||
<button class="ghostIcon" type="button" aria-label="상태">⌗</button>
|
<button class="ghostIcon" type="button" aria-label="상태">⌗</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -409,6 +408,7 @@ async function logout() {
|
|||||||
.appUserCard {
|
.appUserCard {
|
||||||
position: relative;
|
position: relative;
|
||||||
margin-bottom: 14px;
|
margin-bottom: 14px;
|
||||||
|
min-height: 58px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.appUserCard__button,
|
.appUserCard__button,
|
||||||
|
|||||||
@@ -629,77 +629,6 @@ onUnmounted(() => {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<section class="head">
|
|
||||||
<div class="heroCard">
|
|
||||||
<div class="heroCard__main">
|
|
||||||
<input v-model="title" class="titleInput" placeholder="티어표 이름을 입력하세요" :readonly="!canEdit" />
|
|
||||||
<div v-if="untitledWarning" class="titleNotice">{{ untitledWarning }}</div>
|
|
||||||
<input
|
|
||||||
v-model="description"
|
|
||||||
class="descInput"
|
|
||||||
placeholder="설명(선택): 이 티어표의 기준/룰"
|
|
||||||
:readonly="!canEdit"
|
|
||||||
/>
|
|
||||||
<div class="hint">
|
|
||||||
<template v-if="canEdit">
|
|
||||||
그룹 이름/순서 변경과 아이템 드래그&드롭이 가능합니다. 저장하려면 <b>저장</b>을 누르세요.
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
공개된 티어표를 보는 중입니다. 로그인한 작성자만 수정할 수 있어요.
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="heroCard__side">
|
|
||||||
<div class="thumbComposer">
|
|
||||||
<input ref="thumbnailFileEl" type="file" accept="image/*" class="hidden" @change="onThumbnailChange" />
|
|
||||||
<div class="thumbComposer__header">
|
|
||||||
<div class="thumbComposer__eyebrow">대표 썸네일</div>
|
|
||||||
<div class="thumbComposer__caption">목록 카드 상단에 표시됩니다.</div>
|
|
||||||
</div>
|
|
||||||
<div class="thumbComposer__preview">
|
|
||||||
<img v-if="displayThumbnailUrl" class="thumbComposer__image" :src="displayThumbnailUrl" alt="썸네일 미리보기" />
|
|
||||||
<div v-else class="thumbComposer__empty">썸네일 없음</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="canEdit" class="thumbComposer__actions">
|
|
||||||
<button class="btn btn--ghost thumbComposer__button" @click="openThumbnailFile">썸네일 선택</button>
|
|
||||||
<button class="btn btn--danger thumbComposer__button" :disabled="!pendingThumbnailFile && !thumbnailSrc" @click="clearThumbnail">제거</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="actions">
|
|
||||||
<div class="actions__left">
|
|
||||||
<button class="btn btn--download" @click="downloadImage">이미지 다운로드</button>
|
|
||||||
<button v-if="canEdit && !isNewTierList" class="btn btn--danger" @click="removeTierList">삭제</button>
|
|
||||||
</div>
|
|
||||||
<div class="actions__right">
|
|
||||||
<button v-if="canFavorite" class="btn btn--ghost" :disabled="isFavoriteBusy" @click="toggleFavorite">
|
|
||||||
{{ isFavorited ? '★ 즐겨찾기' : '☆ 즐겨찾기' }} {{ favoriteCount }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="canRequestTemplateCreate"
|
|
||||||
class="btn btn--ghost"
|
|
||||||
:disabled="isRequestingTemplate"
|
|
||||||
@click="openTemplateRequestModal"
|
|
||||||
>
|
|
||||||
템플릿 등록 요청
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="canRequestTemplateUpdate"
|
|
||||||
class="btn btn--ghost"
|
|
||||||
:disabled="isRequestingTemplate"
|
|
||||||
@click="requestTemplate('update')"
|
|
||||||
>
|
|
||||||
{{ isRequestingTemplate ? '요청중...' : '템플릿 업데이트 요청' }}
|
|
||||||
</button>
|
|
||||||
<label class="toggle" :class="{ 'toggle--disabled': !canEdit }">
|
|
||||||
<input v-model="isPublic" type="checkbox" :disabled="!canEdit" />
|
|
||||||
<span>{{ isPublic ? '공개 ON' : '공개 OFF' }}</span>
|
|
||||||
</label>
|
|
||||||
<button v-if="canEdit" class="btn btn--save" :disabled="isSaving" @click="save">{{ isSaving ? '저장중...' : '저장' }}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div v-if="isSaveModalOpen" class="modalOverlay" @click.self="closeSaveModal">
|
<div v-if="isSaveModalOpen" class="modalOverlay" @click.self="closeSaveModal">
|
||||||
<div class="modalCard" role="dialog" aria-modal="true" aria-labelledby="saveModalTitle">
|
<div class="modalCard" role="dialog" aria-modal="true" aria-labelledby="saveModalTitle">
|
||||||
@@ -741,7 +670,22 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="layout" :style="{ '--thumb-size': `${iconSize}px` }">
|
<section class="layout" :style="{ '--thumb-size': `${iconSize}px` }">
|
||||||
<div ref="boardEl" class="board">
|
<div class="editorMain">
|
||||||
|
<section class="head">
|
||||||
|
<div class="editorMain__headCopy">
|
||||||
|
<div class="editorMain__title">{{ gameName || gameId }}</div>
|
||||||
|
<div class="editorMain__subtitle">
|
||||||
|
<template v-if="canEdit">
|
||||||
|
그룹 이름/순서 변경과 아이템 드래그&드롭이 가능합니다.
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
공개된 티어표를 보는 중입니다. 로그인한 작성자만 수정할 수 있어요.
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div ref="boardEl" class="board">
|
||||||
<div v-if="canEdit && !isExporting" class="boardTools">
|
<div v-if="canEdit && !isExporting" class="boardTools">
|
||||||
<div class="boardTools__left">
|
<div class="boardTools__left">
|
||||||
<button class="btn btn--ghost" @click="addGroup">티어 추가</button>
|
<button class="btn btn--ghost" @click="addGroup">티어 추가</button>
|
||||||
@@ -765,91 +709,156 @@ onUnmounted(() => {
|
|||||||
<div v-if="isExporting" class="exportBoard__title">{{ effectiveTitle }}</div>
|
<div v-if="isExporting" class="exportBoard__title">{{ effectiveTitle }}</div>
|
||||||
<div v-if="isExporting && description" class="exportBoard__description">{{ description }}</div>
|
<div v-if="isExporting && description" class="exportBoard__description">{{ description }}</div>
|
||||||
<div ref="groupListEl" class="rows">
|
<div ref="groupListEl" class="rows">
|
||||||
<div v-for="g in groups" :key="g.id" class="row">
|
<div v-for="g in groups" :key="g.id" class="row">
|
||||||
<div class="row__label">
|
<div class="row__label">
|
||||||
<template v-if="isExporting">
|
<template v-if="isExporting">
|
||||||
<div class="row__exportName">{{ g.name }}</div>
|
<div class="row__exportName">{{ g.name }}</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<span class="grab" title="드래그로 순서 변경" data-group-handle>↕</span>
|
<span class="grab" title="드래그로 순서 변경" data-group-handle>↕</span>
|
||||||
<input v-model="g.name" class="groupName" :readonly="!canEdit" />
|
<input v-model="g.name" class="groupName" :readonly="!canEdit" />
|
||||||
<button v-if="canEdit" class="rowRemoveBtn" :disabled="groups.length <= 1" @click="removeGroup(g.id)">삭제</button>
|
<button v-if="canEdit" class="rowRemoveBtn" :disabled="groups.length <= 1" @click="removeGroup(g.id)">삭제</button>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="row__drop"
|
class="row__drop"
|
||||||
:data-list-type="'group'"
|
:data-list-type="'group'"
|
||||||
:data-group-id="g.id"
|
:data-group-id="g.id"
|
||||||
:ref="(el) => setGroupDropEl(g.id, el)"
|
:ref="(el) => setGroupDropEl(g.id, el)"
|
||||||
>
|
>
|
||||||
<div v-if="!isExporting" class="row__empty" v-show="g.itemIds.length === 0">여기로 드래그해서 배치</div>
|
<div v-if="!isExporting" class="row__empty" v-show="g.itemIds.length === 0">여기로 드래그해서 배치</div>
|
||||||
<div v-for="id in g.itemIds" :key="id" class="cell" :data-item-id="id">
|
<div v-for="id in g.itemIds" :key="id" class="cell" :data-item-id="id">
|
||||||
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
|
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
|
||||||
<button
|
<button
|
||||||
v-if="canEdit && !isExporting"
|
v-if="canEdit && !isExporting"
|
||||||
class="cellRemoveBtn"
|
class="cellRemoveBtn"
|
||||||
type="button"
|
type="button"
|
||||||
title="아이템 빼내기"
|
title="아이템 빼내기"
|
||||||
@pointerdown.stop
|
@pointerdown.stop
|
||||||
@click.stop="removeItemFromGroup(g.id, id)"
|
@click.stop="removeItemFromGroup(g.id, id)"
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div v-if="isExporting" class="exportBoard__footer">
|
<div v-if="isExporting" class="exportBoard__footer">
|
||||||
<span>{{ effectiveAuthorName }}</span>
|
<span>{{ effectiveAuthorName }}</span>
|
||||||
<span>{{ formatExportDate(fallbackTimestamp) }}</span>
|
<span>{{ formatExportDate(fallbackTimestamp) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar">
|
||||||
|
<div class="sidebar__title">아이템</div>
|
||||||
|
<div class="sidebar__hint">
|
||||||
|
{{ canEdit ? '게임별 기본 이미지와 커스텀 업로드를 여기에 모읍니다.' : '공개 티어표는 보기 전용입니다.' }}
|
||||||
|
</div>
|
||||||
|
<div ref="poolEl" class="pool" data-list-type="pool">
|
||||||
|
<div v-for="id in pool" :key="id" class="poolItem" :data-item-id="id">
|
||||||
|
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
|
||||||
|
<div class="poolItem__label">{{ itemsById[id]?.label || id }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="canEdit && customItems.length" class="customItemEditor">
|
||||||
|
<div class="customItemEditor__title">추가한 커스텀 아이템 이름 정리</div>
|
||||||
|
<div class="customItemEditor__desc">
|
||||||
|
템플릿 요청 전에 이름을 정리해두면 관리자가 그대로 기본 템플릿으로 반영할 수 있어요.
|
||||||
|
</div>
|
||||||
|
<div class="customItemEditor__list">
|
||||||
|
<label v-for="item in customItems" :key="item.id" class="customItemEditor__row">
|
||||||
|
<img class="customItemEditor__thumb" :src="resolveItemSrc(item)" :alt="item.label" />
|
||||||
|
<input
|
||||||
|
class="customItemEditor__input"
|
||||||
|
:value="item.label"
|
||||||
|
maxlength="60"
|
||||||
|
placeholder="아이템 이름"
|
||||||
|
@input="updateCustomItemLabel(item.id, $event.target.value)"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="canEdit"
|
||||||
|
class="dropzone"
|
||||||
|
:class="{ 'dropzone--active': isDragActive }"
|
||||||
|
@dragenter.prevent="onDragEnter"
|
||||||
|
@dragover.prevent="onDragEnter"
|
||||||
|
@dragleave="onDragLeave"
|
||||||
|
@drop.prevent="onDropFiles"
|
||||||
|
>
|
||||||
|
<div class="dropzone__title">커스텀 이미지 추가</div>
|
||||||
|
<div class="dropzone__desc">여러 이미지를 한 번에 드래그하거나 파일 선택으로 추가할 수 있어요.</div>
|
||||||
|
</div>
|
||||||
|
<input ref="fileEl" type="file" accept="image/*" multiple class="hidden" @change="onFileChange" />
|
||||||
|
<button v-if="canEdit" class="btn btn--ghost" @click="openFile">파일 선택</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sidebar">
|
<aside class="editorSidebar">
|
||||||
<div class="sidebar__title">아이템</div>
|
<div class="editorSidebar__section">
|
||||||
<div class="sidebar__hint">
|
<div class="editorSidebar__label">Title</div>
|
||||||
{{ canEdit ? '게임별 기본 이미지와 커스텀 업로드를 여기에 모읍니다.' : '공개 티어표는 보기 전용입니다.' }}
|
<input v-model="title" class="editorSidebar__input" placeholder="Title Text" :readonly="!canEdit" />
|
||||||
|
<div v-if="untitledWarning" class="editorSidebar__hint editorSidebar__hint--warn">{{ untitledWarning }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div ref="poolEl" class="pool" data-list-type="pool">
|
|
||||||
<div v-for="id in pool" :key="id" class="poolItem" :data-item-id="id">
|
<div class="editorSidebar__section">
|
||||||
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
|
<div class="editorSidebar__label">Desc</div>
|
||||||
<div class="poolItem__label">{{ itemsById[id]?.label || id }}</div>
|
<textarea
|
||||||
|
v-model="description"
|
||||||
|
class="editorSidebar__textarea"
|
||||||
|
placeholder="Description Text"
|
||||||
|
:readonly="!canEdit"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="editorSidebar__section">
|
||||||
|
<div class="editorSidebar__label">대표 썸네일</div>
|
||||||
|
<input ref="thumbnailFileEl" type="file" accept="image/*" class="hidden" @change="onThumbnailChange" />
|
||||||
|
<div class="editorSidebar__thumbFrame">
|
||||||
|
<img v-if="displayThumbnailUrl" class="editorSidebar__thumbImage" :src="displayThumbnailUrl" alt="썸네일 미리보기" />
|
||||||
|
<div v-else class="editorSidebar__thumbEmpty">대표 썸네일</div>
|
||||||
</div>
|
</div>
|
||||||
|
<button v-if="canEdit" class="btn btn--ghost editorSidebar__button" @click="openThumbnailFile">파일 업로드</button>
|
||||||
|
<div v-if="pendingThumbnailFile" class="editorSidebar__fileName">{{ pendingThumbnailFile.name }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="canEdit && customItems.length" class="customItemEditor">
|
|
||||||
<div class="customItemEditor__title">추가한 커스텀 아이템 이름 정리</div>
|
<div class="editorSidebar__section">
|
||||||
<div class="customItemEditor__desc">
|
<button v-if="canFavorite" class="editorSidebar__favorite" :disabled="isFavoriteBusy" @click="toggleFavorite">
|
||||||
템플릿 요청 전에 이름을 정리해두면 관리자가 그대로 기본 템플릿으로 반영할 수 있어요.
|
<span>♡ 즐겨찾기</span>
|
||||||
</div>
|
<span>{{ favoriteCount }}</span>
|
||||||
<div class="customItemEditor__list">
|
</button>
|
||||||
<label v-for="item in customItems" :key="item.id" class="customItemEditor__row">
|
</div>
|
||||||
<img class="customItemEditor__thumb" :src="resolveItemSrc(item)" :alt="item.label" />
|
|
||||||
<input
|
<div class="editorSidebar__section editorSidebar__section--footer">
|
||||||
class="customItemEditor__input"
|
<label class="toggle" :class="{ 'toggle--disabled': !canEdit }">
|
||||||
:value="item.label"
|
<input v-model="isPublic" type="checkbox" :disabled="!canEdit" />
|
||||||
maxlength="60"
|
<span>공개</span>
|
||||||
placeholder="아이템 이름"
|
</label>
|
||||||
@input="updateCustomItemLabel(item.id, $event.target.value)"
|
<div class="editorSidebar__actionGrid">
|
||||||
/>
|
<button class="btn btn--ghost editorSidebar__button" @click="downloadImage">이미지 다운로드</button>
|
||||||
</label>
|
<button v-if="canEdit" class="btn btn--save editorSidebar__button" :disabled="isSaving" @click="save">{{ isSaving ? '저장중...' : '저장' }}</button>
|
||||||
</div>
|
</div>
|
||||||
|
<button v-if="canEdit && !isNewTierList" class="btn btn--danger editorSidebar__button" @click="removeTierList">삭제</button>
|
||||||
|
<button
|
||||||
|
v-if="canRequestTemplateCreate"
|
||||||
|
class="btn btn--ghost editorSidebar__button"
|
||||||
|
:disabled="isRequestingTemplate"
|
||||||
|
@click="openTemplateRequestModal"
|
||||||
|
>
|
||||||
|
템플릿 등록 요청
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="canRequestTemplateUpdate"
|
||||||
|
class="btn btn--ghost editorSidebar__button"
|
||||||
|
:disabled="isRequestingTemplate"
|
||||||
|
@click="requestTemplate('update')"
|
||||||
|
>
|
||||||
|
{{ isRequestingTemplate ? '요청중...' : '템플릿 업데이트 요청' }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div
|
</aside>
|
||||||
v-if="canEdit"
|
|
||||||
class="dropzone"
|
|
||||||
:class="{ 'dropzone--active': isDragActive }"
|
|
||||||
@dragenter.prevent="onDragEnter"
|
|
||||||
@dragover.prevent="onDragEnter"
|
|
||||||
@dragleave="onDragLeave"
|
|
||||||
@drop.prevent="onDropFiles"
|
|
||||||
>
|
|
||||||
<div class="dropzone__title">커스텀 이미지 추가</div>
|
|
||||||
<div class="dropzone__desc">여러 이미지를 한 번에 드래그하거나 파일 선택으로 추가할 수 있어요.</div>
|
|
||||||
</div>
|
|
||||||
<input ref="fileEl" type="file" accept="image/*" multiple class="hidden" @change="onFileChange" />
|
|
||||||
<button v-if="canEdit" class="btn btn--ghost" @click="openFile">파일 선택</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
@@ -857,8 +866,23 @@ onUnmounted(() => {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.head {
|
.head {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 16px;
|
gap: 8px;
|
||||||
padding: 2px 2px 16px;
|
padding: 2px 2px 8px;
|
||||||
|
}
|
||||||
|
.editorMain {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.editorMain__title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
}
|
||||||
|
.editorMain__subtitle {
|
||||||
|
color: rgba(255, 255, 255, 0.58);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
.previewOnly {
|
.previewOnly {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
@@ -936,128 +960,6 @@ onUnmounted(() => {
|
|||||||
.previewOnly__poolItem {
|
.previewOnly__poolItem {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
}
|
}
|
||||||
.heroCard {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1.65fr) minmax(260px, 320px);
|
|
||||||
gap: 16px;
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
.heroCard__main,
|
|
||||||
.heroCard__side {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.heroCard__main {
|
|
||||||
display: grid;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
.heroCard__side {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
.titleInput {
|
|
||||||
width: 100%;
|
|
||||||
font-size: 22px;
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
padding: 14px 16px;
|
|
||||||
border-radius: 18px;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
||||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.07), rgba(255, 255, 255, 0.04));
|
|
||||||
color: rgba(255, 255, 255, 0.92);
|
|
||||||
outline: none;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
.descInput {
|
|
||||||
width: 100%;
|
|
||||||
min-height: 92px;
|
|
||||||
padding: 14px 16px;
|
|
||||||
border-radius: 18px;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
||||||
background: rgba(0, 0, 0, 0.18);
|
|
||||||
color: rgba(255, 255, 255, 0.92);
|
|
||||||
outline: none;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
.hint {
|
|
||||||
opacity: 0.78;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.6;
|
|
||||||
padding: 0 2px;
|
|
||||||
}
|
|
||||||
.titleNotice {
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.5;
|
|
||||||
color: rgba(251, 191, 36, 0.94);
|
|
||||||
padding: 0 2px;
|
|
||||||
}
|
|
||||||
.thumbComposer {
|
|
||||||
display: grid;
|
|
||||||
gap: 10px;
|
|
||||||
width: 100%;
|
|
||||||
padding: 16px;
|
|
||||||
border-radius: 22px;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
||||||
background:
|
|
||||||
radial-gradient(circle at top right, rgba(96, 165, 250, 0.12), transparent 46%),
|
|
||||||
rgba(255, 255, 255, 0.04);
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
.thumbComposer__header {
|
|
||||||
display: grid;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
.thumbComposer__eyebrow {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 900;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
}
|
|
||||||
.thumbComposer__caption {
|
|
||||||
font-size: 12px;
|
|
||||||
opacity: 0.68;
|
|
||||||
}
|
|
||||||
.thumbComposer__preview {
|
|
||||||
width: 100%;
|
|
||||||
aspect-ratio: 16 / 9;
|
|
||||||
border-radius: 18px;
|
|
||||||
overflow: hidden;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
||||||
background: rgba(11, 18, 32, 0.78);
|
|
||||||
}
|
|
||||||
.thumbComposer__image {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
.thumbComposer__empty {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
color: rgba(255, 255, 255, 0.62);
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
.thumbComposer__actions {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
.thumbComposer__button {
|
|
||||||
width: 100%;
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
.actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 14px;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.actions__left,
|
|
||||||
.actions__right {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.toggle {
|
.toggle {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -1123,7 +1025,7 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
.layout {
|
.layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) 284px;
|
grid-template-columns: minmax(0, 1fr) 320px;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
@@ -1424,6 +1326,101 @@ onUnmounted(() => {
|
|||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
|
.editorSidebar {
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 14px 12px;
|
||||||
|
border-radius: 18px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
background: rgba(18, 18, 18, 0.96);
|
||||||
|
position: sticky;
|
||||||
|
top: 14px;
|
||||||
|
}
|
||||||
|
.editorSidebar__section {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.editorSidebar__label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: rgba(255, 255, 255, 0.82);
|
||||||
|
}
|
||||||
|
.editorSidebar__input,
|
||||||
|
.editorSidebar__textarea {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
color: rgba(255, 255, 255, 0.92);
|
||||||
|
padding: 11px 12px;
|
||||||
|
outline: none;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
.editorSidebar__textarea {
|
||||||
|
min-height: 92px;
|
||||||
|
}
|
||||||
|
.editorSidebar__hint {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: rgba(255, 255, 255, 0.56);
|
||||||
|
}
|
||||||
|
.editorSidebar__hint--warn {
|
||||||
|
color: rgba(251, 191, 36, 0.92);
|
||||||
|
}
|
||||||
|
.editorSidebar__thumbFrame {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
background: #4c4c4c;
|
||||||
|
}
|
||||||
|
.editorSidebar__thumbImage {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.editorSidebar__thumbEmpty {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: rgba(255, 255, 255, 0.36);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.editorSidebar__button {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.editorSidebar__fileName {
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(255, 255, 255, 0.56);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.editorSidebar__favorite {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 0 0;
|
||||||
|
border: 0;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
background: transparent;
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
font-weight: 800;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.editorSidebar__section--footer {
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
.editorSidebar__actionGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
.sidebar__title {
|
.sidebar__title {
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
@@ -1537,26 +1534,14 @@ onUnmounted(() => {
|
|||||||
.layout {
|
.layout {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
.actions {
|
|
||||||
justify-content: stretch;
|
|
||||||
}
|
|
||||||
.actions__left,
|
|
||||||
.actions__right {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.actions__right {
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
.row {
|
.row {
|
||||||
grid-template-columns: 150px 1fr;
|
grid-template-columns: 150px 1fr;
|
||||||
}
|
}
|
||||||
.thumbComposer {
|
.editorSidebar {
|
||||||
padding: 14px;
|
position: static;
|
||||||
border-radius: 18px;
|
|
||||||
}
|
}
|
||||||
.titleInput,
|
.editorSidebar__actionGrid {
|
||||||
.descInput {
|
grid-template-columns: 1fr;
|
||||||
border-radius: 16px;
|
|
||||||
}
|
}
|
||||||
.requestChecklist__item {
|
.requestChecklist__item {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
Reference in New Issue
Block a user