From c61a5ff3c54364092f62b9d675a1ac3b6ad624f0 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Thu, 26 Mar 2026 00:50:11 +0100 Subject: [PATCH] refactor --- backend/src/auth.rs | 12 + backend/src/error.rs | 41 ++ backend/src/handlers/config.rs | 46 ++ backend/src/handlers/mod.rs | 3 + backend/src/handlers/posts.rs | 105 ++++ backend/src/handlers/upload.rs | 105 ++++ backend/src/main.rs | 391 +------------- backend/src/models.rs | 64 +++ frontend/src/components/PostCard.astro | 25 + frontend/src/components/ui/Alert.ts | 18 + frontend/src/layouts/AdminLayout.astro | 45 ++ frontend/src/pages/admin/editor.astro | 661 +++++++++++------------- frontend/src/pages/admin/index.astro | 226 ++++---- frontend/src/pages/admin/settings.astro | 231 ++++----- frontend/src/pages/index.astro | 27 +- 15 files changed, 1005 insertions(+), 995 deletions(-) create mode 100644 backend/src/auth.rs create mode 100644 backend/src/error.rs create mode 100644 backend/src/handlers/config.rs create mode 100644 backend/src/handlers/mod.rs create mode 100644 backend/src/handlers/posts.rs create mode 100644 backend/src/handlers/upload.rs create mode 100644 backend/src/models.rs create mode 100644 frontend/src/components/PostCard.astro create mode 100644 frontend/src/components/ui/Alert.ts create mode 100644 frontend/src/layouts/AdminLayout.astro diff --git a/backend/src/auth.rs b/backend/src/auth.rs new file mode 100644 index 0000000..861bf38 --- /dev/null +++ b/backend/src/auth.rs @@ -0,0 +1,12 @@ +use axum::http::HeaderMap; +use tracing::warn; +use crate::error::AppError; + +pub fn check_auth(headers: &HeaderMap, admin_token: &str) -> Result<(), AppError> { + let auth_header = headers.get("Authorization").and_then(|h| h.to_str().ok()); + if auth_header != Some(&format!("Bearer {}", admin_token)) { + warn!("Unauthorized access attempt detected"); + return Err(AppError::Unauthorized); + } + Ok(()) +} diff --git a/backend/src/error.rs b/backend/src/error.rs new file mode 100644 index 0000000..363c8c4 --- /dev/null +++ b/backend/src/error.rs @@ -0,0 +1,41 @@ +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; + +use crate::models::ErrorResponse; + +pub enum AppError { + Unauthorized, + NotFound(String), + BadRequest(String), + Internal(String, Option), +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + let (status, error_message, details) = match self { + AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "Unauthorized".to_string(), None), + AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg, None), + AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg, None), + AppError::Internal(msg, details) => (StatusCode::INTERNAL_SERVER_ERROR, msg, details), + }; + + let body = Json(ErrorResponse { + error: error_message, + details, + }); + + (status, body).into_response() + } +} + +impl From for AppError +where + E: std::error::Error + Send + Sync + 'static, +{ + fn from(err: E) -> Self { + AppError::Internal("Internal Server Error".to_string(), Some(err.to_string())) + } +} \ No newline at end of file diff --git a/backend/src/handlers/config.rs b/backend/src/handlers/config.rs new file mode 100644 index 0000000..0f949e9 --- /dev/null +++ b/backend/src/handlers/config.rs @@ -0,0 +1,46 @@ +use axum::{ + extract::State, + http::HeaderMap, + response::IntoResponse, + Json, +}; +use std::{fs, sync::Arc}; +use tracing::error; + +use crate::{ + auth::check_auth, + error::AppError, + models::SiteConfig, + AppState, +}; + +pub async fn get_config(State(state): State>) -> 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::(&c).ok()) + .unwrap_or_default(); + + Json(config) +} + +pub async fn update_config( + State(state): State>, + headers: HeaderMap, + Json(payload): Json, +) -> Result, AppError> { + 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(|e| { + error!("Serialization error: {}", e); + AppError::Internal("Serialization error".to_string(), Some(e.to_string())) + })?; + + fs::write(&config_path, config_str).map_err(|e| { + error!("Write error for config: {}", e); + AppError::Internal("Write error".to_string(), Some(e.to_string())) + })?; + + Ok(Json(payload)) +} \ No newline at end of file diff --git a/backend/src/handlers/mod.rs b/backend/src/handlers/mod.rs new file mode 100644 index 0000000..8aefea9 --- /dev/null +++ b/backend/src/handlers/mod.rs @@ -0,0 +1,3 @@ +pub mod config; +pub mod posts; +pub mod upload; diff --git a/backend/src/handlers/posts.rs b/backend/src/handlers/posts.rs new file mode 100644 index 0000000..21b226b --- /dev/null +++ b/backend/src/handlers/posts.rs @@ -0,0 +1,105 @@ +use axum::{ + extract::{Path, State}, + http::{HeaderMap, StatusCode}, + response::IntoResponse, + Json, +}; +use std::{fs, sync::Arc}; +use tracing::{error, info, warn}; + +use crate::{ + auth::check_auth, + error::AppError, + models::{CreatePostRequest, PostDetail, PostInfo}, + AppState, +}; + +pub async fn create_post( + State(state): State>, + headers: HeaderMap, + Json(payload): Json, +) -> Result, AppError> { + check_auth(&headers, &state.admin_token)?; + + if payload.slug.contains('/') || payload.slug.contains('\\') || payload.slug.contains("..") { + return Err(AppError::BadRequest("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(|e| { + error!("Write error for post {}: {}", payload.slug, e); + AppError::Internal("Write error".to_string(), Some(e.to_string())) + })?; + + info!("Post created/updated: {}", payload.slug); + Ok(Json(PostDetail { + slug: payload.slug, + content: payload.content, + })) +} + +pub async fn delete_post( + State(state): State>, + headers: HeaderMap, + Path(slug): Path, +) -> Result { + check_auth(&headers, &state.admin_token)?; + + let file_path = state.data_dir.join("posts").join(format!("{}.md", slug)); + info!("Attempting to delete post at: {:?}", file_path); + + if !file_path.exists() { + warn!("Post not found for deletion: {}", slug); + return Err(AppError::NotFound("Post not found".to_string())); + } + + fs::remove_file(file_path).map_err(|e| { + error!("Delete error for post {}: {}", slug, e); + AppError::Internal("Delete error".to_string(), Some(e.to_string())) + })?; + + info!("Post deleted: {}", slug); + Ok(StatusCode::NO_CONTENT) +} + +pub async fn list_posts(State(state): State>) -> impl IntoResponse { + let posts_dir = state.data_dir.join("posts"); + let mut posts = Vec::new(); + + if let Ok(entries) = fs::read_dir(posts_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) == Some("md") { + if let Some(slug) = path.file_stem().and_then(|s| s.to_str()) { + let mut excerpt = String::new(); + if let Ok(content) = fs::read_to_string(&path) { + let clean_content = content.replace("#", "").replace("\n", " "); + excerpt = clean_content.chars().take(200).collect::(); + if clean_content.len() > 200 { + excerpt.push_str("..."); + } + } + posts.push(PostInfo { + slug: slug.to_string(), + excerpt: excerpt.trim().to_string(), + }); + } + } + } + } + + Json(posts) +} + +pub async fn get_post( + State(state): State>, + Path(slug): Path, +) -> Result, AppError> { + let file_path = state.data_dir.join("posts").join(format!("{}.md", slug)); + + match fs::read_to_string(&file_path) { + Ok(content) => Ok(Json(PostDetail { slug, content })), + Err(_) => Err(AppError::NotFound("Post not found".to_string())), + } +} \ No newline at end of file diff --git a/backend/src/handlers/upload.rs b/backend/src/handlers/upload.rs new file mode 100644 index 0000000..b1e4c54 --- /dev/null +++ b/backend/src/handlers/upload.rs @@ -0,0 +1,105 @@ +use axum::{ + extract::{Multipart, State}, + http::HeaderMap, + response::IntoResponse, + Json, +}; +use std::{fs, sync::Arc}; +use tracing::{error, info, warn}; + +use crate::{ + auth::check_auth, + error::AppError, + models::{FileInfo, UploadResponse}, + AppState, +}; + +pub async fn list_uploads( + State(state): State>, + headers: HeaderMap, +) -> Result>, AppError> { + check_auth(&headers, &state.admin_token)?; + + let uploads_dir = state.data_dir.join("uploads"); + let mut files = Vec::new(); + + if let Ok(entries) = fs::read_dir(uploads_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_file() { + if let Some(name) = path.file_name().and_then(|n| n.to_str()) { + files.push(FileInfo { + name: name.to_string(), + url: format!("/uploads/{}", name), + }); + } + } + } + } + + Ok(Json(files)) +} + +pub async fn upload_file( + State(state): State>, + headers: HeaderMap, + mut multipart: Multipart, +) -> Result, AppError> { + check_auth(&headers, &state.admin_token)?; + + info!("Upload requested"); + + while let Ok(Some(field)) = multipart.next_field().await { + let file_name = match field.file_name() { + Some(name) => name.to_string(), + None => continue, + }; + + info!("Processing upload for: {}", file_name); + let slugified_name = slug::slugify(&file_name); + + let extension = std::path::Path::new(&file_name) + .extension() + .and_then(|e| e.to_str()) + .unwrap_or(""); + + let final_name = if !extension.is_empty() { + format!("{}.{}", slugified_name, extension) + } else { + slugified_name + }; + + let uploads_dir = state.data_dir.join("uploads"); + let file_path = uploads_dir.join(&final_name); + + let final_path = if file_path.exists() { + let timestamp = chrono::Utc::now().timestamp(); + uploads_dir.join(format!("{}_{}", timestamp, final_name)) + } else { + file_path + }; + + let final_name_str = final_path.file_name().unwrap().to_str().unwrap().to_string(); + + let data = match field.bytes().await { + Ok(bytes) => bytes, + Err(e) => { + error!("Failed to read multipart bytes: {}", e); + return Err(AppError::BadRequest(format!("Read error: {}", e))); + } + }; + + if let Err(e) = fs::write(&final_path, &data) { + error!("Failed to write file to {:?}: {}", final_path, e); + return Err(AppError::Internal("Write error".to_string(), Some(e.to_string()))); + } + + info!("File uploaded successfully to {:?}", final_path); + return Ok(Json(UploadResponse { + url: format!("/uploads/{}", final_name_str), + })); + } + + warn!("Upload failed: no file found in multipart stream"); + Err(AppError::BadRequest("No file found".to_string())) +} \ No newline at end of file diff --git a/backend/src/main.rs b/backend/src/main.rs index 6ad652f..edb3635 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -1,84 +1,24 @@ +pub mod auth; +pub mod error; +pub mod handlers; +pub mod models; + use axum::{ - Router, - extract::{DefaultBodyLimit, Multipart, Path, State}, - http::{HeaderMap, StatusCode}, - response::{IntoResponse, Json}, + extract::DefaultBodyLimit, routing::{delete, get, post}, + Router, }; -use serde::{Deserialize, Serialize}; use std::{env, fs, path::PathBuf, sync::Arc}; use tower_http::{ cors::{Any, CorsLayer}, services::ServeDir, }; -use tracing::{error, info, warn}; +use tracing::{error, info}; #[derive(Clone)] -struct AppState { - admin_token: String, - data_dir: PathBuf, -} - -#[derive(Serialize, Deserialize, Clone)] -struct SiteConfig { - title: String, - subtitle: String, - welcome_title: String, - welcome_subtitle: String, - footer: String, - favicon: String, - theme: String, - custom_css: String, -} - -impl Default for SiteConfig { - fn default() -> Self { - Self { - title: "Narlblog".to_string(), - subtitle: "A clean, modern blog".to_string(), - welcome_title: "Welcome to my blog".to_string(), - welcome_subtitle: "Thoughts on software, design, and building things with Rust and Astro.".to_string(), - footer: "Built with Rust & Astro".to_string(), - favicon: "/favicon.svg".to_string(), - theme: "mocha".to_string(), - custom_css: "".to_string(), - } - } -} - -#[derive(Serialize)] -struct PostInfo { - slug: String, - excerpt: String, -} - -#[derive(Serialize)] -struct PostDetail { - slug: String, - content: String, -} - -#[derive(Deserialize)] -struct CreatePostRequest { - slug: String, - content: String, -} - -#[derive(Serialize)] -struct ErrorResponse { - error: String, - details: Option, -} - -#[derive(Serialize)] -struct UploadResponse { - url: String, -} - -#[derive(Serialize)] -struct FileInfo { - name: String, - url: String, +pub struct AppState { + pub admin_token: String, + pub data_dir: PathBuf, } #[tokio::main] @@ -114,11 +54,11 @@ async fn main() { .allow_headers(Any); let app = Router::new() - .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).delete(delete_post)) - .route("/api/uploads", get(list_uploads)) - .route("/api/upload", post(upload_file)) + .route("/api/config", get(handlers::config::get_config).post(handlers::config::update_config)) + .route("/api/posts", get(handlers::posts::list_posts).post(handlers::posts::create_post)) + .route("/api/posts/{slug}", get(handlers::posts::get_post).delete(handlers::posts::delete_post)) + .route("/api/uploads", get(handlers::upload::list_uploads)) + .route("/api/upload", post(handlers::upload::upload_file)) .nest_service("/uploads", ServeDir::new(uploads_dir)) .layer(DefaultBodyLimit::max(50 * 1024 * 1024)) .layer(cors) @@ -129,302 +69,3 @@ async fn main() { let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); axum::serve(listener, app).await.unwrap(); } - -fn check_auth( - headers: &HeaderMap, - admin_token: &str, -) -> Result<(), (StatusCode, Json)> { - let auth_header = headers.get("Authorization").and_then(|h| h.to_str().ok()); - if auth_header != Some(&format!("Bearer {}", admin_token)) { - warn!("Unauthorized access attempt detected"); - return Err(( - StatusCode::UNAUTHORIZED, - Json(ErrorResponse { - error: "Unauthorized".to_string(), - details: None, - }), - )); - } - Ok(()) -} - -async fn get_config(State(state): State>) -> 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::(&c).ok()) - .unwrap_or_default(); - - Json(config) -} - -async fn update_config( - State(state): State>, - headers: HeaderMap, - Json(payload): Json, -) -> Result, (StatusCode, Json)> { - 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(|e| { - error!("Serialization error: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ErrorResponse { - error: "Serialization error".to_string(), - details: Some(e.to_string()), - }), - ) - })?; - - fs::write(&config_path, config_str).map_err(|e| { - error!("Write error for config: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ErrorResponse { - error: "Write error".to_string(), - details: Some(e.to_string()), - }), - ) - })?; - - Ok(Json(payload)) -} - -async fn create_post( - State(state): State>, - headers: HeaderMap, - Json(payload): Json, -) -> Result, (StatusCode, Json)> { - check_auth(&headers, &state.admin_token)?; - - if payload.slug.contains('/') || payload.slug.contains('\\') || payload.slug.contains("..") { - return Err(( - StatusCode::BAD_REQUEST, - Json(ErrorResponse { - error: "Invalid slug".to_string(), - details: None, - }), - )); - } - - let file_path = state - .data_dir - .join("posts") - .join(format!("{}.md", payload.slug)); - - fs::write(&file_path, &payload.content).map_err(|e| { - error!("Write error for post {}: {}", payload.slug, e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ErrorResponse { - error: "Write error".to_string(), - details: Some(e.to_string()), - }), - ) - })?; - - info!("Post created/updated: {}", payload.slug); - Ok(Json(PostDetail { - slug: payload.slug, - content: payload.content, - })) -} - -async fn delete_post( - State(state): State>, - headers: HeaderMap, - Path(slug): Path, -) -> Result)> { - check_auth(&headers, &state.admin_token)?; - - let file_path = state.data_dir.join("posts").join(format!("{}.md", slug)); - info!("Attempting to delete post at: {:?}", file_path); - - if !file_path.exists() { - warn!("Post not found for deletion: {}", slug); - return Err(( - StatusCode::NOT_FOUND, - Json(ErrorResponse { - error: "Post not found".to_string(), - details: None, - }), - )); - } - - fs::remove_file(file_path).map_err(|e| { - error!("Delete error for post {}: {}", slug, e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ErrorResponse { - error: "Delete error".to_string(), - details: Some(e.to_string()), - }), - ) - })?; - - info!("Post deleted: {}", slug); - Ok(StatusCode::NO_CONTENT) -} - -async fn list_uploads( - State(state): State>, - headers: HeaderMap, -) -> Result>, (StatusCode, Json)> { - check_auth(&headers, &state.admin_token)?; - - let uploads_dir = state.data_dir.join("uploads"); - let mut files = Vec::new(); - - if let Ok(entries) = fs::read_dir(uploads_dir) { - for entry in entries.flatten() { - let path = entry.path(); - if path.is_file() { - if let Some(name) = path.file_name().and_then(|n| n.to_str()) { - files.push(FileInfo { - name: name.to_string(), - url: format!("/uploads/{}", name), - }); - } - } - } - } - - Ok(Json(files)) -} - -async fn list_posts(State(state): State>) -> impl IntoResponse { - let posts_dir = state.data_dir.join("posts"); - let mut posts = Vec::new(); - - if let Ok(entries) = fs::read_dir(posts_dir) { - for entry in entries.flatten() { - let path = entry.path(); - if path.extension().and_then(|e| e.to_str()) == Some("md") { - if let Some(slug) = path.file_stem().and_then(|s| s.to_str()) { - let mut excerpt = String::new(); - if let Ok(content) = fs::read_to_string(&path) { - // Strip basic markdown hashes and get first ~200 chars - let clean_content = content.replace("#", "").replace("\n", " "); - excerpt = clean_content.chars().take(200).collect::(); - if clean_content.len() > 200 { - excerpt.push_str("..."); - } - } - posts.push(PostInfo { - slug: slug.to_string(), - excerpt: excerpt.trim().to_string(), - }); - } - } - } - } - - Json(posts) -} - -async fn get_post( - State(state): State>, - Path(slug): Path, -) -> Result, (StatusCode, Json)> { - let file_path = state.data_dir.join("posts").join(format!("{}.md", slug)); - - match fs::read_to_string(&file_path) { - Ok(content) => Ok(Json(PostDetail { slug, content })), - Err(_) => Err(( - StatusCode::NOT_FOUND, - Json(ErrorResponse { - error: "Post not found".to_string(), - details: None, - }), - )), - } -} - -async fn upload_file( - State(state): State>, - headers: HeaderMap, - mut multipart: Multipart, -) -> Result, (StatusCode, Json)> { - check_auth(&headers, &state.admin_token)?; - - info!("Upload requested"); - - while let Ok(Some(field)) = multipart.next_field().await { - let file_name = match field.file_name() { - Some(name) => name.to_string(), - None => continue, - }; - - info!("Processing upload for: {}", file_name); - let slugified_name = slug::slugify(&file_name); - - // Handle extension correctly after slugifying - let extension = std::path::Path::new(&file_name) - .extension() - .and_then(|e| e.to_str()) - .unwrap_or(""); - - let final_name = if !extension.is_empty() { - format!("{}.{}", slugified_name, extension) - } else { - slugified_name - }; - - let uploads_dir = state.data_dir.join("uploads"); - let file_path = uploads_dir.join(&final_name); - - let final_path = if file_path.exists() { - let timestamp = chrono::Utc::now().timestamp(); - uploads_dir.join(format!("{}_{}", timestamp, final_name)) - } else { - file_path - }; - - let final_name_str = final_path - .file_name() - .unwrap() - .to_str() - .unwrap() - .to_string(); - - let data = match field.bytes().await { - Ok(bytes) => bytes, - Err(e) => { - error!("Failed to read multipart bytes: {}", e); - return Err(( - StatusCode::BAD_REQUEST, - Json(ErrorResponse { - error: "Read error".to_string(), - details: Some(e.to_string()), - }), - )); - } - }; - - if let Err(e) = fs::write(&final_path, &data) { - error!("Failed to write file to {:?}: {}", final_path, e); - return Err(( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ErrorResponse { - error: "Write error".to_string(), - details: Some(e.to_string()), - }), - )); - } - - info!("File uploaded successfully to {:?}", final_path); - return Ok(Json(UploadResponse { - url: format!("/uploads/{}", final_name_str), - })); - } - - warn!("Upload failed: no file found in multipart stream"); - Err(( - StatusCode::BAD_REQUEST, - Json(ErrorResponse { - error: "No file found".to_string(), - details: None, - }), - )) -} diff --git a/backend/src/models.rs b/backend/src/models.rs new file mode 100644 index 0000000..909d386 --- /dev/null +++ b/backend/src/models.rs @@ -0,0 +1,64 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Clone)] +pub struct SiteConfig { + pub title: String, + pub subtitle: String, + pub welcome_title: String, + pub welcome_subtitle: String, + pub footer: String, + pub favicon: String, + pub theme: String, + pub custom_css: String, +} + +impl Default for SiteConfig { + fn default() -> Self { + Self { + title: "Narlblog".to_string(), + subtitle: "A clean, modern blog".to_string(), + welcome_title: "Welcome to my blog".to_string(), + welcome_subtitle: "Thoughts on software, design, and building things with Rust and Astro.".to_string(), + footer: "Built with Rust & Astro".to_string(), + favicon: "/favicon.svg".to_string(), + theme: "mocha".to_string(), + custom_css: "".to_string(), + } + } +} + +#[derive(Serialize)] +pub struct PostInfo { + pub slug: String, + pub excerpt: String, +} + +#[derive(Serialize)] +pub struct PostDetail { + pub slug: String, + pub content: String, +} + +#[derive(Deserialize)] +pub struct CreatePostRequest { + pub slug: String, + pub content: String, +} + +#[derive(Serialize)] +pub struct ErrorResponse { + pub error: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub details: Option, +} + +#[derive(Serialize)] +pub struct UploadResponse { + pub url: String, +} + +#[derive(Serialize)] +pub struct FileInfo { + pub name: String, + pub url: String, +} diff --git a/frontend/src/components/PostCard.astro b/frontend/src/components/PostCard.astro new file mode 100644 index 0000000..f0ebba6 --- /dev/null +++ b/frontend/src/components/PostCard.astro @@ -0,0 +1,25 @@ +--- +interface Props { + slug: string; + excerpt?: string; + formatSlug: (slug: string) => string; +} + +const { slug, excerpt, formatSlug } = Astro.props; +--- + + +
+
+

