added admin control panel
This commit is contained in:
@@ -19,6 +19,23 @@ struct AppState {
|
||||
data_dir: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
struct SiteConfig {
|
||||
title: String,
|
||||
favicon: String,
|
||||
theme: String,
|
||||
}
|
||||
|
||||
impl Default for SiteConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
title: "Narlblog".to_string(),
|
||||
favicon: "/favicon.svg".to_string(),
|
||||
theme: "catppuccin-mocha".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct PostInfo {
|
||||
slug: String,
|
||||
@@ -30,6 +47,12 @@ struct PostDetail {
|
||||
content: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CreatePostRequest {
|
||||
slug: String,
|
||||
content: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ErrorResponse {
|
||||
error: String,
|
||||
@@ -67,7 +90,8 @@ async fn main() {
|
||||
.allow_headers(Any);
|
||||
|
||||
let app = Router::new()
|
||||
.route("/api/posts", get(list_posts))
|
||||
.route("/api/config", get(get_config).post(update_config))
|
||||
.route("/api/posts", get(list_posts).post(create_post))
|
||||
.route("/api/posts/{slug}", get(get_post))
|
||||
.route("/api/upload", post(upload_file))
|
||||
.nest_service("/uploads", ServeDir::new(uploads_dir))
|
||||
@@ -81,6 +105,73 @@ async fn main() {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
}
|
||||
|
||||
fn check_auth(headers: &HeaderMap, admin_token: &str) -> Result<(), (StatusCode, Json<ErrorResponse>)> {
|
||||
let auth_header = headers.get("Authorization").and_then(|h| h.to_str().ok());
|
||||
if auth_header != Some(&format!("Bearer {}", admin_token)) {
|
||||
return Err((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(ErrorResponse { error: "Unauthorized".to_string() }),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_config(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
let config_path = state.data_dir.join("config.json");
|
||||
let config = fs::read_to_string(&config_path)
|
||||
.ok()
|
||||
.and_then(|c| serde_json::from_str::<SiteConfig>(&c).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
Json(config)
|
||||
}
|
||||
|
||||
async fn update_config(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Json(payload): Json<SiteConfig>,
|
||||
) -> Result<Json<SiteConfig>, (StatusCode, Json<ErrorResponse>)> {
|
||||
check_auth(&headers, &state.admin_token)?;
|
||||
|
||||
let config_path = state.data_dir.join("config.json");
|
||||
let config_str = serde_json::to_string_pretty(&payload).map_err(|_| {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: "Serialization error".to_string() }))
|
||||
})?;
|
||||
|
||||
fs::write(&config_path, config_str).map_err(|_| {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: "Write error".to_string() }))
|
||||
})?;
|
||||
|
||||
Ok(Json(payload))
|
||||
}
|
||||
|
||||
async fn create_post(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Json(payload): Json<CreatePostRequest>,
|
||||
) -> Result<Json<PostDetail>, (StatusCode, Json<ErrorResponse>)> {
|
||||
check_auth(&headers, &state.admin_token)?;
|
||||
|
||||
// Validate slug to prevent directory traversal
|
||||
if payload.slug.contains('/') || payload.slug.contains('\\') || payload.slug.contains("..") {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ErrorResponse { error: "Invalid slug".to_string() }),
|
||||
));
|
||||
}
|
||||
|
||||
let file_path = state.data_dir.join("posts").join(format!("{}.md", payload.slug));
|
||||
|
||||
fs::write(&file_path, &payload.content).map_err(|_| {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: "Write error".to_string() }))
|
||||
})?;
|
||||
|
||||
Ok(Json(PostDetail {
|
||||
slug: payload.slug,
|
||||
content: payload.content,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn list_posts(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
let posts_dir = state.data_dir.join("posts");
|
||||
let mut posts = Vec::new();
|
||||
@@ -129,14 +220,7 @@ async fn upload_file(
|
||||
headers: HeaderMap,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<Json<UploadResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
// Basic Auth Check
|
||||
let auth_header = headers.get("Authorization").and_then(|h| h.to_str().ok());
|
||||
if auth_header != Some(&format!("Bearer {}", state.admin_token)) {
|
||||
return Err((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(ErrorResponse { error: "Unauthorized".to_string() }),
|
||||
));
|
||||
}
|
||||
check_auth(&headers, &state.admin_token)?;
|
||||
|
||||
while let Some(field) = multipart.next_field().await.map_err(|_| {
|
||||
(StatusCode::BAD_REQUEST, Json(ErrorResponse { error: "Multipart error".to_string() }))
|
||||
|
||||
@@ -6,6 +6,24 @@ interface Props {
|
||||
}
|
||||
|
||||
const { title } = Astro.props;
|
||||
|
||||
const API_URL = (typeof process !== 'undefined' ? process.env.PUBLIC_API_URL : import.meta.env.PUBLIC_API_URL) || 'http://localhost:3000';
|
||||
|
||||
let siteConfig = {
|
||||
title: "Narlblog",
|
||||
favicon: "/favicon.svg",
|
||||
theme: "catppuccin-mocha"
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/config`);
|
||||
if (res.ok) {
|
||||
siteConfig = await res.json();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch config:", e);
|
||||
}
|
||||
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
@@ -13,9 +31,9 @@ const { title } = Astro.props;
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" href={siteConfig.favicon} />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<title>{title} | Narlblog</title>
|
||||
<title>{title} | {siteConfig.title}</title>
|
||||
</head>
|
||||
<body class="bg-base text-text selection:bg-surface2 selection:text-text">
|
||||
<div class="fixed inset-0 z-[-1] bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-surface0 via-base to-base"></div>
|
||||
@@ -23,11 +41,12 @@ const { title } = Astro.props;
|
||||
<nav class="max-w-4xl mx-auto px-6 py-8">
|
||||
<header class="glass px-6 py-4 flex items-center justify-between">
|
||||
<a href="/" class="text-2xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-mauve to-blue">
|
||||
Narlblog
|
||||
{siteConfig.title}
|
||||
</a>
|
||||
<div class="flex gap-4">
|
||||
<a href="/" class="text-subtext0 hover:text-text transition-colors">Home</a>
|
||||
<a href="https://github.com/narl" target="_blank" class="text-subtext0 hover:text-text transition-colors">About</a>
|
||||
<a href="/admin" class="text-subtext0 hover:text-mauve transition-colors">Admin</a>
|
||||
</div>
|
||||
</header>
|
||||
</nav>
|
||||
@@ -37,7 +56,7 @@ const { title } = Astro.props;
|
||||
</main>
|
||||
|
||||
<footer class="max-w-4xl mx-auto px-6 py-8 text-center text-sm text-subtext0">
|
||||
© {new Date().getFullYear()} Narlblog. Built with Rust & Astro.
|
||||
© {new Date().getFullYear()} {siteConfig.title}. Built with Rust & Astro.
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
182
frontend/src/pages/admin/editor.astro
Normal file
182
frontend/src/pages/admin/editor.astro
Normal file
@@ -0,0 +1,182 @@
|
||||
---
|
||||
import Layout from '../../layouts/Layout.astro';
|
||||
---
|
||||
|
||||
<Layout title="Post Editor">
|
||||
<div class="glass p-12 mb-12" id="editor-content" style="display: none;">
|
||||
<header class="mb-12 border-b border-white/5 pb-12 flex justify-between items-center">
|
||||
<div>
|
||||
<a href="/admin" class="text-blue hover:text-sky transition-colors mb-8 inline-flex items-center gap-2 group">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="transition-transform group-hover:-translate-x-1"><path d="m15 18-6-6 6-6"/></svg>
|
||||
Back to Dashboard
|
||||
</a>
|
||||
<h1 class="text-4xl font-extrabold text-mauve mt-4">
|
||||
Write Post
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<button
|
||||
id="save-btn"
|
||||
class="bg-mauve text-crust font-bold py-3 px-8 rounded-lg hover:bg-pink transition-colors"
|
||||
>
|
||||
Save Post
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div id="alert" class="hidden p-4 rounded-lg mb-6"></div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<label for="slug" class="block text-sm font-medium text-subtext1 mb-2">Slug (e.g., my-first-post)</label>
|
||||
<input
|
||||
type="text"
|
||||
id="slug"
|
||||
required
|
||||
placeholder="my-awesome-post"
|
||||
class="w-full bg-crust border border-surface1 rounded-lg px-4 py-3 text-text focus:outline-none focus:border-mauve transition-colors font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex justify-between items-end mb-2">
|
||||
<label for="content" class="block text-sm font-medium text-subtext1">Markdown Content</label>
|
||||
|
||||
<div>
|
||||
<input type="file" id="file-upload" class="hidden" accept="image/*" />
|
||||
<label
|
||||
for="file-upload"
|
||||
class="cursor-pointer text-sm bg-surface0 hover:bg-surface1 text-blue px-4 py-2 rounded border border-surface1 transition-colors inline-flex items-center gap-2"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/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="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" x2="12" y1="3" y2="15"/></svg>
|
||||
Upload Image
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
id="content"
|
||||
required
|
||||
rows="20"
|
||||
placeholder="# Hello World Write your markdown here..."
|
||||
class="w-full bg-crust border border-surface1 rounded-lg px-4 py-4 text-text focus:outline-none focus:border-mauve transition-colors font-mono resize-y leading-relaxed"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const token = localStorage.getItem('admin_token');
|
||||
if (!token) {
|
||||
window.location.href = '/admin/login';
|
||||
} else {
|
||||
document.getElementById('editor-content')!.style.display = 'block';
|
||||
}
|
||||
|
||||
const slugInput = document.getElementById('slug') as HTMLInputElement;
|
||||
const contentInput = document.getElementById('content') as HTMLTextAreaElement;
|
||||
const fileInput = document.getElementById('file-upload') as HTMLInputElement;
|
||||
const saveBtn = document.getElementById('save-btn');
|
||||
|
||||
// Allow pre-filling if editing an existing post
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const editSlug = urlParams.get('edit');
|
||||
if (editSlug) {
|
||||
slugInput.value = editSlug;
|
||||
fetch(`/api/posts/${editSlug}`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.content) {
|
||||
contentInput.value = data.content;
|
||||
}
|
||||
})
|
||||
.catch(() => showAlert('Failed to load post for editing.', 'error'));
|
||||
}
|
||||
|
||||
// Handle File Upload
|
||||
fileInput?.addEventListener('change', async (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/upload', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const markdownImage = `\n\n`;
|
||||
|
||||
// Insert at cursor position
|
||||
const startPos = contentInput.selectionStart;
|
||||
const endPos = contentInput.selectionEnd;
|
||||
contentInput.value = contentInput.value.substring(0, startPos)
|
||||
+ markdownImage
|
||||
+ contentInput.value.substring(endPos, contentInput.value.length);
|
||||
|
||||
showAlert('Image uploaded and inserted!', 'success');
|
||||
} else {
|
||||
const err = await res.json();
|
||||
showAlert(`Upload failed: ${err.error}`, 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showAlert('Failed to upload file.', 'error');
|
||||
}
|
||||
|
||||
// Reset input so the same file can be selected again
|
||||
fileInput.value = '';
|
||||
});
|
||||
|
||||
// Handle Save
|
||||
saveBtn?.addEventListener('click', async () => {
|
||||
const payload = {
|
||||
slug: slugInput.value,
|
||||
content: contentInput.value
|
||||
};
|
||||
|
||||
if (!payload.slug || !payload.content) {
|
||||
showAlert('Slug and content are required.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/posts', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
showAlert('Post saved successfully!', 'success');
|
||||
} else {
|
||||
const err = await res.json();
|
||||
showAlert(`Error: ${err.error}`, 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showAlert('Failed to save post.', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
function showAlert(msg: string, type: 'success' | 'error') {
|
||||
const alertEl = document.getElementById('alert');
|
||||
if (alertEl) {
|
||||
alertEl.textContent = msg;
|
||||
alertEl.className = `p-4 rounded-lg mb-6 ${type === 'success' ? 'bg-green/20 text-green border border-green/30' : 'bg-red/20 text-red border border-red/30'}`;
|
||||
alertEl.classList.remove('hidden');
|
||||
|
||||
setTimeout(() => {
|
||||
alertEl.classList.add('hidden');
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</Layout>
|
||||
47
frontend/src/pages/admin/index.astro
Normal file
47
frontend/src/pages/admin/index.astro
Normal file
@@ -0,0 +1,47 @@
|
||||
---
|
||||
import Layout from '../../layouts/Layout.astro';
|
||||
---
|
||||
|
||||
<Layout title="Admin Dashboard">
|
||||
<div class="glass p-12 mb-12" id="admin-content" style="display: none;">
|
||||
<header class="mb-12 border-b border-white/5 pb-12 flex justify-between items-center">
|
||||
<h1 class="text-4xl font-extrabold text-mauve">
|
||||
Admin Dashboard
|
||||
</h1>
|
||||
<button id="logout-btn" class="text-red hover:text-maroon transition-colors font-medium">
|
||||
Logout
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="grid md:grid-cols-2 gap-8">
|
||||
<a href="/admin/editor" class="group">
|
||||
<div class="bg-surface0/50 p-8 rounded-xl border border-white/5 transition-all hover:bg-surface0 hover:scale-[1.02] h-full">
|
||||
<h2 class="text-2xl font-bold text-lavender mb-2 group-hover:text-mauve transition-colors">Write a Post</h2>
|
||||
<p class="text-subtext0">Create or edit markdown posts and upload images.</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="/admin/settings" class="group">
|
||||
<div class="bg-surface0/50 p-8 rounded-xl border border-white/5 transition-all hover:bg-surface0 hover:scale-[1.02] h-full">
|
||||
<h2 class="text-2xl font-bold text-blue mb-2 group-hover:text-sky transition-colors">Site Settings</h2>
|
||||
<p class="text-subtext0">Update the blog title, favicon, and theme configuration.</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const token = localStorage.getItem('admin_token');
|
||||
if (!token) {
|
||||
window.location.href = '/admin/login';
|
||||
} else {
|
||||
const content = document.getElementById('admin-content');
|
||||
if (content) content.style.display = 'block';
|
||||
}
|
||||
|
||||
document.getElementById('logout-btn')?.addEventListener('click', () => {
|
||||
localStorage.removeItem('admin_token');
|
||||
window.location.href = '/';
|
||||
});
|
||||
</script>
|
||||
</Layout>
|
||||
44
frontend/src/pages/admin/login.astro
Normal file
44
frontend/src/pages/admin/login.astro
Normal file
@@ -0,0 +1,44 @@
|
||||
---
|
||||
import Layout from '../../layouts/Layout.astro';
|
||||
---
|
||||
|
||||
<Layout title="Admin Login">
|
||||
<div class="max-w-md mx-auto mt-20">
|
||||
<div class="glass p-12">
|
||||
<h1 class="text-3xl font-bold mb-6 text-mauve">Admin Login</h1>
|
||||
<p class="text-subtext0 mb-8">Enter your admin token to access the dashboard.</p>
|
||||
|
||||
<form id="login-form" class="space-y-6">
|
||||
<div>
|
||||
<label for="token" class="block text-sm font-medium text-subtext1 mb-2">Admin Token</label>
|
||||
<input
|
||||
type="password"
|
||||
id="token"
|
||||
name="token"
|
||||
required
|
||||
class="w-full bg-crust border border-surface1 rounded-lg px-4 py-3 text-text focus:outline-none focus:border-mauve transition-colors"
|
||||
placeholder="••••••••••••"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full bg-mauve text-crust font-bold py-3 rounded-lg hover:bg-pink transition-colors"
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const form = document.getElementById('login-form');
|
||||
form?.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
const token = (document.getElementById('token') as HTMLInputElement).value;
|
||||
if (token) {
|
||||
localStorage.setItem('admin_token', token);
|
||||
window.location.href = '/admin';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</Layout>
|
||||
120
frontend/src/pages/admin/settings.astro
Normal file
120
frontend/src/pages/admin/settings.astro
Normal file
@@ -0,0 +1,120 @@
|
||||
---
|
||||
import Layout from '../../layouts/Layout.astro';
|
||||
---
|
||||
|
||||
<Layout title="Site Settings">
|
||||
<div class="glass p-12 mb-12" id="settings-content" style="display: none;">
|
||||
<header class="mb-12 border-b border-white/5 pb-12">
|
||||
<a href="/admin" class="text-blue hover:text-sky transition-colors mb-8 inline-flex items-center gap-2 group">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="transition-transform group-hover:-translate-x-1"><path d="m15 18-6-6 6-6"/></svg>
|
||||
Back to Dashboard
|
||||
</a>
|
||||
<h1 class="text-4xl font-extrabold text-mauve mt-4">
|
||||
Site Settings
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<form id="settings-form" class="space-y-6">
|
||||
<div id="alert" class="hidden p-4 rounded-lg mb-6"></div>
|
||||
|
||||
<div>
|
||||
<label for="title" class="block text-sm font-medium text-subtext1 mb-2">Blog Title</label>
|
||||
<input
|
||||
type="text"
|
||||
id="title"
|
||||
required
|
||||
class="w-full bg-crust border border-surface1 rounded-lg px-4 py-3 text-text focus:outline-none focus:border-mauve transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="favicon" class="block text-sm font-medium text-subtext1 mb-2">Favicon URL</label>
|
||||
<input
|
||||
type="text"
|
||||
id="favicon"
|
||||
required
|
||||
class="w-full bg-crust border border-surface1 rounded-lg px-4 py-3 text-text focus:outline-none focus:border-mauve transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="theme" class="block text-sm font-medium text-subtext1 mb-2">Theme</label>
|
||||
<input
|
||||
type="text"
|
||||
id="theme"
|
||||
required
|
||||
class="w-full bg-crust border border-surface1 rounded-lg px-4 py-3 text-text focus:outline-none focus:border-mauve transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="bg-blue text-crust font-bold py-3 px-8 rounded-lg hover:bg-sky transition-colors"
|
||||
>
|
||||
Save Settings
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const token = localStorage.getItem('admin_token');
|
||||
if (!token) {
|
||||
window.location.href = '/admin/login';
|
||||
} else {
|
||||
document.getElementById('settings-content')!.style.display = 'block';
|
||||
loadSettings();
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const res = await fetch('/api/config');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
(document.getElementById('title') as HTMLInputElement).value = data.title || '';
|
||||
(document.getElementById('favicon') as HTMLInputElement).value = data.favicon || '';
|
||||
(document.getElementById('theme') as HTMLInputElement).value = data.theme || '';
|
||||
}
|
||||
} catch (e) {
|
||||
showAlert('Failed to load settings.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('settings-form')?.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const payload = {
|
||||
title: (document.getElementById('title') as HTMLInputElement).value,
|
||||
favicon: (document.getElementById('favicon') as HTMLInputElement).value,
|
||||
theme: (document.getElementById('theme') as HTMLInputElement).value,
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/config', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
showAlert('Settings saved successfully!', 'success');
|
||||
} else {
|
||||
const err = await res.json();
|
||||
showAlert(`Error: ${err.error}`, 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showAlert('Failed to save settings.', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
function showAlert(msg: string, type: 'success' | 'error') {
|
||||
const alertEl = document.getElementById('alert');
|
||||
if (alertEl) {
|
||||
alertEl.textContent = msg;
|
||||
alertEl.className = `p-4 rounded-lg mb-6 ${type === 'success' ? 'bg-green/20 text-green border border-green/30' : 'bg-red/20 text-red border border-red/30'}`;
|
||||
alertEl.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</Layout>
|
||||
@@ -63,4 +63,32 @@ function formatSlug(slug: string) {
|
||||
<div class="prose prose-invert max-w-none" set:html={html} />
|
||||
)}
|
||||
</article>
|
||||
|
||||
<script>
|
||||
if (localStorage.getItem('admin_token')) {
|
||||
const editBtn = document.getElementById('edit-btn');
|
||||
if (editBtn) {
|
||||
editBtn.style.display = 'inline-flex';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</Layout>
|
||||
1.5-5.5Z"/><path d="m15 5 4 4"/></svg>
|
||||
Edit
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{error && (
|
||||
<div class="text-red text-center py-12">
|
||||
<h2 class="text-2xl font-bold mb-4">{error}</h2>
|
||||
<a href="/" class="text-blue underline">Return home</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{post && (
|
||||
<div class="prose prose-invert max-w-none" set:html={html} />
|
||||
)}
|
||||
</article>
|
||||
</Layout>
|
||||
|
||||
Reference in New Issue
Block a user