added admin control panel
This commit is contained in:
@@ -19,6 +19,23 @@ struct AppState {
|
|||||||
data_dir: PathBuf,
|
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)]
|
#[derive(Serialize)]
|
||||||
struct PostInfo {
|
struct PostInfo {
|
||||||
slug: String,
|
slug: String,
|
||||||
@@ -30,6 +47,12 @@ struct PostDetail {
|
|||||||
content: String,
|
content: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct CreatePostRequest {
|
||||||
|
slug: String,
|
||||||
|
content: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct ErrorResponse {
|
struct ErrorResponse {
|
||||||
error: String,
|
error: String,
|
||||||
@@ -67,7 +90,8 @@ async fn main() {
|
|||||||
.allow_headers(Any);
|
.allow_headers(Any);
|
||||||
|
|
||||||
let app = Router::new()
|
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/posts/{slug}", get(get_post))
|
||||||
.route("/api/upload", post(upload_file))
|
.route("/api/upload", post(upload_file))
|
||||||
.nest_service("/uploads", ServeDir::new(uploads_dir))
|
.nest_service("/uploads", ServeDir::new(uploads_dir))
|
||||||
@@ -81,6 +105,73 @@ async fn main() {
|
|||||||
axum::serve(listener, app).await.unwrap();
|
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 {
|
async fn list_posts(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||||
let posts_dir = state.data_dir.join("posts");
|
let posts_dir = state.data_dir.join("posts");
|
||||||
let mut posts = Vec::new();
|
let mut posts = Vec::new();
|
||||||
@@ -129,14 +220,7 @@ async fn upload_file(
|
|||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
mut multipart: Multipart,
|
mut multipart: Multipart,
|
||||||
) -> Result<Json<UploadResponse>, (StatusCode, Json<ErrorResponse>)> {
|
) -> Result<Json<UploadResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
// Basic Auth Check
|
check_auth(&headers, &state.admin_token)?;
|
||||||
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() }),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
while let Some(field) = multipart.next_field().await.map_err(|_| {
|
while let Some(field) = multipart.next_field().await.map_err(|_| {
|
||||||
(StatusCode::BAD_REQUEST, Json(ErrorResponse { error: "Multipart error".to_string() }))
|
(StatusCode::BAD_REQUEST, Json(ErrorResponse { error: "Multipart error".to_string() }))
|
||||||
|
|||||||
@@ -6,6 +6,24 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { title } = Astro.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>
|
<!doctype html>
|
||||||
@@ -13,9 +31,9 @@ const { title } = Astro.props;
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width" />
|
<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} />
|
<meta name="generator" content={Astro.generator} />
|
||||||
<title>{title} | Narlblog</title>
|
<title>{title} | {siteConfig.title}</title>
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-base text-text selection:bg-surface2 selection:text-text">
|
<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>
|
<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">
|
<nav class="max-w-4xl mx-auto px-6 py-8">
|
||||||
<header class="glass px-6 py-4 flex items-center justify-between">
|
<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">
|
<a href="/" class="text-2xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-mauve to-blue">
|
||||||
Narlblog
|
{siteConfig.title}
|
||||||
</a>
|
</a>
|
||||||
<div class="flex gap-4">
|
<div class="flex gap-4">
|
||||||
<a href="/" class="text-subtext0 hover:text-text transition-colors">Home</a>
|
<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="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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -37,7 +56,7 @@ const { title } = Astro.props;
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer class="max-w-4xl mx-auto px-6 py-8 text-center text-sm text-subtext0">
|
<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>
|
</footer>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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} />
|
<div class="prose prose-invert max-w-none" set:html={html} />
|
||||||
)}
|
)}
|
||||||
</article>
|
</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>
|
</Layout>
|
||||||
|
|||||||
Reference in New Issue
Block a user