+ {formatSlug(slug)} +

+

+ {excerpt || `Read more about ${formatSlug(slug)}...`} +

+
+ +
+
diff --git a/frontend/src/components/ui/Alert.ts b/frontend/src/components/ui/Alert.ts new file mode 100644 index 0000000..835be37 --- /dev/null +++ b/frontend/src/components/ui/Alert.ts @@ -0,0 +1,18 @@ +export function showAlert(msg: string, type: 'success' | 'error', elementId: string = 'alert') { + const alertEl = document.getElementById(elementId); + if (alertEl) { + alertEl.textContent = msg; + alertEl.className = `p-4 rounded-lg mb-6 text-sm md:text-base ${ + type === 'success' + ? 'bg-green/20 text-green border border-green/30' + : 'bg-red/20 text-red border border-red/30' + }`; + alertEl.classList.remove('hidden'); + + window.scrollTo({ top: 0, behavior: 'smooth' }); + + setTimeout(() => { + alertEl.classList.add('hidden'); + }, 5000); + } +} diff --git a/frontend/src/layouts/AdminLayout.astro b/frontend/src/layouts/AdminLayout.astro new file mode 100644 index 0000000..eada2e3 --- /dev/null +++ b/frontend/src/layouts/AdminLayout.astro @@ -0,0 +1,45 @@ +--- +import Layout from './Layout.astro'; + +interface Props { + title: string; +} + +const { title } = Astro.props; +--- + + + + + + diff --git a/frontend/src/pages/admin/editor.astro b/frontend/src/pages/admin/editor.astro index cf99571..ed9cd06 100644 --- a/frontend/src/pages/admin/editor.astro +++ b/frontend/src/pages/admin/editor.astro @@ -1,110 +1,95 @@ --- -import Layout from '../../layouts/Layout.astro'; +import AdminLayout from '../../layouts/AdminLayout.astro'; --- - + - - - - - + + + + -