Compare commits

...

3 Commits

7 changed files with 194 additions and 38 deletions

View File

@@ -1,5 +1,16 @@
# 의사결정 이력
## 2026-04-02 v1.3.63
- 이미지 최적화 기록은 내부 라우트 카테고리를 그대로 보여주면 운영자가 실제 의미를 해석해야 하므로, 관리자 화면에는 기능 기준의 한국어 라벨과 재사용 여부를 함께 보여주는 편이 맞다고 정리했다.
## 2026-04-02 v1.3.62
- 커스텀 이미지가 많은 상태에서 저장할 때 사용자 체감 순서가 흔들리는 것은 업로드 성공보다 더 직접적인 UX 문제이므로, 내부 객체 키 순서가 아니라 현재 화면 배치 순서를 저장 기준으로 삼는 편이 맞다고 정리했다.
- 템플릿 요청이 저장본에서만 가능하다면 삭제도 같은 기준을 따르는 편이 흐름상 자연스러우므로, 저장되지 않은 초안에는 삭제 액션을 노출하지 않는 쪽으로 판단했다.
## 2026-04-02 v1.3.61
- 업로드 드롭존은 기능만 같고 생김새가 다르면 운영자와 사용자 모두 맥락 전환 비용이 생기므로, 관리자와 에디터에서 같은 아이콘·점선 보더·버튼 문법으로 읽히게 맞추는 편이 낫다고 정리했다.
- 썸네일 교체 영역은 일반 입력 필드처럼 보이면 클릭 가능성이 떨어지므로, 이미지 미리보기 위에서도 업로드 박스라는 인상이 유지되게 밝은 배경과 아이콘을 함께 쓰는 쪽으로 판단했다.
## 2026-04-02 v1.3.60
- 관리자 접근 차단은 유지하되, 이미 로그인된 관리자가 새로고침할 때 홈으로 튕기는 체감은 권한 제어보다 더 큰 문제이므로 인증 결과가 나올 때까지 같은 세션 확인 요청을 기다리는 편이 맞다고 정리했다.
- 관리자 썸네일 드롭존과 에디터 보드 드롭존은 기능은 같아도 현재 상태가 문구와 형태로 바로 드러나야 하므로, 빈 상태와 교체 상태를 텍스트로 구분하고 점선 박스 형태를 더 적극적으로 드러내는 쪽으로 판단했다.

View File

@@ -3,6 +3,9 @@
## 단기 확인
- 관리자 계정으로 `/admin/...`을 직접 새로고침했을 때 홈으로 튕기지 않고 그대로 유지되는지, 실제 세션이 살아 있는 브라우저와 만료된 브라우저 각각에서 한 번 더 QA한다.
- 티어표 만들기 화면의 보드 드롭존은 점선/높이/중앙 정렬로 존재감이 커졌으므로, 데스크톱과 모바일에서 파일 선택 버튼과 안내 문구 밀도를 한 번 더 QA한다.
- 관리자 썸네일 드롭존과 기본 아이템 추가 드롭존은 에디터 드롭존과 시각 문법을 맞췄으므로, 라이트모드와 좁은 화면에서 아이콘 대비와 배경 밝기가 과하지 않은지 한 번 더 QA한다.
- 커스텀 이미지가 많은 티어표를 저장할 때 커스텀 이름 정리 목록과 미배치 아이템 영역 순서가 실제로 안정적으로 유지되는지, 10장 이상 업로드한 상태로 한 번 더 QA한다.
- 관리자 이미지 최적화 현황은 최근 작업 라벨을 읽기 쉬운 이름으로 바꿨으므로, 실제 운영 데이터에서 `커스텀 아이템 / 티어표 썸네일 / 게임·템플릿 이미지 / 프로필 아바타` 흐름이 기대와 맞게 찍히는지 한 번 더 QA한다.
## 중기 개선
- 신규 템플릿 요청은 현재 `첫 생성 1회 후 요청과 게임을 연결해 재사용` 단계까지 정리했으므로, 다음 단계에서는 정말로 게임을 DB에 만들기 전까지 임시 작업 상태로 유지할지 여부를 운영 흐름 기준으로 결정한다.

View File

