feat: upgrade team admin with click-to-edit, Instagram validation, date picker, city autocomplete
- Team list: click card to open editor (remove pencil button), keep drag-to-reorder - Instagram field: username-only input with @ prefix, async account validation via HEAD request - Victory dates: date range picker replacing text input, auto-formats to DD.MM.YYYY / DD-DD.MM.YYYY - Victory location: city autocomplete via Nominatim API with suggestions dropdown - Links: real-time URL validation with error indicators on all link fields - Save button blocked when any validation errors exist Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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<number | null>(null);
|
||||
|
||||
@@ -425,16 +426,12 @@ export function VictoryListField({ label, items, onChange, placeholder }: Victor
|
||||
<input type="file" accept="image/*" onChange={(e) => handleUpload(i, e)} className="hidden" />
|
||||
</label>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Link size={12} className="text-neutral-500" />
|
||||
<input
|
||||
type="url"
|
||||
value={item.link || ""}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<ValidatedLinkField
|
||||
value={item.link || ""}
|
||||
onChange={(v) => updateLink(i, v)}
|
||||
validationKey={`edu-${i}`}
|
||||
onValidate={onLinkValidate}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -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 (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Calendar size={12} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-neutral-500 pointer-events-none" />
|
||||
<input
|
||||
type="date"
|
||||
value={start}
|
||||
onChange={(e) => 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]"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-neutral-500 text-xs">—</span>
|
||||
<div className="relative flex-1">
|
||||
<Calendar size={12} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-neutral-500 pointer-events-none" />
|
||||
<input
|
||||
type="date"
|
||||
value={end}
|
||||
min={start}
|
||||
onChange={(e) => 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]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- 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<HTMLDivElement>(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 (
|
||||
<div ref={containerRef} className="relative flex-1">
|
||||
<div className="relative">
|
||||
<MapPin size={12} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-neutral-500 pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
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 && <AlertCircle size={12} className="absolute right-2.5 top-1/2 -translate-y-1/2 text-red-400" />}
|
||||
</div>
|
||||
{error && <p className="mt-0.5 text-[10px] text-red-400">{error}</p>}
|
||||
{focused && suggestions && suggestions.length > 0 && (
|
||||
<div className="absolute z-50 mt-1 w-full rounded-lg border border-white/10 bg-neutral-800 shadow-xl overflow-hidden">
|
||||
{suggestions.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => {
|
||||
onSelectSuggestion?.(s);
|
||||
setFocused(false);
|
||||
}}
|
||||
className="w-full px-3 py-1.5 text-left text-sm text-white hover:bg-white/5 transition-colors"
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- 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<string | null>(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 (
|
||||
<div className="flex items-center gap-1.5 flex-1">
|
||||
<Link size={12} className="text-neutral-500 shrink-0" />
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
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 && (
|
||||
<span className="absolute right-1.5 top-1/2 -translate-y-1/2">
|
||||
<AlertCircle size={10} className="text-red-400" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface VictoryItemListFieldProps {
|
||||
label: string;
|
||||
items: VictoryItem[];
|
||||
onChange: (items: VictoryItem[]) => void;
|
||||
cityErrors?: Record<number, string>;
|
||||
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<number | null>(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"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
<CityField
|
||||
value={item.location || ""}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={item.date || ""}
|
||||
onChange={(e) => 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)}
|
||||
/>
|
||||
<div className="w-56 shrink-0">
|
||||
<DateRangeField
|
||||
value={item.date || ""}
|
||||
onChange={(v) => update(i, "date", v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pl-1">
|
||||
{item.image ? (
|
||||
@@ -572,16 +779,12 @@ export function VictoryItemListField({ label, items, onChange }: VictoryItemList
|
||||
<input type="file" accept="image/*" onChange={(e) => handleUpload(i, e)} className="hidden" />
|
||||
</label>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Link size={12} className="text-neutral-500" />
|
||||
<input
|
||||
type="url"
|
||||
value={item.link || ""}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<ValidatedLinkField
|
||||
value={item.link || ""}
|
||||
onChange={(v) => update(i, "link", v)}
|
||||
validationKey={`victory-${i}`}
|
||||
onValidate={onLinkValidate}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user