1038 lines
26 KiB
Svelte
1038 lines
26 KiB
Svelte
<script lang="ts">
|
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
/* eslint-disable @typescript-eslint/no-unused-vars */
|
|
import { resolve } from '$app/paths';
|
|
import { page } from '$app/stores';
|
|
import type { PageData, ActionData } from './$types';
|
|
import { deserialize } from '$app/forms';
|
|
import MarkdownRenderer from '$lib/components/MarkdownRenderer.svelte';
|
|
import FeaturedImagePicker from '$lib/components/admin/FeaturedImagePicker.svelte';
|
|
import SelectDropdown from '$lib/components/admin/SelectDropdown.svelte';
|
|
import { parseHugoMarkdown } from '$lib/hugoParser';
|
|
import { untrack } from 'svelte';
|
|
|
|
let { data, form }: { data: PageData; form: ActionData } = $props();
|
|
const initialData = $state.snapshot(untrack(() => data));
|
|
|
|
let title = $state('');
|
|
let slug = $state('');
|
|
let content = $state('');
|
|
let cover = $state('');
|
|
let mode = $state<'edit' | 'preview'>('edit');
|
|
let publishedAt = $state('');
|
|
let updatedAt = $state('');
|
|
let selectedCategoryId = $state<string | number>('');
|
|
let selectedSeriesId = $state<string | number>('');
|
|
let seriesWeight = $state<string | number>('');
|
|
let fileInput: HTMLInputElement;
|
|
|
|
let tagsList = $state(initialData.tags || []);
|
|
let seriesList = $state(initialData.series || []);
|
|
|
|
// 自动从标题生成 Slug 简易逻辑
|
|
function handleTitleInput() {
|
|
if (!slug) {
|
|
slug = title
|
|
.toLowerCase()
|
|
.replace(/[^\w\u4e00-\u9fa5\s-]/g, '')
|
|
.replace(/\s+/g, '-');
|
|
}
|
|
}
|
|
|
|
let selectedTags = $state<number[]>([]);
|
|
function toggleTag(tagId: number) {
|
|
if (selectedTags.includes(tagId)) {
|
|
selectedTags = selectedTags.filter((id) => id !== tagId);
|
|
} else {
|
|
selectedTags = [...selectedTags, tagId];
|
|
}
|
|
}
|
|
|
|
let newTagInput = $state('');
|
|
let isAddingTag = $state(false);
|
|
let showTagDropdown = $state(false);
|
|
|
|
let filteredTags = $derived(
|
|
(() => {
|
|
const input = newTagInput.trim().toLowerCase();
|
|
const available = tagsList.filter((t) => !selectedTags.includes(t.id));
|
|
if (!input) return available.slice(0, 5);
|
|
return available.filter((t) => t.name.toLowerCase().includes(input));
|
|
})()
|
|
);
|
|
|
|
async function handleAddTag(e: KeyboardEvent) {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault(); // 防止整个表单提交
|
|
const val = newTagInput.trim();
|
|
if (!val || isAddingTag) return;
|
|
|
|
// 检查是否已存在同名标签
|
|
const existingTag = tagsList.find((t) => t.name.toLowerCase() === val.toLowerCase());
|
|
if (existingTag) {
|
|
if (!selectedTags.includes(existingTag.id)) {
|
|
selectedTags = [...selectedTags, existingTag.id];
|
|
}
|
|
newTagInput = '';
|
|
showTagDropdown = false;
|
|
return;
|
|
}
|
|
|
|
isAddingTag = true;
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append('name', val);
|
|
|
|
const res = await fetch('/admin/tags?/create', {
|
|
method: 'POST',
|
|
body: formData,
|
|
headers: {
|
|
'x-sveltekit-action': 'true'
|
|
}
|
|
});
|
|
const result = deserialize(await res.text()) as any;
|
|
|
|
if (result.type === 'success' && result.data?.tag) {
|
|
// 将新标签加入列表
|
|
tagsList = [...tagsList, result.data.tag];
|
|
// 自动选中新标签
|
|
selectedTags = [...selectedTags, result.data.tag.id];
|
|
}
|
|
} catch (err: any) {
|
|
console.error('Failed to create tag', err);
|
|
} finally {
|
|
isAddingTag = false;
|
|
newTagInput = '';
|
|
showTagDropdown = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Markdown 工具栏功能
|
|
function insertAtCursor(before: string, after: string = '', defaultText: string = '') {
|
|
const textarea = document.getElementById('content') as HTMLTextAreaElement;
|
|
if (!textarea) return;
|
|
|
|
const start = textarea.selectionStart;
|
|
const end = textarea.selectionEnd;
|
|
const selectedText = textarea.value.substring(start, end);
|
|
|
|
const insertion = selectedText ? before + selectedText + after : before + defaultText + after;
|
|
|
|
content = textarea.value.substring(0, start) + insertion + textarea.value.substring(end);
|
|
|
|
// 恢复焦点并选中文字
|
|
setTimeout(() => {
|
|
textarea.focus();
|
|
if (selectedText) {
|
|
textarea.setSelectionRange(start, start + insertion.length);
|
|
} else {
|
|
textarea.setSelectionRange(
|
|
start + before.length,
|
|
start + before.length + defaultText.length
|
|
);
|
|
}
|
|
}, 0);
|
|
}
|
|
|
|
function insertMore() {
|
|
insertAtCursor('\n<!--more-->\n', '', '');
|
|
}
|
|
|
|
let showAdmonitionMenu = $state(false);
|
|
const admonitionTypes = [
|
|
{ type: 'note', title: '注意', label: '注意 (Note)' },
|
|
{ type: 'abstract', title: '摘要', label: '摘要 (Abstract)' },
|
|
{ type: 'info', title: '信息', label: '信息 (Info)' },
|
|
{ type: 'tip', title: '技巧', label: '技巧 (Tip)' },
|
|
{ type: 'success', title: '成功', label: '成功 (Success)' },
|
|
{ type: 'question', title: '问题', label: '问题 (Question)' },
|
|
{ type: 'warning', title: '警告', label: '警告 (Warning)' },
|
|
{ type: 'failure', title: '失败', label: '失败 (Failure)' },
|
|
{ type: 'danger', title: '危险', label: '危险 (Danger)' },
|
|
{ type: 'bug', title: 'Bug', label: 'Bug' },
|
|
{ type: 'example', title: '示例', label: '示例 (Example)' },
|
|
{ type: 'quote', title: '引用', label: '引用 (Quote)' }
|
|
];
|
|
|
|
function insertAdmonition(type: string, title: string) {
|
|
const openTag = `{{< admonition ${type} "${title}" >}}\n`;
|
|
const closeTag = `\n{{< /admonition >}}`;
|
|
insertAtCursor('\n' + openTag, closeTag, '在这里输入提示内容...');
|
|
showAdmonitionMenu = false;
|
|
}
|
|
|
|
async function handleImportMarkdown(e: Event) {
|
|
const target = e.target as HTMLInputElement;
|
|
const file = target.files?.[0];
|
|
if (!file) return;
|
|
|
|
try {
|
|
const rawText = await file.text();
|
|
const imported = parseHugoMarkdown(rawText, file.name);
|
|
|
|
title = imported.title;
|
|
slug = imported.slug;
|
|
content = imported.content;
|
|
if (imported.cover) cover = imported.cover;
|
|
publishedAt = imported.published_at;
|
|
updatedAt = imported.updated_at;
|
|
|
|
// 1. 分类匹配:严格遵守“遇到全新未知的分类名跳过,由用户决定”的规范
|
|
if (imported.categories.length > 0) {
|
|
const targetName = imported.categories[0].trim().toLowerCase();
|
|
const matchedCat = data.categories.find((c) => c.name.trim().toLowerCase() === targetName);
|
|
if (matchedCat) {
|
|
selectedCategoryId = matchedCat.id;
|
|
}
|
|
}
|
|
|
|
selectedSeriesId = '';
|
|
seriesWeight = imported.series_weight ?? '';
|
|
if (imported.series.length > 0) {
|
|
if (imported.series.length > 1) {
|
|
alert(
|
|
`检测到多个系列:${imported.series.join('、')}。BlogFlare 每篇文章只能属于一个系列,本次将选择第一个系列。`
|
|
);
|
|
}
|
|
|
|
const seriesName = imported.series[0].trim();
|
|
let matchedSeries = seriesList.find(
|
|
(item) => item.name.trim().toLowerCase() === seriesName.toLowerCase()
|
|
);
|
|
if (!matchedSeries && seriesName) {
|
|
const formData = new FormData();
|
|
formData.append('name', seriesName);
|
|
const response = await fetch('/admin/series?/create', {
|
|
method: 'POST',
|
|
body: formData,
|
|
headers: { 'x-sveltekit-action': 'true' }
|
|
});
|
|
|
|
const actionResult = deserialize(await response.text()) as any;
|
|
if (actionResult.type === 'success' && actionResult.data?.series) {
|
|
const createdSeries = actionResult.data.series as {
|
|
id: number;
|
|
name: string;
|
|
slug: string;
|
|
};
|
|
matchedSeries = createdSeries;
|
|
seriesList = [...seriesList, createdSeries];
|
|
} else {
|
|
alert(
|
|
actionResult.data?.error ||
|
|
`无法创建系列“${seriesName}”,请在系列管理中检查名称和 URL 别名。`
|
|
);
|
|
}
|
|
}
|
|
if (matchedSeries) selectedSeriesId = matchedSeries.id;
|
|
}
|
|
|
|
// 3. 标签匹配:库有即点取,不知则自主申请向库内补充建好
|
|
for (const tagName of imported.tags) {
|
|
const val = tagName.trim();
|
|
if (!val) continue;
|
|
const existingTag = tagsList.find((t) => t.name.toLowerCase() === val.toLowerCase());
|
|
if (existingTag) {
|
|
if (!selectedTags.includes(existingTag.id)) {
|
|
selectedTags = [...selectedTags, existingTag.id];
|
|
}
|
|
} else {
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append('name', val);
|
|
const res = await fetch('/admin/tags?/create', {
|
|
method: 'POST',
|
|
body: formData,
|
|
headers: { 'x-sveltekit-action': 'true' }
|
|
});
|
|
|
|
const actionRes = deserialize(await res.text()) as any;
|
|
if (actionRes.type === 'success' && actionRes.data?.tag) {
|
|
tagsList = [...tagsList, actionRes.data.tag];
|
|
selectedTags = [...selectedTags, actionRes.data.tag.id];
|
|
}
|
|
} catch (err: any) {
|
|
console.error('新建导入对应新标签时出错:', err);
|
|
}
|
|
}
|
|
}
|
|
target.value = '';
|
|
} catch (err: any) {
|
|
console.error('导入转换出现错误:');
|
|
alert('解析文档内容受困,请稍后检查所填文书');
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div class="admin-page-header">
|
|
<h2>新{$page.data.aliases?.post || '文章'}</h2>
|
|
<div class="header-actions">
|
|
<button type="button" class="btn btn-secondary" onclick={() => fileInput?.click()}
|
|
>导入 Markdown</button
|
|
>
|
|
<a href={resolve('/admin/posts')} class="btn btn-secondary">← 返回</a>
|
|
</div>
|
|
</div>
|
|
<input
|
|
type="file"
|
|
bind:this={fileInput}
|
|
accept=".md,.markdown,.txt"
|
|
style="display:none"
|
|
onchange={handleImportMarkdown}
|
|
/>
|
|
|
|
{#if form?.error}
|
|
<div class="error-alert">
|
|
{form.error}
|
|
</div>
|
|
{/if}
|
|
|
|
<form method="POST" class="post-form">
|
|
<input type="hidden" name="published_at" value={publishedAt} />
|
|
<input type="hidden" name="updated_at" value={updatedAt} />
|
|
<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-header-right">
|
|
{#if mode === 'edit'}
|
|
<div class="md-toolbar">
|
|
<button
|
|
type="button"
|
|
class="md-btn"
|
|
onclick={() => insertAtCursor('**', '**', '粗体')}
|
|
title="加粗"
|
|
><svg viewBox="0 0 24 24"
|
|
><path d="M6 4h9a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"></path><path
|
|
d="M6 12h10a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"
|
|
></path></svg
|
|
></button
|
|
>
|
|
<button
|
|
type="button"
|
|
class="md-btn"
|
|
onclick={() => insertAtCursor('*', '*', '斜体')}
|
|
title="斜体"
|
|
><svg viewBox="0 0 24 24"
|
|
><line x1="19" y1="4" x2="10" y2="4"></line><line x1="14" y1="20" x2="5" y2="20"
|
|
></line><line x1="15" y1="4" x2="9" y2="20"></line></svg
|
|
></button
|
|
>
|
|
<button
|
|
type="button"
|
|
class="md-btn"
|
|
onclick={() => insertAtCursor('~~', '~~', '删除线')}
|
|
title="删除线"
|
|
><svg viewBox="0 0 24 24"
|
|
><path d="M16 4H9a3 3 0 0 0-2.83 4"></path><path d="M14 12a4 4 0 0 1 0 8H6"
|
|
></path><line x1="4" y1="12" x2="20" y2="12"></line></svg
|
|
></button
|
|
>
|
|
<button
|
|
type="button"
|
|
class="md-btn"
|
|
onclick={() => insertAtCursor('### ', '', '标题')}
|
|
title="标题"
|
|
><svg viewBox="0 0 24 24"><path d="M4 12h16M4 18V6M20 18V6"></path></svg></button
|
|
>
|
|
<div class="md-divider"></div>
|
|
<button
|
|
type="button"
|
|
class="md-btn"
|
|
onclick={() => insertAtCursor('- ', '', '列表项')}
|
|
title="无序列表"
|
|
><svg viewBox="0 0 24 24"
|
|
><line x1="8" y1="6" x2="21" y2="6"></line><line x1="8" y1="12" x2="21" y2="12"
|
|
></line><line x1="8" y1="18" x2="21" y2="18"></line><line
|
|
x1="3"
|
|
y1="6"
|
|
x2="3.01"
|
|
y2="6"
|
|
></line><line x1="3" y1="12" x2="3.01" y2="12"></line><line
|
|
x1="3"
|
|
y1="18"
|
|
x2="3.01"
|
|
y2="18"
|
|
></line></svg
|
|
></button
|
|
>
|
|
<button
|
|
type="button"
|
|
class="md-btn"
|
|
onclick={() => insertAtCursor('[', '](https://)', '链接文字')}
|
|
title="链接"
|
|
><svg viewBox="0 0 24 24"
|
|
><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"
|
|
></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"
|
|
></path></svg
|
|
></button
|
|
>
|
|
<button
|
|
type="button"
|
|
class="md-btn"
|
|
onclick={() => insertAtCursor('', '图片描述')}
|
|
title="图片"
|
|
><svg viewBox="0 0 24 24"
|
|
><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><polygon
|
|
points="6 21, 10 13, 13 16.5, 16.5 9.5, 19 21"
|
|
style="fill: currentColor; stroke: currentColor; stroke-linejoin: round; stroke-width: 1.5;"
|
|
></polygon></svg
|
|
></button
|
|
>
|
|
<button
|
|
type="button"
|
|
class="md-btn"
|
|
onclick={() => insertAtCursor('```\n', '\n```', 'code')}
|
|
title="代码块"
|
|
><svg viewBox="0 0 24 24"
|
|
><polyline points="16 18 22 12 16 6"></polyline><polyline points="8 6 2 12 8 18"
|
|
></polyline></svg
|
|
></button
|
|
>
|
|
<div class="md-divider"></div>
|
|
<div class="toolbar-dropdown-wrapper">
|
|
{#if showAdmonitionMenu}
|
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
<!-- svelte-ignore a11y_interactive_supports_focus -->
|
|
<div
|
|
class="dropdown-overlay"
|
|
onclick={() => (showAdmonitionMenu = false)}
|
|
role="button"
|
|
></div>
|
|
{/if}
|
|
<button
|
|
type="button"
|
|
class="md-btn"
|
|
onclick={() => (showAdmonitionMenu = !showAdmonitionMenu)}
|
|
title="提示框 (Admonition)"
|
|
><svg viewBox="0 0 24 24"
|
|
><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"
|
|
></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg
|
|
></button
|
|
>
|
|
{#if showAdmonitionMenu}
|
|
<div class="toolbar-dropdown">
|
|
{#each admonitionTypes as item (item.type)}
|
|
<button
|
|
type="button"
|
|
class="dropdown-item"
|
|
onclick={() => insertAdmonition(item.type, item.title)}
|
|
>
|
|
{item.label}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
<button type="button" class="md-btn" onclick={insertMore} title="插入摘要分割线"
|
|
><svg viewBox="0 0 24 24"
|
|
><polyline points="5 5 5 9 19 9 19 5"></polyline><polyline
|
|
points="5 19 5 15 19 15 19 19"
|
|
></polyline><line
|
|
x1="4"
|
|
y1="12"
|
|
x2="20"
|
|
y2="12"
|
|
stroke-dasharray="3 3"
|
|
stroke-linecap="butt"
|
|
></line></svg
|
|
></button
|
|
>
|
|
</div>
|
|
{/if}
|
|
<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>
|
|
|
|
<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 publish-btn"
|
|
>发布{$page.data.aliases?.post || '文章'}</button
|
|
>
|
|
</div>
|
|
|
|
<FeaturedImagePicker bind:cover adaptiveHeight previewable />
|
|
|
|
<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 class="sidebar-box">
|
|
<h3>分类</h3>
|
|
<SelectDropdown
|
|
name="category_id"
|
|
label="分类"
|
|
options={data.categories.map((category) => ({
|
|
value: category.id,
|
|
label: category.name
|
|
}))}
|
|
bind:value={selectedCategoryId}
|
|
/>
|
|
</div>
|
|
|
|
<div class="sidebar-box">
|
|
<h3>{$page.data.aliases?.series || '系列'}</h3>
|
|
<SelectDropdown
|
|
name="series_id"
|
|
label={$page.data.aliases?.series || '系列'}
|
|
options={seriesList.map((series) => ({ value: series.id, label: series.name }))}
|
|
bind:value={selectedSeriesId}
|
|
/>
|
|
<input
|
|
type="number"
|
|
name="series_weight"
|
|
class="sidebar-input series-weight-input"
|
|
bind:value={seriesWeight}
|
|
placeholder="顺序(留空自动追加)"
|
|
disabled={!selectedSeriesId}
|
|
step="1"
|
|
/>
|
|
</div>
|
|
|
|
<div class="sidebar-box">
|
|
<h3>标签</h3>
|
|
<div class="selected-tags-vertical">
|
|
{#each selectedTags as tagId (tagId)}
|
|
{#if tagsList.find((t) => t.id === tagId)}
|
|
{@const tag = tagsList.find((t) => t.id === tagId)}
|
|
<div class="selected-tag-row">
|
|
<span>{tag?.name}</span>
|
|
<button
|
|
type="button"
|
|
class="remove-tag-btn"
|
|
onclick={() => toggleTag(tagId)}
|
|
title="移除标签"
|
|
><svg
|
|
viewBox="0 0 24 24"
|
|
width="14"
|
|
height="14"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
fill="none"
|
|
><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"
|
|
></line></svg
|
|
></button
|
|
>
|
|
<input type="hidden" name="tags" value={tagId} />
|
|
</div>
|
|
{/if}
|
|
{/each}
|
|
</div>
|
|
|
|
<div class="tag-input-container">
|
|
<input
|
|
type="text"
|
|
class="sidebar-input tag-input-new"
|
|
placeholder="+ 添加标签..."
|
|
bind:value={newTagInput}
|
|
onkeydown={handleAddTag}
|
|
onfocus={() => (showTagDropdown = true)}
|
|
onblur={() => setTimeout(() => (showTagDropdown = false), 200)}
|
|
disabled={isAddingTag}
|
|
autocomplete="off"
|
|
/>
|
|
|
|
{#if showTagDropdown && filteredTags.length > 0}
|
|
<div class="tag-autocomplete-dropdown">
|
|
{#each filteredTags as tag (tag.id || tag)}
|
|
<button
|
|
type="button"
|
|
class="tag-dropdown-item"
|
|
onclick={() => {
|
|
if (!selectedTags.includes(tag.id)) {
|
|
selectedTags = [...selectedTags, tag.id];
|
|
}
|
|
newTagInput = '';
|
|
showTagDropdown = false;
|
|
}}
|
|
>
|
|
{tag.name}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</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;
|
|
gap: 10px;
|
|
}
|
|
|
|
.header-actions .btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
box-sizing: border-box;
|
|
min-height: 42px;
|
|
margin: 0;
|
|
line-height: 1;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.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: center;
|
|
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: 16px;
|
|
z-index: 10;
|
|
}
|
|
|
|
@media (max-width: 900px) {
|
|
.form-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.form-sidebar {
|
|
position: static;
|
|
flex-direction: row;
|
|
flex-wrap: wrap;
|
|
gap: 16px;
|
|
margin-top: 20px;
|
|
}
|
|
}
|
|
|
|
.form-group {
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.form-group label {
|
|
display: block;
|
|
font-weight: 700;
|
|
margin-bottom: 12px;
|
|
color: var(--text-color);
|
|
font-size: 0.9rem;
|
|
letter-spacing: 1px;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.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-box {
|
|
background: transparent;
|
|
border: none;
|
|
border-radius: 0;
|
|
padding: 0;
|
|
}
|
|
|
|
.sidebar-box h3 {
|
|
margin: 0 0 12px 0;
|
|
font-size: 0.9rem;
|
|
font-weight: 700;
|
|
letter-spacing: 1px;
|
|
text-transform: uppercase;
|
|
color: var(--text-color);
|
|
border-bottom: none;
|
|
}
|
|
|
|
.series-weight-input {
|
|
margin-top: 10px;
|
|
}
|
|
|
|
.selected-tags-vertical {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
margin-bottom: 15px;
|
|
}
|
|
|
|
.selected-tag-row {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 6px 10px;
|
|
border: 1px solid var(--text-color);
|
|
background: transparent;
|
|
font-weight: 600;
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.remove-tag-btn {
|
|
background: transparent;
|
|
border: none;
|
|
color: var(--text-color);
|
|
font-size: 1.1rem;
|
|
cursor: pointer;
|
|
opacity: 0.7;
|
|
padding: 0 4px;
|
|
}
|
|
|
|
.remove-tag-btn:hover {
|
|
opacity: 1;
|
|
color: #d9534f;
|
|
}
|
|
|
|
.tag-input-container {
|
|
position: relative;
|
|
}
|
|
|
|
.tag-autocomplete-dropdown {
|
|
position: absolute;
|
|
top: 100%;
|
|
left: 0;
|
|
right: 0;
|
|
background: var(--bg-color);
|
|
border: 1px solid var(--text-color);
|
|
box-shadow: 2px 2px 0 rgba(0, 0, 0, 0.15);
|
|
z-index: 50;
|
|
max-height: 200px;
|
|
overflow-y: auto;
|
|
display: flex;
|
|
flex-direction: column;
|
|
margin-top: 4px;
|
|
}
|
|
|
|
.tag-dropdown-item {
|
|
background: transparent;
|
|
border: none;
|
|
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
|
padding: 8px 12px;
|
|
text-align: left;
|
|
font-family: inherit;
|
|
font-size: 0.85rem;
|
|
color: var(--text-color);
|
|
cursor: pointer;
|
|
font-weight: 600;
|
|
transition: all 0.2s ease;
|
|
}
|
|
|
|
.tag-dropdown-item:last-child {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.tag-dropdown-item:hover {
|
|
background: var(--text-color);
|
|
color: var(--bg-color);
|
|
}
|
|
|
|
.editor-header-right {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 15px;
|
|
}
|
|
|
|
.md-toolbar {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
}
|
|
|
|
.md-btn {
|
|
background: transparent;
|
|
border: none;
|
|
padding: 4px;
|
|
cursor: pointer;
|
|
color: var(--text-color);
|
|
opacity: 0.65;
|
|
transition: all 0.2s ease;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 28px;
|
|
height: 28px;
|
|
border-radius: 2px;
|
|
}
|
|
|
|
.md-btn:hover {
|
|
opacity: 1;
|
|
color: var(--accent-color, #419b38);
|
|
background-color: rgba(0, 0, 0, 0.03);
|
|
}
|
|
|
|
.md-btn svg {
|
|
width: 18px;
|
|
height: 18px;
|
|
stroke: currentColor;
|
|
stroke-width: 1.5;
|
|
stroke-linecap: round;
|
|
stroke-linejoin: round;
|
|
fill: none;
|
|
}
|
|
|
|
.md-divider {
|
|
width: 2px;
|
|
height: 16px;
|
|
background: var(--text-color);
|
|
opacity: 0.15;
|
|
margin: 0 4px;
|
|
}
|
|
|
|
.toolbar-dropdown-wrapper {
|
|
position: relative;
|
|
display: inline-block;
|
|
}
|
|
|
|
.dropdown-overlay {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
z-index: 99;
|
|
}
|
|
|
|
.toolbar-dropdown {
|
|
position: absolute;
|
|
top: 100%;
|
|
left: 0;
|
|
margin-top: 4px;
|
|
background: var(--bg-color, #ffffff);
|
|
border: 1px solid var(--text-color);
|
|
z-index: 100;
|
|
width: 140px;
|
|
box-shadow: 2px 2px 0 rgba(0, 0, 0, 0.15);
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.dropdown-item {
|
|
background: transparent;
|
|
border: none;
|
|
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
|
padding: 8px 12px;
|
|
text-align: left;
|
|
font-family: inherit;
|
|
font-size: 0.85rem;
|
|
color: var(--text-color);
|
|
cursor: pointer;
|
|
font-weight: 600;
|
|
transition: all 0.2s ease;
|
|
}
|
|
|
|
.dropdown-item:last-child {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.dropdown-item:hover {
|
|
background: var(--text-color);
|
|
color: var(--bg-color, #ffffff);
|
|
}
|
|
</style>
|