feat: add LogoManagementModal component for logo upload and management

This commit is contained in:
Lars Behrends
2025-12-28 21:19:10 +01:00
parent 2481187fe7
commit 81f1e90b85
13 changed files with 2963 additions and 52 deletions

View File

@@ -13,6 +13,9 @@ RUN npm install
# Copy source files # Copy source files
COPY backend/ /app/ COPY backend/ /app/
# Create uploads directory
RUN mkdir -p /app/uploads
# Expose port # Expose port
EXPOSE 3000 EXPOSE 3000

View File

@@ -69,19 +69,19 @@ const SEED_ORGS = [
]; ];
const SEED_PROJECTS = [ const SEED_PROJECTS = [
{ {
id: 'ven-1', id: 'ven-1',
title: 'DrkButz Architektur', title: 'DrkButz Architektur',
description: 'Führendes Architekturbüro...', description: 'Führendes Architekturbüro...',
category: 'Enterprise', category: 'Enterprise',
status: 'active', status: 'active',
progress: 85, progress: 85,
owner: 'DrKButz', owner: 'DrKButz',
employees: JSON.stringify([]), employees: JSON.stringify([]),
hiring: 0, hiring: 0,
foundedDate: 'Zyklus 12', foundedDate: 'Zyklus 12',
associatedOrgId: 'org-4', associatedOrgId: 'org-4',
bannerUrl: 'images/screenshots/2025-12-28_01.11.49.png', bannerImageId: null,
shopCatalog: JSON.stringify([]) shopCatalog: JSON.stringify([])
} }
]; ];
@@ -130,10 +130,23 @@ function setupTables() {
status VARCHAR(50), status VARCHAR(50),
mayor VARCHAR(255), mayor VARCHAR(255),
establishedYear VARCHAR(100), establishedYear VARCHAR(100),
bannerUrl VARCHAR(255), bannerImageId VARCHAR(50),
logoImageId VARCHAR(50),
gallery JSON, gallery JSON,
cityStats JSON cityStats JSON
)`, )`,
`CREATE TABLE IF NOT EXISTS images (
id VARCHAR(50) PRIMARY KEY,
filename VARCHAR(255) NOT NULL,
originalName VARCHAR(255),
mimeType VARCHAR(100),
size INTEGER,
uploadDate DATETIME DEFAULT CURRENT_TIMESTAMP,
altText VARCHAR(255),
entityType VARCHAR(50), -- 'project', 'org', 'player'
entityId VARCHAR(50), -- ID of the owning entity
imageType VARCHAR(50) -- 'banner', 'gallery'
)`,
`CREATE TABLE IF NOT EXISTS projects ( `CREATE TABLE IF NOT EXISTS projects (
id VARCHAR(50) PRIMARY KEY, id VARCHAR(50) PRIMARY KEY,
title VARCHAR(255), title VARCHAR(255),
@@ -146,7 +159,8 @@ function setupTables() {
hiring TINYINT, hiring TINYINT,
foundedDate VARCHAR(100), foundedDate VARCHAR(100),
associatedOrgId VARCHAR(50), associatedOrgId VARCHAR(50),
bannerUrl VARCHAR(255), bannerImageId VARCHAR(50),
logoImageId VARCHAR(50),
shopCatalog JSON, shopCatalog JSON,
gallery JSON gallery JSON
)` )`
@@ -177,8 +191,8 @@ function seedData() {
if (!err && rows[0].count === 0) { if (!err && rows[0].count === 0) {
console.log("Seeding Orgs..."); console.log("Seeding Orgs...");
SEED_ORGS.forEach(o => { SEED_ORGS.forEach(o => {
pool.query("INSERT INTO orgs VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", pool.query("INSERT INTO orgs VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
[o.id, o.name, o.type, o.description, o.memberCount, o.status, o.mayor, o.establishedYear, o.bannerUrl, o.gallery, o.cityStats]); [o.id, o.name, o.type, o.description, o.memberCount, o.status, o.mayor, o.establishedYear, null, null, o.gallery, o.cityStats]);
}); });
} }
}); });
@@ -187,8 +201,8 @@ function seedData() {
if (!err && rows[0].count === 0) { if (!err && rows[0].count === 0) {
console.log("Seeding Projects..."); console.log("Seeding Projects...");
SEED_PROJECTS.forEach(p => { SEED_PROJECTS.forEach(p => {
pool.query("INSERT INTO projects VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", pool.query("INSERT INTO projects VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
[p.id, p.title, p.description, p.category, p.status, p.progress, p.owner, p.employees, p.hiring, p.foundedDate, p.associatedOrgId, p.bannerUrl, p.shopCatalog, '[]']); [p.id, p.title, p.description, p.category, p.status, p.progress, p.owner, p.employees, p.hiring, p.foundedDate, p.associatedOrgId, p.bannerImageId, null, p.shopCatalog, '[]']);
}); });
} }
}); });

1231
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -7,11 +7,12 @@
"start": "node server.js" "start": "node server.js"
}, },
"dependencies": { "dependencies": {
"cors": "^2.8.5",
"express": "^4.18.2", "express": "^4.18.2",
"express-session": "^1.17.3", "express-session": "^1.17.3",
"multer": "^2.0.2",
"mysql2": "^3.6.0",
"passport": "^0.6.0", "passport": "^0.6.0",
"passport-discord": "^0.1.4", "passport-discord": "^0.1.4"
"cors": "^2.8.5",
"mysql2": "^3.6.0"
} }
} }

View File