@@ -1,5 +1,19 @@
# 업데이트 로그
## 2026-04-02 v1.3.63
- 관리자 이미지 최적화 최근 작업 목록은 더 이상 내부 카테고리 문자열 `custom / tierlists / games / avatars`를 그대로 노출하지 않고, 각각 `커스텀 아이템 / 티어표 썸네일 / 게임·템플릿 이미지 / 프로필 아바타`처럼 사람이 이해할 수 있는 이름으로 표시함.
- 같은 이미지 해시를 다시 업로드해 기존 최적화 파일을 재사용한 경우에는 최근 작업 목록에 `기존 최적화 파일 재사용` 문구를 함께 보여, 새로 압축된 건지 중복 자산이 재사용된 건지 운영자가 바로 구분할 수 있게 함.
## 2026-04-02 v1.3.62
- 티어표 저장과 템플릿 요청 전 커스텀 이미지 업로드에서는 더 이상 `itemsById` 객체 키 순서에 기대지 않고, 실제 화면에 보이는 `아이템 영역 + 보드 배치 순서` 기준으로 아이템 배열을 만들도록 바꿔 저장 중 이미지 목록이 흔들리던 현상을 줄임.
- 따라서 커스텀 아이템 이름 정리 목록, 저장 payload, 템플릿 요청 payload 모두 같은 순서 기준을 공유하게 되어, 이미지를 여러 장 올린 뒤 저장해도 사용자가 보고 있던 흐름이 덜 흔들리도록 정리함.
- 티어표 삭제 버튼은 이제 템플릿 요청과 같은 기준으로 `저장된 티어표`에서만 노출되며, 실제 삭제도 저장본 ID가 있을 때만 동작하도록 맞춰 저장 전 초안 상태의 어색한 삭제 액션을 제거함.
## 2026-04-02 v1.3.61
- 관리자 게임 관리의 썸네일 드롭존, 관리자 기본 아이템 추가 드롭존, 티어표 에디터의 커스텀 이미지 드롭존에 `add_photo_alternate` 아이콘을 넣어 업로드 영역임을 더 빠르게 인식할 수 있게 정리함.
- 관리자와 에디터 드롭존은 점선 보더 굵기, 라운드, 밝은 배경 톤, 활성화 상태 색 변화, 파일 선택 버튼 크기를 같은 계열로 맞춰 서로 다른 화면에서도 같은 업로드 컴포넌트처럼 읽히도록 통일함.
- 썸네일 드롭존 역시 배경을 일반 입력 필드보다 더 밝고 넓은 업로드 박스처럼 보이게 조정해, 일반 폼 필드와 대표 이미지 교체 영역을 시각적으로 더 분명하게 구분함.
## 2026-04-02 v1.3.60
- 관리자 게임 관리의 대표 썸네일 드롭존은 이제 썸네일이 없을 때는 `클릭 & 드래그`, 이미 등록된 썸네일이 있을 때는 `썸네일 변경`으로 문구가 바뀌어 현재 동작을 더 바로 읽을 수 있게 함.
- 관리자 인증 상태는 라우터 가드와 앱 셸이 동시에 `/api/auth/me`를 호출할 때, 가드가 아직 끝나지 않은 요청을 기다리지 못해 새로고침 직후 홈으로 튕기던 흐름이 있었으므로 인증 스토어에서 진행 중인 `refresh` Promise를 재사용하도록 정리함.

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="ffffff"><path d="M480-480ZM200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h320v80H200v560h560v-320h80v320q0 33-23.5 56.5T760-120H200Zm40-160h480L570-480 450-320l-90-120-120 160Zm440-320v-80h-80v-80h80v-80h80v80h80v80h-80v80h-80Z"/></svg>

After

Width:  |  Height:  |  Size: 347 B

View File

