diff --git a/public/images/team/75398-original-1773399182323.jpg b/public/images/team/75398-original-1773399182323.jpg new file mode 100644 index 0000000..b4c8131 Binary files /dev/null and b/public/images/team/75398-original-1773399182323.jpg differ diff --git a/public/images/team/angel-1773234723454.PNG b/public/images/team/angel-1773234723454.PNG new file mode 100644 index 0000000..05af693 Binary files /dev/null and b/public/images/team/angel-1773234723454.PNG differ diff --git a/public/images/team/photo-2025-06-28-23-11-20-1773234496259.jpg b/public/images/team/photo-2025-06-28-23-11-20-1773234496259.jpg new file mode 100644 index 0000000..15ad8ff Binary files /dev/null and b/public/images/team/photo-2025-06-28-23-11-20-1773234496259.jpg differ diff --git a/src/app/admin/_components/FormField.tsx b/src/app/admin/_components/FormField.tsx index a98242f..622a7c7 100644 --- a/src/app/admin/_components/FormField.tsx +++ b/src/app/admin/_components/FormField.tsx @@ -1,5 +1,6 @@ import { useRef, useEffect, useState } from "react"; -import { Plus, X } from "lucide-react"; +import { Plus, X, Upload, Loader2, Link, ImageIcon } from "lucide-react"; +import type { RichListItem, VictoryItem } from "@/types/content"; interface InputFieldProps { label: string; @@ -104,12 +105,10 @@ export function SelectField({ const filtered = search ? options.filter((o) => { const q = search.toLowerCase(); - // Match any word that starts with the search query return o.label.toLowerCase().split(/\s+/).some((word) => word.startsWith(q)); }) : options; - // Close on outside click useEffect(() => { if (!open) return; function handle(e: MouseEvent) { @@ -188,7 +187,6 @@ interface TimeRangeFieldProps { } export function TimeRangeField({ label, value, onChange, onBlur }: TimeRangeFieldProps) { - // Parse "HH:MM–HH:MM" into start and end const parts = value.split("–"); const start = parts[0]?.trim() || ""; const end = parts[1]?.trim() || ""; @@ -204,7 +202,6 @@ export function TimeRangeField({ label, value, onChange, onBlur }: TimeRangeFiel } function handleStartChange(newStart: string) { - // Reset end if start >= end if (newStart && end && newStart >= end) { update(newStart, ""); } else { @@ -213,7 +210,6 @@ export function TimeRangeField({ label, value, onChange, onBlur }: TimeRangeFiel } function handleEndChange(newEnd: string) { - // Ignore if end <= start if (start && newEnd && newEnd <= start) return; update(start, newEnd); } @@ -339,3 +335,265 @@ export function ListField({ label, items, onChange, placeholder }: ListFieldProp ); } + +interface VictoryListFieldProps { + label: string; + items: RichListItem[]; + onChange: (items: RichListItem[]) => void; + placeholder?: string; +} + +export function VictoryListField({ label, items, onChange, placeholder }: VictoryListFieldProps) { + const [draft, setDraft] = useState(""); + const [uploadingIndex, setUploadingIndex] = useState(null); + + function add() { + const val = draft.trim(); + if (!val) return; + onChange([...items, { text: val }]); + setDraft(""); + } + + function remove(index: number) { + onChange(items.filter((_, i) => i !== index)); + } + + function updateText(index: number, text: string) { + onChange(items.map((item, i) => (i === index ? { ...item, text } : item))); + } + + function updateLink(index: number, link: string) { + onChange(items.map((item, i) => (i === index ? { ...item, link: link || undefined } : item))); + } + + function removeImage(index: number) { + onChange(items.map((item, i) => (i === index ? { ...item, image: undefined } : item))); + } + + async function handleUpload(index: number, e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + setUploadingIndex(index); + 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) { + onChange(items.map((item, i) => (i === index ? { ...item, image: result.path } : item))); + } + } catch { /* upload failed */ } finally { + setUploadingIndex(null); + } + } + + return ( +
+ +
+ {items.map((item, i) => ( +
+
+ updateText(i, e.target.value)} + className="flex-1 rounded-lg border border-white/10 bg-neutral-800 px-4 py-2 text-sm text-white outline-none focus:border-gold transition-colors" + /> + +
+
+ {item.image ? ( +
+ + {item.image.split("/").pop()} + +
+ ) : ( + + )} +
+ + updateLink(i, e.target.value)} + placeholder="Ссылка..." + className="w-48 rounded-md border border-white/5 bg-neutral-800 px-2 py-1 text-xs text-white placeholder-neutral-600 outline-none focus:border-gold/50 transition-colors" + /> +
+
+
+ ))} +
+ setDraft(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); add(); } }} + placeholder={placeholder || "Добавить..."} + className="flex-1 rounded-lg border border-dashed border-white/10 bg-neutral-800/50 px-4 py-2 text-sm text-white placeholder-neutral-600 outline-none focus:border-gold/50 transition-colors" + /> + +
+
+
+ ); +} + +interface VictoryItemListFieldProps { + label: string; + items: VictoryItem[]; + onChange: (items: VictoryItem[]) => void; +} + +export function VictoryItemListField({ label, items, onChange }: VictoryItemListFieldProps) { + const [uploadingIndex, setUploadingIndex] = useState(null); + + function add() { + onChange([...items, { place: "", category: "", competition: "" }]); + } + + function remove(index: number) { + onChange(items.filter((_, i) => i !== index)); + } + + function update(index: number, field: keyof VictoryItem, value: string) { + onChange(items.map((item, i) => (i === index ? { ...item, [field]: value || undefined } : item))); + } + + function removeImage(index: number) { + onChange(items.map((item, i) => (i === index ? { ...item, image: undefined } : item))); + } + + async function handleUpload(index: number, e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + setUploadingIndex(index); + 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) { + onChange(items.map((item, i) => (i === index ? { ...item, image: result.path } : item))); + } + } catch { /* upload failed */ } finally { + setUploadingIndex(null); + } + } + + return ( +
+ +
+ {items.map((item, i) => ( +
+
+ update(i, "place", e.target.value)} + placeholder="Место (🥇, 1 место...)" + className="w-32 rounded-lg border border-white/10 bg-neutral-800 px-3 py-2 text-sm text-white placeholder-neutral-600 outline-none focus:border-gold transition-colors" + /> + update(i, "category", e.target.value)} + placeholder="Категория (Exotic Semi-Pro...)" + className="flex-1 rounded-lg border border-white/10 bg-neutral-800 px-3 py-2 text-sm text-white placeholder-neutral-600 outline-none focus:border-gold transition-colors" + /> + +
+ update(i, "competition", e.target.value)} + placeholder="Чемпионат (REVOLUTION 2025...)" + className="w-full rounded-lg border border-white/10 bg-neutral-800 px-3 py-2 text-sm text-white placeholder-neutral-600 outline-none focus:border-gold transition-colors" + /> +
+ update(i, "location", e.target.value)} + placeholder="Город, страна" + className="flex-1 rounded-lg border border-white/10 bg-neutral-800 px-3 py-2 text-sm text-white placeholder-neutral-600 outline-none focus:border-gold transition-colors" + /> + update(i, "date", e.target.value)} + placeholder="Дата (22-23.02.2025)" + className="w-40 rounded-lg border border-white/10 bg-neutral-800 px-3 py-2 text-sm text-white placeholder-neutral-600 outline-none focus:border-gold transition-colors" + /> +
+
+ {item.image ? ( +
+ + {item.image.split("/").pop()} + +
+ ) : ( + + )} +
+ + update(i, "link", e.target.value)} + placeholder="Ссылка..." + className="w-48 rounded-md border border-white/5 bg-neutral-800 px-2 py-1 text-xs text-white placeholder-neutral-600 outline-none focus:border-gold/50 transition-colors" + /> +
+
+
+ ))} + +
+
+ ); +} diff --git a/src/app/admin/team/[id]/page.tsx b/src/app/admin/team/[id]/page.tsx index 11f4ed2..5d66516 100644 --- a/src/app/admin/team/[id]/page.tsx +++ b/src/app/admin/team/[id]/page.tsx @@ -4,7 +4,8 @@ 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, ListField } from "../../_components/FormField"; +import { InputField, TextareaField, ListField, VictoryListField, VictoryItemListField } from "../../_components/FormField"; +import type { RichListItem, VictoryItem } from "@/types/content"; interface MemberForm { name: string; @@ -13,8 +14,8 @@ interface MemberForm { instagram: string; description: string; experience: string[]; - victories: string[]; - education: string[]; + victories: VictoryItem[]; + education: RichListItem[]; } export default function TeamMemberEditorPage() { @@ -211,13 +212,12 @@ export default function TeamMemberEditorPage() { onChange={(items) => setData({ ...data, experience: items })} placeholder="Например: 10 лет в танцах" /> - setData({ ...data, victories: items })} - placeholder="Например: 1 место — чемпионат..." /> - setData({ ...data, education: items })} diff --git a/src/app/api/admin/team/route.ts b/src/app/api/admin/team/route.ts index c4713d5..fe5d8af 100644 --- a/src/app/api/admin/team/route.ts +++ b/src/app/api/admin/team/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getTeamMembers, createTeamMember } from "@/lib/db"; import { revalidatePath } from "next/cache"; +import type { RichListItem, VictoryItem } from "@/types/content"; export async function GET() { const members = getTeamMembers(); @@ -15,8 +16,8 @@ export async function POST(request: NextRequest) { instagram?: string; description?: string; experience?: string[]; - victories?: string[]; - education?: string[]; + victories?: VictoryItem[]; + education?: RichListItem[]; }; if (!data.name || !data.role || !data.image) { diff --git a/src/components/sections/Team.tsx b/src/components/sections/Team.tsx index 9789516..ba8cafd 100644 --- a/src/components/sections/Team.tsx +++ b/src/components/sections/Team.tsx @@ -36,33 +36,35 @@ export function Team({ data: team }: TeamProps) { {team.title} + - -
- {!showProfile ? ( - <> - + +
+ {!showProfile ? ( + <> + +
setShowProfile(true)} /> - - ) : ( - setShowProfile(false)} - /> - )} -
- -
+
+ + ) : ( + setShowProfile(false)} + /> + )} + +
); } diff --git a/src/components/sections/team/TeamProfile.tsx b/src/components/sections/team/TeamProfile.tsx index 93560ec..9812bdc 100644 --- a/src/components/sections/team/TeamProfile.tsx +++ b/src/components/sections/team/TeamProfile.tsx @@ -1,29 +1,26 @@ +import { useState } from "react"; import Image from "next/image"; -import { ArrowLeft, Instagram, Trophy, Award, GraduationCap } from "lucide-react"; -import type { TeamMember } from "@/types/content"; +import { ArrowLeft, Instagram, Trophy, GraduationCap, ExternalLink, X, MapPin, Calendar } from "lucide-react"; +import type { TeamMember, RichListItem, VictoryItem } from "@/types/content"; interface TeamProfileProps { member: TeamMember; onBack: () => void; } -const BIO_SECTIONS = [ - { key: "experience" as const, label: "Опыт", icon: Trophy }, - { key: "victories" as const, label: "Достижения", icon: Award }, - { key: "education" as const, label: "Образование", icon: GraduationCap }, -]; - export function TeamProfile({ member, onBack }: TeamProfileProps) { - const hasBio = BIO_SECTIONS.some( - (s) => member[s.key] && member[s.key]!.length > 0 - ); + const [lightbox, setLightbox] = useState(null); + const hasVictories = member.victories && member.victories.length > 0; + const hasExperience = member.experience && member.experience.length > 0; + const hasEducation = member.education && member.education.length > 0; + const hasBio = hasVictories || hasExperience || hasEducation; return (
- {/* Back button */} + {/* Back button — above card */} +
+ Достижение +
)} ); } + +function VictoryCard({ victory, onImageClick }: { victory: VictoryItem; onImageClick: (src: string) => void }) { + const hasImage = !!victory.image; + const hasLink = !!victory.link; + + if (hasImage) { + return ( +
+ +
+ ); + } + + return ( +
+
+ {victory.place && ( +

{victory.place}

+ )} + {victory.category && ( +

{victory.category}

+ )} +

{victory.competition}

+ {(victory.location || victory.date) && ( +
+ {victory.location && ( + + + {victory.location} + + )} + {victory.date && ( + + + {victory.date} + + )} +
+ )} + {hasLink && ( + + + Подробнее + + )} +
+
+ ); +} + +function RichCard({ item, onImageClick }: { item: RichListItem; onImageClick: (src: string) => void }) { + const hasImage = !!item.image; + const hasLink = !!item.link; + + if (hasImage) { + return ( +
+ +
+ ); + } + + return ( +
+
+

{item.text}

+ {hasLink && ( + + + Подробнее + + )} +
+
+ ); +} diff --git a/src/lib/db.ts b/src/lib/db.ts index d27b990..940d5ca 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -1,6 +1,6 @@ import Database from "better-sqlite3"; import path from "path"; -import type { SiteContent, TeamMember } from "@/types/content"; +import type { SiteContent, TeamMember, RichListItem, VictoryItem } from "@/types/content"; const DB_PATH = process.env.DATABASE_PATH || @@ -88,6 +88,32 @@ function parseJsonArray(val: string | null): string[] | undefined { try { const arr = JSON.parse(val); return Array.isArray(arr) && arr.length > 0 ? arr : undefined; } catch { return undefined; } } +function parseRichList(val: string | null): RichListItem[] | undefined { + if (!val) return undefined; + try { + const arr = JSON.parse(val); + if (!Array.isArray(arr) || arr.length === 0) return undefined; + // Handle both old string[] and new RichListItem[] formats + return arr.map((item: string | RichListItem) => + typeof item === "string" ? { text: item } : item + ); + } catch { return undefined; } +} + +function parseVictories(val: string | null): VictoryItem[] | undefined { + if (!val) return undefined; + try { + const arr = JSON.parse(val); + if (!Array.isArray(arr) || arr.length === 0) return undefined; + // Handle old string[], old RichListItem[], and new VictoryItem[] formats + return arr.map((item: string | Record) => { + if (typeof item === "string") return { place: "", category: "", competition: item }; + if ("text" in item && !("competition" in item)) return { place: "", category: "", competition: item.text as string, image: item.image as string | undefined, link: item.link as string | undefined }; + return item as unknown as VictoryItem; + }); + } catch { return undefined; } +} + export function getTeamMembers(): (TeamMember & { id: number })[] { const db = getDb(); const rows = db @@ -101,8 +127,8 @@ export function getTeamMembers(): (TeamMember & { id: number })[] { instagram: r.instagram ?? undefined, description: r.description ?? undefined, experience: parseJsonArray(r.experience), - victories: parseJsonArray(r.victories), - education: parseJsonArray(r.education), + victories: parseVictories(r.victories), + education: parseRichList(r.education), })); } @@ -122,8 +148,8 @@ export function getTeamMember( instagram: r.instagram ?? undefined, description: r.description ?? undefined, experience: parseJsonArray(r.experience), - victories: parseJsonArray(r.victories), - education: parseJsonArray(r.education), + victories: parseVictories(r.victories), + education: parseRichList(r.education), }; } diff --git a/src/types/content.ts b/src/types/content.ts index db997f2..607cb1f 100644 --- a/src/types/content.ts +++ b/src/types/content.ts @@ -7,6 +7,22 @@ export interface ClassItem { color?: string; } +export interface RichListItem { + text: string; + image?: string; + link?: string; +} + +export interface VictoryItem { + place: string; + category: string; + competition: string; + location?: string; + date?: string; + image?: string; + link?: string; +} + export interface TeamMember { name: string; role: string; @@ -14,8 +30,8 @@ export interface TeamMember { instagram?: string; description?: string; experience?: string[]; - victories?: string[]; - education?: string[]; + victories?: VictoryItem[]; + education?: RichListItem[]; } export interface FAQItem {