feat: admin panel with SQLite, auth, and calendar-style schedule editor
Complete admin panel for content management: - SQLite database with better-sqlite3, seed script from content.ts - Simple password auth with HMAC-signed cookies (Edge + Node compatible) - 9 section editors: meta, hero, about, team, classes, schedule, pricing, FAQ, contact - Team CRUD with image upload and drag reorder - Schedule editor with Google Calendar-style visual timeline (colored blocks, overlap detection, click-to-add) - All public components refactored to accept data props from DB (with fallback to static content) - Middleware protecting /admin/* and /api/admin/* routes Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
199
src/app/admin/team/[id]/page.tsx
Normal file
199
src/app/admin/team/[id]/page.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import { Save, Loader2, Check, ArrowLeft, Upload } from "lucide-react";
|
||||
import { InputField, TextareaField } from "../../_components/FormField";
|
||||
|
||||
interface MemberForm {
|
||||
name: string;
|
||||
role: string;
|
||||
image: string;
|
||||
instagram: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export default function TeamMemberEditorPage() {
|
||||
const router = useRouter();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const isNew = id === "new";
|
||||
|
||||
const [data, setData] = useState<MemberForm>({
|
||||
name: "",
|
||||
role: "",
|
||||
image: "/images/team/placeholder.webp",
|
||||
instagram: "",
|
||||
description: "",
|
||||
});
|
||||
const [loading, setLoading] = useState(!isNew);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isNew) return;
|
||||
fetch(`/api/admin/team/${id}`)
|
||||
.then((r) => r.json())
|
||||
.then((member) =>
|
||||
setData({
|
||||
name: member.name,
|
||||
role: member.role,
|
||||
image: member.image,
|
||||
instagram: member.instagram || "",
|
||||
description: member.description || "",
|
||||
})
|
||||
)
|
||||
.finally(() => setLoading(false));
|
||||
}, [id, isNew]);
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
setSaved(false);
|
||||
|
||||
if (isNew) {
|
||||
const res = await fetch("/api/admin/team", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (res.ok) {
|
||||
router.push("/admin/team");
|
||||
}
|
||||
} else {
|
||||
const res = await fetch(`/api/admin/team/${id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (res.ok) {
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
}
|
||||
}
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setUploading(true);
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("folder", "team");
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/admin/upload", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
const result = await res.json();
|
||||
if (result.path) {
|
||||
setData((prev) => ({ ...prev, image: result.path }));
|
||||
}
|
||||
} catch {
|
||||
// Upload failed silently
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-neutral-400">
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
Загрузка...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => router.push("/admin/team")}
|
||||
className="rounded-lg p-2 text-neutral-400 hover:text-white transition-colors"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
</button>
|
||||
<h1 className="text-2xl font-bold">
|
||||
{isNew ? "Новый участник" : data.name}
|
||||
</h1>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || !data.name || !data.role}
|
||||
className="flex items-center gap-2 rounded-lg bg-gold px-4 py-2.5 text-sm font-medium text-black transition-opacity hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : saved ? (
|
||||
<Check size={16} />
|
||||
) : (
|
||||
<Save size={16} />
|
||||
)}
|
||||
{saving ? "Сохранение..." : saved ? "Сохранено!" : "Сохранить"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-6 lg:grid-cols-[240px_1fr]">
|
||||
{/* Photo */}
|
||||
<div>
|
||||
<p className="text-sm text-neutral-400 mb-2">Фото</p>
|
||||
<div className="relative aspect-[3/4] w-full overflow-hidden rounded-xl border border-white/10">
|
||||
<Image
|
||||
src={data.image}
|
||||
alt={data.name || "Фото"}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="240px"
|
||||
/>
|
||||
</div>
|
||||
<label className="mt-3 flex cursor-pointer items-center justify-center gap-2 rounded-lg border border-dashed border-white/20 px-4 py-2.5 text-sm text-neutral-400 hover:text-white hover:border-white/40 transition-colors">
|
||||
{uploading ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : (
|
||||
<Upload size={16} />
|
||||
)}
|
||||
{uploading ? "Загрузка..." : "Загрузить фото"}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Fields */}
|
||||
<div className="space-y-4">
|
||||
<InputField
|
||||
label="Имя"
|
||||
value={data.name}
|
||||
onChange={(v) => setData({ ...data, name: v })}
|
||||
/>
|
||||
<InputField
|
||||
label="Роль / Специализация"
|
||||
value={data.role}
|
||||
onChange={(v) => setData({ ...data, role: v })}
|
||||
/>
|
||||
<InputField
|
||||
label="Instagram"
|
||||
value={data.instagram}
|
||||
onChange={(v) => setData({ ...data, instagram: v })}
|
||||
type="url"
|
||||
placeholder="https://instagram.com/..."
|
||||
/>
|
||||
<TextareaField
|
||||
label="Описание"
|
||||
value={data.description}
|
||||
onChange={(v) => setData({ ...data, description: v })}
|
||||
rows={6}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user