feat: implement admin page with data import and editing functionality

This commit is contained in:
Space-Banane
2026-03-14 14:33:45 +01:00
parent c458dcd722
commit b7ffff499e
2 changed files with 552 additions and 2 deletions

510
src/app/admin/page.tsx Normal file
View File

@@ -0,0 +1,510 @@
"use client";
import { useState, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { useRouter } from "next/navigation";
import type { Project, MiniProject, Experience, RealWork } from "../../types";
const API_BASE = "https://shsf-api.reversed.dev/api/exec/4/e942538c-caa1-49d1-8953-dfab1e62f8cb";
type EditableFile = "projects.json" | "mini_projects.json" | "experience.json" | "real_work.json";
export default function AdminPage() {
const [password, setPassword] = useState("");
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [error, setError] = useState("");
const [isLoading, setIsLoading] = useState(false);
// Editor State
const [selectedFile, setSelectedFile] = useState<EditableFile>("real_work.json");
const [data, setData] = useState<any[]>([]);
const [isSaving, setIsSaving] = useState(false);
const [saveStatus, setSaveStatus] = useState<{ type: "success" | "error"; message: string } | null>(null);
const [newTagDrafts, setNewTagDrafts] = useState<Record<number, string>>({});
// Import state
const [importFileName, setImportFileName] = useState("");
const [importFileContent, setImportFileContent] = useState<string | null>(null);
const [importError, setImportError] = useState("");
// Handle file import
const handleImportFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
setImportError("");
const file = e.target.files?.[0];
if (!file) return;
setImportFileName(file.name); // Auto-fill filename input
const reader = new FileReader();
reader.onload = (ev) => {
try {
const text = ev.target?.result as string;
setImportFileContent(text);
} catch (err) {
setImportError("Failed to read file");
}
};
reader.onerror = () => setImportError("Failed to read file");
reader.readAsText(file);
};
// Apply imported file content
const handleApplyImport = () => {
setImportError("");
if (!importFileContent) {
setImportError("No file content loaded");
return;
}
try {
const parsed = JSON.parse(importFileContent);
setData(Array.isArray(parsed) ? parsed : []);
if (importFileName && ["projects.json", "mini_projects.json", "experience.json", "real_work.json"].includes(importFileName)) {
setSelectedFile(importFileName as EditableFile);
}
setImportFileContent(null);
setImportFileName("");
} catch (err) {
setImportError("Invalid JSON file");
}
};
const router = useRouter();
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setError("");
try {
const response = await fetch(`${API_BASE}/password`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }),
});
const result = await response.text();
if (response.ok && result.trim().toLowerCase() === "yay") {
setIsAuthenticated(true);
} else {
setError("Invalid password");
}
} catch (err) {
setError("An error occurred. Please try again.");
} finally {
setIsLoading(false);
}
};
const fetchData = async (file: EditableFile) => {
setIsLoading(true);
setSaveStatus(null);
try {
const route = file.replace(".json", "");
const response = await fetch(`${API_BASE}/${route}`);
if (response.ok) {
const json = await response.json();
setData(Array.isArray(json) ? json : []);
} else if (response.status === 404) {
// No data found for this file, show empty list
setData([]);
} else {
setSaveStatus({ type: "error", message: `Failed to fetch data (${response.status})` });
}
} catch (err) {
setSaveStatus({ type: "error", message: "Failed to fetch data" });
} finally {
setIsLoading(false);
}
};
const handleSave = async () => {
setIsSaving(true);
setSaveStatus(null);
try {
const response = await fetch(`${API_BASE}/writefile`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
password,
filename: selectedFile,
content: JSON.stringify(data, null, 2),
}),
});
const result = await response.text();
if (response.ok && result.trim().toLowerCase() === "yay") {
setSaveStatus({ type: "success", message: "Changes deployed successfully!" });
} else {
setSaveStatus({ type: "error", message: "Deploy failed." });
}
} catch (err) {
setSaveStatus({ type: "error", message: "Network error" });
} finally {
setIsSaving(false);
}
};
useEffect(() => {
if (isAuthenticated) fetchData(selectedFile);
}, [isAuthenticated, selectedFile]);
const updateEntry = (index: number, field: string, value: any) => {
const newData = [...data];
const keys = field.split(".");
if (keys.length === 2) {
newData[index][keys[0]] = { ...newData[index][keys[0]], [keys[1]]: value };
} else {
newData[index][field] = value;
}
setData(newData);
};
const addEntry = () => {
const templates: Record<EditableFile, any> = {
"projects.json": { name: "", description: "", link: "", open_source: false },
"mini_projects.json": { title: "", description: "", why: "" },
"experience.json": { name: "", type: "experience", description: "" },
"real_work.json": { company: "", summary: "" }
};
setData([templates[selectedFile], ...data]);
};
const removeEntry = (index: number) => {
setData(data.filter((_, i) => i !== index));
};
const updateRealWorkTag = (index: number, tagIndex: number, value: string) => {
const item = data[index] as RealWork;
const currentTags = [...(item.tags || [])];
currentTags[tagIndex] = value;
updateEntry(index, "tags", currentTags);
};
const removeRealWorkTag = (index: number, tagIndex: number) => {
const item = data[index] as RealWork;
const nextTags = (item.tags || []).filter((_, i) => i !== tagIndex);
updateEntry(index, "tags", nextTags);
};
const addRealWorkTag = (index: number) => {
const draft = (newTagDrafts[index] || "").trim();
if (!draft) return;
const item = data[index] as RealWork;
const nextTags = [...(item.tags || []), draft];
updateEntry(index, "tags", nextTags);
setNewTagDrafts((prev) => ({ ...prev, [index]: "" }));
};
const renderProjectEditor = (item: Project, idx: number) => (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1">
<label className="text-xs font-bold text-gray-500 uppercase">Name</label>
<input type="text" value={item.name || ""} onChange={(e) => updateEntry(idx, "name", e.target.value)} className="w-full bg-black/40 border border-white/10 rounded-lg p-2 text-sm" />
</div>
<div className="space-y-1">
<label className="text-xs font-bold text-gray-500 uppercase">Link</label>
<input type="text" value={item.link || ""} onChange={(e) => updateEntry(idx, "link", e.target.value)} className="w-full bg-black/40 border border-white/10 rounded-lg p-2 text-sm" />
</div>
<div className="md:col-span-2 space-y-1">
<label className="text-xs font-bold text-gray-500 uppercase">Description</label>
<textarea rows={3} value={item.description || ""} onChange={(e) => updateEntry(idx, "description", e.target.value)} className="w-full bg-black/40 border border-white/10 rounded-lg p-2 text-sm" />
</div>
<div className="space-y-1">
<label className="text-xs font-bold text-gray-500 uppercase">Image</label>
<input type="text" value={item.image || ""} onChange={(e) => updateEntry(idx, "image", e.target.value)} className="w-full bg-black/40 border border-white/10 rounded-lg p-2 text-sm" />
</div>
<div className="space-y-1">
<label className="text-xs font-bold text-gray-500 uppercase">Open Source URL</label>
<input
type="text"
value={typeof item.open_source === "object" ? item.open_source.link || "" : ""}
onChange={(e) => updateEntry(idx, "open_source", e.target.value ? { link: e.target.value } : false)}
className="w-full bg-black/40 border border-white/10 rounded-lg p-2 text-sm"
placeholder="Leave empty for false"
/>
</div>
</div>
);
const renderMiniProjectEditor = (item: MiniProject, idx: number) => {
const noteEnabled = Boolean(item.note);
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1">
<label className="text-xs font-bold text-gray-500 uppercase">Title</label>
<input type="text" value={item.title || ""} onChange={(e) => updateEntry(idx, "title", e.target.value)} className="w-full bg-black/40 border border-white/10 rounded-lg p-2 text-sm" />
</div>
<div className="space-y-1">
<label className="text-xs font-bold text-gray-500 uppercase">GitHub</label>
<input type="text" value={item.github || ""} onChange={(e) => updateEntry(idx, "github", e.target.value)} className="w-full bg-black/40 border border-white/10 rounded-lg p-2 text-sm" />
</div>
<div className="md:col-span-2 space-y-1">
<label className="text-xs font-bold text-gray-500 uppercase">Description</label>
<textarea rows={3} value={item.description || ""} onChange={(e) => updateEntry(idx, "description", e.target.value)} className="w-full bg-black/40 border border-white/10 rounded-lg p-2 text-sm" />
</div>
<div className="md:col-span-2 space-y-1">
<label className="text-xs font-bold text-gray-500 uppercase">Why</label>
<textarea rows={3} value={item.why || ""} onChange={(e) => updateEntry(idx, "why", e.target.value)} className="w-full bg-black/40 border border-white/10 rounded-lg p-2 text-sm" />
</div>
<div className="space-y-1">
<label className="text-xs font-bold text-gray-500 uppercase">Image</label>
<input type="text" value={item.image || ""} onChange={(e) => updateEntry(idx, "image", e.target.value)} className="w-full bg-black/40 border border-white/10 rounded-lg p-2 text-sm" />
</div>
<div className="space-y-1">
<label className="text-xs font-bold text-gray-500 uppercase">Reproduction</label>
<input type="text" value={item.reproduction || ""} onChange={(e) => updateEntry(idx, "reproduction", e.target.value)} className="w-full bg-black/40 border border-white/10 rounded-lg p-2 text-sm" />
</div>
<div className="md:col-span-2 rounded-xl border border-white/10 bg-white/5 p-3 space-y-3">
<div className="flex items-center justify-between">
<label className="text-xs font-bold text-gray-400 uppercase">Enable Note</label>
<button
type="button"
onClick={() => {
if (noteEnabled) {
updateEntry(idx, "note", undefined);
} else {
updateEntry(idx, "note", { color: "#60a5fa", content: "" });
}
}}
className={`px-3 py-1 rounded-lg text-xs font-bold ${noteEnabled ? "bg-red-500/20 text-red-300" : "bg-green-500/20 text-green-300"}`}
>
{noteEnabled ? "Disable" : "Enable"}
</button>
</div>
{noteEnabled && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="space-y-1">
<label className="text-xs font-bold text-gray-500 uppercase">Note Color</label>
<input type="text" value={item.note?.color || ""} onChange={(e) => updateEntry(idx, "note.color", e.target.value)} className="w-full bg-black/40 border border-white/10 rounded-lg p-2 text-sm" placeholder="#HEX" />
</div>
<div className="space-y-1">
<label className="text-xs font-bold text-gray-500 uppercase">Note Content</label>
<input type="text" value={item.note?.content || ""} onChange={(e) => updateEntry(idx, "note.content", e.target.value)} className="w-full bg-black/40 border border-white/10 rounded-lg p-2 text-sm" />
</div>
</div>
)}
</div>
</div>
);
};
const renderExperienceEditor = (item: Experience, idx: number) => (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1">
<label className="text-xs font-bold text-gray-500 uppercase">Name</label>
<input type="text" value={item.name || ""} onChange={(e) => updateEntry(idx, "name", e.target.value)} className="w-full bg-black/40 border border-white/10 rounded-lg p-2 text-sm" />
</div>
<div className="space-y-1">
<label className="text-xs font-bold text-gray-500 uppercase">Type</label>
<select value={item.type} onChange={(e) => updateEntry(idx, "type", e.target.value)} className="w-full bg-black/40 border border-white/10 rounded-lg p-2 text-sm">
<option value="languages">languages</option>
<option value="software">software</option>
<option value="plattforms">plattforms</option>
<option value="experience">experience</option>
<option value="other">other</option>
</select>
</div>
<div className="md:col-span-2 space-y-1">
<label className="text-xs font-bold text-gray-500 uppercase">Description</label>
<textarea rows={3} value={item.description || ""} onChange={(e) => updateEntry(idx, "description", e.target.value)} className="w-full bg-black/40 border border-white/10 rounded-lg p-2 text-sm" />
</div>
<div className="space-y-1">
<label className="text-xs font-bold text-gray-500 uppercase">Image</label>
<input type="text" value={item.image || ""} onChange={(e) => updateEntry(idx, "image", e.target.value)} className="w-full bg-black/40 border border-white/10 rounded-lg p-2 text-sm" />
</div>
<div className="space-y-1">
<label className="text-xs font-bold text-gray-500 uppercase">Learned At</label>
<input type="text" value={item.learned_at || ""} onChange={(e) => updateEntry(idx, "learned_at", e.target.value)} className="w-full bg-black/40 border border-white/10 rounded-lg p-2 text-sm" />
</div>
<div className="space-y-1">
<label className="text-xs font-bold text-gray-500 uppercase">Learned From</label>
<input type="text" value={item.learned_from || ""} onChange={(e) => updateEntry(idx, "learned_from", e.target.value)} className="w-full bg-black/40 border border-white/10 rounded-lg p-2 text-sm" />
</div>
<div className="space-y-1">
<label className="text-xs font-bold text-gray-500 uppercase">Learned Because</label>
<input type="text" value={item.learned_because || ""} onChange={(e) => updateEntry(idx, "learned_because", e.target.value)} className="w-full bg-black/40 border border-white/10 rounded-lg p-2 text-sm" />
</div>
</div>
);
const renderRealWorkEditor = (item: RealWork, idx: number) => (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1">
<label className="text-xs font-bold text-gray-500 uppercase">Company</label>
<input type="text" value={item.company || ""} onChange={(e) => updateEntry(idx, "company", e.target.value)} className="w-full bg-black/40 border border-white/10 rounded-lg p-2 text-sm" />
</div>
<div className="space-y-1">
<label className="text-xs font-bold text-gray-500 uppercase">URL</label>
<input type="text" value={item.url || ""} onChange={(e) => updateEntry(idx, "url", e.target.value)} className="w-full bg-black/40 border border-white/10 rounded-lg p-2 text-sm" />
</div>
<div className="md:col-span-2 space-y-1">
<label className="text-xs font-bold text-gray-500 uppercase">Summary</label>
<textarea rows={3} value={item.summary || ""} onChange={(e) => updateEntry(idx, "summary", e.target.value)} className="w-full bg-black/40 border border-white/10 rounded-lg p-2 text-sm" />
</div>
<div className="md:col-span-2 space-y-1">
<label className="text-xs font-bold text-gray-500 uppercase">Tags</label>
<div className="space-y-2 rounded-lg border border-white/10 bg-black/30 p-3">
{(item.tags || []).length === 0 && (
<p className="text-xs text-gray-500">No tags yet. Add one below.</p>
)}
{(item.tags || []).map((tag, tagIndex) => (
<div key={`${idx}-${tagIndex}`} className="flex items-center gap-2">
<input
type="text"
value={tag}
onChange={(e) => updateRealWorkTag(idx, tagIndex, e.target.value)}
className="flex-1 bg-black/40 border border-white/10 rounded-lg p-2 text-sm"
placeholder="Tag"
/>
<button
type="button"
onClick={() => removeRealWorkTag(idx, tagIndex)}
className="px-3 py-2 rounded-lg border border-red-500/40 text-red-300 hover:bg-red-500/15 text-xs font-bold"
>
Remove
</button>
</div>
))}
<div className="flex items-center gap-2 pt-1">
<input
type="text"
value={newTagDrafts[idx] || ""}
onChange={(e) => setNewTagDrafts((prev) => ({ ...prev, [idx]: e.target.value }))}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addRealWorkTag(idx);
}
}}
className="flex-1 bg-black/40 border border-white/10 rounded-lg p-2 text-sm"
placeholder="New tag"
/>
<button
type="button"
onClick={() => addRealWorkTag(idx)}
className="px-3 py-2 rounded-lg border border-green-500/40 text-green-300 hover:bg-green-500/15 text-xs font-bold"
>
Add Tag
</button>
</div>
</div>
</div>
</div>
);
const renderEntryEditor = (item: any, idx: number) => {
if (selectedFile === "projects.json") {
return renderProjectEditor(item as Project, idx);
}
if (selectedFile === "mini_projects.json") {
return renderMiniProjectEditor(item as MiniProject, idx);
}
if (selectedFile === "experience.json") {
return renderExperienceEditor(item as Experience, idx);
}
return renderRealWorkEditor(item as RealWork, idx);
};
if (!isAuthenticated) {
return (
<div className="min-h-screen flex items-center justify-center bg-[#0a0a0a] px-4 font-sans text-white">
<motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} className="w-full max-w-sm p-8 bg-white/5 backdrop-blur-3xl border border-white/10 rounded-2xl shadow-2xl">
<h1 className="text-2xl font-black mb-6 text-center bg-gradient-to-r from-blue-400 to-purple-500 bg-clip-text text-transparent">ADMIN ACCESS</h1>
<form onSubmit={handleLogin} className="space-y-4">
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="Password" className="w-full bg-black/50 border border-white/10 rounded-xl px-4 py-3 focus:ring-2 focus:ring-purple-500/50 outline-none transition-all" autoFocus />
{error && <p className="text-red-400 text-xs text-center">{error}</p>}
<button type="submit" disabled={isLoading} className="w-full bg-white text-black font-bold py-3 rounded-xl hover:opacity-90 transition-all disabled:opacity-50">{isLoading ? "Validating..." : "Unlock"}</button>
</form>
</motion.div>
</div>
);
}
return (
<div className="min-h-screen bg-[#0a0a0a] text-gray-200 font-sans">
<div className="max-w-6xl mx-auto p-6">
{/* Import file section */}
<section className="mb-8 bg-white/5 border border-white/10 rounded-xl p-4">
<h3 className="text-lg font-bold mb-2 text-white">Import Data File</h3>
<div className="flex flex-col md:flex-row gap-3 items-center">
<input
type="file"
accept="application/json"
onChange={handleImportFile}
className="bg-black/40 border border-white/10 rounded-lg p-2 text-sm"
/>
<input
type="text"
placeholder="Filename (e.g. projects.json)"
value={importFileName}
onChange={e => setImportFileName(e.target.value)}
className="bg-black/40 border border-white/10 rounded-lg p-2 text-sm"
/>
<button
type="button"
disabled={!importFileContent || !importFileName}
onClick={handleApplyImport}
className="px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-500 text-white font-bold text-sm disabled:opacity-50"
>
Import
</button>
</div>
{importError && <p className="text-red-400 text-xs mt-2">{importError}</p>}
</section>
<header className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-10">
<div>
<h1 className="text-3xl font-black text-white">PORTFOLIO CRM</h1>
<p className="text-gray-500 text-sm">Visual interface for your data stores</p>
</div>
<div className="flex gap-3">
<button onClick={() => router.push("/")} className="px-4 py-2 rounded-lg bg-white/5 hover:bg-white/10 border border-white/10 text-sm">Portfolio</button>
<button onClick={handleSave} disabled={isSaving} className="px-6 py-2 rounded-lg bg-blue-600 hover:bg-blue-500 text-white font-bold text-sm shadow-lg shadow-blue-900/40 transition-all disabled:opacity-50">{isSaving ? "Saving..." : "Deploy Changes"}</button>
</div>
</header>
<div className="flex flex-col lg:flex-row gap-8">
<aside className="lg:w-64 flex-shrink-0 space-y-2">
{(["projects.json", "mini_projects.json", "experience.json", "real_work.json"] as EditableFile[]).map(file => (
<button key={file} onClick={() => setSelectedFile(file)} className={`w-full text-left px-4 py-3 rounded-xl border transition-all text-sm font-bold ${selectedFile === file ? "bg-white text-black border-white" : "bg-white/5 border-transparent text-gray-400 hover:bg-white/10"}`}>
{file.replace(".json", "").replace("_", " ")}
</button>
))}
</aside>
<main className="flex-1 space-y-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold text-white capitalize">{selectedFile.replace(".json", "").replace("_", " ")}</h2>
<button onClick={addEntry} className="px-4 py-2 rounded-lg bg-green-600/20 text-green-400 border border-green-500/30 hover:bg-green-600/30 text-xs font-bold transition-all">+ New Entry</button>
</div>
<AnimatePresence>
{isLoading ? (
<div className="h-64 flex items-center justify-center text-gray-500 font-mono italic animate-pulse">Retrieving encrypted data stores...</div>
) : (
data.map((item, idx) => (
<motion.div key={idx} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} className="p-6 bg-white/5 border border-white/10 rounded-2xl space-y-4 group">
<div className="flex justify-between items-center border-b border-white/5 pb-2">
<span className="text-[10px] font-mono text-gray-500 uppercase">Item #{data.length - idx}</span>
<button onClick={() => removeEntry(idx)} className="text-red-500/50 hover:text-red-400 text-xs transition-colors">Delete</button>
</div>
{renderEntryEditor(item, idx)}
</motion.div>
))
)}
</AnimatePresence>
</main>
</div>
</div>
{saveStatus && (
<motion.div initial={{ opacity: 0, y: 100 }} animate={{ opacity: 1, y: 0 }} className={`fixed bottom-8 left-1/2 -translate-x-1/2 px-8 py-4 rounded-2xl shadow-2xl border font-bold text-sm ${saveStatus.type === "success" ? "bg-green-500 text-white border-green-400" : "bg-red-500 text-white border-red-400"}`}>
{saveStatus.message}
<button onClick={() => setSaveStatus(null)} className="ml-4 opacity-70">×</button>
</motion.div>
)}
</div>
);
}

