364 lines
7.9 KiB
Svelte
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})`;
|
|
|
|
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>
|