performance improvements

This commit is contained in:
2026-05-14 17:52:13 +02:00
parent 6bc51d6d14
commit 046f60dcb6
9 changed files with 299 additions and 134 deletions
+5 -2
View File
@@ -1,5 +1,6 @@
use axum::{Json, extract::State, http::HeaderMap, response::IntoResponse};
use std::{fs, sync::Arc};
use std::sync::Arc;
use tokio::fs;
use tracing::error;
use crate::{
@@ -12,6 +13,7 @@ use crate::{
pub 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)
.await
.ok()
.and_then(|c| serde_json::from_str::<SiteConfig>(&c).ok())
.unwrap_or_default();
@@ -28,6 +30,7 @@ pub async fn update_config(
let config_path = state.data_dir.join("config.json");
let mut config: SiteConfig = fs::read_to_string(&config_path)
.await
.ok()
.and_then(|c| serde_json::from_str(&c).ok())
.unwrap_or_default();
@@ -48,7 +51,7 @@ pub async fn update_config(
AppError::Internal("Serialization error".to_string(), Some(e.to_string()))
})?;
fs::write(&config_path, config_str).map_err(|e| {
fs::write(&config_path, config_str).await.map_err(|e| {
error!("Write error for config: {}", e);
AppError::Internal("Write error".to_string(), Some(e.to_string()))
})?;
+31 -22
View File
@@ -7,10 +7,10 @@ use axum::{
use chrono::Utc;
use std::{
collections::hash_map::DefaultHasher,
fs,
hash::{Hash, Hasher},
sync::Arc,
};
use tokio::fs;
use tracing::{error, info, warn};
use crate::{
@@ -22,7 +22,7 @@ use crate::{
const MIN_FILL_TIME_MS: i64 = 3_000;
const MAX_FORM_AGE_MS: i64 = 24 * 60 * 60 * 1000;
const RATE_LIMIT_WINDOW_MS: i64 = 60 * 60 * 1000;
pub const RATE_LIMIT_WINDOW_MS: i64 = 60 * 60 * 1000;
const RATE_LIMIT_MAX: usize = 5;
const MAX_NAME: usize = 200;
const MAX_EMAIL: usize = 200;
@@ -155,7 +155,7 @@ pub async fn submit_contact(
};
let messages_dir = state.data_dir.join("messages");
fs::create_dir_all(&messages_dir).map_err(|e| {
fs::create_dir_all(&messages_dir).await.map_err(|e| {
error!("Failed to create messages dir: {}", e);
AppError::Internal("Storage error".into(), Some(e.to_string()))
})?;
@@ -163,7 +163,7 @@ pub async fn submit_contact(
let json = serde_json::to_string_pretty(&msg).map_err(|e| {
AppError::Internal("Serialization error".into(), Some(e.to_string()))
})?;
fs::write(&path, json).map_err(|e| {
fs::write(&path, json).await.map_err(|e| {
error!("Failed to write message {}: {}", id, e);
AppError::Internal("Storage error".into(), Some(e.to_string()))
})?;
@@ -181,11 +181,11 @@ pub async fn list_messages(
}
let messages_dir = state.data_dir.join("messages");
if !messages_dir.exists() {
if !fs::try_exists(&messages_dir).await.unwrap_or(false) {
return Json(Vec::<Message>::new()).into_response();
}
let entries = match fs::read_dir(&messages_dir) {
let mut rd = match fs::read_dir(&messages_dir).await {
Ok(e) => e,
Err(e) => {
error!("Failed to read messages dir: {}", e);
@@ -195,21 +195,30 @@ pub async fn list_messages(
};
let mut messages: Vec<Message> = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
let content = match fs::read_to_string(&path) {
Ok(c) => c,
Err(e) => {
error!("Failed to read {:?}: {}", path, e);
continue;
loop {
match rd.next_entry().await {
Ok(Some(entry)) => {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
let content = match fs::read_to_string(&path).await {
Ok(c) => c,
Err(e) => {
error!("Failed to read {:?}: {}", path, e);
continue;
}
};
match serde_json::from_str::<Message>(&content) {
Ok(m) => messages.push(m),
Err(e) => error!("Malformed message {:?}: {}", path, e),
}
}
Ok(None) => break,
Err(e) => {
error!("Error iterating messages dir: {}", e);
break;
}
};
match serde_json::from_str::<Message>(&content) {
Ok(m) => messages.push(m),
Err(e) => error!("Malformed message {:?}: {}", path, e),
}
}
messages.sort_by(|a, b| b.received_at.cmp(&a.received_at));
@@ -227,10 +236,10 @@ pub async fn delete_message(
return Err(AppError::BadRequest("Invalid id.".into()));
}
let path = state.data_dir.join("messages").join(format!("{}.json", id));
if !path.exists() {
if !fs::try_exists(&path).await.unwrap_or(false) {
return Err(AppError::NotFound("Message not found.".into()));
}
fs::remove_file(&path).map_err(|e| {
fs::remove_file(&path).await.map_err(|e| {
AppError::Internal("Delete failed".into(), Some(e.to_string()))
})?;
Ok(Json(ContactResponse { ok: true }))
+120 -55
View File
@@ -4,14 +4,15 @@ use axum::{
http::{HeaderMap, StatusCode},
};
use chrono::Utc;
use std::{fs, sync::Arc};
use std::sync::Arc;
use tokio::fs;
use tracing::{error, info, warn};
use crate::{
AppState,
auth::is_authed,
error::AppError,
models::{CoverImage, CreatePostRequest, PostDetail, PostInfo, PostMeta},
models::{CoverImage, CreatePostRequest, PostDetail, PostInfo, PostMeta, PostNeighbor},
};
const WORDS_PER_MINUTE: u32 = 200;
@@ -183,6 +184,70 @@ fn excerpt_from(meta: &PostMeta, body: &str) -> String {
out.trim().to_string()
}
fn build_post_info(slug: &str, meta: &PostMeta, body: &str) -> PostInfo {
let images = extract_images(body);
PostInfo {
slug: slug.to_string(),
date: meta.date,
title: meta.title.clone(),
summary: meta.summary.clone(),
tags: meta.tags.clone(),
draft: meta.draft,
reading_time: reading_time(body),
excerpt: excerpt_from(meta, body),
cover_image: cover_from(&images),
image_count: images.len() as u32,
}
}
/// Scans the posts directory and replaces the in-memory cache.
/// Called at startup and after any mutation (create/rename/delete).
pub async fn rebuild_posts_cache(state: &AppState) {
let posts_dir = state.data_dir.join("posts");
let mut posts: Vec<PostInfo> = Vec::new();
let mut rd = match fs::read_dir(&posts_dir).await {
Ok(rd) => rd,
Err(_) => {
*state.posts_cache.write().await = posts;
return;
}
};
loop {
match rd.next_entry().await {
Ok(Some(entry)) => {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
let Some(slug) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
if slug.starts_with('.') {
continue;
}
let Ok(raw) = fs::read_to_string(&path).await else {
continue;
};
let Ok((meta, body)) = parse_post(&raw) else {
warn!("Skipping post with bad frontmatter: {}", slug);
continue;
};
posts.push(build_post_info(slug, &meta, &body));
}
Ok(None) => break,
Err(e) => {
warn!("Error iterating posts dir: {}", e);
break;
}
}
}
posts.sort_by(|a, b| b.date.cmp(&a.date).then_with(|| a.slug.cmp(&b.slug)));
*state.posts_cache.write().await = posts;
}
async fn write_post_atomic(
state: &AppState,
slug: &str,
@@ -191,13 +256,16 @@ async fn write_post_atomic(
let _guard = state.post_lock.lock().await;
let final_path = state.data_dir.join("posts").join(format!("{}.md", slug));
let tmp_path = state.data_dir.join("posts").join(format!(".{}.md.tmp", slug));
fs::write(&tmp_path, contents).map_err(|e| {
fs::write(&tmp_path, contents).await.map_err(|e| {
AppError::Internal("Write error".to_string(), Some(e.to_string()))
})?;
fs::rename(&tmp_path, &final_path).map_err(|e| {
let _ = fs::remove_file(&tmp_path);
AppError::Internal("Rename error".to_string(), Some(e.to_string()))
})?;
if let Err(e) = fs::rename(&tmp_path, &final_path).await {
let _ = fs::remove_file(&tmp_path).await;
return Err(AppError::Internal(
"Rename error".to_string(),
Some(e.to_string()),
));
}
Ok(())
}
@@ -227,14 +295,14 @@ pub async fn create_post(
if let Some(ref old_slug) = payload.old_slug {
if old_slug != &slug {
let old_path = posts_dir.join(format!("{}.md", old_slug));
if old_path.exists() {
if file_path.exists() {
if fs::try_exists(&old_path).await.unwrap_or(false) {
if fs::try_exists(&file_path).await.unwrap_or(false) {
return Err(AppError::BadRequest(
"A post with this new title already exists".to_string(),
));
}
let _guard = state.post_lock.lock().await;
fs::rename(&old_path, &file_path).map_err(|e| {
fs::rename(&old_path, &file_path).await.map_err(|e| {
error!("Rename error from {} to {}: {}", old_slug, slug, e);
AppError::Internal("Rename error".to_string(), Some(e.to_string()))
})?;
@@ -264,6 +332,10 @@ pub async fn create_post(
info!("Post saved: {}", slug);
let image_count = images.len() as u32;
let cover = cover_from(&images);
rebuild_posts_cache(&state).await;
let (prev, next) = neighbors_from_cache(&state, &slug, true).await;
Ok(Json(PostDetail {
slug,
date: meta.date,
@@ -275,6 +347,8 @@ pub async fn create_post(
content: payload.content,
cover_image: cover,
image_count,
prev,
next,
}))
}
@@ -291,17 +365,19 @@ pub async fn delete_post(
let _guard = state.post_lock.lock().await;
let file_path = state.data_dir.join("posts").join(format!("{}.md", slug));
if !file_path.exists() {
if !fs::try_exists(&file_path).await.unwrap_or(false) {
warn!("Post not found for deletion: {}", slug);
return Err(AppError::NotFound("Post not found".to_string()));
}
fs::remove_file(file_path).map_err(|e| {
fs::remove_file(file_path).await.map_err(|e| {
error!("Delete error for post {}: {}", slug, e);
AppError::Internal("Delete error".to_string(), Some(e.to_string()))
})?;
drop(_guard);
info!("Post deleted: {}", slug);
rebuild_posts_cache(&state).await;
Ok(StatusCode::NO_CONTENT)
}
@@ -310,51 +386,37 @@ pub async fn list_posts(
headers: HeaderMap,
) -> Json<Vec<PostInfo>> {
let admin = is_authed(&headers, &state.admin_token);
let posts_dir = state.data_dir.join("posts");
let mut posts: Vec<PostInfo> = 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") {
continue;
}
let Some(slug) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
if slug.starts_with('.') {
continue;
}
let Ok(raw) = fs::read_to_string(&path) else {
continue;
};
let Ok((meta, body)) = parse_post(&raw) else {
warn!("Skipping post with bad frontmatter: {}", slug);
continue;
};
if meta.draft && !admin {
continue;
}
let images = extract_images(&body);
posts.push(PostInfo {
slug: slug.to_string(),
date: meta.date,
title: meta.title.clone(),
summary: meta.summary.clone(),
tags: meta.tags.clone(),
draft: meta.draft,
reading_time: reading_time(&body),
excerpt: excerpt_from(&meta, &body),
cover_image: cover_from(&images),
image_count: images.len() as u32,
});
}
}
posts.sort_by(|a, b| b.date.cmp(&a.date).then_with(|| a.slug.cmp(&b.slug)));
let cache = state.posts_cache.read().await;
let posts: Vec<PostInfo> = cache
.iter()
.filter(|p| admin || !p.draft)
.cloned()
.collect();
Json(posts)
}
async fn neighbors_from_cache(
state: &AppState,
slug: &str,
admin: bool,
) -> (Option<PostNeighbor>, Option<PostNeighbor>) {
let cache = state.posts_cache.read().await;
let visible: Vec<&PostInfo> = cache
.iter()
.filter(|p| admin || !p.draft)
.collect();
let Some(i) = visible.iter().position(|p| p.slug == slug) else {
return (None, None);
};
let to_neighbor = |p: &PostInfo| PostNeighbor {
slug: p.slug.clone(),
title: p.title.clone(),
};
let prev = if i > 0 { Some(to_neighbor(visible[i - 1])) } else { None };
let next = visible.get(i + 1).map(|p| to_neighbor(p));
(prev, next)
}
pub async fn get_post(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
@@ -364,7 +426,7 @@ pub async fn get_post(
let admin = is_authed(&headers, &state.admin_token);
let file_path = state.data_dir.join("posts").join(format!("{}.md", slug));
let raw = fs::read_to_string(&file_path)
let raw = fs::read_to_string(&file_path).await
.map_err(|_| AppError::NotFound("Post not found".to_string()))?;
let (meta, body) = parse_post(&raw)?;
@@ -373,6 +435,7 @@ pub async fn get_post(
}
let images = extract_images(&body);
let (prev, next) = neighbors_from_cache(&state, &slug, admin).await;
Ok(Json(PostDetail {
slug,
date: meta.date,
@@ -384,5 +447,7 @@ pub async fn get_post(
content: body,
cover_image: cover_from(&images),
image_count: images.len() as u32,
prev,
next,
}))
}
+35 -17
View File
@@ -4,7 +4,8 @@ use axum::{
http::{HeaderMap, StatusCode},
};
use serde::Deserialize;
use std::{fs, sync::Arc};
use std::sync::Arc;
use tokio::fs;
use tracing::{error, info, warn};
use crate::{
@@ -76,15 +77,15 @@ pub async fn delete_upload(
let uploads_dir = state.data_dir.join("uploads");
let file_path = uploads_dir.join(&filename);
let canonical_dir = uploads_dir.canonicalize().map_err(|e| {
let canonical_dir = fs::canonicalize(&uploads_dir).await.map_err(|e| {
AppError::Internal("Path resolution".to_string(), Some(e.to_string()))
})?;
if let Ok(canonical_file) = file_path.canonicalize() {
if let Ok(canonical_file) = fs::canonicalize(&file_path).await {
if !canonical_file.starts_with(&canonical_dir) {
warn!("Refused delete outside uploads dir: {}", filename);
return Err(AppError::BadRequest("Invalid filename".to_string()));
}
fs::remove_file(canonical_file).map_err(|e| {
fs::remove_file(canonical_file).await.map_err(|e| {
error!("Delete error for file {}: {}", filename, e);
AppError::Internal("Delete error".to_string(), Some(e.to_string()))
})?;
@@ -104,15 +105,30 @@ pub async fn list_uploads(
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),
});
if let Ok(mut rd) = fs::read_dir(&uploads_dir).await {
loop {
match rd.next_entry().await {
Ok(Some(entry)) => {
let path = entry.path();
let is_file = entry
.file_type()
.await
.map(|t| t.is_file())
.unwrap_or(false);
if !is_file {
continue;
}
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(None) => break,
Err(e) => {
warn!("Error iterating uploads dir: {}", e);
break;
}
}
}
@@ -178,7 +194,9 @@ pub async fn upload_file(
let uploads_dir = state.data_dir.join("uploads");
let target_path = uploads_dir.join(&final_name);
let final_path = if target_path.exists() && !query.replace.unwrap_or(false) {
let final_path = if fs::try_exists(&target_path).await.unwrap_or(false)
&& !query.replace.unwrap_or(false)
{
let timestamp = chrono::Utc::now().timestamp();
uploads_dir.join(format!("{}_{}", timestamp, final_name))
} else {
@@ -186,11 +204,11 @@ pub async fn upload_file(
};
// Final containment check.
let canonical_dir = uploads_dir.canonicalize().map_err(|e| {
let canonical_dir = fs::canonicalize(&uploads_dir).await.map_err(|e| {
AppError::Internal("Path resolution".to_string(), Some(e.to_string()))
})?;
if let Some(parent) = final_path.parent() {
let canonical_parent = parent.canonicalize().map_err(|e| {
let canonical_parent = fs::canonicalize(parent).await.map_err(|e| {
AppError::Internal("Path resolution".to_string(), Some(e.to_string()))
})?;
if canonical_parent != canonical_dir {
@@ -204,7 +222,7 @@ pub async fn upload_file(
.unwrap_or(&final_name)
.to_string();
fs::write(&final_path, &data).map_err(|e| {
fs::write(&final_path, &data).await.map_err(|e| {
error!("Failed to write file to {:?}: {}", final_path, e);
AppError::Internal("Write error".to_string(), Some(e.to_string()))
})?;
+32 -4
View File
@@ -9,19 +9,23 @@ use axum::{
http::{HeaderValue, header},
routing::{delete, get, post},
};
use std::{collections::HashMap, env, fs, path::PathBuf, sync::Arc};
use tokio::sync::Mutex;
use std::{collections::HashMap, env, path::PathBuf, sync::Arc, time::Duration};
use tokio::sync::{Mutex, RwLock};
use tower_http::{
cors::{AllowOrigin, CorsLayer},
services::ServeDir,
};
use tracing::{error, info, warn};
use crate::handlers::contact::RATE_LIMIT_WINDOW_MS;
use crate::models::PostInfo;
pub struct AppState {
pub admin_token: String,
pub data_dir: PathBuf,
pub cookie_secure: bool,
pub post_lock: Mutex<()>,
pub posts_cache: RwLock<Vec<PostInfo>>,
pub contact_rate_limit: Mutex<HashMap<String, Vec<i64>>>,
}
@@ -50,10 +54,10 @@ async fn main() {
let posts_dir = data_dir.join("posts");
let uploads_dir = data_dir.join("uploads");
if let Err(e) = fs::create_dir_all(&posts_dir) {
if let Err(e) = tokio::fs::create_dir_all(&posts_dir).await {
error!("Failed to create posts directory: {}", e);
}
if let Err(e) = fs::create_dir_all(&uploads_dir) {
if let Err(e) = tokio::fs::create_dir_all(&uploads_dir).await {
error!("Failed to create uploads directory: {}", e);
}
@@ -62,9 +66,15 @@ async fn main() {
data_dir,
cookie_secure,
post_lock: Mutex::new(()),
posts_cache: RwLock::new(Vec::new()),
contact_rate_limit: Mutex::new(HashMap::new()),
});
handlers::posts::rebuild_posts_cache(&state).await;
info!("Posts cache primed with {} entries", state.posts_cache.read().await.len());
spawn_rate_limit_reaper(state.clone());
// CORS — locked down by default. Set FRONTEND_ORIGIN to the public URL of
// the frontend if you ever expose the backend directly to browsers.
// Normal deployments hit the backend through the Astro proxy, which is
@@ -126,3 +136,21 @@ async fn main() {
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
axum::serve(listener, app).await.unwrap();
}
/// Periodically prunes expired entries from the contact rate-limit map so it
/// can't grow unbounded across the lifetime of the process.
fn spawn_rate_limit_reaper(state: Arc<AppState>) {
tokio::spawn(async move {
let mut ticker = tokio::time::interval(Duration::from_secs(300));
ticker.tick().await;
loop {
ticker.tick().await;
let now_ms = chrono::Utc::now().timestamp_millis();
let mut map = state.contact_rate_limit.lock().await;
map.retain(|_, times| {
times.retain(|t| now_ms - *t < RATE_LIMIT_WINDOW_MS);
!times.is_empty()
});
}
});
}
+12 -1
View File
@@ -86,7 +86,7 @@ pub struct CoverImage {
pub alt: String,
}
#[derive(Serialize)]
#[derive(Serialize, Clone)]
pub struct PostInfo {
pub slug: String,
pub date: NaiveDate,
@@ -103,6 +103,13 @@ pub struct PostInfo {
pub image_count: u32,
}
#[derive(Serialize, Clone)]
pub struct PostNeighbor {
pub slug: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
}
#[derive(Serialize)]
pub struct PostDetail {
pub slug: String,
@@ -118,6 +125,10 @@ pub struct PostDetail {
#[serde(skip_serializing_if = "Option::is_none")]
pub cover_image: Option<CoverImage>,
pub image_count: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub prev: Option<PostNeighbor>,
#[serde(skip_serializing_if = "Option::is_none")]
pub next: Option<PostNeighbor>,
}
#[derive(Deserialize)]
+40 -8
View File
@@ -100,6 +100,14 @@ export default function Editor({ editSlug }: Props) {
const [isDragging, setIsDragging] = useState(false);
const [uploadingCount, setUploadingCount] = useState(0);
const dragDepthRef = useRef(0);
const assetsCacheRef = useRef<Asset[] | null>(null);
async function getCachedAssets(): Promise<Asset[]> {
if (assetsCacheRef.current) return assetsCacheRef.current;
const assets = await getAssets();
assetsCacheRef.current = assets;
return assets;
}
function showAlertMsg(msg: string, type: 'success' | 'error') {
setAlert({ msg, type });
@@ -245,7 +253,7 @@ export default function Editor({ editSlug }: Props) {
async function triggerAutocomplete(view: EditorView) {
try {
const assets = await getAssets();
const assets = await getCachedAssets();
setAutocompleteAssets(assets.slice(0, 8));
const pos = view.state.selection.main.head;
const coords = view.coordsAtPos(pos);
@@ -292,22 +300,41 @@ export default function Editor({ editSlug }: Props) {
return;
}
setUploadingCount(c => c + images.length);
// Fire all uploads in parallel; the browser caps per-origin concurrency.
// Insert results in submission order so the markdown reflects user intent.
const uploads = images.map(file =>
uploadAsset(file).then(
asset => ({ ok: true as const, asset }),
err => ({ ok: false as const, err }),
),
);
let pos = typeof insertAt === 'number' ? insertAt : view.state.selection.main.head;
for (const file of images) {
try {
const asset = await uploadAsset(file);
const newAssets: Asset[] = [];
for (const promise of uploads) {
const result = await promise;
setUploadingCount(c => Math.max(0, c - 1));
if (result.ok) {
const { asset } = result;
newAssets.push(asset);
const md = `![${asset.name}](${asset.url})`;
const line = view.state.doc.lineAt(pos);
const atLineEnd = pos === line.to;
const insertText = atLineEnd ? `\n\n${md}\n` : `${md}\n\n`;
view.dispatch({ changes: { from: pos, insert: insertText } });
pos += insertText.length;
} catch (e) {
} else {
const e = result.err;
showAlertMsg(e instanceof ApiError ? `Upload failed: ${e.message}` : 'Upload failed.', 'error');
} finally {
setUploadingCount(c => Math.max(0, c - 1));
}
}
if (newAssets.length > 0) {
assetsCacheRef.current = assetsCacheRef.current
? [...newAssets, ...assetsCacheRef.current]
: null;
}
view.focus();
}
@@ -316,6 +343,11 @@ export default function Editor({ editSlug }: Props) {
setShowModal(false);
}
function closeAssetModal() {
assetsCacheRef.current = null;
setShowModal(false);
}
async function handleSave() {
const content = viewRef.current?.state.doc.toString() || '';
if (!title.trim() || !slug || !content) {
@@ -647,7 +679,7 @@ export default function Editor({ editSlug }: Props) {
<h2 className="font-display italic text-2xl md:text-3xl text-[var(--text)] leading-tight">Add image</h2>
<p className="text-xs text-[var(--subtext0)] font-display italic mt-1">Click an image to insert it. Drag new files in to upload.</p>
</div>
<button onClick={() => setShowModal(false)} className="p-2 text-[var(--subtext0)] hover:text-[var(--red)] transition-colors">
<button onClick={closeAssetModal} className="p-2 text-[var(--subtext0)] hover:text-[var(--red)] transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
</button>
</header>
+12 -19
View File
@@ -7,6 +7,10 @@ const { slug } = Astro.params;
const API_URL = (typeof process !== 'undefined' ? process.env.PUBLIC_API_URL : import.meta.env.PUBLIC_API_URL) || 'http://localhost:3000';
interface CoverImage { url: string; alt: string }
interface PostNeighbor {
slug: string;
title?: string;
}
interface PostDetail {
slug: string;
date: string;
@@ -18,10 +22,8 @@ interface PostDetail {
reading_time: number;
cover_image?: CoverImage;
image_count: number;
}
interface PostInfo {
slug: string;
title?: string;
prev?: PostNeighbor;
next?: PostNeighbor;
}
function formatDate(d: string) {
@@ -36,35 +38,26 @@ function formatSlug(s: string) {
let post: PostDetail | null = null;
let html = '';
let error = '';
let neighbors: { prev?: PostInfo; next?: PostInfo } = {};
try {
const [postRes, listRes] = await Promise.all([
fetch(`${API_URL}/api/posts/${encodeURIComponent(slug ?? '')}`),
fetch(`${API_URL}/api/posts`),
]);
const postRes = await fetch(`${API_URL}/api/posts/${encodeURIComponent(slug ?? '')}`);
if (postRes.ok) {
post = await postRes.json();
html = renderMarkdown(post!.content);
} else {
error = 'Work not found in the catalogue';
}
if (listRes.ok) {
const list: PostInfo[] = await listRes.json();
const i = list.findIndex(p => p.slug === slug);
if (i >= 0) {
neighbors = {
prev: i > 0 ? list[i - 1] : undefined,
next: i < list.length - 1 ? list[i + 1] : undefined,
};
}
}
} catch (e) {
const cause = (e as any)?.cause;
error = `Could not connect to backend at ${API_URL}: ${e instanceof Error ? e.message : String(e)}${cause ? ' (Cause: ' + (cause.message || cause.code || JSON.stringify(cause)) + ')' : ''}`;
console.error(error);
}
const neighbors = {
prev: post?.prev,
next: post?.next,
};
const isAdmin = Astro.cookies.get('admin_session')?.value === '1';
const displayTitle = post ? (post.title || formatSlug(post.slug)) : 'Work';
---
+12 -6
View File
@@ -8,10 +8,16 @@ if (!response.ok) {
return new Response(null, { status: 404 });
}
return new Response(await response.blob(), {
headers: {
'content-type': response.headers.get('content-type') || 'application/octet-stream',
'cache-control': 'public, max-age=3600'
}
});
const headers = new Headers();
const contentType = response.headers.get('content-type');
if (contentType) headers.set('content-type', contentType);
const contentLength = response.headers.get('content-length');
if (contentLength) headers.set('content-length', contentLength);
const etag = response.headers.get('etag');
if (etag) headers.set('etag', etag);
const lastModified = response.headers.get('last-modified');
if (lastModified) headers.set('last-modified', lastModified);
headers.set('cache-control', 'public, max-age=3600');
return new Response(response.body, { headers });
---