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:
2026-03-11 16:59:12 +03:00
parent d5afaf92ba
commit 27c1348f89
44 changed files with 3709 additions and 69 deletions

View File

@@ -0,0 +1,171 @@
interface InputFieldProps {
label: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
type?: "text" | "url" | "tel";
}
export function InputField({
label,
value,
onChange,
placeholder,
type = "text",
}: InputFieldProps) {
return (
<div>
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
<input
type={type}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white placeholder-neutral-500 outline-none focus:border-gold transition-colors"
/>
</div>
);
}
interface TextareaFieldProps {
label: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
rows?: number;
}
export function TextareaField({
label,
value,
onChange,
placeholder,
rows = 3,
}: TextareaFieldProps) {
return (
<div>
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
rows={rows}
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white placeholder-neutral-500 outline-none focus:border-gold transition-colors resize-y"
/>
</div>
);
}
interface SelectFieldProps {
label: string;
value: string;
onChange: (value: string) => void;
options: { value: string; label: string }[];
placeholder?: string;
}
export function SelectField({
label,
value,
onChange,
options,
placeholder,
}: SelectFieldProps) {
return (
<div>
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white outline-none focus:border-gold transition-colors"
>
{placeholder && (
<option value="" disabled>
{placeholder}
</option>
)}
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
);
}
interface TimeRangeFieldProps {
label: string;
value: string;
onChange: (value: string) => void;
onBlur?: () => void;
}
export function TimeRangeField({ label, value, onChange, onBlur }: TimeRangeFieldProps) {
// Parse "HH:MMHH:MM" into start and end
const parts = value.split("");
const start = parts[0]?.trim() || "";
const end = parts[1]?.trim() || "";
function update(s: string, e: string) {
if (s && e) {
onChange(`${s}${e}`);
} else if (s) {
onChange(s);
} else {
onChange("");
}
}
return (
<div>
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
<div className="flex items-center gap-2">
<input
type="time"
value={start}
onChange={(e) => update(e.target.value, end)}
onBlur={onBlur}
className="flex-1 rounded-lg border border-white/10 bg-neutral-800 px-3 py-2.5 text-white outline-none focus:border-gold transition-colors"
/>
<span className="text-neutral-500"></span>
<input
type="time"
value={end}
onChange={(e) => update(start, e.target.value)}
onBlur={onBlur}
className="flex-1 rounded-lg border border-white/10 bg-neutral-800 px-3 py-2.5 text-white outline-none focus:border-gold transition-colors"
/>
</div>
</div>
);
}
interface ToggleFieldProps {
label: string;
checked: boolean;
onChange: (checked: boolean) => void;
}
export function ToggleField({ label, checked, onChange }: ToggleFieldProps) {
return (
<label className="flex items-center gap-2 cursor-pointer">
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className={`relative h-6 w-11 rounded-full transition-colors ${
checked ? "bg-gold" : "bg-neutral-700"
}`}
>
<span
className={`absolute top-0.5 left-0.5 h-5 w-5 rounded-full bg-white transition-transform ${
checked ? "translate-x-5" : ""
}`}
/>
</button>
<span className="text-sm text-neutral-300">{label}</span>
</label>
);
}