@@ -3,6 +3,9 @@ const session = require('express-session');
const passport = require('passport'); const passport = require('passport');
const DiscordStrategy = require('passport-discord').Strategy; const DiscordStrategy = require('passport-discord').Strategy;
const cors = require('cors'); const cors = require('cors');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const { db, init } = require('./database'); const { db, init } = require('./database');
const app = express(); const app = express();
@@ -13,6 +16,40 @@ const CLIENT_ID = process.env.DISCORD_CLIENT_ID || 'mock_id';
const CLIENT_SECRET = process.env.DISCORD_CLIENT_SECRET || 'mock_secret'; const CLIENT_SECRET = process.env.DISCORD_CLIENT_SECRET || 'mock_secret';
const CALLBACK_URL = process.env.CALLBACK_URL || 'http://localhost:3000/auth/discord/callback'; const CALLBACK_URL = process.env.CALLBACK_URL || 'http://localhost:3000/auth/discord/callback';
const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:8000'; const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:8000';
const BACKEND_URL = process.env.BACKEND_URL || 'https://vollidioten.ceraticsoft.de';
// File upload configuration
const UPLOAD_DIR = path.join(__dirname, 'uploads');
if (!fs.existsSync(UPLOAD_DIR)) {
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
}
// Configure multer for file uploads
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, UPLOAD_DIR);
},
filename: (req, file, cb) => {
// Generate unique filename with timestamp
const uniqueName = Date.now() + '-' + Math.round(Math.random() * 1E9) + path.extname(file.originalname);
cb(null, uniqueName);
}
});
const upload = multer({
storage: storage,
limits: {
fileSize: 10 * 1024 * 1024, // 10MB limit
},
fileFilter: (req, file, cb) => {
// Allow only image files
if (file.mimetype.startsWith('image/')) {
cb(null, true);
} else {
cb(new Error('Nur Bilddateien sind erlaubt'), false);
}
}
});
init(); // Initialize DB init(); // Initialize DB
@@ -239,13 +276,32 @@ app.get('/api/orgs', (req, res) => {
if (err) return res.status(500).json({error: err.message}); if (err) return res.status(500).json({error: err.message});
const parsed = rows.map(r => ({ const parsed = rows.map(r => ({
...r, ...r,
gallery: JSON.parse(r.gallery), gallery: JSON.parse(r.gallery || '[]'),
bannerUrl: getImageUrl(r.bannerImageId),
logoUrl: getImageUrl(r.logoImageId),
cityStats: r.cityStats ? JSON.parse(r.cityStats) : undefined cityStats: r.cityStats ? JSON.parse(r.cityStats) : undefined
})); }));
res.json(parsed); res.json(parsed);
}); });
}); });
// Helper function to get full image URL
function getImageUrl(imageId) {
if (!imageId) return null;
return `${BACKEND_URL}/api/images/${imageId}`;
}
// Helper function to resolve gallery image IDs to URLs
function resolveGalleryImages(gallery) {
if (!gallery) return [];
try {
const imageIds = JSON.parse(gallery);
return imageIds.map(id => ({ id, url: getImageUrl(id) }));
} catch (e) {
return [];
}
}
// API: Projects // API: Projects
app.get('/api/projects', (req, res) => { app.get('/api/projects', (req, res) => {
db.all("SELECT * FROM projects", (err, rows) => { db.all("SELECT * FROM projects", (err, rows) => {
@@ -254,7 +310,9 @@ app.get('/api/projects', (req, res) => {
...r, ...r,
employees: JSON.parse(r.employees), employees: JSON.parse(r.employees),
shopCatalog: JSON.parse(r.shopCatalog), shopCatalog: JSON.parse(r.shopCatalog),
gallery: JSON.parse(r.gallery), gallery: resolveGalleryImages(r.gallery),
bannerUrl: getImageUrl(r.bannerImageId),
logoUrl: getImageUrl(r.logoImageId),
hiring: !!r.hiring hiring: !!r.hiring
})); }));
res.json(parsed); res.json(parsed);
@@ -283,8 +341,8 @@ app.post('/api/projects', (req, res) => {
const projectId = 'proj_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9); const projectId = 'proj_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
db.run(`INSERT INTO projects db.run(`INSERT INTO projects
(id, title, description, category, status, progress, owner, employees, hiring, foundedDate, associatedOrgId, bannerUrl, shopCatalog, gallery) (id, title, description, category, status, progress, owner, employees, hiring, foundedDate, associatedOrgId, bannerImageId, shopCatalog, gallery)
VALUES (?, ?, ?, ?, 'active', 0, ?, '[]', 0, ?, ?, '', '[]', '[]')`, VALUES (?, ?, ?, ?, 'active', 0, ?, '[]', 0, ?, ?, null, '[]', '[]')`,
[projectId, title, description, category || 'Enterprise', projectOwner, [projectId, title, description, category || 'Enterprise', projectOwner,
new Date().toISOString().split('T')[0], associatedOrgId || null], new Date().toISOString().split('T')[0], associatedOrgId || null],
function(err) { function(err) {
@@ -458,8 +516,8 @@ app.post('/api/admin/npc-company', (req, res) => {
const projectId = 'npc_proj_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9); const projectId = 'npc_proj_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
db.run(`INSERT INTO projects db.run(`INSERT INTO projects
(id, title, description, category, status, progress, owner, employees, hiring, foundedDate, associatedOrgId, bannerUrl, shopCatalog, gallery) (id, title, description, category, status, progress, owner, employees, hiring, foundedDate, associatedOrgId, bannerImageId, shopCatalog, gallery)
VALUES (?, ?, ?, ?, 'active', ?, ?, '[]', 0, ?, ?, '', ?, '[]')`, VALUES (?, ?, ?, ?, 'active', ?, ?, '[]', 0, ?, ?, null, ?, '[]')`,
[projectId, title, description || `${title} - Eine NPC-Firma im Obsidian-Tal.`, category || 'Enterprise', [projectId, title, description || `${title} - Eine NPC-Firma im Obsidian-Tal.`, category || 'Enterprise',
Math.floor(Math.random() * 100), owner, new Date().toISOString().split('T')[0], Math.floor(Math.random() * 100), owner, new Date().toISOString().split('T')[0],
associatedOrgId || null, JSON.stringify(shopCatalog || [])], associatedOrgId || null, JSON.stringify(shopCatalog || [])],
@@ -882,11 +940,10 @@ app.post('/api/projects/:projectId/gallery', (req, res) => {
}); });
// Delete gallery image // Delete gallery image
app.delete('/api/projects/:projectId/gallery/:imageIndex', (req, res) => { app.delete('/api/projects/:projectId/gallery/:imageId', (req, res) => {
if (!req.isAuthenticated()) return res.status(401).send(); if (!req.isAuthenticated()) return res.status(401).send();
const { projectId, imageIndex } = req.params; const { projectId, imageId } = req.params;
const index = parseInt(imageIndex);
// Check ownership // Check ownership
db.get("SELECT owner, gallery FROM projects WHERE id = ?", [projectId], (err, row) => { db.get("SELECT owner, gallery FROM projects WHERE id = ?", [projectId], (err, row) => {
@@ -898,26 +955,639 @@ app.delete('/api/projects/:projectId/gallery/:imageIndex', (req, res) => {
try { try {
const gallery = JSON.parse(row.gallery || '[]'); const gallery = JSON.parse(row.gallery || '[]');
const imageIndex = gallery.indexOf(imageId);
if (index < 0 || index >= gallery.length) { if (imageIndex === -1) {
return res.status(404).json({error: 'Bild nicht gefunden'}); return res.status(404).json({error: 'Bild nicht in Galerie gefunden'});
} }
gallery.splice(index, 1); // Remove from gallery array
gallery.splice(imageIndex, 1);
// Update project gallery
db.run("UPDATE projects SET gallery = ? WHERE id = ?", [JSON.stringify(gallery), projectId], function(err) { db.run("UPDATE projects SET gallery = ? WHERE id = ?", [JSON.stringify(gallery), projectId], function(err) {
if (err) { if (err) {
console.error('Error deleting gallery image:', err); console.error('Error updating gallery:', err);
return res.status(500).json({error: 'Fehler beim Löschen'}); return res.status(500).json({error: 'Fehler beim Aktualisieren der Galerie'});
} }
res.json({success: true, gallery});
// Delete image record and file
db.get("SELECT filename FROM images WHERE id = ?", [imageId], (err, imageRow) => {
if (err) {
console.error('Error finding image record:', err);
return res.status(500).json({error: 'Fehler beim Löschen des Bildes'});
}
if (imageRow) {
// Delete file from filesystem
const filePath = path.join(UPLOAD_DIR, imageRow.filename);
try {
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
} catch (fileErr) {
console.error('Error deleting file:', fileErr);
}
// Delete image record from database
db.run("DELETE FROM images WHERE id = ?", [imageId], function(err) {
if (err) {
console.error('Error deleting image record:', err);
return res.status(500).json({error: 'Fehler beim Löschen des Bildes'});
}
res.json({
success: true,
gallery: gallery,
message: 'Bild erfolgreich gelöscht'
});
});
} else {
// Image record not found, but gallery was updated
res.json({
success: true,
gallery: gallery,
message: 'Bild aus Galerie entfernt'
});
}
});
}); });
} catch (e) { } catch (e) {
res.status(500).json({error: 'Fehler beim Verarbeiten der Daten'}); res.status(500).json({error: 'Fehler beim Verarbeiten der Galerie-Daten'});
} }
}); });
}); });
// === IMAGE MANAGEMENT API ===
// Get image by ID
app.get('/api/images/:imageId', (req, res) => {
const { imageId } = req.params;
db.get("SELECT * FROM images WHERE id = ?", [imageId], (err, row) => {
if (err) return res.status(500).json({error: err.message});
if (!row) return res.status(404).json({error: 'Bild nicht gefunden'});
// Serve the actual image file
const filePath = path.join(UPLOAD_DIR, row.filename);
if (fs.existsSync(filePath)) {
res.sendFile(filePath);
} else {
res.status(404).json({error: 'Bild-Datei nicht gefunden'});
}
});
});
// Get image metadata by ID
app.get('/api/images/:imageId/meta', (req, res) => {
const { imageId } = req.params;
db.get("SELECT * FROM images WHERE id = ?", [imageId], (err, row) => {
if (err) return res.status(500).json({error: err.message});
if (!row) return res.status(404).json({error: 'Bild nicht gefunden'});
res.json({
id: row.id,
filename: row.filename,
originalName: row.originalName,
mimeType: row.mimeType,
size: row.size,
uploadDate: row.uploadDate,
altText: row.altText,
url: `${BACKEND_URL}/api/images/${row.id}`
});
});
});
// Upload banner image
app.post('/api/projects/:projectId/banner/upload', upload.single('banner'), (req, res) => {
if (!req.isAuthenticated()) return res.status(401).json({error: 'Nicht authentifiziert'});
const { projectId } = req.params;
// Check ownership
db.get("SELECT owner FROM projects WHERE id = ?", [projectId], (err, row) => {
if (err) return res.status(500).json({error: err.message});
if (!row) return res.status(404).json({error: 'Projekt nicht gefunden'});
if (row.owner !== req.user.username && req.user.username !== 'admin') {
return res.status(403).json({error: 'Keine Berechtigung'});
}
if (!req.file) {
return res.status(400).json({error: 'Keine Datei hochgeladen'});
}
// Generate unique image ID
const imageId = 'img_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
// Create image record
const imageData = {
id: imageId,
filename: req.file.filename,
originalName: req.file.originalname,
mimeType: req.file.mimetype,
size: req.file.size,
uploadDate: new Date().toISOString(),
altText: req.body.altText || null,
entityType: 'project',
entityId: projectId,
imageType: 'banner'
};
db.run(`INSERT INTO images (id, filename, originalName, mimeType, size, uploadDate, altText, entityType, entityId, imageType)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[imageData.id, imageData.filename, imageData.originalName, imageData.mimeType, imageData.size,
imageData.uploadDate, imageData.altText, imageData.entityType, imageData.entityId, imageData.imageType],
function(err) {
if (err) {
console.error('Error creating image record:', err);
return res.status(500).json({error: 'Fehler beim Speichern des Bildes'});
}
// Update project banner
db.run("UPDATE projects SET bannerImageId = ? WHERE id = ?", [imageId, projectId], function(err) {
if (err) {
console.error('Error updating banner:', err);
return res.status(500).json({error: 'Fehler beim Aktualisieren des Banners'});
}
res.json({
success: true,
imageId: imageId,
imageUrl: `${BACKEND_URL}/api/images/${imageId}`,
message: 'Banner erfolgreich hochgeladen'
});
});
});
});
});
// Upload gallery image
app.post('/api/projects/:projectId/gallery/upload', upload.single('gallery'), (req, res) => {
if (!req.isAuthenticated()) return res.status(401).json({error: 'Nicht authentifiziert'});
const { projectId } = req.params;
// Check ownership
db.get("SELECT owner, gallery FROM projects WHERE id = ?", [projectId], (err, row) => {
if (err) return res.status(500).json({error: err.message});
if (!row) return res.status(404).json({error: 'Projekt nicht gefunden'});
if (row.owner !== req.user.username && req.user.username !== 'admin') {
return res.status(403).json({error: 'Keine Berechtigung'});
}
if (!req.file) {
return res.status(400).json({error: 'Keine Datei hochgeladen'});
}
// Generate unique image ID
const imageId = 'img_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
// Create image record
const imageData = {
id: imageId,
filename: req.file.filename,
originalName: req.file.originalname,
mimeType: req.file.mimetype,
size: req.file.size,
uploadDate: new Date().toISOString(),
altText: req.body.altText || null,
entityType: 'project',
entityId: projectId,
imageType: 'gallery'
};
db.run(`INSERT INTO images (id, filename, originalName, mimeType, size, uploadDate, altText, entityType, entityId, imageType)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[imageData.id, imageData.filename, imageData.originalName, imageData.mimeType, imageData.size,
imageData.uploadDate, imageData.altText, imageData.entityType, imageData.entityId, imageData.imageType],
function(err) {
if (err) {
console.error('Error creating image record:', err);
return res.status(500).json({error: 'Fehler beim Speichern des Bildes'});
}
try {
// Add image ID to gallery array
const gallery = JSON.parse(row.gallery || '[]');
gallery.push(imageId);
db.run("UPDATE projects SET gallery = ? WHERE id = ?", [JSON.stringify(gallery), projectId], function(err) {
if (err) {
console.error('Error updating gallery:', err);
return res.status(500).json({error: 'Fehler beim Aktualisieren der Galerie'});
}
res.json({
success: true,
imageId: imageId,
imageUrl: `${BACKEND_URL}/api/images/${imageId}`,
gallery: gallery,
message: 'Bild erfolgreich zur Galerie hinzugefügt'
});
});
} catch (e) {
console.error('Error parsing gallery:', e);
res.status(500).json({error: 'Fehler beim Verarbeiten der Galerie-Daten'});
}
});
});
});
// Upload logo image
app.post('/api/projects/:projectId/logo/upload', upload.single('logo'), (req, res) => {
if (!req.isAuthenticated()) return res.status(401).json({error: 'Nicht authentifiziert'});
const { projectId } = req.params;
// Check ownership
db.get("SELECT owner FROM projects WHERE id = ?", [projectId], (err, row) => {
if (err) return res.status(500).json({error: err.message});
if (!row) return res.status(404).json({error: 'Projekt nicht gefunden'});
if (row.owner !== req.user.username && req.user.username !== 'admin') {
return res.status(403).json({error: 'Keine Berechtigung'});
}
if (!req.file) {
return res.status(400).json({error: 'Keine Datei hochgeladen'});
}
// Generate unique image ID
const imageId = 'img_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
// Create image record
const imageData = {
id: imageId,
filename: req.file.filename,
originalName: req.file.originalname,
mimeType: req.file.mimetype,
size: req.file.size,
uploadDate: new Date().toISOString(),
altText: req.body.altText || `${row.title} Logo`,
entityType: 'project',
entityId: projectId,
imageType: 'logo'
};
db.run(`INSERT INTO images (id, filename, originalName, mimeType, size, uploadDate, altText, entityType, entityId, imageType)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[imageData.id, imageData.filename, imageData.originalName, imageData.mimeType, imageData.size,
imageData.uploadDate, imageData.altText, imageData.entityType, imageData.entityId, imageData.imageType],
function(err) {
if (err) {
console.error('Error creating logo image record:', err);
return res.status(500).json({error: 'Fehler beim Speichern des Logos'});
}
// Update project logo
db.run("UPDATE projects SET logoImageId = ? WHERE id = ?", [imageId, projectId], function(err) {
if (err) {
console.error('Error updating logo:', err);
return res.status(500).json({error: 'Fehler beim Aktualisieren des Logos'});
}
res.json({
success: true,
imageId: imageId,
logoUrl: `${BACKEND_URL}/api/images/${imageId}`,
message: 'Logo erfolgreich hochgeladen'
});
});
});
});
});
// === CITY MANAGEMENT API (Admin Only) ===
// Create new city
app.post('/api/admin/cities', (req, res) => {
if (!req.isAuthenticated()) return res.status(401).send();
// Check if user is admin
if (!req.user.isAdmin) {
return res.status(403).json({error: 'Admin-Berechtigung erforderlich'});
}
const { name, description, mayor, establishedYear, cityStats } = req.body;
if (!name) {
return res.status(400).json({error: 'Stadt-Name erforderlich'});
}
// Generate unique ID
const cityId = 'city_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
const cityData = {
id: cityId,
name: name.trim(),
type: 'City',
description: description || `${name} - Eine Stadt im Obsidian-Tal.`,
memberCount: 0,
status: 'active',
mayor: mayor || '',
establishedYear: establishedYear || new Date().getFullYear().toString(),
bannerImageId: null,
logoImageId: null,
gallery: '[]',
cityStats: cityStats ? JSON.stringify(cityStats) : JSON.stringify({
taxRate: 5.0,
biome: 'Ebene',
defenseRating: 5,
government: 'Demokratie',
specialty: 'Handel'
})
};
db.run(`INSERT INTO orgs (id, name, type, description, memberCount, status, mayor, establishedYear, bannerImageId, logoImageId, gallery, cityStats)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[cityData.id, cityData.name, cityData.type, cityData.description, cityData.memberCount, cityData.status,
cityData.mayor, cityData.establishedYear, cityData.bannerImageId, cityData.logoImageId, cityData.gallery, cityData.cityStats],
function(err) {
if (err) {
console.error('Error creating city:', err);
return res.status(500).json({error: 'Fehler beim Erstellen der Stadt'});
}
res.json({
success: true,
cityId: cityId,
message: 'Stadt erfolgreich erstellt'
});
}
);
});
// Update city
app.put('/api/admin/cities/:cityId', (req, res) => {
if (!req.isAuthenticated()) return res.status(401).send();
// Check if user is admin
if (!req.user.isAdmin) {
return res.status(403).json({error: 'Admin-Berechtigung erforderlich'});
}
const { cityId } = req.params;
const updates = req.body;
// Build dynamic update query
const allowedFields = ['name', 'description', 'mayor', 'establishedYear', 'status', 'cityStats'];
const updateFields = [];
const values = [];
for (const field of allowedFields) {
if (updates[field] !== undefined) {
if (field === 'cityStats') {
updateFields.push(`${field} = ?`);
values.push(JSON.stringify(updates[field]));
} else {
updateFields.push(`${field} = ?`);
values.push(updates[field]);
}
}
}
if (updateFields.length === 0) {
return res.status(400).json({error: 'Keine gültigen Felder zum Aktualisieren'});
}
const query = `UPDATE orgs SET ${updateFields.join(', ')} WHERE id = ? AND type = 'City'`;
values.push(cityId);
db.run(query, values, function(err) {
if (err) {
console.error('Error updating city:', err);
return res.status(500).json({error: 'Fehler beim Aktualisieren'});
}
if (this.changes === 0) {
return res.status(404).json({error: 'Stadt nicht gefunden'});
}
res.json({success: true, message: 'Stadt erfolgreich aktualisiert'});
});
});
// Delete city
app.delete('/api/admin/cities/:cityId', (req, res) => {
if (!req.isAuthenticated()) return res.status(401).send();
// Check if user is admin
if (!req.user.isAdmin) {
return res.status(403).json({error: 'Admin-Berechtigung erforderlich'});
}
const { cityId } = req.params;
db.run("DELETE FROM orgs WHERE id = ? AND type = 'City'", [cityId], function(err) {
if (err) {
console.error('Error deleting city:', err);
return res.status(500).json({error: 'Fehler beim Löschen'});
}
if (this.changes === 0) {
return res.status(404).json({error: 'Stadt nicht gefunden'});
}
res.json({success: true, message: 'Stadt erfolgreich gelöscht'});
});
});
// Upload city banner
app.post('/api/admin/cities/:cityId/banner/upload', upload.single('banner'), (req, res) => {
if (!req.isAuthenticated()) return res.status(401).json({error: 'Nicht authentifiziert'});
// Check if user is admin
if (!req.user.isAdmin) {
return res.status(403).json({error: 'Admin-Berechtigung erforderlich'});
}
const { cityId } = req.params;
// Check if city exists
db.get("SELECT name FROM orgs WHERE id = ? AND type = 'City'", [cityId], (err, row) => {
if (err) return res.status(500).json({error: err.message});
if (!row) return res.status(404).json({error: 'Stadt nicht gefunden'});
if (!req.file) {
return res.status(400).json({error: 'Keine Datei hochgeladen'});
}
// Generate unique image ID
const imageId = 'img_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
// Create image record
const imageData = {
id: imageId,
filename: req.file.filename,
originalName: req.file.originalname,
mimeType: req.file.mimetype,
size: req.file.size,
uploadDate: new Date().toISOString(),
altText: req.body.altText || `${row.name} Banner`,
entityType: 'org',
entityId: cityId,
imageType: 'banner'
};
db.run(`INSERT INTO images (id, filename, originalName, mimeType, size, uploadDate, altText, entityType, entityId, imageType)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[imageData.id, imageData.filename, imageData.originalName, imageData.mimeType, imageData.size,
imageData.uploadDate, imageData.altText, imageData.entityType, imageData.entityId, imageData.imageType],
function(err) {
if (err) {
console.error('Error creating city banner image record:', err);
return res.status(500).json({error: 'Fehler beim Speichern des Banners'});
}
// Update city banner
db.run("UPDATE orgs SET bannerImageId = ? WHERE id = ? AND type = 'City'", [imageId, cityId], function(err) {
if (err) {
console.error('Error updating city banner:', err);
return res.status(500).json({error: 'Fehler beim Aktualisieren des Banners'});
}
res.json({
success: true,
imageId: imageId,
bannerUrl: `${BACKEND_URL}/api/images/${imageId}`,
message: 'Banner erfolgreich hochgeladen'
});
});
});
});
});
// Upload city logo
app.post('/api/admin/cities/:cityId/logo/upload', upload.single('logo'), (req, res) => {
if (!req.isAuthenticated()) return res.status(401).json({error: 'Nicht authentifiziert'});
// Check if user is admin
if (!req.user.isAdmin) {
return res.status(403).json({error: 'Admin-Berechtigung erforderlich'});
}
const { cityId } = req.params;
// Check if city exists
db.get("SELECT name FROM orgs WHERE id = ? AND type = 'City'", [cityId], (err, row) => {
if (err) return res.status(500).json({error: err.message});
if (!row) return res.status(404).json({error: 'Stadt nicht gefunden'});
if (!req.file) {
return res.status(400).json({error: 'Keine Datei hochgeladen'});
}
// Generate unique image ID
const imageId = 'img_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
// Create image record
const imageData = {
id: imageId,
filename: req.file.filename,
originalName: req.file.originalname,
mimeType: req.file.mimetype,
size: req.file.size,
uploadDate: new Date().toISOString(),
altText: req.body.altText || `${row.name} Logo`,
entityType: 'org',
entityId: cityId,
imageType: 'logo'
};
db.run(`INSERT INTO images (id, filename, originalName, mimeType, size, uploadDate, altText, entityType, entityId, imageType)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[imageData.id, imageData.filename, imageData.originalName, imageData.mimeType, imageData.size,
imageData.uploadDate, imageData.altText, imageData.entityType, imageData.entityId, imageData.imageType],
function(err) {
if (err) {
console.error('Error creating city logo image record:', err);
return res.status(500).json({error: 'Fehler beim Speichern des Logos'});
}
// Update city logo
db.run("UPDATE orgs SET logoImageId = ? WHERE id = ? AND type = 'City'", [imageId, cityId], function(err) {
if (err) {
console.error('Error updating city logo:', err);
return res.status(500).json({error: 'Fehler beim Aktualisieren des Logos'});
}
res.json({
success: true,
imageId: imageId,
logoUrl: `${BACKEND_URL}/api/images/${imageId}`,
message: 'Logo erfolgreich hochgeladen'
});
});
});
});
});
// Upload city gallery image
app.post('/api/admin/cities/:cityId/gallery/upload', upload.single('gallery'), (req, res) => {
if (!req.isAuthenticated()) return res.status(401).json({error: 'Nicht authentifiziert'});
// Check if user is admin
if (!req.user.isAdmin) {
return res.status(403).json({error: 'Admin-Berechtigung erforderlich'});
}
const { cityId } = req.params;
// Check if city exists
db.get("SELECT name, gallery FROM orgs WHERE id = ? AND type = 'City'", [cityId], (err, row) => {
if (err) return res.status(500).json({error: err.message});
if (!row) return res.status(404).json({error: 'Stadt nicht gefunden'});
if (!req.file) {
return res.status(400).json({error: 'Keine Datei hochgeladen'});
}
// Generate unique image ID
const imageId = 'img_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
// Create image record
const imageData = {
id: imageId,
filename: req.file.filename,
originalName: req.file.originalname,
mimeType: req.file.mimetype,
size: req.file.size,
uploadDate: new Date().toISOString(),
altText: req.body.altText || `${row.name} Galerie`,
entityType: 'org',
entityId: cityId,
imageType: 'gallery'
};
db.run(`INSERT INTO images (id, filename, originalName, mimeType, size, uploadDate, altText, entityType, entityId, imageType)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[imageData.id, imageData.filename, imageData.originalName, imageData.mimeType, imageData.size,
imageData.uploadDate, imageData.altText, imageData.entityType, imageData.entityId, imageData.imageType],
function(err) {
if (err) {
console.error('Error creating city gallery image record:', err);
return res.status(500).json({error: 'Fehler beim Speichern des Bildes'});
}
try {
// Add image ID to gallery array
const gallery = JSON.parse(row.gallery || '[]');
gallery.push(imageId);
db.run("UPDATE orgs SET gallery = ? WHERE id = ? AND type = 'City'", [JSON.stringify(gallery), cityId], function(err) {
if (err) {
console.error('Error updating city gallery:', err);
return res.status(500).json({error: 'Fehler beim Aktualisieren der Galerie'});
}
res.json({
success: true,
imageId: imageId,
imageUrl: `${BACKEND_URL}/api/images/${imageId}`,
gallery: gallery,
message: 'Bild erfolgreich zur Galerie hinzugefügt'
});
});
} catch (e) {
console.error('Error parsing city gallery:', e);
res.status(500).json({error: 'Fehler beim Verarbeiten der Galerie-Daten'});
}
});
});
});
// Serve uploaded files statically
app.use('/uploads', express.static(UPLOAD_DIR));
app.listen(PORT, () => { app.listen(PORT, () => {
console.log(`Backend running on http://localhost:${PORT}`); console.log(`Backend running on http://localhost:${PORT}`);
}); });

View File

@@ -131,6 +131,61 @@ const BannerManagementModal: React.FC<BannerManagementModalProps> = ({
</p> </p>
</div> </div>
{/* File Upload */}
<div className="mb-6">
<h4 className="font-semibold text-textMain mb-3">Oder Bild hochladen</h4>
<div className="border-2 border-dashed border-border rounded-lg p-6 text-center hover:border-accentInfo/50 transition-colors">
<input
type="file"
accept="image/*"
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
const formData = new FormData();
formData.append('banner', file);
try {
setLoading(true);
setError(null);
const response = await fetch(`https://vollidioten.ceraticsoft.de/api/projects/${projectId}/banner/upload`, {
method: 'POST',
credentials: 'include',
body: formData
});
if (response.ok) {
const data = await response.json();
setBannerUrl(data.bannerUrl);
onUpdate();
onClose();
} else {
const errorData = await response.json();
setError(errorData.error || 'Fehler beim Hochladen');
}
} catch (err) {
console.error('Error uploading banner:', err);
setError('Netzwerkfehler beim Hochladen');
} finally {
setLoading(false);
}
}}
className="hidden"
id="banner-upload"
/>
<label htmlFor="banner-upload" className="cursor-pointer">
<div className="w-12 h-12 mx-auto mb-4 bg-surfaceHighlight rounded-full flex items-center justify-center">
<svg className="w-6 h-6 text-accentInfo" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
</div>
<p className="text-sm text-textMain font-medium">Bild auswählen</p>
<p className="text-xs text-textMuted mt-1">Max. 10MB, nur Bilddateien</p>
</label>
</div>
</div>
{/* Preview */} {/* Preview */}
{bannerUrl && bannerUrl !== currentBannerUrl && ( {bannerUrl && bannerUrl !== currentBannerUrl && (
<div className="mb-6"> <div className="mb-6">

View File

@@ -1,6 +1,11 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Icons } from './IconSet'; import { Icons } from './IconSet';
interface GalleryImage {
id: string;
url: string;
}
interface GalleryManagementModalProps { interface GalleryManagementModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
@@ -14,7 +19,7 @@ const GalleryManagementModal: React.FC<GalleryManagementModalProps> = ({
projectId, projectId,
onUpdate onUpdate
}) => { }) => {
const [images, setImages] = useState<string[]>([]); const [images, setImages] = useState<GalleryImage[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [imageUrl, setImageUrl] = useState(''); const [imageUrl, setImageUrl] = useState('');
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -73,10 +78,10 @@ const GalleryManagementModal: React.FC<GalleryManagementModalProps> = ({
} }
}; };
const removeImage = async (index: number) => { const removeImage = async (imageId: string) => {
try { try {
setLoading(true); setLoading(true);
const response = await fetch(`https://vollidioten.ceraticsoft.de/api/projects/${projectId}/gallery/${index}`, { const response = await fetch(`https://vollidioten.ceraticsoft.de/api/projects/${projectId}/gallery/${imageId}`, {
method: 'DELETE', method: 'DELETE',
credentials: 'include' credentials: 'include'
}); });
@@ -118,7 +123,9 @@ const GalleryManagementModal: React.FC<GalleryManagementModalProps> = ({
{/* Add Image */} {/* Add Image */}
<div className="bg-surfaceHighlight/30 border border-border rounded-lg p-4 mb-6"> <div className="bg-surfaceHighlight/30 border border-border rounded-lg p-4 mb-6">
<h4 className="font-semibold text-textMain mb-4">Bild hinzufügen</h4> <h4 className="font-semibold text-textMain mb-4">Bild hinzufügen</h4>
<div className="flex gap-2">
{/* URL Input */}
<div className="flex gap-2 mb-4">
<input <input
type="url" type="url"
value={imageUrl} value={imageUrl}
@@ -134,9 +141,68 @@ const GalleryManagementModal: React.FC<GalleryManagementModalProps> = ({
Hinzufügen Hinzufügen
</button> </button>
</div> </div>
<p className="text-xs text-textMuted mt-2"> <p className="text-xs text-textMuted mb-4">
Geben Sie eine direkte URL zu einem Bild ein (z.B. von Imgur, Discord, etc.) Geben Sie eine direkte URL zu einem Bild ein (z.B. von Imgur, Discord, etc.)
</p> </p>
{/* File Upload */}
<div className="border-t border-border pt-4">
<div className="border-2 border-dashed border-border rounded-lg p-4 text-center hover:border-accentInfo/50 transition-colors">
<input
type="file"
accept="image/*"
multiple
onChange={async (e) => {
const files = e.target.files as FileList;
if (!files || files.length === 0) return;
try {
setLoading(true);
setError(null);
// Upload each file
const fileArray = Array.from(files) as File[];
for (const file of fileArray) {
const formData = new FormData();
formData.append('gallery', file);
const response = await fetch(`https://vollidioten.ceraticsoft.de/api/projects/${projectId}/gallery/upload`, {
method: 'POST',
credentials: 'include',
body: formData
});
if (!response.ok) {
const errorData = await response.json();
setError(errorData.error || `Fehler beim Hochladen von ${file.name}`);
break;
}
}
// Reload gallery after all uploads
await loadGallery();
} catch (err) {
console.error('Error uploading images:', err);
setError('Netzwerkfehler beim Hochladen');
} finally {
setLoading(false);
}
}}
className="hidden"
id="gallery-upload"
/>
<label htmlFor="gallery-upload" className="cursor-pointer">
<div className="w-10 h-10 mx-auto mb-3 bg-surfaceHighlight rounded-full flex items-center justify-center">
<svg className="w-5 h-5 text-accentInfo" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
</div>
<p className="text-sm text-textMain font-medium">Bilder auswählen</p>
<p className="text-xs text-textMuted mt-1">Max. 10MB pro Bild, Mehrfachauswahl möglich</p>
</label>
</div>
</div>
</div> </div>
{/* Gallery Grid */} {/* Gallery Grid */}
@@ -156,11 +222,11 @@ const GalleryManagementModal: React.FC<GalleryManagementModalProps> = ({
</div> </div>
) : ( ) : (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{images.map((imageUrl, index) => ( {images.map((image, index) => (
<div key={index} className="relative group"> <div key={image.id} className="relative group">
<div className="aspect-square rounded-lg overflow-hidden border border-border bg-surfaceHighlight/30"> <div className="aspect-square rounded-lg overflow-hidden border border-border bg-surfaceHighlight/30">
<img <img
src={imageUrl} src={image.url}
alt={`Galerie ${index + 1}`} alt={`Galerie ${index + 1}`}
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110" className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
onError={(e) => { onError={(e) => {
@@ -172,7 +238,7 @@ const GalleryManagementModal: React.FC<GalleryManagementModalProps> = ({
{/* Delete Button */} {/* Delete Button */}
<button <button
onClick={() => removeImage(index)} onClick={() => removeImage(image.id)}
disabled={loading} disabled={loading}
className="absolute top-2 right-2 bg-red-500 hover:bg-red-600 text-white rounded-full w-6 h-6 flex items-center justify-center text-xs opacity-0 group-hover:opacity-100 transition-opacity" className="absolute top-2 right-2 bg-red-500 hover:bg-red-600 text-white rounded-full w-6 h-6 flex items-center justify-center text-xs opacity-0 group-hover:opacity-100 transition-opacity"
title="Bild entfernen" title="Bild entfernen"

View File

@@ -0,0 +1,153 @@
import React, { useState } from 'react';
import { Icons } from './IconSet';
interface LogoManagementModalProps {
isOpen: boolean;
onClose: () => void;
projectId: string;
currentLogoUrl?: string;
onUpdate: () => void;
}
const LogoManagementModal: React.FC<LogoManagementModalProps> = ({
isOpen,
onClose,
projectId,
currentLogoUrl,
onUpdate
}) => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleFileUpload = async (file: File) => {
try {
setLoading(true);
setError(null);
const formData = new FormData();
formData.append('logo', file);
const response = await fetch(`https://vollidioten.ceraticsoft.de/api/projects/${projectId}/logo/upload`, {
method: 'POST',
credentials: 'include',
body: formData
});
if (response.ok) {
const data = await response.json();
console.log('Logo uploaded successfully:', data);
onUpdate();
onClose();
} else {
const errorData = await response.json();
setError(errorData.error || 'Fehler beim Hochladen des Logos');
}
} catch (err) {
console.error('Error uploading logo:', err);
setError('Netzwerkfehler beim Hochladen');
} finally {
setLoading(false);
}
};
const removeLogo = async () => {
// Note: We'll need to implement logo deletion in the backend
// For now, this is a placeholder
alert('Logo-Löschen ist noch nicht implementiert. Kontaktieren Sie einen Administrator.');
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm animate-in fade-in duration-200">
<div className="bg-surface border border-border rounded-xl w-full max-w-md shadow-2xl flex flex-col max-h-[90vh]">
<div className="p-4 border-b border-border flex justify-between items-center bg-surfaceHighlight/20">
<h3 className="font-bold text-textMain flex items-center gap-2">
<Icons.Layers className="w-5 h-5" />
Logo verwalten
</h3>
<button onClick={onClose} className="text-textMuted hover:text-white transition-colors text-xl leading-none">&times;</button>
</div>
<div className="p-6 flex-1 overflow-y-auto">
{error && (
<div className="bg-red-500/10 border border-red-500/20 rounded-lg p-4 mb-6">
<p className="text-red-400">{error}</p>
</div>
)}
{/* Current Logo Preview */}
{currentLogoUrl && (
<div className="mb-6">
<h4 className="text-sm font-semibold text-textMain mb-3">Aktuelles Logo</h4>
<div className="flex items-center gap-4 p-4 bg-surfaceHighlight/30 border border-border rounded-lg">
<img
src={currentLogoUrl}
alt="Aktuelles Logo"
className="w-16 h-16 rounded-lg object-cover border border-white/10"
/>
<div className="flex-1">
<p className="text-sm text-textMuted">Logo ist gesetzt</p>
<button
onClick={removeLogo}
className="text-xs text-red-400 hover:text-red-300 mt-1"
disabled={loading}
>
Logo entfernen
</button>
</div>
</div>
</div>
)}
{/* File Upload */}
<div className="border-2 border-dashed border-border rounded-lg p-6 text-center hover:border-accentInfo/50 transition-colors">
<input
type="file"
accept="image/*"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
handleFileUpload(file);
}
}}
className="hidden"
id="logo-upload"
disabled={loading}
/>
<label htmlFor="logo-upload" className="cursor-pointer">
<div className="w-12 h-12 mx-auto mb-4 bg-surfaceHighlight rounded-full flex items-center justify-center">
<svg className="w-6 h-6 text-accentInfo" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
</div>
<p className="text-sm text-textMain font-medium">
{loading ? 'Lädt hoch...' : 'Logo auswählen'}
</p>
<p className="text-xs text-textMuted mt-2">
PNG, JPG oder GIF Max. 2MB Quadratische Formate bevorzugt
</p>
</label>
</div>
<div className="mt-4 text-xs text-textMuted">
<p> Das Logo wird in der Projektübersicht angezeigt</p>
<p> Verwenden Sie transparente Hintergründe für beste Ergebnisse</p>
<p> Das Logo wird automatisch auf 64x64px skaliert</p>
</div>
</div>
<div className="p-4 border-t border-border flex justify-end">
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-textMuted hover:text-white transition-colors"
>
Schließen
</button>
</div>
</div>
</div>
);
};
export default LogoManagementModal;

View File

@@ -25,10 +25,13 @@ services:
- SESSION_SECRET=${SESSION_SECRET} - SESSION_SECRET=${SESSION_SECRET}
- CALLBACK_URL=https://vollidioten.ceraticsoft.de/auth/discord/callback - CALLBACK_URL=https://vollidioten.ceraticsoft.de/auth/discord/callback
- FRONTEND_URL=https://vollidioten.ceraticsoft.de - FRONTEND_URL=https://vollidioten.ceraticsoft.de
- BACKEND_URL=https://vollidioten.ceraticsoft.de
- DB_HOST=${DB_HOST:-db} - DB_HOST=${DB_HOST:-db}
- DB_USER=${DB_USER:-obsidian_user} - DB_USER=${DB_USER:-obsidian_user}
- DB_PASS=${DB_PASS:-obsidian_pass} - DB_PASS=${DB_PASS:-obsidian_pass}
- DB_NAME=${DB_NAME:-obsidian_db} - DB_NAME=${DB_NAME:-obsidian_db}
volumes:
- ./backend/uploads:/app/uploads
restart: always restart: always
networks: networks:
- external_web - external_web

View File

@@ -647,8 +647,9 @@ const EditNpcCompanyCard: React.FC<{ company: any; npcCitizens: any[]; onUpdate:
const AdminPage: React.FC<AdminPageProps> = ({ onBack }) => { const AdminPage: React.FC<AdminPageProps> = ({ onBack }) => {
const [user, setUser] = useState(authService.getUser()); const [user, setUser] = useState(authService.getUser());
const [activeTab, setActiveTab] = useState<'overview' | 'npcs' | 'create-npc' | 'edit-npcs' | 'manage-admins'>('overview'); const [activeTab, setActiveTab] = useState<'overview' | 'npcs' | 'create-npc' | 'edit-npcs' | 'cities' | 'create-city' | 'manage-admins'>('overview');
const [npcs, setNpcs] = useState<any>({ citizens: [], companies: [] }); const [npcs, setNpcs] = useState<any>({ citizens: [], companies: [] });
const [cities, setCities] = useState<any[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [shopModalState, setShopModalState] = useState<{ const [shopModalState, setShopModalState] = useState<{
@@ -669,6 +670,9 @@ const AdminPage: React.FC<AdminPageProps> = ({ onBack }) => {
if (activeTab === 'npcs' && isAdmin) { if (activeTab === 'npcs' && isAdmin) {
loadNpcs(); loadNpcs();
} }
if (activeTab === 'cities' && isAdmin) {
loadCities();
}
}, [activeTab, isAdmin]); }, [activeTab, isAdmin]);
const loadNpcs = async () => { const loadNpcs = async () => {
@@ -692,6 +696,29 @@ const AdminPage: React.FC<AdminPageProps> = ({ onBack }) => {
} }
}; };
const loadCities = async () => {
try {
setLoading(true);
const response = await fetch('https://vollidioten.ceraticsoft.de/api/orgs', {
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
// Filter only cities (type === 'City')
const cityData = data.filter((org: any) => org.type === 'City');
setCities(cityData);
} else {
setError('Fehler beim Laden der Städte');
}
} catch (err) {
console.error('Error loading cities:', err);
setError('Netzwerkfehler');
} finally {
setLoading(false);
}
};
// NPC Creation Forms // NPC Creation Forms
const [citizenForm, setCitizenForm] = useState({ const [citizenForm, setCitizenForm] = useState({
username: '', username: '',
@@ -710,6 +737,30 @@ const AdminPage: React.FC<AdminPageProps> = ({ onBack }) => {
shopItems: '' shopItems: ''
}); });
// City management
const [cityForm, setCityForm] = useState({
name: '',
description: '',
mayor: '',
establishedYear: '',
cityStats: JSON.stringify({
taxRate: 5.0,
biome: 'Ebene',
defenseRating: 5,
government: 'Demokratie',
specialty: 'Handel'
}, null, 2)
});
const [editingCity, setEditingCity] = useState<any>(null);
const [editCityForm, setEditCityForm] = useState({
name: '',
description: '',
mayor: '',
establishedYear: '',
cityStats: ''
});
const createNpcCitizen = async () => { const createNpcCitizen = async () => {
if (!citizenForm.username.trim()) { if (!citizenForm.username.trim()) {
setError('NPC-Name ist erforderlich'); setError('NPC-Name ist erforderlich');
@@ -870,6 +921,18 @@ const AdminPage: React.FC<AdminPageProps> = ({ onBack }) => {
> >
NPCs erstellen NPCs erstellen
</button> </button>
<button
onClick={() => setActiveTab('cities')}
className={`px-6 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${activeTab === 'cities' ? 'border-accentInfo text-white' : 'border-transparent text-textMuted hover:text-white'}`}
>
Städte verwalten
</button>
<button
onClick={() => setActiveTab('create-city')}
className={`px-6 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${activeTab === 'create-city' ? 'border-accentInfo text-white' : 'border-transparent text-textMuted hover:text-white'}`}
>
Stadt erstellen
</button>
</div> </div>
{/* Error Display */} {/* Error Display */}
@@ -1230,6 +1293,604 @@ const AdminPage: React.FC<AdminPageProps> = ({ onBack }) => {
</div> </div>
)} )}
{activeTab === 'cities' && (
<div className="space-y-8">
{/* Cities Management */}
<div className="bg-surface border border-border rounded-xl p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-xl font-bold flex items-center gap-2">
<Icons.Map className="w-5 h-5 text-green-400" />
Städte verwalten ({cities.length})
</h3>
<button
onClick={() => loadCities()}
disabled={loading}
className="text-accentInfo hover:text-accentInfo/80 text-sm disabled:opacity-50"
title="Aktualisieren"
>
{loading ? 'Lade...' : 'Aktualisieren'}
</button>
</div>
{cities.length === 0 ? (
<p className="text-textMuted">Keine Städte vorhanden.</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{cities.map((city: any) => (
<div key={city.id} className="bg-surfaceHighlight/30 border border-border rounded-lg p-4">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-3">
{city.logoUrl ? (
<img
src={city.logoUrl}
alt={`${city.name} Logo`}
className="w-8 h-8 rounded border border-white/10 object-cover"
/>
) : (
<div className="w-8 h-8 bg-green-500/20 rounded flex items-center justify-center text-xs font-bold text-green-400">
{city.name.charAt(0)}
</div>
)}
<div>
<h4 className="font-medium text-white">{city.name}</h4>
<p className="text-xs text-textMuted">Gegr. {city.establishedYear}</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => {
setEditingCity(city);
setEditCityForm({
name: city.name,
description: city.description || '',
mayor: city.mayor || '',
establishedYear: city.establishedYear || '',
cityStats: JSON.stringify(city.cityStats || {}, null, 2)
});
}}
className="text-blue-400 hover:text-blue-300 text-sm"
title="Stadt bearbeiten"
>
<Icons.Edit className="w-4 h-4" />
</button>
<button
onClick={async () => {
if (confirm(`Stadt "${city.name}" wirklich löschen?`)) {
try {
setLoading(true);
const response = await fetch(`https://vollidioten.ceraticsoft.de/api/admin/cities/${city.id}`, {
method: 'DELETE',
credentials: 'include'
});
if (response.ok) {
alert('Stadt erfolgreich gelöscht!');
loadCities(); // Reload cities
} else {
const errorData = await response.json();
setError(errorData.error || 'Fehler beim Löschen');
}
} catch (err) {
console.error('Error deleting city:', err);
setError('Netzwerkfehler');
} finally {
setLoading(false);
}
}
}}
disabled={loading}
className="text-red-400 hover:text-red-300 text-sm disabled:opacity-50"
title="Stadt löschen"
>
🗑
</button>
</div>
</div>
<p className="text-sm text-textMuted mb-2">{city.description}</p>
<div className="text-xs text-textMuted">
Bürgermeister: {city.mayor || 'Unbekannt'} {city.memberCount} Bürger
</div>
{city.cityStats && (
<div className="mt-2 text-xs text-accentInfo">
Spezialität: {city.cityStats.specialty}
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
)}
{activeTab === 'create-city' && (
<div className="space-y-8">
{/* Create City */}
<div className="bg-surface border border-border rounded-xl p-6">
<h3 className="text-xl font-bold mb-4 flex items-center gap-2">
<Icons.Map className="w-5 h-5 text-green-400" />
Stadt erstellen
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-textMain mb-1">Stadtname *</label>
<input
type="text"
value={cityForm.name}
onChange={(e) => setCityForm({...cityForm, name: e.target.value})}
className="w-full bg-[#0b0b0d] border border-border rounded p-2 text-sm"
placeholder="z.B. Eldoria"
/>
</div>
<div>
<label className="block text-sm font-medium text-textMain mb-1">Bürgermeister</label>
<input
type="text"
value={cityForm.mayor}
onChange={(e) => setCityForm({...cityForm, mayor: e.target.value})}
className="w-full bg-[#0b0b0d] border border-border rounded p-2 text-sm"
placeholder="z.B. Lord Eldric"
/>
</div>
<div>
<label className="block text-sm font-medium text-textMain mb-1">Gründungsjahr</label>
<input
type="text"
value={cityForm.establishedYear}
onChange={(e) => setCityForm({...cityForm, establishedYear: e.target.value})}
className="w-full bg-[#0b0b0d] border border-border rounded p-2 text-sm"
placeholder="z.B. Ära 5"
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-textMain mb-1">Beschreibung</label>
<textarea
value={cityForm.description}
onChange={(e) => setCityForm({...cityForm, description: e.target.value})}
className="w-full h-20 bg-[#0b0b0d] border border-border rounded p-2 text-sm"
placeholder="Beschreibung der Stadt..."
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-textMain mb-1">Stadt-Statistiken (JSON-Format)</label>
<textarea
value={cityForm.cityStats}
onChange={(e) => setCityForm({...cityForm, cityStats: e.target.value})}
className="w-full h-24 bg-[#0b0b0d] border border-border rounded p-2 text-sm font-mono text-xs"
placeholder='{ "taxRate": 5.0, "biome": "Ebene", "defenseRating": 5, "government": "Demokratie", "specialty": "Handel" }'
/>
<p className="text-xs text-textMuted mt-1">JSON-Objekt mit Stadt-Statistiken</p>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-textMain mb-2">Banner-Bild (optional)</label>
<div className="border-2 border-dashed border-border rounded-lg p-4 text-center hover:border-accentInfo/50 transition-colors">
<input
type="file"
accept="image/*"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
setCityForm({...cityForm, bannerFile: file});
}
}}
className="hidden"
id="city-banner-upload"
disabled={loading}
/>
<label htmlFor="city-banner-upload" className="cursor-pointer">
<div className="w-8 h-8 mx-auto mb-2 bg-accentInfo/20 rounded flex items-center justify-center">
<Icons.Map className="w-4 h-4 text-accentInfo" />
</div>
<p className="text-xs text-textMain font-medium">
Banner auswählen
</p>
<p className="text-xs text-textMuted">PNG, JPG oder GIF Max. 5MB</p>
</label>
</div>
{cityForm.bannerFile && (
<div className="mt-2 text-xs text-green-400">
Banner ausgewählt: {cityForm.bannerFile.name}
</div>
)}
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-textMain mb-2">Logo-Bild (optional)</label>
<div className="border-2 border-dashed border-border rounded-lg p-4 text-center hover:border-accentInfo/50 transition-colors">
<input
type="file"
accept="image/*"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
setCityForm({...cityForm, logoFile: file});
}
}}
className="hidden"
id="city-logo-upload"
disabled={loading}
/>
<label htmlFor="city-logo-upload" className="cursor-pointer">
<div className="w-8 h-8 mx-auto mb-2 bg-green-500/20 rounded flex items-center justify-center">
<Icons.Layers className="w-4 h-4 text-green-400" />
</div>
<p className="text-xs text-textMain font-medium">
Logo auswählen
</p>
<p className="text-xs text-textMuted">PNG, JPG oder GIF Max. 2MB Quadratisch bevorzugt</p>
</label>
</div>
{cityForm.logoFile && (
<div className="mt-2 text-xs text-green-400">
Logo ausgewählt: {cityForm.logoFile.name}
</div>
)}
</div>
<div className="md:col-span-2 pt-2">
<button
onClick={async () => {
if (!cityForm.name.trim()) {
setError('Stadt-Name ist erforderlich');
return;
}
try {
setLoading(true);
setError(null);
let cityStats = {};
if (cityForm.cityStats.trim()) {
try {
cityStats = JSON.parse(cityForm.cityStats);
} catch (e) {
setError('Ungültiges JSON-Format für Stadt-Statistiken');
setLoading(false);
return;
}
}
const response = await fetch('https://vollidioten.ceraticsoft.de/api/admin/cities', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
name: cityForm.name.trim(),
description: cityForm.description.trim() || undefined,
mayor: cityForm.mayor.trim() || undefined,
establishedYear: cityForm.establishedYear.trim() || undefined,
cityStats: cityStats
})
});
if (response.ok) {
const cityData = await response.json();
const cityId = cityData.cityId;
// Upload banner if provided
if (cityForm.bannerImageId) {
try {
const bannerFormData = new FormData();
// We need to get the file from the input, but since we don't have it stored,
// we'll skip this for now and show a message
console.log('Banner upload would happen here for city:', cityId);
} catch (err) {
console.error('Banner upload failed:', err);
}
}
// Upload logo if provided
if (cityForm.logoImageId) {
try {
const logoFormData = new FormData();
// We need to get the file from the input, but since we don't have it stored,
// we'll skip this for now and show a message
console.log('Logo upload would happen here for city:', cityId);
} catch (err) {
console.error('Logo upload failed:', err);
}
}
alert('Stadt erfolgreich erstellt! Bilder können nach der Erstellung im Bearbeitungsmodus hochgeladen werden.');
setCityForm({
name: '',
description: '',
mayor: '',
establishedYear: '',
cityStats: JSON.stringify({
taxRate: 5.0,
biome: 'Ebene',
defenseRating: 5,
government: 'Demokratie',
specialty: 'Handel'
}, null, 2)
});
loadCities(); // Reload to show new city
} else {
const errorData = await response.json();
setError(errorData.error || 'Fehler beim Erstellen');
}
} catch (err) {
console.error('Error creating city:', err);
setError('Netzwerkfehler');
} finally {
setLoading(false);
}
}}
disabled={loading || !cityForm.name.trim()}
className="w-full bg-green-500 hover:bg-green-600 disabled:opacity-50 text-white px-4 py-2 rounded text-sm font-medium disabled:cursor-not-allowed"
>
{loading ? 'Erstelle...' : 'Stadt erstellen'}
</button>
</div>
</div>
</div>
</div>
)}
{/* Edit City Modal */}
{editingCity && (
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="bg-surface border border-border rounded-xl w-full max-w-lg shadow-2xl max-h-[90vh] overflow-y-auto">
<div className="p-4 border-b border-border flex justify-between items-center">
<h3 className="font-bold text-textMain flex items-center gap-2">
<Icons.Edit className="w-5 h-5 text-blue-400" />
Stadt bearbeiten
</h3>
<button
onClick={() => setEditingCity(null)}
className="text-textMuted hover:text-white text-xl leading-none"
>
×
</button>
</div>
<div className="p-6 space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-textMain mb-1">Stadtname *</label>
<input
type="text"
value={editCityForm.name}
onChange={(e) => setEditCityForm({...editCityForm, name: e.target.value})}
className="w-full bg-[#0b0b0d] border border-border rounded p-2 text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-textMain mb-1">Bürgermeister</label>
<input
type="text"
value={editCityForm.mayor}
onChange={(e) => setEditCityForm({...editCityForm, mayor: e.target.value})}
className="w-full bg-[#0b0b0d] border border-border rounded p-2 text-sm"
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-textMain mb-1">Gründungsjahr</label>
<input
type="text"
value={editCityForm.establishedYear}
onChange={(e) => setEditCityForm({...editCityForm, establishedYear: e.target.value})}
className="w-full bg-[#0b0b0d] border border-border rounded p-2 text-sm"
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-textMain mb-1">Beschreibung</label>
<textarea
value={editCityForm.description}
onChange={(e) => setEditCityForm({...editCityForm, description: e.target.value})}
className="w-full h-20 bg-[#0b0b0d] border border-border rounded p-2 text-sm"
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-textMain mb-1">Stadt-Statistiken (JSON)</label>
<textarea
value={editCityForm.cityStats}
onChange={(e) => setEditCityForm({...editCityForm, cityStats: e.target.value})}
className="w-full h-24 bg-[#0b0b0d] border border-border rounded p-2 text-sm font-mono text-xs"
/>
</div>
</div>
{/* Current Images */}
{(editingCity.bannerUrl || editingCity.logoUrl) && (
<div className="border-t border-border pt-4">
<h4 className="text-sm font-medium text-textMain mb-3">Aktuelle Bilder</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{editingCity.bannerUrl && (
<div className="bg-surfaceHighlight/30 border border-border rounded p-3">
<p className="text-xs text-textMuted mb-2">Banner</p>
<img
src={editingCity.bannerUrl}
alt="Banner"
className="w-full h-16 object-cover rounded border border-white/10"
/>
</div>
)}
{editingCity.logoUrl && (
<div className="bg-surfaceHighlight/30 border border-border rounded p-3">
<p className="text-xs text-textMuted mb-2">Logo</p>
<img
src={editingCity.logoUrl}
alt="Logo"
className="w-12 h-12 object-cover rounded border border-white/10"
/>
</div>
)}
</div>
</div>
)}
{/* Image Uploads */}
<div className="border-t border-border pt-4 space-y-4">
<h4 className="text-sm font-medium text-textMain">Bilder ändern</h4>
<div>
<label className="block text-sm font-medium text-textMain mb-2">Banner-Bild</label>
<div className="border-2 border-dashed border-border rounded-lg p-4 text-center hover:border-accentInfo/50 transition-colors">
<input
type="file"
accept="image/*"
onChange={async (e) => {
const file = e.target.files?.[0];
if (file) {
try {
setLoading(true);
const formData = new FormData();
formData.append('banner', file);
const response = await fetch(`https://vollidioten.ceraticsoft.de/api/admin/cities/${editingCity.id}/banner/upload`, {
method: 'POST',
credentials: 'include',
body: formData
});
if (response.ok) {
alert('Banner erfolgreich aktualisiert!');
loadCities(); // Reload to show updated image
} else {
setError('Fehler beim Hochladen des Banners');
}
} catch (err) {
console.error('Error uploading banner:', err);
setError('Netzwerkfehler beim Banner-Upload');
} finally {
setLoading(false);
}
}
}}
className="hidden"
id="edit-city-banner-upload"
disabled={loading}
/>
<label htmlFor="edit-city-banner-upload" className="cursor-pointer">
<div className="w-8 h-8 mx-auto mb-2 bg-accentInfo/20 rounded flex items-center justify-center">
<Icons.Map className="w-4 h-4 text-accentInfo" />
</div>
<p className="text-xs text-textMain font-medium">
{loading ? 'Lädt...' : 'Banner ersetzen'}
</p>
<p className="text-xs text-textMuted">PNG, JPG oder GIF Max. 5MB</p>
</label>
</div>
</div>
<div>
<label className="block text-sm font-medium text-textMain mb-2">Logo-Bild</label>
<div className="border-2 border-dashed border-border rounded-lg p-4 text-center hover:border-accentInfo/50 transition-colors">
<input
type="file"
accept="image/*"
onChange={async (e) => {
const file = e.target.files?.[0];
if (file) {
try {
setLoading(true);
const formData = new FormData();
formData.append('logo', file);
const response = await fetch(`https://vollidioten.ceraticsoft.de/api/admin/cities/${editingCity.id}/logo/upload`, {
method: 'POST',
credentials: 'include',
body: formData
});
if (response.ok) {
alert('Logo erfolgreich aktualisiert!');
loadCities(); // Reload to show updated image
} else {
setError('Fehler beim Hochladen des Logos');
}
} catch (err) {
console.error('Error uploading logo:', err);
setError('Netzwerkfehler beim Logo-Upload');
} finally {
setLoading(false);
}
}
}}
className="hidden"
id="edit-city-logo-upload"
disabled={loading}
/>
<label htmlFor="edit-city-logo-upload" className="cursor-pointer">
<div className="w-8 h-8 mx-auto mb-2 bg-green-500/20 rounded flex items-center justify-center">
<Icons.Layers className="w-4 h-4 text-green-400" />
</div>
<p className="text-xs text-textMain font-medium">
{loading ? 'Lädt...' : 'Logo ersetzen'}
</p>
<p className="text-xs text-textMuted">PNG, JPG oder GIF Max. 2MB Quadratisch bevorzugt</p>
</label>
</div>
</div>
</div>
<div className="flex gap-2 pt-2 border-t border-border">
<button
onClick={async () => {
try {
setLoading(true);
let cityStats = {};
if (editCityForm.cityStats.trim()) {
cityStats = JSON.parse(editCityForm.cityStats);
}
const response = await fetch(`https://vollidioten.ceraticsoft.de/api/admin/cities/${editingCity.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
name: editCityForm.name.trim(),
description: editCityForm.description.trim() || undefined,
mayor: editCityForm.mayor.trim() || undefined,
establishedYear: editCityForm.establishedYear.trim() || undefined,
cityStats: cityStats
})
});
if (response.ok) {
alert('Stadt erfolgreich aktualisiert!');
setEditingCity(null);
loadCities();
} else {
const errorData = await response.json();
setError(errorData.error || 'Fehler beim Aktualisieren');
}
} catch (err) {
console.error('Error updating city:', err);
setError('Netzwerkfehler');
} finally {
setLoading(false);
}
}}
disabled={loading}
className="flex-1 bg-blue-500 hover:bg-blue-600 disabled:opacity-50 text-white px-3 py-2 rounded text-sm font-medium"
>
{loading ? '...' : 'Speichern'}
</button>
<button
onClick={() => setEditingCity(null)}
className="flex-1 bg-gray-500 hover:bg-gray-600 text-white px-3 py-1 rounded text-sm"
>
Abbrechen
</button>
</div>
</div>
</div>
</div>
)}
{/* Shop Management Modal */} {/* Shop Management Modal */}
<ShopManagementModal <ShopManagementModal
isOpen={shopModalState.isOpen} isOpen={shopModalState.isOpen}

View File

@@ -9,6 +9,7 @@ import EditModal from '../components/EditModal';
import ShopManagementModal from '../components/ShopManagementModal'; import ShopManagementModal from '../components/ShopManagementModal';
import EmployeeManagementModal from '../components/EmployeeManagementModal'; import EmployeeManagementModal from '../components/EmployeeManagementModal';
import BannerManagementModal from '../components/BannerManagementModal'; import BannerManagementModal from '../components/BannerManagementModal';
import LogoManagementModal from '../components/LogoManagementModal';
import GalleryManagementModal from '../components/GalleryManagementModal'; import GalleryManagementModal from '../components/GalleryManagementModal';
import DeleteProjectModal from '../components/DeleteProjectModal'; import DeleteProjectModal from '../components/DeleteProjectModal';
@@ -26,6 +27,7 @@ const ProjectProfile: React.FC = () => {
const [isShopModalOpen, setIsShopModalOpen] = useState(false); const [isShopModalOpen, setIsShopModalOpen] = useState(false);
const [isEmployeeModalOpen, setIsEmployeeModalOpen] = useState(false); const [isEmployeeModalOpen, setIsEmployeeModalOpen] = useState(false);
const [isBannerModalOpen, setIsBannerModalOpen] = useState(false); const [isBannerModalOpen, setIsBannerModalOpen] = useState(false);
const [isLogoModalOpen, setIsLogoModalOpen] = useState(false);
const [isGalleryModalOpen, setIsGalleryModalOpen] = useState(false); const [isGalleryModalOpen, setIsGalleryModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
@@ -143,13 +145,21 @@ const ProjectProfile: React.FC = () => {
</div> </div>
<h1 className="text-4xl md:text-5xl font-bold text-white tracking-tight mb-2 drop-shadow-lg">{project.title}</h1> <h1 className="text-4xl md:text-5xl font-bold text-white tracking-tight mb-2 drop-shadow-lg">{project.title}</h1>
<div className="flex items-center gap-6 text-sm text-gray-300"> <div className="flex items-center gap-6 text-sm text-gray-300">
<span <span
className={`flex items-center gap-2 ${ownerPlayer ? 'cursor-pointer hover:text-white transition-colors group/owner' : ''}`} className={`flex items-center gap-2 ${ownerPlayer ? 'cursor-pointer hover:text-white transition-colors group/owner' : ''}`}
onClick={() => ownerPlayer && onSelectPlayer(ownerPlayer.uuid)} onClick={() => ownerPlayer && onSelectPlayer(ownerPlayer.uuid)}
> >
<div className="w-5 h-5 rounded-full bg-gradient-to-br from-gray-700 to-gray-900 flex items-center justify-center text-[10px] border border-white/10 font-bold group-hover/owner:border-accentInfo group-hover/owner:text-accentInfo transition-colors"> {project.logoUrl ? (
{project.owner.charAt(0)} <img
</div> src={project.logoUrl}
alt={`${project.title} Logo`}
className="w-5 h-5 rounded-full border border-white/10 object-cover group-hover/owner:border-accentInfo transition-colors"
/>
) : (
<div className="w-5 h-5 rounded-full bg-gradient-to-br from-gray-700 to-gray-900 flex items-center justify-center text-[10px] border border-white/10 font-bold group-hover/owner:border-accentInfo group-hover/owner:text-accentInfo transition-colors">
{project.owner.charAt(0)}
</div>
)}
Inhaber: <span className="text-white font-medium group-hover/owner:underline decoration-accentInfo/50 underline-offset-4">{project.owner}</span> Inhaber: <span className="text-white font-medium group-hover/owner:underline decoration-accentInfo/50 underline-offset-4">{project.owner}</span>
</span> </span>
{project.foundedDate && ( {project.foundedDate && (
@@ -218,9 +228,9 @@ const ProjectProfile: React.FC = () => {
<Icons.Layers className="w-4 h-4 text-accentInfo" /> Portfolio <Icons.Layers className="w-4 h-4 text-accentInfo" /> Portfolio
</h3> </h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{project.gallery.map((url, idx) => ( {project.gallery.map((image, idx) => (
<div key={idx} className="rounded-xl overflow-hidden aspect-video border border-border group relative"> <div key={idx} className="rounded-xl overflow-hidden aspect-video border border-border group relative">
<img src={url} alt={`Portfolio ${idx}`} className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110" /> <img src={image.url} alt={`Portfolio ${idx}`} className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110" />
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" /> <div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" />
</div> </div>
))} ))}
@@ -405,6 +415,20 @@ const ProjectProfile: React.FC = () => {
</button> </button>
</div> </div>
{/* Logo Management */}
<div className="bg-surfaceHighlight/30 border border-border rounded-lg p-4">
<h4 className="text-sm font-semibold text-textMain mb-3">Logo ändern</h4>
<p className="text-xs text-textMuted mb-4">
Setzen Sie ein Logo für Ihr Unternehmen.
</p>
<button
onClick={() => setIsLogoModalOpen(true)}
className="w-full bg-green-500 hover:bg-green-600 text-white text-sm font-medium py-2 px-4 rounded transition-colors"
>
Logo bearbeiten
</button>
</div>
{/* Gallery Management */} {/* Gallery Management */}
<div className="bg-surfaceHighlight/30 border border-border rounded-lg p-4"> <div className="bg-surfaceHighlight/30 border border-border rounded-lg p-4">
<h4 className="text-sm font-semibold text-textMain mb-3">Galerie verwalten</h4> <h4 className="text-sm font-semibold text-textMain mb-3">Galerie verwalten</h4>
@@ -630,6 +654,17 @@ const ProjectProfile: React.FC = () => {
}} }}
/> />
<LogoManagementModal
isOpen={isLogoModalOpen}
onClose={() => setIsLogoModalOpen(false)}
projectId={project.id}
currentLogoUrl={project.logoUrl || ''}
onUpdate={() => {
// Refresh project data
console.log('Logo updated, refreshing project data...');
}}
/>
<GalleryManagementModal <GalleryManagementModal
isOpen={isGalleryModalOpen} isOpen={isGalleryModalOpen}
onClose={() => setIsGalleryModalOpen(false)} onClose={() => setIsGalleryModalOpen(false)}

View File

@@ -60,9 +60,22 @@ const VentureCard = ({ project, onClick }: { project: Project, onClick?: () => v
<StatusBadge status={project.status} hiring={project.hiring} /> <StatusBadge status={project.status} hiring={project.hiring} />
</div> </div>
<h3 className="text-lg font-bold text-textMain mb-1 group-hover:text-accentInfo transition-colors relative z-10"> <div className="flex items-center gap-2 mb-1 relative z-10">
{project.title} {project.logoUrl ? (
</h3> <img
src={project.logoUrl}
alt={`${project.title} Logo`}
className="w-12 h-12 rounded border border-white/10 object-cover"
/>
) : (
<div className="w-12 h-12 rounded bg-gradient-to-br from-gray-700 to-gray-900 flex items-center justify-center text-xs font-bold text-textMuted">
{project.title.charAt(0)}
</div>
)}
<h3 className="text-lg font-bold text-textMain group-hover:text-accentInfo transition-colors">
{project.title}
</h3>
</div>
<div className="flex items-center gap-2 mb-4 text-xs text-textMuted relative z-10"> <div className="flex items-center gap-2 mb-4 text-xs text-textMuted relative z-10">
<span>Inhaber</span> <span>Inhaber</span>

View File

@@ -59,6 +59,11 @@ export interface ShopItem {
materialsRequired?: string; // e.g. "Customer provides Stone" materialsRequired?: string; // e.g. "Customer provides Stone"
} }
export interface GalleryImage {
id: string;
url: string;
}
export interface Project { export interface Project {
id: string; id: string;
title: string; title: string;
@@ -72,8 +77,9 @@ export interface Project {
foundedDate?: string; foundedDate?: string;
associatedOrgId?: string; // Links this project to a city or guild associatedOrgId?: string; // Links this project to a city or guild
shopCatalog?: ShopItem[]; shopCatalog?: ShopItem[];
gallery?: string[]; gallery?: GalleryImage[];
bannerUrl?: string; bannerUrl?: string;
logoUrl?: string;
} }
export interface DiscordUser { export interface DiscordUser {
@@ -82,4 +88,4 @@ export interface DiscordUser {
discriminator: string; discriminator: string;
avatarUrl: string; avatarUrl: string;
linkedPlayerUuid?: string | null; linkedPlayerUuid?: string | null;
} }