Files
blogflare/src/routes/admin/files/+page.svelte
T

364 lines
7.9 KiB
Svelte

<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import { enhance } from '$app/forms';
import { File, Upload } from '@lucide/svelte';
import type { ActionData, PageData } from './$types';
import { addToast } from '$lib/stores/toast.svelte';
import { confirmState } from '$lib/stores/confirm.svelte';
let { data, form } = $props<{ data: PageData; form: ActionData }>();
$effect(() => {
if (form?.success) {
addToast('操作成功!', 'success');
} else if (form?.error) {
addToast(form.error, 'error');
}
});
$effect(() => {
if (
typeof window !== 'undefined' &&
data.files.some((f: any) => f.mime_type?.startsWith('image/'))
) {
(async () => {
const { Fancybox } = await import('@fancyapps/ui/dist/index.js');
await import('@fancyapps/ui/dist/fancybox/fancybox.css');
Fancybox.bind('[data-fancybox="gallery"]', {
mainStyle: { '--f-carousel-slide-padding': '24px 0' },
Carousel: {
Toolbar: { absolute: false },
Thumbs: { type: 'scrollable', showOnStart: false }
}
});
})();
}
return () => {
if (typeof window !== 'undefined') {
import('@fancyapps/ui/dist/index.js').then(({ Fancybox }) => {
Fancybox.unbind('[data-fancybox="gallery"]');
Fancybox.close();
});
}
};
});
let isUploading = $state(false);
let fileInput: HTMLInputElement;
function formatBytes(bytes: number, decimals = 2) {
if (!+bytes) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
}
function copyMarkdown(file: any) {
const url = `${data.r2Domain}/${file.key}`;
const isImage = file.mime_type?.startsWith('image/');
const markdown = isImage ? `![${file.filename}](${url})` : `[${file.filename}](${url})`;
navigator.clipboard
.writeText(markdown)
.then(() => {
addToast('Markdown 链接已复制到剪贴板!', 'success');
})
.catch((err) => {
console.error('Failed to copy: ', err);
addToast('复制失败,请手动复制。', 'error');
});
}
function handleFileChange() {
if (fileInput.files && fileInput.files.length > 0) {
fileInput.form?.requestSubmit();
}
}
</script>
<svelte:head>
<title>文件管理 - BlogFlare Admin</title>
</svelte:head>
<div class="admin-page-header">
<h2>文件管理</h2>
</div>
<div class="upload-zone">
<form
method="POST"
action="?/upload"
enctype="multipart/form-data"
use:enhance={() => {
isUploading = true;
return async ({ update }) => {
isUploading = false;
await update();
};
}}
>
<button type="button" class="upload-box" onclick={() => fileInput.click()}>
<Upload size={48} stroke-width={1.5} aria-hidden="true" />
<h3>{isUploading ? '正在上传...' : '点击或拖拽文件上传'}</h3>
<p>存储于 Cloudflare R2</p>
</button>
<input
type="file"
name="file"
bind:this={fileInput}
onchange={handleFileChange}
style="display: none;"
/>
</form>
</div>
<div class="files-grid">
{#each data.files as file (file.id || file)}
{@const url = `${data.r2Domain}/${file.key}`}
{@const isImage = file.mime_type?.startsWith('image/')}
<div class="file-card">
<div class="file-preview">
{#if isImage}
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a data-fancybox="gallery" data-caption={file.filename} href={url}>
<img src={url} alt={file.filename} loading="lazy" />
</a>
{:else}
<div class="file-icon">
<File size={48} stroke-width={1.5} aria-hidden="true" />
</div>
{/if}
</div>
<div class="file-info">
<div class="file-name" title={file.filename}>{file.filename}</div>
<div class="file-meta">
<span>{formatBytes(file.size)}</span>
<span>{new Date(file.created_at).toLocaleDateString()}</span>
</div>
</div>
<div class="file-actions">
<button
class="action-btn copy-btn"
onclick={() => copyMarkdown(file)}
title="复制 Markdown"
>
复制链接
</button>
<form
method="POST"
action="?/delete"
use:enhance={() => {
return async ({ update }) => {
await update();
};
}}
>
<input type="hidden" name="id" value={file.id} />
<button
class="action-btn delete-btn"
type="submit"
onclick={async (e) => {
if ((e.currentTarget as HTMLElement).dataset.confirmed === 'true') {
(e.currentTarget as HTMLElement).removeAttribute('data-confirmed');
return;
}
e.preventDefault();
const btn = e.currentTarget as HTMLElement;
const form = btn.closest('form');
const confirmed = await confirmState.show('确定要删除此文件吗?此操作不可恢复。');
if (confirmed && form) {
btn.dataset.confirmed = 'true';
form.requestSubmit(btn);
}
}}
title="删除文件"
>
删除
</button>
</form>
</div>
</div>
{/each}
{#if data.files.length === 0}
<div class="empty-state">暂无文件,点击上方按钮上传</div>
{/if}
</div>
<style>
.admin-page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 25px;
border-bottom: 3px solid var(--text-color);
padding-bottom: 15px;
}
.admin-page-header h2 {
margin: 0;
font-size: 2rem;
font-weight: 800;
}
.upload-zone {
margin-bottom: 30px;
}
.upload-box {
width: 100%;
border: 3px dashed var(--text-color);
padding: 40px 20px;
text-align: center;
cursor: pointer;
background: var(--bg-color);
transition: all 0.2s;
display: block;
font-family: inherit;
color: inherit;
}
.upload-box:hover {
background: rgba(0, 0, 0, 0.02);
border-color: var(--accent-color, #419b38);
color: var(--accent-color, #419b38);
}
.upload-box h3 {
margin: 15px 0 5px 0;
font-size: 1.2rem;
}
.upload-box p {
margin: 0;
color: var(--text-muted);
font-size: 0.9rem;
}
.files-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 20px;
}
.file-card {
border: 2px solid var(--text-color);
background: var(--bg-color);
display: flex;
flex-direction: column;
}
.file-card:hover .file-preview img {
transform: scale(1.05);
}
.file-card:hover .file-icon {
transform: scale(1.1);
}
.file-preview {
height: 150px;
border-bottom: 2px solid var(--text-color);
display: flex;
align-items: center;
justify-content: center;
background: #f5f5f5;
overflow: hidden;
}
.file-preview a {
display: block;
width: 100%;
height: 100%;
}
.file-preview img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
.file-icon {
color: var(--text-muted);
transition: transform 0.3s ease;
}
.file-info {
padding: 12px;
flex-grow: 1;
}
.file-name {
font-weight: 700;
font-size: 0.95rem;
margin-bottom: 5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-meta {
display: flex;
justify-content: space-between;
font-size: 0.8rem;
color: var(--text-muted);
font-weight: 500;
}
.file-actions {
display: flex;
border-top: 2px solid var(--text-color);
}
.file-actions form {
flex: 1;
display: flex;
}
.action-btn {
flex: 1;
background: transparent;
border: none;
padding: 10px 0;
font-family: inherit;
font-weight: 700;
cursor: pointer;
color: var(--text-color);
transition: all 0.2s;
}
.action-btn:hover {
background: var(--text-color);
color: var(--bg-color);
}
.copy-btn {
border-right: 2px solid var(--text-color);
}
.delete-btn {
width: 100%;
}
.delete-btn:hover {
background: #d9534f;
color: white;
}
.empty-state {
grid-column: 1 / -1;
text-align: center;
padding: 40px;
border: 2px solid var(--text-color);
font-weight: 600;
color: var(--text-muted);
}
</style>