diff --git a/src/app/admin/_components/FormField.tsx b/src/app/admin/_components/FormField.tsx index 622a7c7..840b144 100644 --- a/src/app/admin/_components/FormField.tsx +++ b/src/app/admin/_components/FormField.tsx @@ -1,5 +1,5 @@ import { useRef, useEffect, useState } from "react"; -import { Plus, X, Upload, Loader2, Link, ImageIcon } from "lucide-react"; +import { Plus, X, Upload, Loader2, Link, ImageIcon, Calendar, AlertCircle, MapPin } from "lucide-react"; import type { RichListItem, VictoryItem } from "@/types/content"; interface InputFieldProps { @@ -341,9 +341,10 @@ interface VictoryListFieldProps { items: RichListItem[]; onChange: (items: RichListItem[]) => void; placeholder?: string; + onLinkValidate?: (key: string, error: string | null) => void; } -export function VictoryListField({ label, items, onChange, placeholder }: VictoryListFieldProps) { +export function VictoryListField({ label, items, onChange, placeholder, onLinkValidate }: VictoryListFieldProps) { const [draft, setDraft] = useState(""); const [uploadingIndex, setUploadingIndex] = useState(null); @@ -425,16 +426,12 @@ export function VictoryListField({ label, items, onChange, placeholder }: Victor handleUpload(i, e)} className="hidden" /> )} -
- - 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" - /> -
+ updateLink(i, v)} + validationKey={`edu-${i}`} + onValidate={onLinkValidate} + /> ))} @@ -461,13 +458,223 @@ export function VictoryListField({ label, items, onChange, placeholder }: Victor ); } +// --- Date Range Picker --- +// Parses Russian date formats: "22.02.2025", "22-23.02.2025", "22.02-01.03.2025" +function parseDateRange(value: string): { start: string; end: string } { + if (!value) return { start: "", end: "" }; + + // "22-23.02.2025" → same month range + const sameMonth = value.match(/^(\d{1,2})-(\d{1,2})\.(\d{2})\.(\d{4})$/); + if (sameMonth) { + const [, d1, d2, m, y] = sameMonth; + return { + start: `${y}-${m}-${d1.padStart(2, "0")}`, + end: `${y}-${m}-${d2.padStart(2, "0")}`, + }; + } + + // "22.02-01.03.2025" → cross-month range + const crossMonth = value.match(/^(\d{1,2})\.(\d{2})-(\d{1,2})\.(\d{2})\.(\d{4})$/); + if (crossMonth) { + const [, d1, m1, d2, m2, y] = crossMonth; + return { + start: `${y}-${m1}-${d1.padStart(2, "0")}`, + end: `${y}-${m2}-${d2.padStart(2, "0")}`, + }; + } + + // "22.02.2025" → single date + const single = value.match(/^(\d{1,2})\.(\d{2})\.(\d{4})$/); + if (single) { + const [, d, m, y] = single; + const iso = `${y}-${m}-${d.padStart(2, "0")}`; + return { start: iso, end: "" }; + } + + return { start: "", end: "" }; +} + +function formatDateRange(start: string, end: string): string { + if (!start) return ""; + const [sy, sm, sd] = start.split("-"); + if (!end) return `${sd}.${sm}.${sy}`; + const [ey, em, ed] = end.split("-"); + if (sm === em && sy === ey) return `${sd}-${ed}.${sm}.${sy}`; + return `${sd}.${sm}-${ed}.${em}.${ey}`; +} + +interface DateRangeFieldProps { + value: string; + onChange: (value: string) => void; +} + +export function DateRangeField({ value, onChange }: DateRangeFieldProps) { + const { start, end } = parseDateRange(value); + + function handleChange(s: string, e: string) { + onChange(formatDateRange(s, e)); + } + + return ( +
+
+ + handleChange(e.target.value, end)} + className="w-full rounded-lg border border-white/10 bg-neutral-800 pl-7 pr-2 py-2 text-sm text-white outline-none focus:border-gold transition-colors [color-scheme:dark]" + /> +
+ +
+ + handleChange(start, e.target.value)} + placeholder="(один день)" + className="w-full rounded-lg border border-white/10 bg-neutral-800 pl-7 pr-2 py-2 text-sm text-white outline-none focus:border-gold transition-colors [color-scheme:dark]" + /> +
+
+ ); +} + +// --- City Autocomplete Field --- +interface CityFieldProps { + value: string; + onChange: (value: string) => void; + error?: string; + onSearch?: (query: string) => void; + suggestions?: string[]; + onSelectSuggestion?: (value: string) => void; +} + +export function CityField({ value, onChange, error, onSearch, suggestions, onSelectSuggestion }: CityFieldProps) { + const [focused, setFocused] = useState(false); + const containerRef = useRef(null); + + useEffect(() => { + if (!focused) return; + function handle(e: MouseEvent) { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setFocused(false); + } + } + document.addEventListener("mousedown", handle); + return () => document.removeEventListener("mousedown", handle); + }, [focused]); + + return ( +
+
+ + { + onChange(e.target.value); + onSearch?.(e.target.value); + }} + onFocus={() => setFocused(true)} + placeholder="Город, страна" + className={`w-full rounded-lg border bg-neutral-800 pl-7 pr-3 py-2 text-sm text-white placeholder-neutral-600 outline-none transition-colors ${ + error ? "border-red-500/50" : "border-white/10 focus:border-gold" + }`} + /> + {error && } +
+ {error &&

{error}

} + {focused && suggestions && suggestions.length > 0 && ( +
+ {suggestions.map((s) => ( + + ))} +
+ )} +
+ ); +} + +// --- Link Field with Validation --- +interface ValidatedLinkFieldProps { + value: string; + onChange: (value: string) => void; + onValidate?: (key: string, error: string | null) => void; + validationKey?: string; + placeholder?: string; +} + +export function ValidatedLinkField({ value, onChange, onValidate, validationKey, placeholder }: ValidatedLinkFieldProps) { + const [error, setError] = useState(null); + + function validate(url: string) { + if (!url) { + setError(null); + onValidate?.(validationKey || "", null); + return; + } + try { + new URL(url); + setError(null); + onValidate?.(validationKey || "", null); + } catch { + setError("Некорректная ссылка"); + onValidate?.(validationKey || "", "invalid"); + } + } + + return ( +
+ +
+ { + onChange(e.target.value); + validate(e.target.value); + }} + placeholder={placeholder || "Ссылка..."} + className={`w-full rounded-md border bg-neutral-800 px-2 py-1 text-xs text-white placeholder-neutral-600 outline-none transition-colors ${ + error ? "border-red-500/50" : "border-white/5 focus:border-gold/50" + }`} + /> + {error && ( + + + + )} +
+
+ ); +} + interface VictoryItemListFieldProps { label: string; items: VictoryItem[]; onChange: (items: VictoryItem[]) => void; + cityErrors?: Record; + citySuggestions?: { index: number; items: string[] } | null; + onCitySearch?: (index: number, query: string) => void; + onCitySelect?: (index: number, value: string) => void; + onLinkValidate?: (key: string, error: string | null) => void; } -export function VictoryItemListField({ label, items, onChange }: VictoryItemListFieldProps) { +export function VictoryItemListField({ label, items, onChange, cityErrors, citySuggestions, onCitySearch, onCitySelect, onLinkValidate }: VictoryItemListFieldProps) { const [uploadingIndex, setUploadingIndex] = useState(null); function add() { @@ -541,20 +748,20 @@ export function VictoryItemListField({ label, items, onChange }: VictoryItemList 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" + onChange={(v) => update(i, "location", v)} + error={cityErrors?.[i]} + onSearch={(q) => onCitySearch?.(i, q)} + suggestions={citySuggestions?.index === i ? citySuggestions.items : undefined} + onSelectSuggestion={(v) => onCitySelect?.(i, v)} /> +
+ update(i, "date", v)} + /> +
{item.image ? ( @@ -572,16 +779,12 @@ export function VictoryItemListField({ label, items, onChange }: VictoryItemList handleUpload(i, e)} className="hidden" /> )} -
- - 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" - /> -
+ update(i, "link", v)} + validationKey={`victory-${i}`} + onValidate={onLinkValidate} + />
))} diff --git a/src/app/admin/team/[id]/page.tsx b/src/app/admin/team/[id]/page.tsx index 5d66516..9ac104c 100644 --- a/src/app/admin/team/[id]/page.tsx +++ b/src/app/admin/team/[id]/page.tsx @@ -1,12 +1,19 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef, useCallback } from "react"; import { useRouter, useParams } from "next/navigation"; import Image from "next/image"; -import { Save, Loader2, Check, ArrowLeft, Upload } from "lucide-react"; +import { Save, Loader2, Check, ArrowLeft, Upload, AlertCircle } from "lucide-react"; import { InputField, TextareaField, ListField, VictoryListField, VictoryItemListField } from "../../_components/FormField"; import type { RichListItem, VictoryItem } from "@/types/content"; +function extractUsername(value: string): string { + if (!value) return ""; + // Strip full URL → username + const cleaned = value.replace(/^https?:\/\/(www\.)?instagram\.com\//, "").replace(/\/$/, "").replace(/^@/, ""); + return cleaned; +} + interface MemberForm { name: string; role: string; @@ -38,34 +45,112 @@ export default function TeamMemberEditorPage() { const [saved, setSaved] = useState(false); const [uploading, setUploading] = useState(false); + // Instagram validation + const [igStatus, setIgStatus] = useState<"idle" | "checking" | "valid" | "invalid">("idle"); + const igTimerRef = useRef | null>(null); + + const validateInstagram = useCallback((username: string) => { + if (igTimerRef.current) clearTimeout(igTimerRef.current); + if (!username) { setIgStatus("idle"); return; } + setIgStatus("checking"); + igTimerRef.current = setTimeout(async () => { + try { + const res = await fetch(`/api/admin/validate-instagram?username=${encodeURIComponent(username)}`); + const result = await res.json(); + setIgStatus(result.valid ? "valid" : "invalid"); + } catch { + setIgStatus("idle"); + } + }, 800); + }, []); + + // Link validation for bio + const [linkErrors, setLinkErrors] = useState>({}); + + function validateUrl(url: string): boolean { + if (!url) return true; + try { new URL(url); return true; } catch { return false; } + } + + // City validation for victories + const [cityErrors, setCityErrors] = useState>({}); + const [citySuggestions, setCitySuggestions] = useState<{ index: number; items: string[] } | null>(null); + const cityTimerRef = useRef | null>(null); + + const searchCity = useCallback((index: number, query: string) => { + if (cityTimerRef.current) clearTimeout(cityTimerRef.current); + if (!query || query.length < 2) { setCitySuggestions(null); return; } + cityTimerRef.current = setTimeout(async () => { + try { + const res = await fetch( + `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(query)}&format=json&addressdetails=1&limit=5&accept-language=ru`, + { headers: { "User-Agent": "BlackheartAdmin/1.0" } } + ); + const results = await res.json(); + const cities = results + .filter((r: Record) => { + const type = r.type as string; + const cls = r.class as string; + return cls === "place" || type === "city" || type === "town" || type === "village" || type === "administrative"; + }) + .map((r: Record) => { + const addr = r.address as Record | undefined; + const city = addr?.city || addr?.town || addr?.village || (r.name as string); + const country = addr?.country || ""; + return country ? `${city}, ${country}` : city; + }) + .filter((v: string, i: number, a: string[]) => a.indexOf(v) === i); + setCitySuggestions(cities.length > 0 ? { index, items: cities } : null); + if (cities.length === 0 && query.length >= 3) { + setCityErrors((prev) => ({ ...prev, [index]: "Город не найден" })); + } else { + setCityErrors((prev) => { const n = { ...prev }; delete n[index]; return n; }); + } + } catch { + setCitySuggestions(null); + } + }, 500); + }, []); + useEffect(() => { if (isNew) return; fetch(`/api/admin/team/${id}`) .then((r) => r.json()) - .then((member) => + .then((member) => { + const username = extractUsername(member.instagram || ""); setData({ name: member.name, role: member.role, image: member.image, - instagram: member.instagram || "", + instagram: username, description: member.description || "", experience: member.experience || [], victories: member.victories || [], education: member.education || [], - }) - ) + }); + if (username) setIgStatus("valid"); // existing data is trusted + }) .finally(() => setLoading(false)); }, [id, isNew]); + const hasErrors = igStatus === "invalid" || Object.keys(linkErrors).length > 0 || Object.keys(cityErrors).length > 0; + async function handleSave() { + if (hasErrors) return; setSaving(true); setSaved(false); + // Build instagram as full URL for storage if username is provided + const payload = { + ...data, + instagram: data.instagram ? `https://instagram.com/${data.instagram}` : "", + }; + if (isNew) { const res = await fetch("/api/admin/team", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(data), + body: JSON.stringify(payload), }); if (res.ok) { router.push("/admin/team"); @@ -74,7 +159,7 @@ export default function TeamMemberEditorPage() { const res = await fetch(`/api/admin/team/${id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(data), + body: JSON.stringify(payload), }); if (res.ok) { setSaved(true); @@ -134,7 +219,7 @@ export default function TeamMemberEditorPage() { - + )); } @@ -237,7 +238,7 @@ export default function TeamEditorPage() { key={member.id} ref={(el) => { itemRefs.current[i] = el; }} onMouseDown={(e) => handleCardMouseDown(e, i)} - className="flex items-center gap-4 rounded-lg border border-white/10 bg-neutral-900/50 p-3 mb-2 hover:border-white/25 hover:bg-neutral-800/50 transition-colors" + className="flex items-center gap-4 rounded-lg border border-white/10 bg-neutral-900/50 p-3 mb-2 hover:border-white/25 hover:bg-neutral-800/50 transition-colors cursor-pointer" >
{member.name}

{member.role}

-
- - - - -
+ ); visualIndex++; diff --git a/src/app/api/admin/validate-instagram/route.ts b/src/app/api/admin/validate-instagram/route.ts new file mode 100644 index 0000000..8cfd534 --- /dev/null +++ b/src/app/api/admin/validate-instagram/route.ts @@ -0,0 +1,27 @@ +import { NextRequest, NextResponse } from "next/server"; + +export async function GET(request: NextRequest) { + const username = request.nextUrl.searchParams.get("username")?.trim(); + if (!username) { + return NextResponse.json({ valid: false, error: "No username" }); + } + + try { + const res = await fetch(`https://www.instagram.com/${username}/`, { + method: "HEAD", + redirect: "follow", + headers: { + "User-Agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + }, + signal: AbortSignal.timeout(5000), + }); + + // Instagram returns 200 for existing profiles, 404 for non-existing + const valid = res.ok; + return NextResponse.json({ valid }); + } catch { + // Network error or timeout — don't block the user + return NextResponse.json({ valid: true, uncertain: true }); + } +} diff --git a/src/components/sections/team/TeamProfile.tsx b/src/components/sections/team/TeamProfile.tsx index 9812bdc..5c8db0b 100644 --- a/src/components/sections/team/TeamProfile.tsx +++ b/src/components/sections/team/TeamProfile.tsx @@ -20,7 +20,7 @@ export function TeamProfile({ member, onBack }: TeamProfileProps) { className="w-full" style={{ animation: "team-info-in 0.6s cubic-bezier(0.16, 1, 0.3, 1)" }} > - {/* Back button — above card */} + {/* Back button */} @@ -226,8 +207,8 @@ function VictoryCard({ victory, onImageClick }: { victory: VictoryItem; onImageC } return ( -
-
+
+
{victory.place && (

{victory.place}

)} @@ -273,41 +254,39 @@ function RichCard({ item, onImageClick }: { item: RichListItem; onImageClick: (s if (hasImage) { return ( -
+
+
+

{item.text}

+ {hasLink && ( + + + Подробнее + + )} +
); } return ( -
+

{item.text}

{hasLink && (