1185 lines
27 KiB
Vue
1185 lines
27 KiB
Vue
<script setup lang="ts">
|
||
defineOptions({ name: 'AdminMediaLibrary' });
|
||
|
||
import { ref, computed, onMounted, onActivated, watch } from 'vue';
|
||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||
import { useAdminLocale } from '../composables/useAdminLocale';
|
||
import api from '../api';
|
||
|
||
const { t } = useAdminLocale();
|
||
|
||
interface MediaFile {
|
||
id: string;
|
||
filename: string;
|
||
category: string;
|
||
mimeType: string;
|
||
size: number;
|
||
url: string;
|
||
uploadedBy: string | null;
|
||
createdAt: string;
|
||
inUse: boolean;
|
||
}
|
||
|
||
const CATEGORIES = ['banners', 'teams', 'contents'] as const;
|
||
type Category = (typeof CATEGORIES)[number];
|
||
|
||
const files = ref<MediaFile[]>([]);
|
||
const total = ref(0);
|
||
const loading = ref(false);
|
||
const purging = ref(false);
|
||
const uploading = ref(false);
|
||
|
||
const activeCategory = ref<Category | ''>('');
|
||
const currentPage = ref(1);
|
||
const pageSize = 40;
|
||
|
||
const uploadDialogVisible = ref(false);
|
||
const uploadCategory = ref<Category>('banners');
|
||
const uploadFile = ref<File | null>(null);
|
||
const dropActive = ref(false);
|
||
const fileInputRef = ref<HTMLInputElement | null>(null);
|
||
|
||
const unusedCount = computed(() => files.value.filter((f) => !f.inUse).length);
|
||
|
||
const storageStats = ref<{
|
||
categories: Array<{ category: string; count: number; sizeBytes: number }>;
|
||
total: { count: number; sizeBytes: number };
|
||
} | null>(null);
|
||
|
||
const cleanupConfig = ref({ enabled: false, keepDays: 180 });
|
||
const manualCleanupBefore = ref('');
|
||
|
||
function getCategoryLabel(cat: string) {
|
||
if (cat === 'deposits') return t('media.deposits_on_disk');
|
||
return categoryLabel(cat);
|
||
}
|
||
|
||
async function loadStorageStats() {
|
||
try {
|
||
const res = await api.get('/admin/files/storage-stats');
|
||
storageStats.value = res.data.data;
|
||
} catch (err) {
|
||
console.error('Failed to load storage stats:', err);
|
||
}
|
||
}
|
||
|
||
async function loadCleanupConfig() {
|
||
try {
|
||
const res = await api.get('/admin/deposits/screenshot-cleanup-config');
|
||
cleanupConfig.value = res.data.data;
|
||
} catch (err) {
|
||
console.error('Failed to load cleanup config:', err);
|
||
}
|
||
}
|
||
|
||
async function saveCleanupConfig() {
|
||
try {
|
||
const res = await api.put('/admin/deposits/screenshot-cleanup-config', {
|
||
enabled: cleanupConfig.value.enabled,
|
||
keepDays: cleanupConfig.value.keepDays,
|
||
});
|
||
cleanupConfig.value = res.data.data;
|
||
ElMessage.success(t('msg.saved'));
|
||
} catch (err: any) {
|
||
ElMessage.error(err?.response?.data?.message || t('msg.save_failed'));
|
||
}
|
||
}
|
||
|
||
async function runManualCleanup() {
|
||
if (!manualCleanupBefore.value) return;
|
||
const beforeDate = manualCleanupBefore.value;
|
||
await ElMessageBox.confirm(
|
||
`${t('media.delete_confirm')} (${beforeDate} ${t('common.to')})`,
|
||
{ type: 'warning' }
|
||
);
|
||
try {
|
||
const res = await api.delete(`/admin/deposits/screenshots?before=${beforeDate}T00:00:00.000Z`);
|
||
const cleaned = res.data.data.cleaned;
|
||
const freedBytes = res.data.data.freedBytes;
|
||
|
||
const msg = t('media.cleanup_result')
|
||
.replace('{cleaned}', String(cleaned))
|
||
.replace('{size}', formatSize(freedBytes));
|
||
|
||
ElMessage.success(msg);
|
||
loadStorageStats();
|
||
loadFiles();
|
||
} catch (err: any) {
|
||
ElMessage.error(err?.response?.data?.message || t('msg.delete_failed'));
|
||
}
|
||
}
|
||
|
||
function categoryLabel(cat: string) {
|
||
const key = `media.category.${cat}` as const;
|
||
return t(key as any) || cat;
|
||
}
|
||
|
||
function formatSize(bytes: number) {
|
||
if (bytes < 1024) return `${bytes} B`;
|
||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||
}
|
||
|
||
function formatDate(iso: string) {
|
||
return new Date(iso).toLocaleString();
|
||
}
|
||
|
||
async function loadFiles(opts?: { silent?: boolean }) {
|
||
if (!opts?.silent) loading.value = true;
|
||
try {
|
||
const params: Record<string, string | number> = { page: currentPage.value, pageSize };
|
||
if (activeCategory.value) params.category = activeCategory.value;
|
||
const res = await api.get('/admin/files', { params });
|
||
files.value = res.data.data.items;
|
||
total.value = res.data.data.total;
|
||
void loadStorageStats();
|
||
} catch {
|
||
ElMessage.error(t('common.loading'));
|
||
} finally {
|
||
if (!opts?.silent) loading.value = false;
|
||
}
|
||
}
|
||
|
||
watch(activeCategory, () => {
|
||
currentPage.value = 1;
|
||
loadFiles();
|
||
});
|
||
|
||
watch(currentPage, () => {
|
||
void loadFiles();
|
||
});
|
||
|
||
onMounted(() => {
|
||
void loadFiles();
|
||
void loadCleanupConfig();
|
||
});
|
||
onActivated(() => {
|
||
if (files.value.length > 0) {
|
||
void loadFiles({ silent: true });
|
||
void loadCleanupConfig();
|
||
}
|
||
});
|
||
|
||
async function confirmDelete(file: MediaFile) {
|
||
await ElMessageBox.confirm(t('media.delete_confirm'), { type: 'warning' });
|
||
try {
|
||
await api.delete(`/admin/files/${file.id}`);
|
||
ElMessage.success(t('media.delete_success'));
|
||
loadFiles();
|
||
} catch {
|
||
ElMessage.error(t('media.upload_failed'));
|
||
}
|
||
}
|
||
|
||
async function purgeUnused() {
|
||
if (unusedCount.value === 0) {
|
||
ElMessage.info(t('media.purge_none'));
|
||
return;
|
||
}
|
||
const msg = t('media.purge_confirm').replace('{n}', String(unusedCount.value));
|
||
await ElMessageBox.confirm(msg, { type: 'warning' });
|
||
purging.value = true;
|
||
try {
|
||
const res = await api.delete('/admin/files/unused');
|
||
const deleted = res.data.data.deleted;
|
||
ElMessage.success(t('media.purge_success').replace('{n}', String(deleted)));
|
||
loadFiles();
|
||
} catch {
|
||
ElMessage.error(t('media.upload_failed'));
|
||
} finally {
|
||
purging.value = false;
|
||
}
|
||
}
|
||
|
||
function copyUrl(url: string) {
|
||
navigator.clipboard.writeText(location.origin + url).then(
|
||
() => ElMessage.success(t('media.url_copied')),
|
||
() => ElMessage.error(t('media.upload_failed')),
|
||
);
|
||
}
|
||
|
||
function openUploadDialog() {
|
||
uploadFile.value = null;
|
||
uploadDialogVisible.value = true;
|
||
}
|
||
|
||
function onFileChange(e: Event) {
|
||
const input = e.target as HTMLInputElement;
|
||
if (input.files?.[0]) {
|
||
uploadFile.value = input.files[0];
|
||
}
|
||
}
|
||
|
||
function onDrop(e: DragEvent) {
|
||
e.preventDefault();
|
||
dropActive.value = false;
|
||
if (e.dataTransfer?.files?.[0]) {
|
||
uploadFile.value = e.dataTransfer.files[0];
|
||
}
|
||
}
|
||
|
||
async function doUpload() {
|
||
if (!uploadFile.value) return;
|
||
uploading.value = true;
|
||
try {
|
||
const fd = new FormData();
|
||
fd.append('file', uploadFile.value);
|
||
await api.post(`/admin/uploads?category=${uploadCategory.value}`, fd, {
|
||
headers: { 'Content-Type': 'multipart/form-data' },
|
||
});
|
||
ElMessage.success(t('media.upload_success'));
|
||
uploadDialogVisible.value = false;
|
||
uploadFile.value = null;
|
||
if (fileInputRef.value) fileInputRef.value.value = '';
|
||
loadFiles();
|
||
} catch (err: any) {
|
||
const msg = err?.response?.data?.message || t('media.upload_failed');
|
||
ElMessage.error(msg);
|
||
} finally {
|
||
uploading.value = false;
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="media-page">
|
||
<!-- Header toolbar -->
|
||
<div class="toolbar">
|
||
<div class="filter-tabs">
|
||
<button
|
||
class="tab-btn"
|
||
:class="{ active: activeCategory === '' }"
|
||
@click="activeCategory = ''"
|
||
>{{ t('media.category.all') }}</button>
|
||
<button
|
||
v-for="cat in CATEGORIES"
|
||
:key="cat"
|
||
class="tab-btn"
|
||
:class="{ active: activeCategory === cat }"
|
||
@click="activeCategory = cat"
|
||
>{{ categoryLabel(cat) }}</button>
|
||
</div>
|
||
<div class="toolbar-right">
|
||
<span v-if="unusedCount > 0" class="unused-badge">
|
||
{{ t('media.unused_count').replace('{n}', String(unusedCount)) }}
|
||
</span>
|
||
<button class="btn btn-ghost" :disabled="purging" @click="purgeUnused">
|
||
{{ t('media.purge_btn') }}
|
||
</button>
|
||
<button class="btn btn-primary" @click="openUploadDialog">
|
||
+ {{ t('media.upload_btn') }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Storage Stats -->
|
||
<div class="stats-banner" v-if="storageStats">
|
||
<div v-for="item in storageStats.categories" :key="item.category" class="stat-card">
|
||
<span class="stat-label">{{ getCategoryLabel(item.category) }}</span>
|
||
<span class="stat-value">{{ item.count }} <span class="stat-unit">{{ t('common.times') }}</span></span>
|
||
<span class="stat-size">{{ formatSize(item.sizeBytes) }}</span>
|
||
</div>
|
||
<div class="stat-card total-card">
|
||
<span class="stat-label">{{ t('media.storage_stats') }}</span>
|
||
<span class="stat-value">{{ storageStats.total.count }} <span class="stat-unit">{{ t('common.times') }}</span></span>
|
||
<span class="stat-size">{{ formatSize(storageStats.total.sizeBytes) }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Cleanup Panel -->
|
||
<div class="cleanup-card">
|
||
<div class="cleanup-title">
|
||
<span>{{ t('media.screenshot_cleanup') }}</span>
|
||
</div>
|
||
<div class="cleanup-grid">
|
||
<!-- Auto Cleanup Config -->
|
||
<div class="cleanup-section">
|
||
<h4 class="section-subtitle">{{ t('media.cleanup_auto_enabled') }}</h4>
|
||
<div class="config-row">
|
||
<label class="switch-container">
|
||
<input type="checkbox" v-model="cleanupConfig.enabled" @change="saveCleanupConfig" />
|
||
<span class="switch-slider"></span>
|
||
</label>
|
||
<div class="days-input-group" v-if="cleanupConfig.enabled">
|
||
<span>{{ t('media.cleanup_keep_days') }}</span>
|
||
<input type="number" v-model.number="cleanupConfig.keepDays" min="1" class="num-input" />
|
||
<button class="btn btn-ghost btn-sm" @click="saveCleanupConfig">{{ t('common.save') }}</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Manual Cleanup -->
|
||
<div class="cleanup-section manual-section">
|
||
<h4 class="section-subtitle">{{ t('media.cleanup_before_date') }}</h4>
|
||
<div class="config-row">
|
||
<input type="date" v-model="manualCleanupBefore" class="date-input" />
|
||
<button class="btn btn-primary" :disabled="!manualCleanupBefore" @click="runManualCleanup">
|
||
{{ t('media.cleanup_run_now') }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="cleanup-tip">
|
||
* 仅清理【已同意】或【已拒绝】的充值订单截图,【待处理】的截图绝不会被删除。清理后文件会被替换为已过期占位图。
|
||
</div>
|
||
</div>
|
||
|
||
<!-- File grid -->
|
||
<div v-if="loading" class="state-center">{{ t('common.loading') }}</div>
|
||
<div v-else-if="files.length === 0" class="state-center muted">{{ t('media.no_files') }}</div>
|
||
<div v-else class="file-grid">
|
||
<div v-for="file in files" :key="file.id" class="file-card">
|
||
<div class="card-thumb">
|
||
<img
|
||
v-if="file.mimeType !== 'image/svg+xml'"
|
||
:src="file.url"
|
||
:alt="file.filename"
|
||
loading="lazy"
|
||
/>
|
||
<div v-else class="svg-badge">SVG</div>
|
||
<span class="use-badge" :class="file.inUse ? 'badge-used' : 'badge-unused'">
|
||
{{ file.inUse ? t('media.status.used') : t('media.status.unused') }}
|
||
</span>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="card-filename" :title="file.filename">{{ file.filename }}</div>
|
||
<div class="card-meta">
|
||
<span class="cat-tag">{{ categoryLabel(file.category) }}</span>
|
||
<span>{{ formatSize(file.size) }}</span>
|
||
</div>
|
||
<div class="card-date">{{ formatDate(file.createdAt) }}</div>
|
||
</div>
|
||
<div class="card-actions">
|
||
<button class="act-btn" @click="copyUrl(file.url)">{{ t('media.copy_url') }}</button>
|
||
<button class="act-btn act-delete" @click="confirmDelete(file)">{{ t('common.delete') }}</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Pagination -->
|
||
<div v-if="total > pageSize" class="pagination">
|
||
<button
|
||
class="btn btn-ghost"
|
||
:disabled="currentPage <= 1"
|
||
@click="currentPage--"
|
||
>«</button>
|
||
<span class="page-info">{{ currentPage }} / {{ Math.ceil(total / pageSize) }}</span>
|
||
<button
|
||
class="btn btn-ghost"
|
||
:disabled="currentPage >= Math.ceil(total / pageSize)"
|
||
@click="currentPage++"
|
||
>»</button>
|
||
</div>
|
||
|
||
<!-- Upload dialog -->
|
||
<div v-if="uploadDialogVisible" class="dialog-overlay">
|
||
<div class="dialog">
|
||
<div class="dialog-header">
|
||
<span>{{ t('media.upload_dialog') }}</span>
|
||
<button class="dialog-close" @click="uploadDialogVisible = false">×</button>
|
||
</div>
|
||
<div class="dialog-body">
|
||
<div class="form-row">
|
||
<label>{{ t('media.upload_category') }}</label>
|
||
<div class="cat-buttons">
|
||
<button
|
||
v-for="cat in CATEGORIES"
|
||
:key="cat"
|
||
class="tab-btn"
|
||
:class="{ active: uploadCategory === cat }"
|
||
@click="uploadCategory = cat"
|
||
>{{ categoryLabel(cat) }}</button>
|
||
</div>
|
||
</div>
|
||
<div
|
||
class="drop-zone"
|
||
:class="{ 'drop-active': dropActive }"
|
||
@dragover.prevent="dropActive = true"
|
||
@dragleave="dropActive = false"
|
||
@drop="onDrop"
|
||
@click="fileInputRef?.click()"
|
||
>
|
||
<div v-if="!uploadFile" class="drop-hint">{{ t('media.drop_hint') }}</div>
|
||
<div v-else class="drop-selected">
|
||
<span class="sel-name">{{ uploadFile.name }}</span>
|
||
<span class="sel-size">{{ formatSize(uploadFile.size) }}</span>
|
||
</div>
|
||
<input
|
||
ref="fileInputRef"
|
||
type="file"
|
||
accept="image/png,image/jpeg,image/webp,image/gif,image/svg+xml"
|
||
style="display:none"
|
||
@change="onFileChange"
|
||
/>
|
||
</div>
|
||
<div class="upload-hint-text">{{ t('media.upload_hint') }}</div>
|
||
</div>
|
||
<div class="dialog-footer">
|
||
<button class="btn btn-ghost" @click="uploadDialogVisible = false">{{ t('common.cancel') }}</button>
|
||
<button
|
||
class="btn btn-primary"
|
||
:disabled="!uploadFile || uploading"
|
||
@click="doUpload"
|
||
>{{ uploading ? t('common.loading') : t('common.confirm') }}</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.media-page {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
height: 100%;
|
||
min-height: 0;
|
||
}
|
||
|
||
/* ── Storage Stats ── */
|
||
.stats-banner {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||
gap: 12px;
|
||
flex-shrink: 0;
|
||
}
|
||
.stat-card {
|
||
background: #ffffff;
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
padding: 12px 16px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
box-shadow: var(--shadow);
|
||
transition: all 0.15s ease-in-out;
|
||
}
|
||
.stat-card:hover {
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 6px 16px rgba(56, 49, 37, 0.06);
|
||
}
|
||
.total-card {
|
||
border-color: var(--primary);
|
||
background: var(--accent-hover);
|
||
}
|
||
.stat-label {
|
||
font-size: 12px;
|
||
color: var(--text-muted);
|
||
font-weight: 550;
|
||
}
|
||
.stat-value {
|
||
font-size: 20px;
|
||
font-weight: 800;
|
||
color: var(--text);
|
||
}
|
||
.stat-unit {
|
||
font-size: 12px;
|
||
font-weight: 400;
|
||
color: var(--text-muted);
|
||
}
|
||
.stat-size {
|
||
font-size: 12px;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
/* ── Cleanup Panel ── */
|
||
.cleanup-card {
|
||
background: #ffffff;
|
||
border: 1px solid var(--border);
|
||
border-radius: 10px;
|
||
padding: 16px;
|
||
box-shadow: var(--shadow);
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 14px;
|
||
flex-shrink: 0;
|
||
}
|
||
.cleanup-title {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
font-size: 14px;
|
||
font-weight: 800;
|
||
color: var(--text);
|
||
border-bottom: 1px solid var(--border-soft);
|
||
padding-bottom: 8px;
|
||
}
|
||
.cleanup-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||
gap: 20px;
|
||
}
|
||
.cleanup-section {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
.section-subtitle {
|
||
margin: 0;
|
||
font-size: 13px;
|
||
color: var(--text);
|
||
font-weight: 700;
|
||
}
|
||
.config-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 14px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.days-input-group {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
font-size: 13px;
|
||
color: var(--text-muted);
|
||
}
|
||
.num-input {
|
||
width: 75px;
|
||
padding: 5px 8px;
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px;
|
||
background: #ffffff;
|
||
color: var(--text);
|
||
font-family: inherit;
|
||
font-size: 13px;
|
||
outline: none;
|
||
}
|
||
.num-input:focus {
|
||
border-color: var(--primary);
|
||
}
|
||
.date-input {
|
||
padding: 5px 10px;
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px;
|
||
background: #ffffff;
|
||
color: var(--text);
|
||
font-family: inherit;
|
||
font-size: 13px;
|
||
outline: none;
|
||
}
|
||
.date-input:focus {
|
||
border-color: var(--primary);
|
||
}
|
||
.btn-sm {
|
||
padding: 4px 10px;
|
||
font-size: 12px;
|
||
}
|
||
.cleanup-tip {
|
||
font-size: 12px;
|
||
color: var(--text-muted);
|
||
line-height: 1.4;
|
||
padding: 8px 12px;
|
||
background: var(--accent-hover);
|
||
border-radius: 6px;
|
||
border-left: 3px solid var(--primary);
|
||
}
|
||
|
||
/* ── Custom Toggle Switch ── */
|
||
.switch-container {
|
||
position: relative;
|
||
display: inline-block;
|
||
width: 44px;
|
||
height: 22px;
|
||
}
|
||
.switch-container input {
|
||
opacity: 0;
|
||
width: 0;
|
||
height: 0;
|
||
}
|
||
.switch-slider {
|
||
position: absolute;
|
||
cursor: pointer;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background-color: #d5cfc3;
|
||
transition: .2s;
|
||
border-radius: 22px;
|
||
}
|
||
.switch-slider:before {
|
||
position: absolute;
|
||
content: "";
|
||
height: 16px;
|
||
width: 16px;
|
||
left: 3px;
|
||
bottom: 3px;
|
||
background-color: white;
|
||
transition: .2s;
|
||
border-radius: 50%;
|
||
}
|
||
input:checked + .switch-slider {
|
||
background-color: var(--primary);
|
||
}
|
||
input:checked + .switch-slider:before {
|
||
transform: translateX(22px);
|
||
}
|
||
|
||
/* ── Toolbar ── */
|
||
.toolbar {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
flex-wrap: wrap;
|
||
flex-shrink: 0;
|
||
}
|
||
.filter-tabs {
|
||
display: flex;
|
||
gap: 6px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.toolbar-right {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.unused-badge {
|
||
font-size: 12px;
|
||
color: #f0a020;
|
||
background: rgba(240, 160, 32, 0.12);
|
||
border: 1px solid rgba(240, 160, 32, 0.3);
|
||
border-radius: 4px;
|
||
padding: 3px 8px;
|
||
}
|
||
|
||
/* ── Tabs / Buttons ── */
|
||
.tab-btn {
|
||
padding: 6px 14px;
|
||
border-radius: 6px;
|
||
border: 1px solid #2a2a2a;
|
||
background: transparent;
|
||
color: #888;
|
||
font-size: 13px;
|
||
font-family: inherit;
|
||
cursor: pointer;
|
||
transition: all 0.15s;
|
||
}
|
||
.tab-btn:hover { border-color: #444; color: #ccc; }
|
||
.tab-btn.active {
|
||
background: rgba(255, 255, 255, 0.06);
|
||
border-color: rgba(255, 255, 255, 0.12);
|
||
color: #f5f5f5;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.btn {
|
||
padding: 7px 16px;
|
||
border-radius: 6px;
|
||
font-size: 13px;
|
||
font-family: inherit;
|
||
cursor: pointer;
|
||
border: 1px solid transparent;
|
||
transition: all 0.15s;
|
||
white-space: nowrap;
|
||
}
|
||
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
|
||
.btn-ghost {
|
||
background: transparent;
|
||
border-color: #2a2a2a;
|
||
color: #888;
|
||
}
|
||
.btn-ghost:hover:not(:disabled) { border-color: #444; color: #ccc; }
|
||
.btn-primary {
|
||
background: #f5f5f5;
|
||
color: #0a0a0a;
|
||
font-weight: 500;
|
||
border-color: #f5f5f5;
|
||
box-shadow: none;
|
||
}
|
||
.btn-primary:hover:not(:disabled) { background: #ffffff; border-color: #ffffff; }
|
||
|
||
/* ── Grid ── */
|
||
.file-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||
align-content: start;
|
||
gap: 14px;
|
||
overflow-y: auto;
|
||
flex: 1;
|
||
padding-right: 4px;
|
||
padding-bottom: 8px;
|
||
}
|
||
|
||
.file-card {
|
||
background: rgba(255, 255, 255, 0.03);
|
||
border: 1px solid #1e1e1e;
|
||
border-radius: 10px;
|
||
overflow: hidden;
|
||
display: flex;
|
||
flex-direction: column;
|
||
transition: border-color 0.15s, box-shadow 0.15s;
|
||
}
|
||
.file-card:hover {
|
||
border-color: #333;
|
||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||
}
|
||
|
||
.card-thumb {
|
||
position: relative;
|
||
height: 120px;
|
||
background: #111;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
overflow: hidden;
|
||
}
|
||
.card-thumb img {
|
||
max-width: 100%;
|
||
max-height: 100%;
|
||
object-fit: contain;
|
||
}
|
||
.svg-badge {
|
||
font-size: 14px;
|
||
font-weight: 700;
|
||
color: #666;
|
||
letter-spacing: 0.1em;
|
||
}
|
||
|
||
.use-badge {
|
||
position: absolute;
|
||
top: 6px;
|
||
right: 6px;
|
||
font-size: 10px;
|
||
font-weight: 700;
|
||
padding: 2px 6px;
|
||
border-radius: 4px;
|
||
letter-spacing: 0.04em;
|
||
}
|
||
.badge-used {
|
||
background: rgba(212, 175, 55, 0.2);
|
||
color: #c9a227;
|
||
border: 1px solid rgba(212, 175, 55, 0.35);
|
||
}
|
||
.badge-unused {
|
||
background: rgba(120, 120, 120, 0.18);
|
||
color: #666;
|
||
border: 1px solid rgba(120, 120, 120, 0.2);
|
||
}
|
||
|
||
.card-body {
|
||
padding: 10px 12px 6px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
}
|
||
.card-filename {
|
||
font-size: 12px;
|
||
color: #ccc;
|
||
font-weight: 500;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
max-width: 100%;
|
||
}
|
||
.card-meta {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: 11px;
|
||
color: #555;
|
||
}
|
||
.cat-tag {
|
||
background: rgba(255, 255, 255, 0.05);
|
||
border: 1px solid #2a2a2a;
|
||
border-radius: 3px;
|
||
padding: 1px 5px;
|
||
font-size: 10px;
|
||
color: #666;
|
||
}
|
||
.card-date {
|
||
font-size: 10px;
|
||
color: #444;
|
||
}
|
||
|
||
.card-actions {
|
||
display: flex;
|
||
border-top: 1px solid #1a1a1a;
|
||
}
|
||
.act-btn {
|
||
flex: 1;
|
||
padding: 7px 4px;
|
||
background: transparent;
|
||
border: none;
|
||
border-right: 1px solid #1a1a1a;
|
||
color: #666;
|
||
font-size: 11px;
|
||
font-family: inherit;
|
||
cursor: pointer;
|
||
transition: all 0.15s;
|
||
}
|
||
.act-btn:last-child { border-right: none; }
|
||
.act-btn:hover { background: rgba(255, 255, 255, 0.04); color: #ccc; }
|
||
.act-delete:hover { color: #e05555; }
|
||
|
||
/* ── Pagination ── */
|
||
.pagination {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 12px;
|
||
flex-shrink: 0;
|
||
padding-bottom: 4px;
|
||
}
|
||
.page-info {
|
||
font-size: 13px;
|
||
color: #666;
|
||
min-width: 60px;
|
||
text-align: center;
|
||
}
|
||
|
||
/* ── Empty / loading states ── */
|
||
.state-center {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex: 1;
|
||
font-size: 14px;
|
||
color: #444;
|
||
}
|
||
.muted { color: #333; }
|
||
|
||
.file-grid::-webkit-scrollbar { width: 6px; }
|
||
.file-grid::-webkit-scrollbar-track { background: transparent; }
|
||
.file-grid::-webkit-scrollbar-thumb {
|
||
background: #2a2a2a;
|
||
border-radius: 3px;
|
||
}
|
||
.file-grid::-webkit-scrollbar-thumb:hover { background: #444; }
|
||
|
||
/* ── Upload dialog ── */
|
||
.dialog-overlay {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(0, 0, 0, 0.65);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 500;
|
||
padding: 20px;
|
||
}
|
||
.dialog {
|
||
background: #141414;
|
||
border: 1px solid #2a2a2a;
|
||
border-radius: 12px;
|
||
width: 100%;
|
||
max-width: 480px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.6);
|
||
}
|
||
.dialog-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 16px 20px;
|
||
border-bottom: 1px solid #1e1e1e;
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
color: #e0e0e0;
|
||
}
|
||
.dialog-close {
|
||
background: transparent;
|
||
border: none;
|
||
color: #555;
|
||
font-size: 20px;
|
||
cursor: pointer;
|
||
line-height: 1;
|
||
padding: 0 4px;
|
||
}
|
||
.dialog-close:hover { color: #ccc; }
|
||
|
||
.dialog-body {
|
||
padding: 20px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
}
|
||
.form-row {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
.form-row label {
|
||
font-size: 13px;
|
||
color: #888;
|
||
}
|
||
.cat-buttons {
|
||
display: flex;
|
||
gap: 6px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.drop-zone {
|
||
border: 2px dashed #2a2a2a;
|
||
border-radius: 10px;
|
||
min-height: 120px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
cursor: pointer;
|
||
transition: border-color 0.15s, background 0.15s;
|
||
padding: 16px;
|
||
}
|
||
.drop-zone:hover,
|
||
.drop-zone.drop-active {
|
||
border-color: rgba(212, 175, 55, 0.5);
|
||
background: rgba(212, 175, 55, 0.04);
|
||
}
|
||
.drop-hint {
|
||
font-size: 13px;
|
||
color: #444;
|
||
text-align: center;
|
||
}
|
||
.drop-selected {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
.sel-name {
|
||
font-size: 13px;
|
||
color: #ccc;
|
||
font-weight: 500;
|
||
word-break: break-all;
|
||
text-align: center;
|
||
}
|
||
.sel-size {
|
||
font-size: 11px;
|
||
color: #555;
|
||
}
|
||
|
||
.upload-hint-text {
|
||
font-size: 11px;
|
||
color: #444;
|
||
}
|
||
|
||
.dialog-footer {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 10px;
|
||
padding: 16px 20px;
|
||
border-top: 1px solid #1e1e1e;
|
||
}
|
||
|
||
.media-page {
|
||
color: var(--text);
|
||
}
|
||
|
||
.toolbar {
|
||
padding: 14px;
|
||
border: 1px solid var(--border);
|
||
border-radius: 10px;
|
||
background: #ffffff;
|
||
box-shadow: var(--shadow);
|
||
}
|
||
|
||
.unused-badge {
|
||
border-color: var(--warning-border);
|
||
background: var(--warning-bg);
|
||
color: var(--warning-text);
|
||
}
|
||
|
||
.tab-btn,
|
||
.btn {
|
||
border-radius: 7px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.tab-btn {
|
||
border-color: var(--border);
|
||
background: #ffffff;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.tab-btn:hover {
|
||
border-color: #d5cfc3;
|
||
background: var(--accent-hover);
|
||
color: var(--text);
|
||
}
|
||
|
||
.tab-btn.active {
|
||
border-color: var(--primary);
|
||
background: var(--primary);
|
||
color: #ffffff;
|
||
}
|
||
|
||
.btn-ghost {
|
||
border-color: var(--border);
|
||
background: #ffffff;
|
||
color: var(--text);
|
||
}
|
||
|
||
.btn-ghost:hover:not(:disabled) {
|
||
border-color: #d5cfc3;
|
||
background: var(--accent-hover);
|
||
color: var(--text);
|
||
}
|
||
|
||
.btn-primary {
|
||
border-color: var(--primary);
|
||
background: var(--primary);
|
||
color: #ffffff;
|
||
}
|
||
|
||
.btn-primary:hover:not(:disabled) {
|
||
border-color: var(--primary-light);
|
||
background: var(--primary-light);
|
||
}
|
||
|
||
.file-grid {
|
||
padding: 2px 2px 8px;
|
||
}
|
||
|
||
.file-card {
|
||
border-color: var(--border);
|
||
border-radius: 8px;
|
||
background: #ffffff;
|
||
box-shadow: var(--shadow);
|
||
}
|
||
|
||
.file-card:hover {
|
||
border-color: #d5cfc3;
|
||
box-shadow: 0 10px 26px rgba(56, 49, 37, 0.08);
|
||
}
|
||
|
||
.card-thumb,
|
||
.media-picker-thumb {
|
||
background: #f4f0e8;
|
||
}
|
||
|
||
.svg-badge,
|
||
.media-picker-svg {
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.badge-used {
|
||
border-color: var(--warning-border);
|
||
background: var(--warning-bg);
|
||
color: var(--warning-text);
|
||
}
|
||
|
||
.badge-unused {
|
||
border-color: var(--border);
|
||
background: #f0ede6;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.card-filename {
|
||
color: var(--text);
|
||
font-weight: 700;
|
||
}
|
||
|
||
.card-meta,
|
||
.card-date,
|
||
.page-info,
|
||
.state-center,
|
||
.muted,
|
||
.drop-hint,
|
||
.sel-size,
|
||
.upload-hint-text,
|
||
.form-row label {
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.cat-tag {
|
||
border-color: var(--border-soft);
|
||
background: #f0ede6;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.card-actions {
|
||
border-top-color: var(--border-soft);
|
||
}
|
||
|
||
.act-btn {
|
||
border-right-color: var(--border-soft);
|
||
color: var(--text-muted);
|
||
font-weight: 700;
|
||
}
|
||
|
||
.act-btn:hover {
|
||
background: var(--accent-hover);
|
||
color: var(--text);
|
||
}
|
||
|
||
.act-delete:hover {
|
||
color: var(--danger-text);
|
||
}
|
||
|
||
.file-grid::-webkit-scrollbar-thumb {
|
||
background: #d5cfc3;
|
||
}
|
||
|
||
.file-grid::-webkit-scrollbar-thumb:hover {
|
||
background: #bdb6a9;
|
||
}
|
||
|
||
.dialog-overlay {
|
||
background: rgba(31, 35, 32, 0.28);
|
||
backdrop-filter: blur(3px);
|
||
}
|
||
|
||
.dialog {
|
||
border-color: var(--border);
|
||
background: #ffffff;
|
||
box-shadow: 0 24px 64px rgba(56, 49, 37, 0.14);
|
||
}
|
||
|
||
.dialog-header {
|
||
border-bottom-color: var(--border-soft);
|
||
color: var(--text);
|
||
font-weight: 800;
|
||
}
|
||
|
||
.dialog-close {
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.dialog-close:hover {
|
||
color: var(--danger-text);
|
||
}
|
||
|
||
.drop-zone {
|
||
border-color: #d5cfc3;
|
||
background: #fbfaf7;
|
||
}
|
||
|
||
.drop-zone:hover,
|
||
.drop-zone.drop-active {
|
||
border-color: var(--primary);
|
||
background: var(--accent-hover);
|
||
}
|
||
|
||
.sel-name {
|
||
color: var(--text);
|
||
}
|
||
|
||
.dialog-footer {
|
||
border-top-color: var(--border-soft);
|
||
}
|
||
|
||
@media (max-width: 700px) {
|
||
.toolbar,
|
||
.toolbar-right,
|
||
.filter-tabs {
|
||
align-items: stretch;
|
||
width: 100%;
|
||
}
|
||
|
||
.tab-btn,
|
||
.toolbar-right .btn {
|
||
flex: 1 1 auto;
|
||
}
|
||
|
||
.file-grid {
|
||
grid-template-columns: repeat(auto-fill, minmax(148px, 1fr));
|
||
}
|
||
|
||
.dialog {
|
||
max-height: calc(100dvh - 32px);
|
||
}
|
||
}
|
||
</style>
|