ui redesign, markdown fix + metadata and auth header
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
---
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
---
|
||||
|
||||
<Layout title="Not found" description="The page you're looking for doesn't exist.">
|
||||
<div class="glass p-8 md:p-16 text-center max-w-2xl mx-auto mt-8 md:mt-16">
|
||||
<p class="text-7xl md:text-8xl font-extrabold bg-clip-text text-transparent bg-gradient-to-r from-mauve via-blue to-teal mb-4">
|
||||
404
|
||||
</p>
|
||||
<h1 class="text-2xl md:text-3xl font-semibold text-text mb-3">Page not found</h1>
|
||||
<p class="text-subtext1 mb-8">
|
||||
The page you're looking for has moved, been deleted, or never existed.
|
||||
</p>
|
||||
<a
|
||||
href="/"
|
||||
class="inline-flex items-center gap-2 bg-mauve text-crust font-semibold px-5 py-2.5 rounded-lg hover:bg-pink transition-colors"
|
||||
>
|
||||
<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="m12 19-7-7 7-7"/><path d="M19 12H5"/></svg>
|
||||
Back to home
|
||||
</a>
|
||||
</div>
|
||||
</Layout>
|
||||
@@ -1,57 +1,45 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
|
||||
const FORBIDDEN_HEADERS = new Set([
|
||||
'host', 'connection', 'content-length', 'transfer-encoding', 'origin', 'referer',
|
||||
]);
|
||||
|
||||
export const ALL: APIRoute = async ({ request, params }) => {
|
||||
const API_URL = process.env.PUBLIC_API_URL || 'http://backend:3000';
|
||||
const path = params.path;
|
||||
|
||||
|
||||
if (!path) {
|
||||
return new Response(JSON.stringify({ error: 'Missing path' }), { status: 400 });
|
||||
}
|
||||
|
||||
// Ensure path is properly encoded
|
||||
const url = new URL(`${API_URL}/api/${path}`);
|
||||
const requestUrl = new URL(request.url);
|
||||
url.search = requestUrl.search;
|
||||
|
||||
const headers = new Headers();
|
||||
// Filter headers to avoid conflicts.
|
||||
const forbiddenHeaders = ['host', 'connection', 'content-length', 'transfer-encoding', 'origin', 'referer'];
|
||||
|
||||
request.headers.forEach((value, key) => {
|
||||
if (!forbiddenHeaders.includes(key.toLowerCase())) {
|
||||
if (!FORBIDDEN_HEADERS.has(key.toLowerCase())) {
|
||||
headers.set(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`\n[Proxy Req] ${request.method} -> ${url.toString()}`);
|
||||
console.log(`[Proxy Req] Auth Header Present:`, headers.has('authorization'));
|
||||
console.log(`[Proxy Req] Content-Type:`, headers.get('content-type'));
|
||||
|
||||
try {
|
||||
const fetchOptions: RequestInit = {
|
||||
method: request.method,
|
||||
headers: headers,
|
||||
headers,
|
||||
};
|
||||
|
||||
// Safely handle body for mutating requests
|
||||
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
||||
// Clone the request to safely access the body stream
|
||||
const reqClone = request.clone();
|
||||
|
||||
// For DELETE requests, check if a body actually exists before attaching it
|
||||
// Some fetch implementations fail if a body is provided for DELETE
|
||||
if (request.method !== 'DELETE' || reqClone.body) {
|
||||
fetchOptions.body = reqClone.body;
|
||||
// Required by Node.js fetch when body is a ReadableStream
|
||||
// @ts-ignore
|
||||
fetchOptions.duplex = 'half';
|
||||
}
|
||||
const reqClone = request.clone();
|
||||
if (request.method !== 'DELETE' || reqClone.body) {
|
||||
fetchOptions.body = reqClone.body;
|
||||
// @ts-ignore — required by Node fetch when body is a stream
|
||||
fetchOptions.duplex = 'half';
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString(), fetchOptions);
|
||||
|
||||
console.log(`[Proxy Res] Backend returned ${response.status}`);
|
||||
|
||||
const responseHeaders = new Headers();
|
||||
response.headers.forEach((value, key) => {
|
||||
responseHeaders.set(key, value);
|
||||
@@ -59,16 +47,13 @@ export const ALL: APIRoute = async ({ request, params }) => {
|
||||
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
headers: responseHeaders
|
||||
headers: responseHeaders,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(`[Proxy Error] ${request.method} ${url}:`, e);
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Proxy connection failed',
|
||||
details: e instanceof Error ? e.message : String(e)
|
||||
}), {
|
||||
console.error(`[Proxy] ${request.method} ${url} failed:`, e);
|
||||
return new Response(JSON.stringify({ error: 'Proxy connection failed' }), {
|
||||
status: 502,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
|
||||
interface PostInfo {
|
||||
slug: string;
|
||||
date: string;
|
||||
summary?: string;
|
||||
excerpt?: string;
|
||||
tags: string[];
|
||||
draft: boolean;
|
||||
}
|
||||
|
||||
interface SiteConfig {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
}
|
||||
|
||||
function escapeXml(s: string): string {
|
||||
return s.replace(/[<>&'"]/g, c => ({
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'&': '&',
|
||||
"'": ''',
|
||||
'"': '"',
|
||||
}[c]!));
|
||||
}
|
||||
|
||||
function formatSlug(slug: string): string {
|
||||
return slug
|
||||
.split('-')
|
||||
.map(w => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
export const GET: APIRoute = async ({ site }) => {
|
||||
const API_URL = process.env.PUBLIC_API_URL || 'http://backend:3000';
|
||||
const origin = site?.toString().replace(/\/$/, '') || '';
|
||||
|
||||
let posts: PostInfo[] = [];
|
||||
let config: SiteConfig = { title: 'Narlblog', subtitle: 'A clean, modern blog' };
|
||||
try {
|
||||
const [pr, cr] = await Promise.all([
|
||||
fetch(`${API_URL}/api/posts`),
|
||||
fetch(`${API_URL}/api/config`),
|
||||
]);
|
||||
if (pr.ok) posts = await pr.json();
|
||||
if (cr.ok) config = await cr.json();
|
||||
} catch (e) {
|
||||
console.error('feed.xml backend fetch failed', e);
|
||||
}
|
||||
|
||||
const items = posts
|
||||
.filter(p => !p.draft)
|
||||
.map(p => {
|
||||
const url = `${origin}/posts/${p.slug}`;
|
||||
const description = p.summary || p.excerpt || '';
|
||||
const pubDate = new Date(p.date).toUTCString();
|
||||
const categories = p.tags.map(t => ` <category>${escapeXml(t)}</category>`).join('\n');
|
||||
return ` <item>
|
||||
<title>${escapeXml(formatSlug(p.slug))}</title>
|
||||
<link>${escapeXml(url)}</link>
|
||||
<guid isPermaLink="true">${escapeXml(url)}</guid>
|
||||
<pubDate>${pubDate}</pubDate>
|
||||
<description>${escapeXml(description)}</description>
|
||||
${categories}
|
||||
</item>`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
||||
<channel>
|
||||
<title>${escapeXml(config.title)}</title>
|
||||
<link>${escapeXml(origin)}</link>
|
||||
<description>${escapeXml(config.subtitle)}</description>
|
||||
<language>en</language>
|
||||
<atom:link href="${escapeXml(origin)}/feed.xml" rel="self" type="application/rss+xml" />
|
||||
${items}
|
||||
</channel>
|
||||
</rss>`;
|
||||
|
||||
return new Response(xml, {
|
||||
headers: {
|
||||
'Content-Type': 'application/xml; charset=utf-8',
|
||||
'Cache-Control': 'public, max-age=600',
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -6,7 +6,11 @@ const API_URL = process.env.PUBLIC_API_URL || 'http://localhost:3000';
|
||||
|
||||
interface Post {
|
||||
slug: string;
|
||||
date: string;
|
||||
excerpt?: string;
|
||||
tags: string[];
|
||||
draft: boolean;
|
||||
reading_time: number;
|
||||
}
|
||||
|
||||
let posts: Post[] = [];
|
||||
@@ -46,7 +50,7 @@ function formatSlug(slug: string) {
|
||||
}
|
||||
---
|
||||
|
||||
<Layout title="Home">
|
||||
<Layout title="Home" description={siteConfig.welcome_subtitle}>
|
||||
<div class="space-y-6 md:space-y-8">
|
||||
<section class="text-center py-6 md:py-12">
|
||||
<h1 class="text-3xl md:text-5xl font-extrabold mb-3 md:mb-4 pb-2 md:pb-4 leading-tight bg-clip-text text-transparent bg-gradient-to-r from-mauve via-blue to-teal">
|
||||
@@ -71,10 +75,14 @@ function formatSlug(slug: string) {
|
||||
)}
|
||||
|
||||
{posts.map((post) => (
|
||||
<PostCard
|
||||
slug={post.slug}
|
||||
excerpt={post.excerpt}
|
||||
formatSlug={formatSlug}
|
||||
<PostCard
|
||||
slug={post.slug}
|
||||
date={post.date}
|
||||
excerpt={post.excerpt}
|
||||
tags={post.tags}
|
||||
draft={post.draft}
|
||||
readingTime={post.reading_time}
|
||||
formatSlug={formatSlug}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
---
|
||||
import Layout from '../../layouts/Layout.astro';
|
||||
import PostEnhancer from '../../components/react/PostEnhancer';
|
||||
import { marked } from 'marked';
|
||||
import { renderMarkdown } from '../../lib/markdown';
|
||||
|
||||
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 PostDetail {
|
||||
slug: string;
|
||||
date: string;
|
||||
content: string;
|
||||
summary?: string;
|
||||
tags: string[];
|
||||
draft: boolean;
|
||||
reading_time: number;
|
||||
}
|
||||
|
||||
function formatDate(d: string) {
|
||||
return new Date(d).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||
}
|
||||
|
||||
let post: PostDetail | null = null;
|
||||
@@ -19,7 +27,7 @@ try {
|
||||
const response = await fetch(`${API_URL}/api/posts/${slug}`);
|
||||
if (response.ok) {
|
||||
post = await response.json();
|
||||
html = await marked.parse(post!.content);
|
||||
html = renderMarkdown(post!.content);
|
||||
} else {
|
||||
error = 'Post not found';
|
||||
}
|
||||
@@ -34,10 +42,14 @@ function formatSlug(s: string) {
|
||||
return s.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
|
||||
}
|
||||
|
||||
const isAdmin = Astro.cookies.has('admin_token');
|
||||
const isAdmin = Astro.cookies.get('admin_session')?.value === '1';
|
||||
---
|
||||
|
||||
<Layout title={post ? formatSlug(post.slug) : 'Post'}>
|
||||
<Layout
|
||||
title={post ? formatSlug(post.slug) : 'Post'}
|
||||
description={post?.summary}
|
||||
type="article"
|
||||
>
|
||||
<article class="glass p-6 md:p-12 mb-8 md:mb-12 animate-in fade-in slide-in-from-bottom-4 duration-700">
|
||||
{error && (
|
||||
<div class="text-red text-center py-12">
|
||||
@@ -58,30 +70,45 @@ const isAdmin = Astro.cookies.has('admin_token');
|
||||
Back to list
|
||||
</a>
|
||||
<div class="flex flex-col md:flex-row md:justify-between md:items-start mt-2 md:mt-4 gap-4">
|
||||
<h1 class="text-3xl md:text-5xl font-extrabold text-mauve">
|
||||
{formatSlug(post.slug)}
|
||||
</h1>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h1 class="text-3xl md:text-5xl font-extrabold text-mauve mb-3">
|
||||
{formatSlug(post.slug)}
|
||||
</h1>
|
||||
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-subtext0">
|
||||
<time datetime={post.date}>{formatDate(post.date)}</time>
|
||||
<span class="opacity-50">·</span>
|
||||
<span>{post.reading_time} min read</span>
|
||||
{post.draft && (
|
||||
<>
|
||||
<span class="opacity-50">·</span>
|
||||
<span class="text-peach uppercase tracking-wide font-semibold">Draft</span>
|
||||
</>
|
||||
)}
|
||||
{post.tags?.length > 0 && (
|
||||
<>
|
||||
<span class="opacity-50">·</span>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{post.tags.map(tag => (
|
||||
<span class="text-[10px] uppercase tracking-wider px-2 py-0.5 rounded-full bg-surface0 text-subtext0 border border-surface1">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
id="edit-link"
|
||||
href={`/admin/editor?edit=${post.slug}`}
|
||||
class="bg-surface0 hover:bg-surface1 text-blue px-3 py-1.5 md:px-4 md:py-2 rounded border border-surface1 transition-colors inline-flex items-center gap-2 text-sm md:text-base self-start hidden"
|
||||
style="color: var(--blue);"
|
||||
class={`bg-surface0 hover:bg-surface1 text-blue px-3 py-1.5 md:px-4 md:py-2 rounded border border-surface1 transition-colors inline-flex items-center gap-2 text-sm md:text-base self-start ${isAdmin ? '' : 'hidden'}`}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="md:w-4 md:h-4"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg>
|
||||
Edit
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
<div id="post-content" class="prose max-w-none" set:html={html} />
|
||||
<PostEnhancer client:only="react" containerId="post-content" />
|
||||
<div id="post-content" class="prose" set:html={html} />
|
||||
</>
|
||||
)}
|
||||
</article>
|
||||
</Layout>
|
||||
|
||||
<script>
|
||||
if (localStorage.getItem('admin_token')) {
|
||||
const el = document.getElementById('edit-link');
|
||||
if (el) el.classList.remove('hidden');
|
||||
}
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user