@@ -1,5 +1,7 @@
<script setup>
import { toApiUrl } from '../../lib/runtime'
import SvgIcon from '../SvgIcon.vue'
import addPhotoAlternateIcon from '../../assets/icons/add_photo_alternate.svg'
const props = defineProps({
activeTemplateRequest: { type: Object, default: null },
@@ -112,13 +114,16 @@ function setGameItemListElement(el) {
@dragleave="props.onItemDragLeave"
@drop="props.onItemDrop"
>
<div class="dropZone__iconWrap">
<SvgIcon :src="addPhotoAlternateIcon" alt="" class="dropZone__icon" />
</div>
<div class="dropZone__title">이미지를 드래그해서 기본 아이템으로 추가</div>
<div class="dropZone__desc">
여러 파일을 번에 올릴 있고, 저장 라벨은 파일명으로 자동 생성됩니다.
<span v-if="props.stagedRequestDraftCount"> 현재 요청에서 가져온 아이템 {{ props.stagedRequestDraftCount }}개도 함께 검토 중이에요.</span>
</div>
<div class="dropZone__actions">
<button class="btn btn--ghost btn--small" type="button" @click.stop="props.openItemFilePicker">파일 선택</button>
<button class="btn btn--ghost btn--small dropZone__button" type="button" @click.stop="props.openItemFilePicker">파일 선택</button>
</div>
</div>
</div>

View File

@@ -5,6 +5,7 @@ import { api } from '../lib/api'
import { toApiUrl } from '../lib/runtime'
import lockResetIcon from '../assets/icons/lock_reset.svg'
import deleteIcon from '../assets/icons/delete.svg'
import addPhotoAlternateIcon from '../assets/icons/add_photo_alternate.svg'
import SvgIcon from '../components/SvgIcon.vue'
import AdminFeaturedSection from '../components/admin/AdminFeaturedSection.vue'
import AdminGamesSection from '../components/admin/AdminGamesSection.vue'
@@ -522,6 +523,36 @@ function formatBytes(value) {
return `${current >= 10 || unitIndex === 0 ? current.toFixed(0) : current.toFixed(1)} ${units[unitIndex]}`
}
function formatImageJobSourceCategory(category) {
switch (String(category || '').trim()) {
case 'custom':
return '커스텀 아이템'
case 'tierlists':
return '티어표 썸네일'
case 'games':
return '게임/템플릿 이미지'
case 'avatars':
return '프로필 아바타'
default:
return '기타 이미지'
}
}
function formatImageJobStatus(status) {
switch (String(status || '').trim()) {
case 'queued':
return '대기'
case 'processing':
return '처리중'
case 'completed':
return '완료'
case 'failed':
return '실패'
default:
return status || '알 수 없음'
}
}
const imageDiagnosticsCards = computed(() => {
const stats = imageStats.value
if (!stats) return []
@@ -1935,6 +1966,9 @@ function userAvatarFallback(user) {
<img v-if="displayThumbnailUrl" class="selectedThumb selectedThumb--sidebar" :src="displayThumbnailUrl" :alt="selectedGame.game.name" />
<div v-else class="selectedThumb selectedThumb--empty selectedThumb--sidebar">대표 썸네일</div>
<div class="thumbDropZone__copy">
<div class="thumbDropZone__iconWrap">
<SvgIcon :src="addPhotoAlternateIcon" alt="" class="thumbDropZone__icon" />
</div>
<div class="thumbDropZone__title">{{ displayThumbnailUrl ? '썸네일 변경' : '클릭 & 드래그' }}</div>
</div>
</button>
@@ -2075,10 +2109,11 @@ function userAvatarFallback(user) {
<div v-else class="imageJobList">
<article v-for="job in imageRecentJobs" :key="job.id" class="imageJobRow">
<div class="imageJobRow__head">
<strong>{{ job.sourceCategory || 'asset' }}</strong>
<span class="imageJobRow__status">{{ job.status }}</span>
<strong>{{ formatImageJobSourceCategory(job.sourceCategory) }}</strong>
<span class="imageJobRow__status">{{ formatImageJobStatus(job.status) }}</span>
</div>
<div class="hint hint--tight">{{ formatBytes(job.originalByteSize) }} {{ formatBytes(job.optimizedByteSize) }}</div>
<div v-if="job.reusedAsset" class="hint hint--tight">기존 최적화 파일 재사용</div>
<div class="hint hint--tight">{{ fmt(job.queuedAt) }}</div>
</article>
</div>
@@ -2714,17 +2749,23 @@ function userAvatarFallback(user) {
.adminUiScope .thumbDropZone {
position: relative;
width: 100%;
display: block;
display: grid;
gap: 0;
padding: 0;
overflow: hidden;
border-radius: 18px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: var(--theme-pill-bg);
border-radius: 22px;
border: 2px dashed color-mix(in srgb, var(--theme-border-strong) 82%, rgba(255, 255, 255, 0.12));
background:
linear-gradient(180deg, color-mix(in srgb, var(--theme-card-bg) 88%, white 4%), color-mix(in srgb, var(--theme-card-bg-hover) 82%, white 6%)),
radial-gradient(circle at top, color-mix(in srgb, var(--theme-accent) 12%, transparent), transparent 60%);
text-align: left;
transition: border-color 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease;
transition: border-color 0.16s ease, background 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease;
}
.adminUiScope .thumbDropZone--active {
border-color: rgba(96, 165, 250, 0.56);
background:
linear-gradient(180deg, color-mix(in srgb, var(--theme-card-bg-hover) 90%, white 6%), color-mix(in srgb, var(--theme-card-bg) 84%, white 4%)),
radial-gradient(circle at top, color-mix(in srgb, var(--theme-accent) 18%, transparent), transparent 60%);
box-shadow: 0 0 0 1px rgba(96, 165, 250, 0.18);
transform: translateY(-1px);
}
@@ -2733,13 +2774,27 @@ function userAvatarFallback(user) {
inset: auto 0 0 0;
display: grid;
place-items: center;
min-height: 52px;
padding: 12px 16px;
gap: 8px;
min-height: 80px;
padding: 16px 18px;
background: linear-gradient(180deg, transparent 0%, color-mix(in srgb, var(--theme-main-bg) 82%, transparent) 46%, color-mix(in srgb, var(--theme-main-bg) 94%, transparent) 100%);
}
.adminUiScope .thumbDropZone__iconWrap {
width: 46px;
height: 46px;
display: grid;
place-items: center;
border-radius: 14px;
background: color-mix(in srgb, var(--theme-text) 10%, transparent);
}
.adminUiScope .thumbDropZone__icon {
width: 24px;
height: 24px;
opacity: 0.86;
}
.adminUiScope .thumbDropZone__title {
font-weight: 900;
font-size: 13px;
font-size: 14px;
letter-spacing: 0.01em;
color: var(--theme-text);
}
@@ -2757,10 +2812,12 @@ function userAvatarFallback(user) {
}
.adminUiScope .dropZone {
min-height: 180px;
padding: 24px 18px;
border-radius: 16px;
padding: 28px 22px;
border-radius: 22px;
border: 2px dashed color-mix(in srgb, var(--theme-border-strong) 82%, rgba(255, 255, 255, 0.12));
background: var(--theme-pill-bg);
background:
linear-gradient(180deg, color-mix(in srgb, var(--theme-card-bg) 88%, white 4%), color-mix(in srgb, var(--theme-card-bg-hover) 82%, white 6%)),
radial-gradient(circle at top, color-mix(in srgb, var(--theme-accent) 12%, transparent), transparent 60%);
display: grid;
place-items: center;
align-content: center;
@@ -2773,9 +2830,25 @@ function userAvatarFallback(user) {
}
.adminUiScope .dropZone--active {
border-color: rgba(96, 165, 250, 0.56);
background: rgba(96, 165, 250, 0.08);
background:
linear-gradient(180deg, color-mix(in srgb, var(--theme-card-bg-hover) 90%, white 6%), color-mix(in srgb, var(--theme-card-bg) 84%, white 4%)),
radial-gradient(circle at top, color-mix(in srgb, var(--theme-accent) 18%, transparent), transparent 60%);
transform: translateY(-1px);
}
.adminUiScope .dropZone__iconWrap {
width: 52px;
height: 52px;
margin: 0 auto 12px;
display: grid;
place-items: center;
border-radius: 16px;
background: color-mix(in srgb, var(--theme-text) 10%, transparent);
}
.adminUiScope .dropZone__icon {
width: 28px;
height: 28px;
opacity: 0.86;
}
.adminUiScope .dropZone__title {
font-weight: 900;
font-size: 16px;
@@ -2794,6 +2867,10 @@ function userAvatarFallback(user) {
flex-wrap: wrap;
justify-content: center;
}
.adminUiScope .dropZone__button {
min-width: 124px;
min-height: 34px;
}
.adminUiScope .itemPreviewCard {
margin-top: 12px;
padding: 12px;

View File

@@ -6,6 +6,7 @@ import * as htmlToImage from 'html-to-image'
import SvgIcon from '../components/SvgIcon.vue'
import addColumnRightIcon from '../assets/icons/add_column_right.svg'
import addRowBelowIcon from '../assets/icons/add_row_below.svg'
import addPhotoAlternateIcon from '../assets/icons/add_photo_alternate.svg'
import { api } from '../lib/api'
import { toApiUrl } from '../lib/runtime'
import { useAuthStore } from '../stores/auth'
@@ -118,10 +119,7 @@ const copiedFromLabel = computed(() => {
if (sourceSnapshotAuthor.value) parts.push(sourceSnapshotAuthor.value)
return parts.join(' · ') || '복사해 온 티어표'
})
const customItems = computed(() =>
Object.values(itemsById.value)
.filter((item) => item?.origin === 'custom')
)
const customItems = computed(() => getOrderedItems().filter((item) => item?.origin === 'custom'))
const hasSavedTierList = computed(() => !!(persistedTierListId.value || (tierListId.value && tierListId.value !== 'new')))
const canRequestTemplateCreate = computed(
() => canEdit.value && hasSavedTierList.value && gameId.value === 'freeform' && customItems.value.length > 0
@@ -165,6 +163,29 @@ function formatExportDate(ts) {
})
}
function getOrderedItemIds() {
const orderedIds = []
const seen = new Set()
const pushId = (itemId) => {
if (!itemId || seen.has(itemId) || !itemsById.value[itemId]) return
seen.add(itemId)
orderedIds.push(itemId)
}
pool.value.forEach(pushId)
groups.value.forEach((group) => {
;(group.cells || []).forEach((cell) => {
;(cell || []).forEach(pushId)
})
})
Object.keys(itemsById.value).forEach(pushId)
return orderedIds
}
function getOrderedItems() {
return getOrderedItemIds().map((itemId) => itemsById.value[itemId]).filter(Boolean)
}
function setIconSize(nextSize) {
iconSize.value = nextSize
}
@@ -654,7 +675,7 @@ function buildPayload(existingId) {
sourceSnapshotTitle: sourceSnapshotTitle.value || '',
sourceSnapshotAuthor: sourceSnapshotAuthor.value || '',
groups: buildGroupPayload(),
pool: Object.values(itemsById.value),
pool: getOrderedItems(),
}
}
@@ -721,6 +742,7 @@ function closeTemplateUpdateModal() {
}
function openDeleteModal() {
if (!hasSavedTierList.value) return
isDeleteModalOpen.value = true
}
@@ -729,11 +751,12 @@ function closeDeleteModal() {
}
async function confirmDeleteTierList() {
if (!canEdit.value || isNewTierList.value || isDeleting.value) return
const currentTierListId = persistedTierListId.value || (tierListId.value && tierListId.value !== 'new' ? tierListId.value : '')
if (!canEdit.value || !currentTierListId || isDeleting.value) return
error.value = ''
try {
isDeleting.value = true
await api.deleteTierList(tierListId.value)
await api.deleteTierList(currentTierListId)
closeDeleteModal()
toast.success('티어표를 삭제했어요.')
router.push(gameId.value === 'freeform' ? '/me' : `/games/${gameId.value}`)
@@ -791,7 +814,7 @@ async function requestTemplate(type) {
isPublic: !!isPublic.value,
showCharacterNames: !!showCharacterNames.value,
groups: buildGroupPayload(),
boardItems: Object.values(itemsById.value),
boardItems: getOrderedItems(),
})
if (type === 'create') closeTemplateRequestModal()
@@ -1184,13 +1207,16 @@ onUnmounted(() => {
@dragleave="onDragLeave"
@drop.prevent="onDropFiles"
>
<div>
<div>
<div class="dropzone__iconWrap">
<SvgIcon :src="addPhotoAlternateIcon" alt="" class="dropzone__icon" />
</div>
<div class="dropzone__title">커스텀 이미지 추가</div>
<div class="dropzone__desc">이곳으로 이미지를 드래그하거나 파일 선택으로 번에 추가할 있어요.</div>
</div>
<div class="dropzone__actions">
<input ref="fileEl" type="file" accept="image/*" multiple class="hidden" @change="onFileChange" />
<button class="btn btn--ghost dropzone__button" @click="openFile">파일 선택</button>
<button class="btn btn--ghost btn--small dropzone__button" @click="openFile">파일 선택</button>
</div>
</div>
</div>
@@ -1298,7 +1324,7 @@ onUnmounted(() => {
<button v-if="canEdit" class="btn btn--save editorSidebar__button" :disabled="isSaving" @click="save">{{ isSaving ? '저장중...' : '저장' }}</button>
</div>
<div class="editorSidebar__utilityLinks">
<button v-if="canEdit && !isNewTierList" class="editorSidebar__utilityLink editorSidebar__utilityLink--danger" @click="openDeleteModal">삭제하기</button>
<button v-if="canEdit && hasSavedTierList" class="editorSidebar__utilityLink editorSidebar__utilityLink--danger" @click="openDeleteModal">삭제하기</button>
<button v-if="canDuplicate" class="editorSidebar__utilityLink" @click="duplicateCurrentTierList">복사해서 티어표로 가져오기</button>
<button
v-if="canRequestTemplateCreate"
@@ -2077,21 +2103,22 @@ onUnmounted(() => {
align-items: center;
justify-content: center;
gap: 18px;
min-height: 176px;
padding: 28px 24px;
border: 2px dashed color-mix(in srgb, var(--theme-accent) 48%, var(--theme-border));
min-height: 180px;
padding: 28px 22px;
border-radius: 22px;
border: 2px dashed color-mix(in srgb, var(--theme-border-strong) 82%, rgba(255, 255, 255, 0.12));
background:
linear-gradient(180deg, color-mix(in srgb, var(--theme-card-bg) 82%, transparent), color-mix(in srgb, var(--theme-card-bg-hover) 74%, transparent)),
radial-gradient(circle at top, color-mix(in srgb, var(--theme-accent) 12%, transparent), transparent 58%);
linear-gradient(180deg, color-mix(in srgb, var(--theme-card-bg) 88%, white 4%), color-mix(in srgb, var(--theme-card-bg-hover) 82%, white 6%)),
radial-gradient(circle at top, color-mix(in srgb, var(--theme-accent) 12%, transparent), transparent 60%);
text-align: center;
}
.dropzone--board.dropzone--active {
border-color: color-mix(in srgb, var(--theme-accent) 78%, white);
border-color: rgba(96, 165, 250, 0.56);
background:
linear-gradient(180deg, color-mix(in srgb, var(--theme-card-bg-hover) 88%, transparent), color-mix(in srgb, var(--theme-card-bg) 82%, transparent)),
radial-gradient(circle at top, color-mix(in srgb, var(--theme-accent) 20%, transparent), transparent 58%);
linear-gradient(180deg, color-mix(in srgb, var(--theme-card-bg-hover) 90%, white 6%), color-mix(in srgb, var(--theme-card-bg) 84%, white 4%)),
radial-gradient(circle at top, color-mix(in srgb, var(--theme-accent) 18%, transparent), transparent 60%);
transform: translateY(-1px);
}
.dropzone__actions {
@@ -2103,7 +2130,24 @@ onUnmounted(() => {
}
.dropzone__button {
min-width: 148px;
min-width: 124px;
min-height: 34px;
font-size: 11px;
}
/* .dropzone__iconWrap {
width: 52px;
height: 52px;
display: grid;
place-items: center;
border-radius: 16px;
background: color-mix(in srgb, var(--theme-text) 10%, transparent);
} */
.dropzone__icon {
width: 48px;
height: 48px;
opacity: 0.86;
}
.dropzone--board .dropzone__title {
@@ -2313,10 +2357,11 @@ onUnmounted(() => {
}
.dropzone {
margin-top: 12px;
padding: 14px;
padding: 28px 22px;
border-radius: 16px;
border: 1px dashed rgba(255, 255, 255, 0.18);
background: var(--theme-surface-soft);
border: 2px dashed color-mix(in srgb, var(--theme-border-strong) 82%, rgba(255, 255, 255, 0.12));
background: linear-gradient(180deg, color-mix(in srgb, var(--theme-card-bg) 88%, white 4%), color-mix(in srgb, var(--theme-card-bg-hover) 82%, white 6%)),
radial-gradient(circle at top, color-mix(in srgb, var(--theme-accent) 12%, transparent), transparent 60%);
}
.dropzone--active {
border-color: rgba(110, 231, 183, 0.6);