@quasarbright/projection
Version:
A static site generator that creates a beautiful, interactive gallery to showcase your coding projects. Features search, filtering, tags, responsive design, and an admin UI.
782 lines • 33.6 kB
JavaScript
"use strict";
/**
* Admin server for managing project data through a web interface
*/
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.AdminServer = void 0;
exports.startAdminServer = startAdminServer;
const express_1 = __importDefault(require("express"));
const cors_1 = __importDefault(require("cors"));
const path_1 = __importDefault(require("path"));
const multer_1 = __importDefault(require("multer"));
const file_manager_1 = require("./file-manager");
const image_manager_1 = require("./image-manager");
const config_file_manager_1 = require("./config-file-manager");
const deployment_service_1 = require("./deployment-service");
const config_1 = require("../../generator/config");
const validator_1 = require("../../generator/validator");
const html_builder_1 = require("../../generator/html-builder");
/**
* Admin server class that provides a web interface for managing projects
*/
class AdminServer {
constructor(config) {
this.config = config;
this.app = (0, express_1.default)();
this.fileManager = new file_manager_1.FileManager(config.projectsFilePath);
this.imageManager = new image_manager_1.ImageManager(path_1.default.dirname(config.projectsFilePath));
this.configFileManager = new config_file_manager_1.ConfigFileManager(path_1.default.dirname(config.projectsFilePath));
this.configLoader = new config_1.ConfigLoader(path_1.default.dirname(config.projectsFilePath));
this.connections = new Set();
// Configure multer for file uploads
this.upload = (0, multer_1.default)({
storage: multer_1.default.memoryStorage(),
limits: {
fileSize: image_manager_1.ImageManager.getMaxFileSize()
},
fileFilter: (req, file, cb) => {
const allowedMimes = image_manager_1.ImageManager.getSupportedMimeTypes();
if (allowedMimes.includes(file.mimetype)) {
cb(null, true);
}
else {
cb(new Error('Invalid file type. Supported types: PNG, JPG, JPEG, GIF, WebP'));
}
}
});
this.setupMiddleware();
this.setupRoutes();
}
/**
* Set up Express middleware
*/
setupMiddleware() {
// Enable CORS for development
if (this.config.cors) {
this.app.use((0, cors_1.default)());
}
// Parse JSON request bodies
this.app.use(express_1.default.json());
// Parse URL-encoded request bodies
this.app.use(express_1.default.urlencoded({ extended: true }));
// Serve screenshots directory for thumbnail images
const screenshotsPath = path_1.default.join(path_1.default.dirname(this.config.projectsFilePath), 'screenshots');
this.app.use('/screenshots', express_1.default.static(screenshotsPath));
// Serve template assets (CSS/JS) for preview iframe
const templatePath = path_1.default.join(__dirname, '../../templates/default');
this.app.use('/styles', express_1.default.static(path_1.default.join(templatePath, 'styles')));
this.app.use('/scripts', express_1.default.static(path_1.default.join(templatePath, 'scripts')));
this.app.use('/assets', express_1.default.static(path_1.default.join(templatePath, 'assets')));
// Serve static files from the admin client build directory
const clientBuildPath = path_1.default.join(__dirname, '../client');
this.app.use(express_1.default.static(clientBuildPath));
}
/**
* Set up Express routes
*/
setupRoutes() {
// Health check endpoint
this.app.get('/api/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// GET /api/projects - Read all projects and config
this.app.get('/api/projects', async (req, res) => {
try {
const projectsData = await this.fileManager.readProjects();
const config = await this.configLoader.load({
configPath: this.config.configFilePath
});
res.json({
projects: projectsData.projects,
config
});
}
catch (error) {
if (error.code === 'ENOENT') {
res.status(404).json({
error: 'Projects file not found',
message: `Could not find projects file at ${this.config.projectsFilePath}. Try running 'projection init' first.`,
path: this.config.projectsFilePath
});
}
else {
console.error('Error reading projects:', error);
res.status(500).json({
error: 'Failed to read projects',
message: error.message
});
}
}
});
// POST /api/projects - Create a new project
this.app.post('/api/projects', async (req, res) => {
try {
const newProject = req.body.project;
if (!newProject) {
res.status(400).json({
error: 'Invalid request',
message: 'Request body must contain a "project" field'
});
return;
}
// Read existing projects for validation
const projectsData = await this.fileManager.readProjects();
const allProjects = [...projectsData.projects, newProject];
// Validate the new project
const validator = new validator_1.Validator(path_1.default.dirname(this.config.projectsFilePath));
const validationResult = validator.validateProjects(allProjects);
if (!validationResult.valid) {
res.status(400).json({
success: false,
errors: validationResult.errors,
message: 'Validation failed'
});
return;
}
// Add the project
await this.fileManager.addProject(newProject);
res.status(201).json({
success: true,
project: newProject,
warnings: validationResult.warnings
});
}
catch (error) {
console.error('Error creating project:', error);
res.status(500).json({
error: 'Failed to create project',
message: error.message
});
}
});
// PUT /api/projects/:id - Update an existing project
this.app.put('/api/projects/:id', async (req, res) => {
try {
const projectId = req.params.id;
const updatedProject = req.body.project;
if (!updatedProject) {
res.status(400).json({
error: 'Invalid request',
message: 'Request body must contain a "project" field'
});
return;
}
// Read existing projects
const projectsData = await this.fileManager.readProjects();
const projectIndex = projectsData.projects.findIndex(p => p.id === projectId);
if (projectIndex === -1) {
res.status(404).json({
error: 'Project not found',
message: `No project found with id: ${projectId}`
});
return;
}
// Create updated projects array for validation
const allProjects = [...projectsData.projects];
allProjects[projectIndex] = updatedProject;
// Validate the updated project
const validator = new validator_1.Validator(path_1.default.dirname(this.config.projectsFilePath));
const validationResult = validator.validateProjects(allProjects);
if (!validationResult.valid) {
res.status(400).json({
success: false,
errors: validationResult.errors,
message: 'Validation failed'
});
return;
}
// Update the project
await this.fileManager.updateProject(projectId, updatedProject);
res.json({
success: true,
project: updatedProject,
warnings: validationResult.warnings
});
}
catch (error) {
console.error('Error updating project:', error);
res.status(500).json({
error: 'Failed to update project',
message: error.message
});
}
});
// DELETE /api/projects/:id - Delete a project
this.app.delete('/api/projects/:id', async (req, res) => {
try {
const projectId = req.params.id;
// Read existing projects to verify it exists
const projectsData = await this.fileManager.readProjects();
const projectExists = projectsData.projects.some(p => p.id === projectId);
if (!projectExists) {
res.status(404).json({
error: 'Project not found',
message: `No project found with id: ${projectId}`
});
return;
}
// Delete the project
await this.fileManager.deleteProject(projectId);
res.json({
success: true,
deletedId: projectId
});
}
catch (error) {
console.error('Error deleting project:', error);
res.status(500).json({
error: 'Failed to delete project',
message: error.message
});
}
});
// GET /api/tags - Get all unique tags with usage counts
this.app.get('/api/tags', async (req, res) => {
try {
const projectsData = await this.fileManager.readProjects();
// Count tag usage
const tagCounts = new Map();
projectsData.projects.forEach(project => {
project.tags.forEach(tag => {
tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1);
});
});
// Convert to array and sort by count (descending), then by name
const tags = Array.from(tagCounts.entries())
.map(([name, count]) => ({ name, count }))
.sort((a, b) => {
if (b.count !== a.count) {
return b.count - a.count;
}
return a.name.localeCompare(b.name);
});
res.json({ tags });
}
catch (error) {
console.error('Error reading tags:', error);
res.status(500).json({
error: 'Failed to read tags',
message: error.message
});
}
});
// GET /api/config - Get merged configuration
this.app.get('/api/config', async (req, res) => {
try {
const config = await this.configLoader.load({
configPath: this.config.configFilePath
});
res.json({ config });
}
catch (error) {
console.error('Error loading config:', error);
res.status(500).json({
error: 'Failed to load configuration',
message: error.message
});
}
});
// PUT /api/config - Update configuration
this.app.put('/api/config', async (req, res) => {
try {
const newConfig = req.body.config;
if (!newConfig) {
res.status(400).json({
error: 'Invalid request',
message: 'Request body must contain a "config" field'
});
return;
}
// Validate required fields
const errors = [];
if (!newConfig.title || typeof newConfig.title !== 'string' || newConfig.title.trim() === '') {
errors.push('title is required and must be a non-empty string');
}
if (!newConfig.description || typeof newConfig.description !== 'string' || newConfig.description.trim() === '') {
errors.push('description is required and must be a non-empty string');
}
if (!newConfig.baseUrl || typeof newConfig.baseUrl !== 'string' || newConfig.baseUrl.trim() === '') {
errors.push('baseUrl is required and must be a non-empty string');
}
// Validate baseUrl format (URL or relative path)
if (newConfig.baseUrl) {
const baseUrl = newConfig.baseUrl.trim();
const isRelativePath = baseUrl.startsWith('./') || baseUrl.startsWith('../') || baseUrl === '.';
const isAbsolutePath = baseUrl.startsWith('/');
if (!isRelativePath && !isAbsolutePath) {
// Try to parse as URL
try {
new URL(baseUrl);
}
catch {
errors.push('baseUrl must be a valid URL or relative path (e.g., "./" or "https://example.com")');
}
}
}
// Validate dynamicBackgrounds if provided
if (newConfig.dynamicBackgrounds !== undefined) {
if (!Array.isArray(newConfig.dynamicBackgrounds)) {
errors.push('dynamicBackgrounds must be an array');
}
else {
newConfig.dynamicBackgrounds.forEach((url, index) => {
if (typeof url !== 'string') {
errors.push(`dynamicBackgrounds[${index}] must be a string`);
}
else {
try {
new URL(url);
}
catch {
errors.push(`dynamicBackgrounds[${index}] must be a valid URL`);
}
}
});
}
}
if (errors.length > 0) {
res.status(400).json({
success: false,
errors: errors.map(msg => ({ message: msg })),
message: 'Validation failed'
});
return;
}
// Write the config to projection.config.json
await this.configFileManager.writeConfig(newConfig);
// Reload config to get the updated version
const updatedConfig = await this.configLoader.load({
configPath: this.config.configFilePath
});
res.json({
success: true,
config: updatedConfig
});
}
catch (error) {
console.error('Error updating config:', error);
res.status(500).json({
error: 'Failed to update configuration',
message: error.message
});
}
});
// GET /api/preview - Generate full portfolio HTML with admin controls
this.app.get('/api/preview', async (req, res) => {
try {
// Load current project data
const projectsData = await this.fileManager.readProjects();
// Load configuration
const config = await this.configLoader.load({
configPath: this.config.configFilePath
});
// Instantiate HTMLBuilder with adminMode: true
const htmlBuilder = new html_builder_1.HTMLBuilder(config, { adminMode: true });
// Generate HTML
const html = htmlBuilder.generateHTML(projectsData);
// Set appropriate headers
res.setHeader('Content-Type', 'text/html');
res.setHeader('X-Frame-Options', 'SAMEORIGIN'); // Allow iframe from same origin
res.send(html);
}
catch (error) {
console.error('Error generating preview:', error);
res.status(500).json({
error: 'Failed to generate preview',
message: error.message
});
}
});
// POST /api/preview - Generate preview HTML for a project
this.app.post('/api/preview', async (req, res) => {
try {
const partialProject = req.body.project;
if (!partialProject) {
res.status(400).json({
error: 'Invalid request',
message: 'Request body must contain a "project" field'
});
return;
}
// Load config for preview rendering
const config = await this.configLoader.load({
configPath: this.config.configFilePath
});
// Create a complete project with defaults for missing fields
const previewProject = {
id: partialProject.id || 'preview',
title: partialProject.title || 'Untitled Project',
description: partialProject.description || 'No description provided',
creationDate: partialProject.creationDate || new Date().toISOString().split('T')[0],
tags: partialProject.tags || [],
pageLink: partialProject.pageLink || '#',
sourceLink: partialProject.sourceLink,
thumbnailLink: partialProject.thumbnailLink,
featured: partialProject.featured || false
};
// Generate HTML using HTMLBuilder
const htmlBuilder = new html_builder_1.HTMLBuilder(config);
const cardHTML = htmlBuilder.generateProjectCard(previewProject);
// Wrap in a complete HTML document with styles
const fullHTML = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Preview</title>
<link rel="stylesheet" href="/styles/main.css">
<link rel="stylesheet" href="/styles/cards.css">
<style>
body {
padding: 20px;
background: #1a1a2e;
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
}
.projects-grid {
display: grid;
grid-template-columns: 1fr;
gap: 20px;
max-width: 600px;
margin: 0 auto;
}
</style>
</head>
<body>
<div class="projects-grid">
${cardHTML}
</div>
</body>
</html>`;
res.send(fullHTML);
}
catch (error) {
console.error('Error generating preview:', error);
res.status(500).json({
error: 'Failed to generate preview',
message: error.message
});
}
});
// POST /api/projects/:id/thumbnail - Upload thumbnail image
this.app.post('/api/projects/:id/thumbnail', this.upload.single('thumbnail'), async (req, res) => {
try {
const projectId = req.params.id;
const file = req.file;
const isEditMode = req.query.edit === 'true'; // Check if editing existing project
if (!file) {
res.status(400).json({
success: false,
error: 'No file uploaded',
message: 'Request must include a file in the "thumbnail" field'
});
return;
}
let thumbnailLink;
if (isEditMode) {
// For edit mode, save as temporary file
thumbnailLink = await this.imageManager.saveTempImage(projectId, {
buffer: file.buffer,
mimetype: file.mimetype,
size: file.size
});
}
else {
// For new projects, save directly
thumbnailLink = await this.imageManager.saveImage(projectId, {
buffer: file.buffer,
mimetype: file.mimetype,
size: file.size
});
// If project exists, update its thumbnailLink
try {
const projectsData = await this.fileManager.readProjects();
const project = projectsData.projects.find(p => p.id === projectId);
if (project) {
const updatedProject = { ...project, thumbnailLink };
await this.fileManager.updateProject(projectId, updatedProject);
}
}
catch (error) {
// Ignore errors reading/updating project - the image is saved regardless
console.log('Note: Image saved but project not updated (may not exist yet)');
}
}
res.json({
success: true,
thumbnailLink,
isTemp: isEditMode
});
}
catch (error) {
console.error('Error uploading thumbnail:', error);
res.status(500).json({
success: false,
error: 'Failed to upload thumbnail',
message: error.message
});
}
});
// DELETE /api/projects/:id/thumbnail - Delete thumbnail image
this.app.delete('/api/projects/:id/thumbnail', async (req, res) => {
try {
const projectId = req.params.id;
const isTemp = req.query.temp === 'true';
if (isTemp) {
// Delete temp file
await this.imageManager.deleteTempImage(projectId);
}
else {
// Delete the image file
await this.imageManager.deleteImage(projectId);
// If project exists, clear its thumbnailLink
try {
const projectsData = await this.fileManager.readProjects();
const project = projectsData.projects.find(p => p.id === projectId);
if (project) {
const updatedProject = { ...project, thumbnailLink: undefined };
await this.fileManager.updateProject(projectId, updatedProject);
}
}
catch (error) {
// Ignore errors reading/updating project
console.log('Note: Image deleted but project not updated (may not exist)');
}
}
res.json({
success: true
});
}
catch (error) {
console.error('Error deleting thumbnail:', error);
res.status(500).json({
success: false,
error: 'Failed to delete thumbnail',
message: error.message
});
}
});
// POST /api/projects/:id/thumbnail/commit - Commit temporary thumbnail
this.app.post('/api/projects/:id/thumbnail/commit', async (req, res) => {
try {
const projectId = req.params.id;
// Commit the temp file (rename to final)
const thumbnailLink = await this.imageManager.commitTempImage(projectId);
if (!thumbnailLink) {
res.status(404).json({
success: false,
error: 'No temporary thumbnail found',
message: `No temporary thumbnail found for project: ${projectId}`
});
return;
}
res.json({
success: true,
thumbnailLink
});
}
catch (error) {
console.error('Error committing thumbnail:', error);
res.status(500).json({
success: false,
error: 'Failed to commit thumbnail',
message: error.message
});
}
});
// POST /api/projects/:id/thumbnail/cancel - Cancel temporary thumbnail
this.app.post('/api/projects/:id/thumbnail/cancel', async (req, res) => {
try {
const projectId = req.params.id;
// Delete the temp file
await this.imageManager.deleteTempImage(projectId);
res.json({
success: true
});
}
catch (error) {
console.error('Error canceling thumbnail:', error);
res.status(500).json({
success: false,
error: 'Failed to cancel thumbnail',
message: error.message
});
}
});
// GET /api/deploy/status - Check deployment readiness and configuration
this.app.get('/api/deploy/status', async (req, res) => {
try {
const cwd = path_1.default.dirname(this.config.projectsFilePath);
const status = await deployment_service_1.DeploymentService.getDeploymentStatus(cwd);
res.json(status);
}
catch (error) {
console.error('Error checking deployment status:', error);
res.status(500).json({
error: 'Failed to check deployment status',
message: error.message
});
}
});
// GET /api/deploy/config - Get deployment configuration details
this.app.get('/api/deploy/config', async (req, res) => {
try {
const cwd = path_1.default.dirname(this.config.projectsFilePath);
const config = await deployment_service_1.DeploymentService.getDeploymentConfig(cwd);
res.json(config);
}
catch (error) {
console.error('Error getting deployment config:', error);
res.status(500).json({
error: 'Failed to get deployment configuration',
message: error.message
});
}
});
// POST /api/deploy - Trigger deployment to GitHub Pages
this.app.post('/api/deploy', async (req, res) => {
try {
const cwd = path_1.default.dirname(this.config.projectsFilePath);
const deployRequest = req.body;
// Validate request body
if (deployRequest.force !== undefined && typeof deployRequest.force !== 'boolean') {
res.status(400).json({
error: 'Invalid request',
message: 'force must be a boolean'
});
return;
}
if (deployRequest.message !== undefined && typeof deployRequest.message !== 'string') {
res.status(400).json({
error: 'Invalid request',
message: 'message must be a string'
});
return;
}
// Execute deployment
const result = await deployment_service_1.DeploymentService.deploy(cwd, deployRequest);
if (result.success) {
res.json(result);
}
else {
res.status(500).json(result);
}
}
catch (error) {
console.error('Error during deployment:', error);
res.status(500).json({
success: false,
message: 'Deployment failed',
error: {
code: 'DEPLOYMENT_ERROR',
message: error.message
}
});
}
});
// Fallback route for client-side routing (SPA)
// Use a regex pattern to match all paths that don't start with /api
this.app.get(/^(?!\/api).*$/, (req, res) => {
const indexPath = path_1.default.join(__dirname, '../client/index.html');
res.sendFile(indexPath, (err) => {
if (err) {
res.status(404).send('Admin client not found. Please build the admin client first.');
}
});
});
}
/**
* Start the admin server
* @returns The actual port the server is listening on
*/
async start() {
return new Promise((resolve, reject) => {
try {
this.server = this.app.listen(this.config.port, () => {
const actualPort = this.server.address().port;
console.log(`Admin server running at http://localhost:${actualPort}`);
resolve(actualPort);
});
// Track connections for graceful shutdown
this.server.on('connection', (conn) => {
this.connections.add(conn);
conn.on('close', () => {
this.connections.delete(conn);
});
});
// Handle port already in use error
this.server.on('error', (error) => {
if (error.code === 'EADDRINUSE') {
const portError = new Error(`Port ${this.config.port} is already in use. Please choose a different port using --port flag.`);
reject(portError);
}
else {
reject(error);
}
});
}
catch (error) {
reject(error);
}
});
}
/**
* Stop the admin server gracefully
*/
async stop() {
return new Promise((resolve, reject) => {
if (!this.server) {
resolve();
return;
}
// Close all active connections
for (const conn of this.connections) {
conn.destroy();
}
this.connections.clear();
this.server.close((err) => {
if (err) {
reject(err);
}
else {
console.log('Admin server stopped gracefully');
resolve();
}
});
});
}
/**
* Get the Express app instance (useful for testing)
*/
getApp() {
return this.app;
}
}
exports.AdminServer = AdminServer;
/**
* Create and start an admin server with the given configuration
* @returns Object containing the server instance and the actual port it's running on
*/
async function startAdminServer(config) {
const server = new AdminServer(config);
const actualPort = await server.start();
// Handle graceful shutdown on SIGINT (Ctrl+C) and SIGTERM
const shutdownHandler = async () => {
console.log('\nShutting down admin server...');
try {
await server.stop();
process.exit(0);
}
catch (error) {
console.error('Error during shutdown:', error);
process.exit(1);
}
};
process.on('SIGINT', shutdownHandler);
process.on('SIGTERM', shutdownHandler);
return { server, port: actualPort };
}
//# sourceMappingURL=index.js.map