SkateSpots/src/components/TagInput.tsx
shakespeare.diy e29d35f1e4 fix: restore full skate-spots source tree from misplaced src/src/ copy
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>
2026-06-15 22:18:28 +07:00

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;