feat: implement admin page with data import and editing functionality
This commit is contained in:
510
src/app/admin/page.tsx
Normal file
510
src/app/admin/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
"use client";
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
export function Hero({
|
export function Hero({
|
||||||
glowColor,
|
glowColor,
|
||||||
@@ -19,9 +20,48 @@ export function Hero({
|
|||||||
setShowOldNames: (show: boolean) => void;
|
setShowOldNames: (show: boolean) => void;
|
||||||
oldUsernames: string[];
|
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 (
|
return (
|
||||||
<section className="mt-20 flex flex-col items-center text-center space-y-8 animate-fade-in">
|
<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
|
<div
|
||||||
className={`absolute -inset-1 rounded-full blur opacity-75 transition duration-500`}
|
className={`absolute -inset-1 rounded-full blur opacity-75 transition duration-500`}
|
||||||
style={{ backgroundColor: glowColor }}
|
style={{ backgroundColor: glowColor }}
|
||||||
|
|||||||
Reference in New Issue
Block a user