feat: 增加文件管理系统及独立页面管理功能
This commit is contained in:
1 parent
068b3dbbbb
commit
5bb5e56b89
8 files changed
+1477
No files matched your search
@@ -0,0 +1,123 @@
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
async function ensureAttachmentsTable(db: any) {
|
||||
try {
|
||||
await db.prepare(`
|
||||
CREATE TABLE IF NOT EXISTS attachments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
filename TEXT NOT NULL,
|
||||
key TEXT NOT NULL UNIQUE,
|
||||
mime_type TEXT,
|
||||
size INTEGER,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`).run();
|
||||
} catch (e) {
|
||||
console.error('Failed to ensure attachments table:', e);
|
||||
}
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ platform }) => {
|
||||
const db = platform?.env?.DB;
|
||||
|
||||
if (!db) {
|
||||
return { files: [], r2Domain: '' };
|
||||
}
|
||||
|
||||
try {
|
||||
// Get R2 domain setting
|
||||
const domainRes = await db.prepare('SELECT value FROM settings WHERE key = ?').bind('r2.public_domain').first();
|
||||
const r2Domain = domainRes?.value || 'https://assets.yourdomain.com';
|
||||
|
||||
const { results } = await db.prepare(
|
||||
`SELECT id, filename, key, mime_type, size, created_at
|
||||
FROM attachments
|
||||
ORDER BY created_at DESC`
|
||||
).all();
|
||||
|
||||
return {
|
||||
files: results as { id: number; filename: string; key: string; mime_type: string; size: number; created_at: string }[],
|
||||
r2Domain
|
||||
};
|
||||
} catch (err: any) {
|
||||
if (err?.message?.includes('no such table')) {
|
||||
await ensureAttachmentsTable(db);
|
||||
return { files: [], r2Domain: 'https://assets.yourdomain.com' };
|
||||
}
|
||||
console.error('Error fetching attachments:', err);
|
||||
return { files: [], r2Domain: 'https://assets.yourdomain.com' };
|
||||
}
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
upload: async ({ request, platform }) => {
|
||||
const db = platform?.env?.DB;
|
||||
const r2 = platform?.env?.R2;
|
||||
|
||||
if (!db || !r2) return fail(500, { error: '数据库或 R2 存储未连接' });
|
||||
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('file') as File;
|
||||
|
||||
if (!file || !(file instanceof File)) {
|
||||
return fail(400, { error: '没有提供有效的文件' });
|
||||
}
|
||||
|
||||
// Generate unique key (timestamp + random string + original name)
|
||||
const ext = file.name.split('.').pop() || '';
|
||||
const randomStr = Math.random().toString(36).substring(2, 8);
|
||||
const key = `${Date.now()}-${randomStr}.${ext}`;
|
||||
|
||||
try {
|
||||
// Upload to R2
|
||||
await r2.put(key, file.stream(), {
|
||||
httpMetadata: { contentType: file.type }
|
||||
});
|
||||
|
||||
// Save metadata to DB
|
||||
await ensureAttachmentsTable(db); // Defensive checking
|
||||
await db.prepare(
|
||||
'INSERT INTO attachments (filename, key, mime_type, size) VALUES (?, ?, ?, ?)'
|
||||
).bind(file.name, key, file.type, file.size).run();
|
||||
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
console.error('Error uploading file:', err);
|
||||
return fail(500, { error: '文件上传失败: ' + err.message });
|
||||
}
|
||||
},
|
||||
|
||||
delete: async ({ request, platform }) => {
|
||||
const db = platform?.env?.DB;
|
||||
const r2 = platform?.env?.R2;
|
||||
|
||||
if (!db || !r2) return fail(500, { error: '数据库或 R2 存储未连接' });
|
||||
|
||||
const formData = await request.formData();
|
||||
const id = formData.get('id');
|
||||
|
||||
if (!id) return fail(400, { error: '缺少文件 ID' });
|
||||
|
||||
try {
|
||||
// Get key first
|
||||
const fileRes = await db.prepare('SELECT key FROM attachments WHERE id = ?').bind(id).first();
|
||||
if (!fileRes) {
|
||||
return fail(404, { error: '文件未找到' });
|
||||
}
|
||||
|
||||
const key = fileRes.key as string;
|
||||
|
||||
// Delete from R2
|
||||
await r2.delete(key);
|
||||
|
||||
// Delete from DB
|
||||
await db.prepare('DELETE FROM attachments WHERE id = ?').bind(id).run();
|
||||
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
console.error('Error deleting file:', err);
|
||||
return fail(500, { error: '删除文件失败' });
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,316 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import type { ActionData, PageData } from './$types';
|
||||
|
||||
let { data, form } = $props<{ data: PageData; form: ActionData }>();
|
||||
|
||||
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(() => {
|
||||
alert('Markdown 链接已复制到剪贴板!');
|
||||
}).catch(err => {
|
||||
console.error('Failed to copy: ', err);
|
||||
alert('复制失败,请手动复制。');
|
||||
});
|
||||
}
|
||||
|
||||
function handleFileChange() {
|
||||
if (fileInput.files && fileInput.files.length > 0) {
|
||||
isUploading = true;
|
||||
// HTML5 form submit automatically triggers enhance
|
||||
fileInput.form?.requestSubmit();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>文件管理 - BlogFlare Admin</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="admin-page-header">
|
||||
<h2>文件管理</h2>
|
||||
</div>
|
||||
|
||||
{#if form?.error}
|
||||
<div class="alert error">{form.error}</div>
|
||||
{/if}
|
||||
|
||||
{#if form?.success}
|
||||
<div class="alert success">操作成功!</div>
|
||||
{/if}
|
||||
|
||||
<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()}>
|
||||
<svg viewBox="0 0 24 24" width="48" height="48" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||
<polyline points="17 8 12 3 7 8"></polyline>
|
||||
<line x1="12" y1="3" x2="12" y2="15"></line>
|
||||
</svg>
|
||||
<h3>{isUploading ? '正在上传...' : '点击或拖拽文件上传'}</h3>
|
||||
<p>存储于 Cloudflare R2</p>
|
||||
<input
|
||||
type="file"
|
||||
name="file"
|
||||
bind:this={fileInput}
|
||||
onchange={handleFileChange}
|
||||
style="display: none;"
|
||||
disabled={isUploading}
|
||||
/>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="files-grid">
|
||||
{#each data.files as file}
|
||||
{@const url = `${data.r2Domain}/${file.key}`}
|
||||
{@const isImage = file.mime_type?.startsWith('image/')}
|
||||
<div class="file-card">
|
||||
<div class="file-preview">
|
||||
{#if isImage}
|
||||
<img src={url} alt={file.filename} loading="lazy" />
|
||||
{:else}
|
||||
<div class="file-icon">
|
||||
<svg viewBox="0 0 24 24" width="48" height="48" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path>
|
||||
<polyline points="13 2 13 9 20 9"></polyline>
|
||||
</svg>
|
||||
</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={(e) => {
|
||||
if(!confirm('确定要删除此文件吗?此操作不可恢复。')) e.preventDefault();
|
||||
}} 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;
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 12px 15px;
|
||||
margin-bottom: 20px;
|
||||
border: 2px solid var(--text-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.alert.error {
|
||||
background-color: #ffcccc;
|
||||
color: #cc0000;
|
||||
}
|
||||
|
||||
.alert.success {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.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;
|
||||
box-shadow: 4px 4px 0 rgba(0,0,0,1);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.file-card:hover {
|
||||
transform: translate(-2px, -2px);
|
||||
box-shadow: 6px 6px 0 rgba(0,0,0,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 img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.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: #ff4444;
|
||||
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>
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
interface PageListRow {
|
||||
id: number;
|
||||
title: string;
|
||||
slug: string;
|
||||
date: string;
|
||||
author: string | null;
|
||||
}
|
||||
export const load: PageServerLoad = async ({ platform }) => {
|
||||
const db = platform?.env?.DB;
|
||||
|
||||
if (!db) {
|
||||
return {
|
||||
pages: []
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const queryRes = await db.prepare(
|
||||
`SELECT p.id, p.title, p.slug, p.published_at as date, u.username as author
|
||||
FROM pages p
|
||||
LEFT JOIN users u ON p.author_id = u.id
|
||||
ORDER BY p.published_at DESC`
|
||||
).all<PageListRow>();
|
||||
|
||||
const results = queryRes.results;
|
||||
|
||||
// Format date for display
|
||||
const formattedPages = results.map((page) => {
|
||||
const d = new Date(page.date as string);
|
||||
const year = d.getFullYear();
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
|
||||
return {
|
||||
...page,
|
||||
dateStr: `${year}-${month}-${day}`
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
pages: formattedPages
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('Error fetching admin pages list:', err);
|
||||
return { pages: [] };
|
||||
}
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
delete: async ({ request, platform }) => {
|
||||
const db = platform?.env?.DB;
|
||||
if (!db) return { success: false, error: 'Database not found' };
|
||||
|
||||
const data = await request.formData();
|
||||
const ids = data.getAll('ids');
|
||||
|
||||
if (!ids || ids.length === 0) return { success: false, error: 'Missing IDs' };
|
||||
|
||||
try {
|
||||
const placeholders = ids.map(() => '?').join(',');
|
||||
await db.prepare(`DELETE FROM pages WHERE id IN (${placeholders})`).bind(...ids).run();
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
console.error('Error deleting page:', err);
|
||||
return { success: false, error: 'Failed to delete page' };
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,247 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import type { PageData } from './$types';
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
let selectedIds = $state<string[]>([]);
|
||||
|
||||
function toggleAll(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
if (target.checked) {
|
||||
selectedIds = data.pages.map(p => p.id.toString());
|
||||
} else {
|
||||
selectedIds = [];
|
||||
}
|
||||
}
|
||||
|
||||
let isAllSelected = $derived(data.pages.length > 0 && selectedIds.length === data.pages.length);
|
||||
</script>
|
||||
|
||||
<div class="admin-page-header">
|
||||
<h2>{$page.data.aliases?.page || '页面'}管理</h2>
|
||||
<a href="/admin/pages/new" class="btn btn-primary">新建{$page.data.aliases?.page || '页面'}</a>
|
||||
</div>
|
||||
|
||||
|
||||
{#if data.pages.length > 0}
|
||||
<div class="batch-actions">
|
||||
{#if selectedIds.length > 0}
|
||||
<span class="selected-count">已选择 {selectedIds.length} 项</span>
|
||||
<form action="?/delete" method="POST" class="inline-form" onsubmit={(e) => {
|
||||
if(!confirm(`确定要删除选中的 ${selectedIds.length} 篇${$page.data.aliases?.page || '项目'}吗?操作不可恢复。`)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}>
|
||||
{#each selectedIds as id}
|
||||
<input type="hidden" name="ids" value={id} />
|
||||
{/each}
|
||||
<button type="submit" class="btn btn-danger btn-sm">批量删除</button>
|
||||
</form>
|
||||
{:else}
|
||||
<span class="selected-count empty">未选择项目</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="table-container">
|
||||
{#if data.pages.length === 0}
|
||||
<div class="empty-state">
|
||||
<p>还没有发布任何{$page.data.aliases?.page || '页面'},现在就开始写作吧!</p>
|
||||
</div>
|
||||
{:else}
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="checkbox-col">
|
||||
<input type="checkbox" checked={isAllSelected} onchange={toggleAll} />
|
||||
</th>
|
||||
<th>ID</th>
|
||||
<th>标题</th>
|
||||
<th>别名 (Slug)</th>
|
||||
<th>作者</th>
|
||||
<th>发布时间</th>
|
||||
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each data.pages as pageItem}
|
||||
<tr>
|
||||
<td class="checkbox-col">
|
||||
<input type="checkbox" name="ids" value={pageItem.id.toString()} bind:group={selectedIds} />
|
||||
</td>
|
||||
<td>{pageItem.id}</td>
|
||||
<td>
|
||||
<a href="/admin/pages/{pageItem.id}" class="title-link"><strong>{pageItem.title}</strong></a>
|
||||
<a href="/{pageItem.slug}" target="_blank" class="preview-link" title="前台预览">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path><polyline points="15 3 21 3 21 9"></polyline><line x1="10" y1="14" x2="21" y2="3"></line></svg>
|
||||
</a>
|
||||
</td>
|
||||
<td><span class="badge">{pageItem.slug}</span></td>
|
||||
<td>{pageItem.author || '未知'}</td>
|
||||
<td>{pageItem.dateStr}</td>
|
||||
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.admin-page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 25px;
|
||||
border-bottom: 2px solid var(--text-color);
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.admin-page-header h2 {
|
||||
margin: 0;
|
||||
font-size: 2rem;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 8px 18px;
|
||||
text-decoration: none;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
cursor: pointer;
|
||||
border: 2px solid var(--text-color);
|
||||
background-color: transparent;
|
||||
color: var(--text-color);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--text-color);
|
||||
color: var(--bg-color, #ffffff);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: var(--accent-color, #419b38);
|
||||
border-color: var(--accent-color, #419b38);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
background: transparent;
|
||||
border: 2px solid var(--text-color);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.data-table th, .data-table td {
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--border-color, rgba(0,0,0,0.08));
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
background-color: transparent;
|
||||
color: var(--text-color);
|
||||
font-weight: 700;
|
||||
font-size: 0.95rem;
|
||||
border-bottom: 2px solid var(--text-color);
|
||||
}
|
||||
|
||||
.data-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.data-table tbody tr:hover {
|
||||
background-color: rgba(0,0,0,0.02);
|
||||
}
|
||||
|
||||
.preview-link {
|
||||
text-decoration: none;
|
||||
margin-left: 6px;
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.2s;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.preview-link:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.empty-state {
|
||||
padding: 50px 20px;
|
||||
text-align: center;
|
||||
color: var(--text-muted, rgba(0,0,0,0.6));
|
||||
}
|
||||
|
||||
.batch-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
margin-bottom: 15px;
|
||||
padding: 10px 15px;
|
||||
background: rgba(0,0,0,0.02);
|
||||
height: 54px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.selected-count {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.selected-count.empty {
|
||||
color: var(--text-muted, rgba(0,0,0,0.4));
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.checkbox-col {
|
||||
width: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.checkbox-col input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: var(--accent-color, #419b38);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.title-link {
|
||||
color: var(--text-color);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.title-link:hover {
|
||||
color: var(--accent-color, #419b38);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 4px;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: transparent;
|
||||
color: #d9534f;
|
||||
border-color: #d9534f;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background-color: #d9534f;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 4px 12px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,63 @@
|
||||
import { error, fail, redirect } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, platform }) => {
|
||||
const db = platform?.env?.DB;
|
||||
const pageId = params.id;
|
||||
|
||||
if (!db) {
|
||||
throw error(500, '数据库未连接');
|
||||
}
|
||||
|
||||
try {
|
||||
const page = await db.prepare(
|
||||
'SELECT id, title, slug, content FROM pages WHERE id = ?'
|
||||
).bind(pageId).first<{ id: number; title: string; slug: string; content: string; }>();
|
||||
|
||||
if (!page) {
|
||||
throw error(404, '页面不存在');
|
||||
}
|
||||
|
||||
return {
|
||||
page
|
||||
};
|
||||
} catch (err: any) {
|
||||
if (err?.status === 404) throw err;
|
||||
console.error('Error loading page for edit:', err);
|
||||
throw error(500, '读取页面详情失败');
|
||||
}
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
update: async ({ params, request, platform }) => {
|
||||
const db = platform?.env?.DB;
|
||||
const pageId = params.id;
|
||||
|
||||
if (!db) return fail(500, { error: '数据库未连接' });
|
||||
|
||||
const formData = await request.formData();
|
||||
const title = formData.get('title')?.toString().trim();
|
||||
const slug = formData.get('slug')?.toString().trim();
|
||||
const content = formData.get('content')?.toString() || '';
|
||||
|
||||
if (!title || !slug || !content) {
|
||||
return fail(400, { error: '标题、URL 别名和正文为必填项' });
|
||||
}
|
||||
|
||||
try {
|
||||
await db.prepare(
|
||||
`UPDATE pages
|
||||
SET title = ?, slug = ?, content = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?`
|
||||
).bind(title, slug, content, pageId).run();
|
||||
} catch (err: any) {
|
||||
console.error('Error updating page:', err);
|
||||
if (err?.message?.includes('UNIQUE constraint failed')) {
|
||||
return fail(400, { error: 'URL 别名 (Slug) 已被其他页面占用' });
|
||||
}
|
||||
return fail(500, { error: '保存修改失败' });
|
||||
}
|
||||
|
||||
throw redirect(303, '/admin/pages');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,309 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import type { PageData, ActionData } from './$types';
|
||||
import MarkdownRenderer from '$lib/components/MarkdownRenderer.svelte';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
|
||||
let title = $state(data.page.title);
|
||||
let slug = $state(data.page.slug);
|
||||
let content = $state(data.page.content);
|
||||
let mode = $state<'edit' | 'preview'>('edit');
|
||||
</script>
|
||||
|
||||
<div class="admin-page-header">
|
||||
<h2>编辑{$page.data.aliases?.page || '页面'}</h2>
|
||||
<div class="header-actions">
|
||||
<a href="/{data.page.slug}" target="_blank" class="btn btn-secondary" style="margin-right: 12px;">前台预览</a>
|
||||
<a href="/admin/pages" class="btn btn-secondary">← 返回</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if form?.error}
|
||||
<div class="error-alert">
|
||||
{form.error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form action="?/update" method="POST" class="post-form">
|
||||
<div class="form-grid">
|
||||
<div class="form-main">
|
||||
<div class="form-group">
|
||||
<label for="title">标题 *</label>
|
||||
<input
|
||||
type="text"
|
||||
id="title"
|
||||
name="title"
|
||||
bind:value={title}
|
||||
placeholder="输入标题..."
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="editor-header">
|
||||
<label for="content">正文 *</label>
|
||||
<div class="editor-tabs">
|
||||
<button type="button" class="tab-btn {mode === 'edit' ? 'active' : ''}" onclick={() => mode = 'edit'}>编辑</button>
|
||||
<button type="button" class="tab-btn {mode === 'preview' ? 'active' : ''}" onclick={() => mode = 'preview'}>预览</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-body">
|
||||
{#if mode === 'edit'}
|
||||
<textarea
|
||||
id="content"
|
||||
name="content"
|
||||
bind:value={content}
|
||||
rows="16"
|
||||
style="min-height: 400px;"
|
||||
placeholder="支持 Markdown 格式..."
|
||||
required
|
||||
></textarea>
|
||||
{:else}
|
||||
<div class="preview-area">
|
||||
<MarkdownRenderer {content} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-sidebar">
|
||||
<div class="sidebar-box">
|
||||
<h3>更新</h3>
|
||||
<button type="submit" class="btn btn-primary submit-btn">保存修改</button>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-box">
|
||||
<h3>别名 *</h3>
|
||||
<input
|
||||
type="text"
|
||||
id="slug"
|
||||
name="slug"
|
||||
class="sidebar-input"
|
||||
bind:value={slug}
|
||||
placeholder="hello-world"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<style>
|
||||
.admin-page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 25px;
|
||||
border-bottom: 2px solid var(--text-color);
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.admin-page-header h2 {
|
||||
margin: 0;
|
||||
font-size: 2rem;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 10px 20px;
|
||||
text-decoration: none;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--text-color);
|
||||
background-color: transparent;
|
||||
color: var(--text-color);
|
||||
font-family: inherit;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--text-color);
|
||||
color: var(--bg-color, #ffffff);
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: var(--accent-color, #419b38);
|
||||
border-color: var(--accent-color, #419b38);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
border-color: var(--accent-color, #419b38);
|
||||
color: var(--accent-color, #419b38);
|
||||
}
|
||||
|
||||
.error-alert {
|
||||
padding: 12px 16px;
|
||||
background-color: transparent;
|
||||
border-left: 4px solid #d9534f;
|
||||
color: #d9534f;
|
||||
margin-bottom: 30px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.editor-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.editor-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 4px 12px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: rgba(0, 0, 0, 0.4);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.tab-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
color: var(--accent-color, #419b38);
|
||||
background: rgba(65, 155, 56, 0.1);
|
||||
}
|
||||
|
||||
.preview-area {
|
||||
padding: 20px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.12);
|
||||
background: rgba(0, 0, 0, 0.015);
|
||||
min-height: 400px;
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 280px;
|
||||
gap: 40px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.form-main {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-sidebar {
|
||||
position: sticky;
|
||||
top: 80px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 30px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.form-sidebar {
|
||||
position: static;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-weight: 700;
|
||||
margin-bottom: 6px;
|
||||
color: var(--text-color);
|
||||
font-size: 0.9rem;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.form-group input[type="text"],
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.12);
|
||||
border-radius: 0;
|
||||
background: rgba(0, 0, 0, 0.015);
|
||||
font-family: inherit;
|
||||
font-size: 1.05rem;
|
||||
color: var(--text-color);
|
||||
box-sizing: border-box;
|
||||
transition: border-color 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
resize: vertical;
|
||||
line-height: 1.8;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.form-group input[type="text"]:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-color, #419b38);
|
||||
box-shadow: 0 0 0 3px rgba(65, 155, 56, 0.08);
|
||||
}
|
||||
|
||||
.sidebar-input:focus {
|
||||
outline: none;
|
||||
border-bottom-color: var(--accent-color, #419b38);
|
||||
}
|
||||
|
||||
.sidebar-input {
|
||||
width: 100%;
|
||||
padding: 10px 0;
|
||||
border: none;
|
||||
border-bottom: 2px solid rgba(0, 0, 0, 0.1);
|
||||
background: transparent;
|
||||
font-family: inherit;
|
||||
font-size: 1rem;
|
||||
color: var(--text-color);
|
||||
box-sizing: border-box;
|
||||
transition: border-bottom-color 0.3s ease;
|
||||
}
|
||||
|
||||
.sidebar-box {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sidebar-box h3 {
|
||||
margin: 0 0 20px 0;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-color);
|
||||
border-bottom: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,37 @@
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
return {};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async ({ request, platform }) => {
|
||||
const db = platform?.env?.DB;
|
||||
if (!db) return fail(500, { error: '数据库未连接' });
|
||||
|
||||
const formData = await request.formData();
|
||||
const title = formData.get('title')?.toString().trim();
|
||||
const slug = formData.get('slug')?.toString().trim();
|
||||
const content = formData.get('content')?.toString() || '';
|
||||
|
||||
if (!title || !slug || !content) {
|
||||
return fail(400, { error: '标题、URL 别名和正文为必填项' });
|
||||
}
|
||||
|
||||
try {
|
||||
await db.prepare(
|
||||
`INSERT INTO pages (title, slug, content, author_id, published_at, updated_at)
|
||||
VALUES (?, ?, ?, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`
|
||||
).bind(title, slug, content).run();
|
||||
} catch (err: any) {
|
||||
console.error('Error creating page:', err);
|
||||
if (err?.message?.includes('UNIQUE constraint failed')) {
|
||||
return fail(400, { error: 'URL 别名 (Slug) 已被占用,请使用其他别名' });
|
||||
}
|
||||
return fail(500, { error: '保存页面失败' });
|
||||
}
|
||||
|
||||
throw redirect(303, '/admin/pages');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,312 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import type { ActionData } from './$types';
|
||||
import MarkdownRenderer from '$lib/components/MarkdownRenderer.svelte';
|
||||
|
||||
let { form }: { form: ActionData } = $props();
|
||||
|
||||
let title = $state('');
|
||||
let slug = $state('');
|
||||
let content = $state('');
|
||||
let mode = $state<'edit' | 'preview'>('edit');
|
||||
|
||||
// 自动从标题生成 Slug 简易逻辑
|
||||
function handleTitleInput() {
|
||||
if (!slug) {
|
||||
slug = title
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\u4e00-\u9fa5\s-]/g, '')
|
||||
.replace(/\s+/g, '-');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="admin-page-header">
|
||||
<h2>新建独立{$page.data.aliases?.page || '页面'}</h2>
|
||||
<a href="/admin/pages" class="btn btn-secondary">← 返回</a>
|
||||
</div>
|
||||
|
||||
{#if form?.error}
|
||||
<div class="error-alert">
|
||||
{form.error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form method="POST" class="post-form">
|
||||
<div class="form-grid">
|
||||
<div class="form-main">
|
||||
<div class="form-group">
|
||||
<label for="title">标题 *</label>
|
||||
<input
|
||||
type="text"
|
||||
id="title"
|
||||
name="title"
|
||||
bind:value={title}
|
||||
oninput={handleTitleInput}
|
||||
placeholder="输入标题..."
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="editor-header">
|
||||
<label for="content">正文 *</label>
|
||||
<div class="editor-tabs">
|
||||
<button type="button" class="tab-btn {mode === 'edit' ? 'active' : ''}" onclick={() => mode = 'edit'}>编辑</button>
|
||||
<button type="button" class="tab-btn {mode === 'preview' ? 'active' : ''}" onclick={() => mode = 'preview'}>预览</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-body">
|
||||
{#if mode === 'edit'}
|
||||
<textarea
|
||||
id="content"
|
||||
name="content"
|
||||
bind:value={content}
|
||||
rows="16"
|
||||
style="min-height: 400px;"
|
||||
placeholder="支持 Markdown 格式..."
|
||||
required
|
||||
></textarea>
|
||||
{:else}
|
||||
<div class="preview-area">
|
||||
<MarkdownRenderer {content} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-sidebar">
|
||||
<div class="sidebar-box">
|
||||
<h3>发布</h3>
|
||||
<button type="submit" class="btn btn-primary submit-btn">发布</button>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-box">
|
||||
<h3>别名 *</h3>
|
||||
<input
|
||||
type="text"
|
||||
id="slug"
|
||||
name="slug"
|
||||
class="sidebar-input"
|
||||
bind:value={slug}
|
||||
placeholder="about"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<style>
|
||||
.admin-page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 25px;
|
||||
border-bottom: 2px solid var(--text-color);
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.admin-page-header h2 {
|
||||
margin: 0;
|
||||
font-size: 2rem;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 10px 20px;
|
||||
text-decoration: none;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--text-color);
|
||||
background-color: transparent;
|
||||
color: var(--text-color);
|
||||
font-family: inherit;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--text-color);
|
||||
color: var(--bg-color, #ffffff);
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: var(--accent-color, #419b38);
|
||||
border-color: var(--accent-color, #419b38);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
border-color: var(--accent-color, #419b38);
|
||||
color: var(--accent-color, #419b38);
|
||||
}
|
||||
|
||||
.error-alert {
|
||||
padding: 12px 16px;
|
||||
background-color: transparent;
|
||||
border-left: 4px solid #d9534f;
|
||||
color: #d9534f;
|
||||
margin-bottom: 30px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.editor-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.editor-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 4px 12px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: rgba(0, 0, 0, 0.4);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.tab-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
color: var(--accent-color, #419b38);
|
||||
background: rgba(65, 155, 56, 0.1);
|
||||
}
|
||||
|
||||
.preview-area {
|
||||
padding: 20px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.12);
|
||||
background: rgba(0, 0, 0, 0.015);
|
||||
min-height: 400px;
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 280px;
|
||||
gap: 40px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.form-main {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-sidebar {
|
||||
position: sticky;
|
||||
top: 80px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 30px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.form-sidebar {
|
||||
position: static;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-weight: 700;
|
||||
margin-bottom: 6px;
|
||||
color: var(--text-color);
|
||||
font-size: 0.9rem;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.form-group input[type="text"],
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.12);
|
||||
border-radius: 0;
|
||||
background: rgba(0, 0, 0, 0.015);
|
||||
font-family: inherit;
|
||||
font-size: 1.05rem;
|
||||
color: var(--text-color);
|
||||
box-sizing: border-box;
|
||||
transition: border-color 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
resize: vertical;
|
||||
line-height: 1.8;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.form-group input[type="text"]:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-color, #419b38);
|
||||
box-shadow: 0 0 0 3px rgba(65, 155, 56, 0.08);
|
||||
}
|
||||
|
||||
.sidebar-input:focus {
|
||||
outline: none;
|
||||
border-bottom-color: var(--accent-color, #419b38);
|
||||
}
|
||||
|
||||
.sidebar-input {
|
||||
width: 100%;
|
||||
padding: 10px 0;
|
||||
border: none;
|
||||
border-bottom: 2px solid rgba(0, 0, 0, 0.1);
|
||||
background: transparent;
|
||||
font-family: inherit;
|
||||
font-size: 1rem;
|
||||
color: var(--text-color);
|
||||
box-sizing: border-box;
|
||||
transition: border-bottom-color 0.3s ease;
|
||||
}
|
||||
|
||||
.sidebar-box {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sidebar-box h3 {
|
||||
margin: 0 0 20px 0;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-color);
|
||||
border-bottom: none;
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user