Added ability to upload images
This commit is contained in:
parent
90e9bba1e0
commit
4f6a31dfa3
4 changed files with 196 additions and 26 deletions
|
|
@ -8,7 +8,7 @@ const app = express();
|
||||||
const PORT = 3000;
|
const PORT = 3000;
|
||||||
const DATA_FILE = path.join(process.cwd(), "cms-data.json");
|
const DATA_FILE = path.join(process.cwd(), "cms-data.json");
|
||||||
|
|
||||||
app.use(express.json({ limit: "10mb" }));
|
app.use(express.json({ limit: "50mb" }));
|
||||||
|
|
||||||
// Helper to read persistent CMS data
|
// Helper to read persistent CMS data
|
||||||
function loadCMSData() {
|
function loadCMSData() {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,152 @@
|
||||||
import React, { useState } from "react";
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
import { X, Lock, Save, RotateCcw, Download, Upload, Plus, Trash2, Check, Sparkles, Layout, BookOpen, Image as ImageIcon, Newspaper, BarChart3, KeyRound, AlertCircle } from "lucide-react";
|
import { X, Lock, Save, RotateCcw, Download, Upload, Plus, Trash2, Check, Sparkles, Layout, BookOpen, Image as ImageIcon, Newspaper, BarChart3, KeyRound, AlertCircle } from "lucide-react";
|
||||||
import { CMSData, StoryTab, ScreenshotItem, NewsPost, DevCategoryProgress, Milestone } from "../types";
|
import { CMSData, StoryTab, ScreenshotItem, NewsPost, DevCategoryProgress, Milestone } from "../types";
|
||||||
|
|
||||||
|
// Helper function to process and compress uploaded images to sharp Base64 Data URLs
|
||||||
|
const processImageFile = (file: File, maxDim = 1920, quality = 0.85): Promise<string> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
let width = img.width;
|
||||||
|
let height = img.height;
|
||||||
|
|
||||||
|
if (width > maxDim || height > maxDim) {
|
||||||
|
if (width > height) {
|
||||||
|
height = Math.round((height * maxDim) / width);
|
||||||
|
width = maxDim;
|
||||||
|
} else {
|
||||||
|
width = Math.round((width * maxDim) / height);
|
||||||
|
height = maxDim;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (ctx) {
|
||||||
|
ctx.drawImage(img, 0, 0, width, height);
|
||||||
|
const mime = file.type === "image/png" ? "image/png" : "image/jpeg";
|
||||||
|
const dataUrl = canvas.toDataURL(mime, quality);
|
||||||
|
resolve(dataUrl);
|
||||||
|
} else {
|
||||||
|
resolve(e.target?.result as string);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
img.onerror = () => resolve(e.target?.result as string);
|
||||||
|
img.src = e.target?.result as string;
|
||||||
|
};
|
||||||
|
reader.onerror = reject;
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ImagePickerInputProps {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (url: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ImagePickerInput: React.FC<ImagePickerInputProps> = ({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder = "Bild-URL oder Datei hochladen...",
|
||||||
|
className = ""
|
||||||
|
}) => {
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
setIsUploading(true);
|
||||||
|
try {
|
||||||
|
const dataUrl = await processImageFile(file);
|
||||||
|
onChange(dataUrl);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Fehler beim Verarbeiten des Bildes:", err);
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`space-y-1.5 ${className}`}>
|
||||||
|
<label className="block text-xs font-mono text-amber-400 flex items-center justify-between">
|
||||||
|
<span>{label}</span>
|
||||||
|
{value && (
|
||||||
|
<span className="text-[10px] text-emerald-400 font-sans flex items-center gap-1">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 inline-block animate-pulse"></span>
|
||||||
|
Bild geladen
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="flex-1 bg-zinc-950 border border-zinc-800 rounded px-3 py-2 text-xs text-zinc-100 font-mono focus:border-amber-500 focus:ring-1 focus:ring-amber-500"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={fileInputRef}
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={isUploading}
|
||||||
|
className="px-3 py-2 rounded bg-amber-950 hover:bg-amber-900 border border-amber-700/60 text-amber-200 text-xs font-mono flex items-center gap-1.5 transition-colors shrink-0 disabled:opacity-50 shadow-md"
|
||||||
|
title="Bilddatei von deinem Computer auswählen"
|
||||||
|
>
|
||||||
|
<Upload className="w-3.5 h-3.5 text-amber-400" />
|
||||||
|
<span>{isUploading ? "Verarbeite..." : "Upload"}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{value && (
|
||||||
|
<div className="mt-1.5 flex items-center gap-2.5 p-1.5 bg-zinc-950/90 border border-zinc-800 rounded-lg">
|
||||||
|
<img
|
||||||
|
src={value}
|
||||||
|
alt="Vorschau"
|
||||||
|
className="w-12 h-10 object-cover rounded border border-zinc-700 shrink-0 bg-black/60 shadow-inner"
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLElement).style.display = 'none';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="text-[11px] text-zinc-400 truncate flex-1 min-w-0">
|
||||||
|
<span className="text-zinc-200 block font-mono text-[10px] truncate">{value.substring(0, 55)}...</span>
|
||||||
|
<span className="text-emerald-400 text-[10px] flex items-center gap-1">
|
||||||
|
<Check className="w-3 h-3 inline" /> Bild aktiv
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange("")}
|
||||||
|
className="text-xs text-red-400 hover:text-red-300 p-1 rounded hover:bg-zinc-800 transition-colors"
|
||||||
|
title="Bild entfernen"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
interface AdminCMSModalProps {
|
interface AdminCMSModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
|
@ -27,10 +172,14 @@ export const AdminCMSModal: React.FC<AdminCMSModalProps> = ({
|
||||||
const [pinError, setPinError] = useState(false);
|
const [pinError, setPinError] = useState(false);
|
||||||
const [activeTab, setActiveTab] = useState<"hero" | "story" | "gallery" | "news" | "progress" | "backup">("hero");
|
const [activeTab, setActiveTab] = useState<"hero" | "story" | "gallery" | "news" | "progress" | "backup">("hero");
|
||||||
|
|
||||||
// Local draft state for editing
|
// Local draft state for editing - sync when cms updates
|
||||||
const [formData, setFormData] = useState<CMSData>(cms);
|
const [formData, setFormData] = useState<CMSData>(cms);
|
||||||
const [isSavedNotice, setIsSavedNotice] = useState(false);
|
const [isSavedNotice, setIsSavedNotice] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setFormData(cms);
|
||||||
|
}, [cms]);
|
||||||
|
|
||||||
const handlePinSubmit = (e: React.FormEvent) => {
|
const handlePinSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (onUnlock(pinInput)) {
|
if (onUnlock(pinInput)) {
|
||||||
|
|
@ -415,26 +564,20 @@ export const AdminCMSModal: React.FC<AdminCMSModalProps> = ({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div>
|
<ImagePickerInput
|
||||||
<label className="block text-xs font-mono text-amber-400 mb-1">Logo-Bild URL</label>
|
label="Logo-Bild (Gothic Mod Logo)"
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData.hero.logoUrl}
|
value={formData.hero.logoUrl}
|
||||||
onChange={(e) => setFormData({ ...formData, hero: { ...formData.hero, logoUrl: e.target.value } })}
|
onChange={(url) => setFormData({ ...formData, hero: { ...formData.hero, logoUrl: url } })}
|
||||||
className="w-full bg-zinc-950 border border-zinc-800 rounded px-3 py-2 text-sm text-zinc-100 font-mono text-xs focus:border-amber-500"
|
placeholder="https://... oder Datei hochladen"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<ImagePickerInput
|
||||||
<label className="block text-xs font-mono text-amber-400 mb-1">Hero Hintergrund-Bild URL</label>
|
label="Hero Hintergrund-Bild"
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData.hero.heroBgUrl}
|
value={formData.hero.heroBgUrl}
|
||||||
onChange={(e) => setFormData({ ...formData, hero: { ...formData.hero, heroBgUrl: e.target.value } })}
|
onChange={(url) => setFormData({ ...formData, hero: { ...formData.hero, heroBgUrl: url } })}
|
||||||
className="w-full bg-zinc-950 border border-zinc-800 rounded px-3 py-2 text-sm text-zinc-100 font-mono text-xs focus:border-amber-500"
|
placeholder="https://... oder Datei hochladen"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -593,12 +736,11 @@ export const AdminCMSModal: React.FC<AdminCMSModalProps> = ({
|
||||||
className="w-full bg-zinc-900 border border-zinc-700 rounded px-2.5 py-1 text-xs text-purple-300 font-mono"
|
className="w-full bg-zinc-900 border border-zinc-700 rounded px-2.5 py-1 text-xs text-purple-300 font-mono"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<input
|
<ImagePickerInput
|
||||||
type="text"
|
label="Screenshot-Datei / URL"
|
||||||
placeholder="Bild-URL"
|
|
||||||
value={sc.imageUrl}
|
value={sc.imageUrl}
|
||||||
onChange={(e) => handleScreenshotChange(idx, "imageUrl", e.target.value)}
|
onChange={(url) => handleScreenshotChange(idx, "imageUrl", url)}
|
||||||
className="w-full bg-zinc-900 border border-zinc-700 rounded px-2.5 py-1 text-[11px] text-zinc-300 font-mono"
|
placeholder="Bild-URL oder Datei hochladen..."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
|
|
@ -675,6 +817,13 @@ export const AdminCMSModal: React.FC<AdminCMSModalProps> = ({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ImagePickerInput
|
||||||
|
label="Beitrags-Bild (Optional)"
|
||||||
|
value={post.imageUrl || ""}
|
||||||
|
onChange={(url) => handleNewsChange(idx, "imageUrl", url)}
|
||||||
|
placeholder="Optionales News-Bild hochladen..."
|
||||||
|
/>
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
rows={2}
|
rows={2}
|
||||||
placeholder="Kurze Vorschau für die Karte..."
|
placeholder="Kurze Vorschau für die Karte..."
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,16 @@ export const NewsSection: React.FC<NewsSectionProps> = ({
|
||||||
key={post.id}
|
key={post.id}
|
||||||
className="flex flex-col bg-zinc-900/90 border border-amber-900/40 rounded-xl p-6 shadow-xl relative group hover:border-amber-500/60 transition-all duration-300"
|
className="flex flex-col bg-zinc-900/90 border border-amber-900/40 rounded-xl p-6 shadow-xl relative group hover:border-amber-500/60 transition-all duration-300"
|
||||||
>
|
>
|
||||||
|
{post.imageUrl && (
|
||||||
|
<div className="mb-4 overflow-hidden rounded-lg border border-amber-900/30 max-h-44 bg-zinc-950">
|
||||||
|
<img
|
||||||
|
src={post.imageUrl}
|
||||||
|
alt={post.title}
|
||||||
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-2 mb-3">
|
<div className="flex items-center justify-between gap-2 mb-3">
|
||||||
<span className="inline-flex items-center gap-1 text-[11px] font-mono px-2.5 py-0.5 rounded-full bg-amber-950 border border-amber-700/50 text-amber-300">
|
<span className="inline-flex items-center gap-1 text-[11px] font-mono px-2.5 py-0.5 rounded-full bg-amber-950 border border-amber-700/50 text-amber-300">
|
||||||
<Tag className="w-3 h-3 text-amber-400" />
|
<Tag className="w-3 h-3 text-amber-400" />
|
||||||
|
|
@ -105,6 +115,16 @@ export const NewsSection: React.FC<NewsSectionProps> = ({
|
||||||
{activeNewsModal.title}
|
{activeNewsModal.title}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
|
{activeNewsModal.imageUrl && (
|
||||||
|
<div className="mb-4 overflow-hidden rounded-lg border border-amber-900/50 max-h-64 bg-zinc-950">
|
||||||
|
<img
|
||||||
|
src={activeNewsModal.imageUrl}
|
||||||
|
alt={activeNewsModal.title}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="overflow-y-auto pr-2 space-y-4 text-zinc-300 leading-relaxed font-sans text-base whitespace-pre-line flex-1">
|
<div className="overflow-y-auto pr-2 space-y-4 text-zinc-300 leading-relaxed font-sans text-base whitespace-pre-line flex-1">
|
||||||
{activeNewsModal.content}
|
{activeNewsModal.content}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ export interface NewsPost {
|
||||||
tag: string;
|
tag: string;
|
||||||
summary: string;
|
summary: string;
|
||||||
content: string;
|
content: string;
|
||||||
|
imageUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DevCategoryProgress {
|
export interface DevCategoryProgress {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue