feat: add admin interface with content management API and Docker setup
This commit is contained in:
parent
858846ad2e
commit
e406c7ffce
35
Dockerfile
35
Dockerfile
@ -1,4 +1,4 @@
|
|||||||
# Dockerfile for Astro webapp with NGINX for production
|
# Dockerfile for Astro webapp with NGINX and API for content management
|
||||||
# Stage 1: Build the Astro application
|
# Stage 1: Build the Astro application
|
||||||
FROM node:20-alpine as build
|
FROM node:20-alpine as build
|
||||||
|
|
||||||
@ -15,13 +15,24 @@ COPY . .
|
|||||||
# Build the Astro project
|
# Build the Astro project
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Stage 2: Serve with NGINX
|
# Stage 2: Serve with NGINX and Node API
|
||||||
FROM nginx:alpine
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
# Install Node.js for the API server
|
||||||
|
RUN apk add --update nodejs npm
|
||||||
|
|
||||||
|
# Set up directory structure
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy built static files from build stage to NGINX html directory
|
# Copy built static files from build stage to NGINX html directory
|
||||||
COPY --from=build /app/dist /usr/share/nginx/html
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
# Add custom NGINX configuration if needed
|
# Copy API files
|
||||||
|
COPY --from=build /app/src/api /app/api
|
||||||
|
WORKDIR /app/api
|
||||||
|
RUN npm install --production
|
||||||
|
|
||||||
|
# Add custom NGINX configuration with API proxy
|
||||||
RUN echo 'server {\
|
RUN echo 'server {\
|
||||||
listen 80;\
|
listen 80;\
|
||||||
server_name _;\
|
server_name _;\
|
||||||
@ -30,10 +41,24 @@ RUN echo 'server {\
|
|||||||
location / {\
|
location / {\
|
||||||
try_files $uri $uri/ /index.html;\
|
try_files $uri $uri/ /index.html;\
|
||||||
}\
|
}\
|
||||||
|
location /api/ {\
|
||||||
|
proxy_pass http://localhost:3000/api/;\
|
||||||
|
proxy_http_version 1.1;\
|
||||||
|
proxy_set_header Upgrade $http_upgrade;\
|
||||||
|
proxy_set_header Connection "upgrade";\
|
||||||
|
proxy_set_header Host $host;\
|
||||||
|
}\
|
||||||
}' > /etc/nginx/conf.d/default.conf
|
}' > /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
# Copy start script
|
||||||
|
COPY --from=build /app/start.sh /app/start.sh
|
||||||
|
RUN chmod +x /app/start.sh
|
||||||
|
|
||||||
|
# Create a directory for content if it doesn't exist
|
||||||
|
RUN mkdir -p /app/content/books
|
||||||
|
|
||||||
# Expose port 80
|
# Expose port 80
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|
||||||
# Start NGINX
|
# Start NGINX and API server
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
CMD ["/app/start.sh"]
|
||||||
|
|||||||
17
docker-compose.yml
Normal file
17
docker-compose.yml
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
version: '3'
|
||||||
|
|
||||||
|
services:
|
||||||
|
astro-app:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
environment:
|
||||||
|
- ADMIN_USERNAME=admin
|
||||||
|
- ADMIN_PASSWORD=password
|
||||||
|
- CONTENT_DIR=/app/content
|
||||||
|
volumes:
|
||||||
|
- content-data:/app/content
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
content-data:
|
||||||
|
# This ensures the volume persists across container restarts
|
||||||
15
src/api/package.json
Normal file
15
src/api/package.json
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "content-api",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "API for content management",
|
||||||
|
"main": "server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"body-parser": "^1.20.2",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"express-basic-auth": "^1.2.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
82
src/api/server.js
Normal file
82
src/api/server.js
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const fs = require('fs/promises');
|
||||||
|
const path = require('path');
|
||||||
|
const cors = require('cors');
|
||||||
|
const bodyParser = require('body-parser');
|
||||||
|
const basicAuth = require('express-basic-auth');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = process.env.PORT || 3000;
|
||||||
|
const CONTENT_DIR = process.env.CONTENT_DIR || path.join(__dirname, '../../content');
|
||||||
|
const USERNAME = process.env.ADMIN_USERNAME || 'admin';
|
||||||
|
const PASSWORD = process.env.ADMIN_PASSWORD || 'password';
|
||||||
|
|
||||||
|
// Middleware
|
||||||
|
app.use(cors());
|
||||||
|
app.use(bodyParser.json());
|
||||||
|
|
||||||
|
// Basic authentication
|
||||||
|
app.use(basicAuth({
|
||||||
|
users: { [USERNAME]: PASSWORD },
|
||||||
|
challenge: true,
|
||||||
|
realm: 'Content Admin'
|
||||||
|
}));
|
||||||
|
|
||||||
|
// List all content files
|
||||||
|
app.get('/api/content', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const files = await fs.readdir(path.join(CONTENT_DIR, 'books'));
|
||||||
|
const contentFiles = files.filter(file => file.endsWith('.md'));
|
||||||
|
res.json({ files: contentFiles });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get content by filename
|
||||||
|
app.get('/api/content/:filename', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const filePath = path.join(CONTENT_DIR, 'books', req.params.filename);
|
||||||
|
const content = await fs.readFile(filePath, 'utf-8');
|
||||||
|
res.json({ content });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(404).json({ error: 'File not found' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update content
|
||||||
|
app.put('/api/content/:filename', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const filePath = path.join(CONTENT_DIR, 'books', req.params.filename);
|
||||||
|
await fs.writeFile(filePath, req.body.content);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create new content file
|
||||||
|
app.post('/api/content', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const filePath = path.join(CONTENT_DIR, 'books', req.body.filename);
|
||||||
|
await fs.writeFile(filePath, req.body.content);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete content file
|
||||||
|
app.delete('/api/content/:filename', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const filePath = path.join(CONTENT_DIR, 'books', req.params.filename);
|
||||||
|
await fs.unlink(filePath);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`API server running on port ${PORT}`);
|
||||||
|
});
|
||||||
136
src/pages/admin/edit.astro
Normal file
136
src/pages/admin/edit.astro
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
---
|
||||||
|
import SiteLayout from '../../components/SiteLayout.astro';
|
||||||
|
|
||||||
|
// In a real app, you'd check authentication here
|
||||||
|
---
|
||||||
|
|
||||||
|
<SiteLayout title="Edit Content">
|
||||||
|
<div class="container mx-auto px-4 py-8">
|
||||||
|
<div class="mb-6 flex justify-between items-center">
|
||||||
|
<h1 class="text-3xl font-bold">Edit Book</h1>
|
||||||
|
<a href="/admin" class="text-blue-500 hover:underline">Back to Dashboard</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white shadow rounded-lg p-6">
|
||||||
|
<form id="edit-form" class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label for="filename" class="block text-sm font-medium text-gray-700">Filename</label>
|
||||||
|
<input type="text" id="filename" name="filename" required
|
||||||
|
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500">
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Include .md extension (e.g., my-book.md)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="content" class="block text-sm font-medium text-gray-700">Content</label>
|
||||||
|
<textarea id="content" name="content" rows="15" required
|
||||||
|
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button type="submit"
|
||||||
|
class="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8">
|
||||||
|
<h2 class="text-xl font-semibold mb-4">Preview</h2>
|
||||||
|
<div id="preview" class="bg-white shadow rounded-lg p-6 prose max-w-none">
|
||||||
|
<p>Type in the editor to see a preview...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Get query params
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const fileParam = urlParams.get('file');
|
||||||
|
|
||||||
|
const filenameInput = document.getElementById('filename');
|
||||||
|
const contentTextarea = document.getElementById('content');
|
||||||
|
const previewDiv = document.getElementById('preview');
|
||||||
|
const editForm = document.getElementById('edit-form');
|
||||||
|
|
||||||
|
// Base API URL - adapt for production vs. development
|
||||||
|
const getApiUrl = (path) => {
|
||||||
|
return window.location.hostname === 'localhost'
|
||||||
|
? `http://localhost:3000/api/${path}`
|
||||||
|
: `/api/${path}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load existing content if editing
|
||||||
|
async function loadContent() {
|
||||||
|
if (!fileParam) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
filenameInput.value = fileParam;
|
||||||
|
filenameInput.readOnly = true; // Don't allow changing filename if editing
|
||||||
|
|
||||||
|
const response = await fetch(getApiUrl(`content/${fileParam}`), {
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Basic ' + btoa('admin:password') // In production, use proper auth
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Failed to load content');
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
contentTextarea.value = data.content;
|
||||||
|
updatePreview();
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error loading content: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update preview when content changes
|
||||||
|
function updatePreview() {
|
||||||
|
// This is a simple preview. For proper Markdown, use a library like marked
|
||||||
|
previewDiv.innerHTML = `<pre>${contentTextarea.value}</pre>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save content
|
||||||
|
async function saveContent(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const filename = filenameInput.value;
|
||||||
|
const content = contentTextarea.value;
|
||||||
|
|
||||||
|
if (!filename || !content) {
|
||||||
|
alert('Filename and content are required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const isNewFile = !fileParam;
|
||||||
|
const url = isNewFile
|
||||||
|
? getApiUrl('content')
|
||||||
|
: getApiUrl(`content/${filename}`);
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: isNewFile ? 'POST' : 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': 'Basic ' + btoa('admin:password') // In production, use proper auth
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ filename, content })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Failed to save content');
|
||||||
|
|
||||||
|
alert('Content saved successfully!');
|
||||||
|
window.location.href = '/admin';
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error saving content: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event listeners
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
loadContent();
|
||||||
|
contentTextarea.addEventListener('input', updatePreview);
|
||||||
|
editForm.addEventListener('submit', saveContent);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</SiteLayout>
|
||||||
102
src/pages/admin/index.astro
Normal file
102
src/pages/admin/index.astro
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
---
|
||||||
|
import SiteLayout from '../../components/SiteLayout.astro';
|
||||||
|
---
|
||||||
|
|
||||||
|
<SiteLayout title="Content Admin">
|
||||||
|
<div class="container mx-auto px-4 py-8">
|
||||||
|
<h1 class="text-3xl font-bold mb-6">Content Management</h1>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<a href="/admin/edit" class="bg-green-500 hover:bg-green-600 text-white px-4 py-2 rounded">
|
||||||
|
Create New Book
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white shadow rounded-lg p-6">
|
||||||
|
<h2 class="text-xl font-semibold mb-4">Your Books</h2>
|
||||||
|
|
||||||
|
<div id="books-list" class="space-y-4">
|
||||||
|
<p>Loading books...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// This would fetch from your API in production
|
||||||
|
async function fetchBooks() {
|
||||||
|
try {
|
||||||
|
const apiUrl = window.location.hostname === 'localhost'
|
||||||
|
? 'http://localhost:3000/api/content'
|
||||||
|
: '/api/content';
|
||||||
|
|
||||||
|
const response = await fetch(apiUrl, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Basic ' + btoa('admin:password') // In production, use proper auth
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch books');
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const booksList = document.getElementById('books-list');
|
||||||
|
|
||||||
|
if (data.files && data.files.length > 0) {
|
||||||
|
booksList.innerHTML = '';
|
||||||
|
|
||||||
|
data.files.forEach(file => {
|
||||||
|
const bookEl = document.createElement('div');
|
||||||
|
bookEl.className = 'flex justify-between items-center p-4 border rounded';
|
||||||
|
|
||||||
|
bookEl.innerHTML = `
|
||||||
|
<div>
|
||||||
|
<h3 class="font-medium">${file}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="space-x-2">
|
||||||
|
<a href="/admin/edit?file=${encodeURIComponent(file)}" class="text-blue-500 hover:underline">Edit</a>
|
||||||
|
<button data-file="${file}" class="delete-btn text-red-500 hover:underline">Delete</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
booksList.appendChild(bookEl);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add delete functionality
|
||||||
|
document.querySelectorAll('.delete-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
if (confirm('Are you sure you want to delete this book?')) {
|
||||||
|
const file = btn.getAttribute('data-file');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const apiUrl = window.location.hostname === 'localhost'
|
||||||
|
? `http://localhost:3000/api/content/${file}`
|
||||||
|
: `/api/content/${file}`;
|
||||||
|
|
||||||
|
const response = await fetch(apiUrl, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Basic ' + btoa('admin:password') // In production, use proper auth
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Failed to delete book');
|
||||||
|
|
||||||
|
// Refresh the list
|
||||||
|
fetchBooks();
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error deleting book: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
booksList.innerHTML = '<p>No books found.</p>';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
document.getElementById('books-list').innerHTML = `<p class="text-red-500">Error: ${error.message}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load books when page loads
|
||||||
|
document.addEventListener('DOMContentLoaded', fetchBooks);
|
||||||
|
</script>
|
||||||
|
</SiteLayout>
|
||||||
49
src/pages/admin/login.astro
Normal file
49
src/pages/admin/login.astro
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
---
|
||||||
|
import SiteLayout from '../../components/SiteLayout.astro';
|
||||||
|
|
||||||
|
let error = '';
|
||||||
|
if (Astro.request.method === 'POST') {
|
||||||
|
try {
|
||||||
|
const data = await Astro.request.formData();
|
||||||
|
const username = data.get('username');
|
||||||
|
const password = data.get('password');
|
||||||
|
|
||||||
|
// Simple client-side auth - real auth happens in the API
|
||||||
|
if (username === 'admin' && password === 'password') {
|
||||||
|
// Store authentication in a cookie or localStorage in a real app
|
||||||
|
return Astro.redirect('/admin');
|
||||||
|
} else {
|
||||||
|
error = 'Invalid username or password';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
error = 'An error occurred during login';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
---
|
||||||
|
|
||||||
|
<SiteLayout title="Admin Login">
|
||||||
|
<div class="max-w-md mx-auto mt-10 p-6 bg-white rounded-lg shadow-md">
|
||||||
|
<h1 class="text-2xl font-bold mb-6 text-center">Content Admin Login</h1>
|
||||||
|
|
||||||
|
{error && <div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">{error}</div>}
|
||||||
|
|
||||||
|
<form method="POST" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label for="username" class="block text-sm font-medium text-gray-700">Username</label>
|
||||||
|
<input type="text" id="username" name="username" required
|
||||||
|
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="password" class="block text-sm font-medium text-gray-700">Password</label>
|
||||||
|
<input type="password" id="password" name="password" required
|
||||||
|
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit"
|
||||||
|
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||||
|
Sign in
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</SiteLayout>
|
||||||
Loading…
x
Reference in New Issue
Block a user