The entire skate-spots codebase was accidentally copied into src/src/ and public/public/ instead of src/ and public/ directly. This left the real src/ directory as blank MKStack template stubs, explaining why the landing page showed the default template instead of SkateSpots. Changes made: - Copied all files from src/src/ up to src/ (App.tsx, AppRouter.tsx, main.tsx, all pages, components, hooks, contexts, lib, i18n, test) - Copied all files from public/public/ up to public/ (images, manifest, etc.) - Deleted src/src/ and public/public/ nested directories - Removed HelloWorld.tsx template stub from pages/ - Retained the previously fixed index.css (Tailwind v3 directives) Full skate-spots feature set is now active: - All 30+ routes (map, events, spots, coaches, marketplace, wallet, etc.) - NWCProvider, LanguageProvider, PWAInstallPrompt, AmberCallbackHandler - Skate community relays (goskatespots.nostr1.com, wotr.relatr.xyz, etc.) - Outfit Variable font, Amber pre-check in main.tsx - All hooks (useSkateSpots, useCoaches, useZaps, etc.) - i18n support (en/es) Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
195 lines
6 KiB
TypeScript
195 lines
6 KiB
TypeScript
import { useState, useRef, useEffect, KeyboardEvent } from 'react';
|
|
import { X, Hash, AlertTriangle } from 'lucide-react';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { cn } from '@/lib/utils';
|
|
import {
|
|
searchTags,
|
|
normalizeTag,
|
|
isCuratedTag,
|
|
MAX_TAGS,
|
|
} from '@/lib/skateTags';
|
|
|
|
interface TagInputProps {
|
|
tags: string[];
|
|
onChange: (tags: string[]) => void;
|
|
placeholder?: string;
|
|
maxTags?: number;
|
|
className?: string;
|
|
disabled?: boolean;
|
|
}
|
|
|
|
export function TagInput({
|
|
tags,
|
|
onChange,
|
|
placeholder = 'Add tags...',
|
|
maxTags = MAX_TAGS,
|
|
className,
|
|
disabled = false,
|
|
}: TagInputProps) {
|
|
const [inputValue, setInputValue] = useState('');
|
|
const [suggestions, setSuggestions] = useState<string[]>([]);
|
|
const [showSuggestions, setShowSuggestions] = useState(false);
|
|
const [selectedIndex, setSelectedIndex] = useState(-1);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Update suggestions when input changes
|
|
useEffect(() => {
|
|
if (inputValue.trim()) {
|
|
const results = searchTags(inputValue, 8).filter(
|
|
(tag) => !tags.includes(tag)
|
|
);
|
|
setSuggestions(results);
|
|
setShowSuggestions(results.length > 0);
|
|
setSelectedIndex(-1);
|
|
} else {
|
|
setSuggestions([]);
|
|
setShowSuggestions(false);
|
|
}
|
|
}, [inputValue, tags]);
|
|
|
|
// Close suggestions when clicking outside
|
|
useEffect(() => {
|
|
function handleClickOutside(event: MouseEvent) {
|
|
if (
|
|
containerRef.current &&
|
|
!containerRef.current.contains(event.target as Node)
|
|
) {
|
|
setShowSuggestions(false);
|
|
}
|
|
}
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
}, []);
|
|
|
|
const addTag = (tag: string) => {
|
|
const normalized = normalizeTag(tag);
|
|
if (normalized && !tags.includes(normalized) && tags.length < maxTags) {
|
|
onChange([...tags, normalized]);
|
|
setInputValue('');
|
|
setShowSuggestions(false);
|
|
inputRef.current?.focus();
|
|
}
|
|
};
|
|
|
|
const removeTag = (tagToRemove: string) => {
|
|
onChange(tags.filter((tag) => tag !== tagToRemove));
|
|
};
|
|
|
|
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
if (selectedIndex >= 0 && selectedIndex < suggestions.length) {
|
|
addTag(suggestions[selectedIndex]);
|
|
} else if (inputValue.trim()) {
|
|
addTag(inputValue);
|
|
}
|
|
} else if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
setSelectedIndex((prev) =>
|
|
prev < suggestions.length - 1 ? prev + 1 : prev
|
|
);
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : -1));
|
|
} else if (e.key === 'Escape') {
|
|
setShowSuggestions(false);
|
|
} else if (e.key === 'Backspace' && !inputValue && tags.length > 0) {
|
|
removeTag(tags[tags.length - 1]);
|
|
} else if (e.key === ',' || e.key === ' ') {
|
|
if (inputValue.trim()) {
|
|
e.preventDefault();
|
|
addTag(inputValue);
|
|
}
|
|
}
|
|
};
|
|
|
|
const isAtLimit = tags.length >= maxTags;
|
|
|
|
return (
|
|
<div ref={containerRef} className={cn('relative', className)}>
|
|
{/* Tags display */}
|
|
<div className="flex flex-wrap gap-1.5 mb-2">
|
|
{tags.map((tag) => {
|
|
const isCurated = isCuratedTag(tag);
|
|
return (
|
|
<Badge
|
|
key={tag}
|
|
variant={isCurated ? 'secondary' : 'outline'}
|
|
className={cn(
|
|
'pl-2 pr-1 py-1 text-sm flex items-center gap-1',
|
|
isCurated
|
|
? 'bg-[#ccff00]/20 text-[#ccff00] border-[#ccff00]/30'
|
|
: 'bg-yellow-500/10 text-yellow-400 border-yellow-500/30'
|
|
)}
|
|
>
|
|
<Hash className="h-3 w-3" />
|
|
{tag}
|
|
{!isCurated && (
|
|
<AlertTriangle className="h-3 w-3 ml-0.5 text-yellow-500" />
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={() => removeTag(tag)}
|
|
className="ml-1 hover:bg-white/10 rounded p-0.5"
|
|
disabled={disabled}
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</button>
|
|
</Badge>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Input */}
|
|
<div className="relative">
|
|
<Hash className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
ref={inputRef}
|
|
value={inputValue}
|
|
onChange={(e) => setInputValue(e.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder={isAtLimit ? `Max ${maxTags} tags reached` : placeholder}
|
|
disabled={disabled || isAtLimit}
|
|
className="pl-9"
|
|
/>
|
|
</div>
|
|
|
|
{/* Tag count */}
|
|
<div className="mt-1.5">
|
|
<p className="text-xs text-muted-foreground">
|
|
{tags.length}/{maxTags} tags
|
|
{tags.some((t) => !isCuratedTag(t)) && (
|
|
<span className="text-yellow-500 ml-2">
|
|
<AlertTriangle className="h-3 w-3 inline mr-1" />
|
|
Custom tags detected
|
|
</span>
|
|
)}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Autocomplete suggestions */}
|
|
{showSuggestions && suggestions.length > 0 && (
|
|
<div className="absolute z-50 w-full mt-1 bg-black border border-gray-700 rounded-lg shadow-lg max-h-48 overflow-y-auto">
|
|
{suggestions.map((suggestion, index) => (
|
|
<button
|
|
key={suggestion}
|
|
type="button"
|
|
className={cn(
|
|
'w-full px-3 py-2 text-left text-sm flex items-center gap-2 hover:bg-gray-800',
|
|
index === selectedIndex && 'bg-gray-800'
|
|
)}
|
|
onClick={() => addTag(suggestion)}
|
|
>
|
|
<Hash className="h-3 w-3 text-[#ccff00]" />
|
|
{suggestion}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default TagInput;
|