View File

@@ -1,4 +1,5 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { useRouter } from "next/navigation";
export function Hero({
glowColor,
@@ -19,9 +20,48 @@ export function Hero({
setShowOldNames: (show: boolean) => void;
oldUsernames: string[];
}) {
const router = useRouter();
const [movementCount, setMovementCount] = useState(0);
const lastX = useRef<number | null>(null);
const lastDir = useRef<"left" | "right" | null>(null);
const lastTime = useRef<number>(0);
const resetTimeout = useRef<NodeJS.Timeout | null>(null);
const handleMouseMove = (e: React.MouseEvent) => {
const currentX = e.clientX;
const currentTime = Date.now();
if (lastX.current !== null) {
const deltaX = currentX - lastX.current;
const velocity = Math.abs(deltaX) / (currentTime - lastTime.current || 1);
const direction = deltaX > 0 ? "right" : "left";
// Thresholds: velocity > 2 (approx 2px/ms) and changed direction
if (velocity > 2 && direction !== lastDir.current) {
setMovementCount((prev) => {
const newCount = prev + 1;
if (newCount >= 10) {
// Trigger navigation outside of the state update to avoid React warning
setTimeout(() => router.push("/admin"), 0);
return 0;
}
return newCount;
});
lastDir.current = direction;
// Reset if no movement for a while
if (resetTimeout.current) clearTimeout(resetTimeout.current);
resetTimeout.current = setTimeout(() => setMovementCount(0), 1000);
}
}
lastX.current = currentX;
lastTime.current = currentTime;
};
return (
<section className="mt-20 flex flex-col items-center text-center space-y-8 animate-fade-in">
<div className="relative group">
<div className="relative group" onMouseMove={handleMouseMove}>
<div
className={`absolute -inset-1 rounded-full blur opacity-75 transition duration-500`}
style={{ backgroundColor: glowColor }}