| import React, { useState, useEffect, useRef } from 'react';
|
| import type { ChainMetadata } from '../../../types/protein';
|
|
|
| type SearchStatus = 'idle' | 'typing' | 'loading' | 'success' | 'not_found' | 'error';
|
|
|
| interface ChainSearchInputProps {
|
| value: string;
|
| onValueChange: (value: string) => void;
|
| onChainSelect: (pdb_id: string, auth_asym_id: string) => void;
|
| onMetadataLoaded: (metadata: ChainMetadata) => void;
|
| onClear: () => void;
|
| resolveChain: (pdb_id: string, auth_asym_id: string) => Promise<ChainMetadata>;
|
| searchStatus: SearchStatus;
|
| resolvedLabel?: string;
|
| errorMessage?: string;
|
| disabled?: boolean;
|
| }
|
|
|
| interface ParsedChain {
|
| pdb_id: string;
|
| auth_asym_id: string;
|
| valid: boolean;
|
| error?: string;
|
| }
|
|
|
| export const ChainSearchInput: React.FC<ChainSearchInputProps> = ({
|
| value,
|
| onValueChange,
|
| onChainSelect,
|
| onMetadataLoaded,
|
| onClear,
|
| resolveChain,
|
| searchStatus,
|
| resolvedLabel,
|
| errorMessage,
|
| disabled
|
| }) => {
|
| const [parsed, setParsed] = useState<ParsedChain | null>(null);
|
| const [debouncedValue, setDebouncedValue] = useState('');
|
| const lastResolvedKey = useRef<string | null>(null);
|
|
|
|
|
| useEffect(() => {
|
| const timer = setTimeout(() => {
|
| setDebouncedValue(value);
|
| }, 500);
|
|
|
| return () => clearTimeout(timer);
|
| }, [value]);
|
|
|
|
|
| const parseChainId = (value: string): ParsedChain => {
|
| const trimmed = value.trim();
|
| if (!trimmed) {
|
| return { pdb_id: '', auth_asym_id: '', valid: false };
|
| }
|
|
|
|
|
| const separatorRegex = /[.\s:_]+/;
|
| const parts = trimmed.split(separatorRegex);
|
|
|
| if (parts.length < 2) {
|
| return {
|
| pdb_id: '',
|
| auth_asym_id: '',
|
| valid: false,
|
| error: 'Format: PDB_ID + Chain (e.g., "3vgm.A", "3VGM:A", "3VGM_A")'
|
| };
|
| }
|
|
|
| const pdb_id = parts[0].trim();
|
| const auth_asym_id = parts.slice(1).join('').trim();
|
|
|
|
|
| if (pdb_id.length !== 4) {
|
| return {
|
| pdb_id,
|
| auth_asym_id,
|
| valid: false,
|
| error: 'PDB ID must be exactly 4 characters'
|
| };
|
| }
|
|
|
|
|
| if (auth_asym_id.length === 0) {
|
| return {
|
| pdb_id,
|
| auth_asym_id,
|
| valid: false,
|
| error: 'Chain ID is required (e.g., "A", "B", "AA")'
|
| };
|
| }
|
|
|
| return {
|
| pdb_id: pdb_id.toLowerCase(),
|
| auth_asym_id: auth_asym_id.toUpperCase(),
|
| valid: true
|
| };
|
| };
|
|
|
|
|
| useEffect(() => {
|
| if (!debouncedValue) {
|
| setParsed(null);
|
| lastResolvedKey.current = null;
|
| return;
|
| }
|
|
|
| const result = parseChainId(debouncedValue);
|
| setParsed(result);
|
|
|
| if (!result.valid) {
|
| return;
|
| }
|
|
|
|
|
| const currentKey = `${result.pdb_id}|${result.auth_asym_id}`;
|
|
|
|
|
| if (currentKey === lastResolvedKey.current) {
|
| return;
|
| }
|
|
|
| lastResolvedKey.current = currentKey;
|
|
|
|
|
| resolveChain(result.pdb_id, result.auth_asym_id)
|
| .then(metadata => {
|
| onChainSelect(result.pdb_id, result.auth_asym_id);
|
| onMetadataLoaded(metadata);
|
| })
|
| .catch(() => {
|
|
|
| });
|
| }, [debouncedValue, resolveChain, onChainSelect, onMetadataLoaded]);
|
|
|
| const handleClear = () => {
|
| setParsed(null);
|
| setDebouncedValue('');
|
| lastResolvedKey.current = null;
|
| onClear();
|
| };
|
|
|
| const getBorderColor = () => {
|
| if (searchStatus === 'loading') return 'border-blue-400';
|
| if (searchStatus === 'error' || searchStatus === 'not_found' || (parsed && !parsed.valid)) return 'border-red-400';
|
| if (searchStatus === 'success') return 'border-green-400';
|
| return 'border-gray-300';
|
| };
|
|
|
| const showError = searchStatus === 'error' || searchStatus === 'not_found' || (parsed && !parsed.valid && value);
|
| const displayError = errorMessage || parsed?.error || 'Invalid chain identifier';
|
|
|
| return (
|
| <div className="space-y-3">
|
| <label className="block">
|
| <span className="text-sm font-medium text-slate-900">Chain Identifier</span>
|
| <span className="block text-xs text-slate-500 mt-1">
|
| Format: PDB ID + Chain (e.g., 3vgm.A, 3VGM:A, 3VGM_A, 3vgm A)
|
| </span>
|
| </label>
|
|
|
| <div className={`relative flex items-center border-2 ${getBorderColor()} rounded-lg transition-all duration-200 focus-within:ring-2 focus-within:ring-slate-900 focus-within:ring-offset-2 bg-white`}>
|
| <input
|
| type="text"
|
| className="flex-1 px-4 py-3 text-sm text-slate-900 bg-transparent rounded-lg outline-none placeholder:text-slate-400 disabled:bg-slate-50 disabled:cursor-not-allowed"
|
| value={value}
|
| onChange={(e) => onValueChange(e.target.value)}
|
| placeholder="e.g., 3vgm.A"
|
| disabled={disabled}
|
| />
|
|
|
| {value && searchStatus !== 'loading' && (
|
| <button
|
| className="absolute right-3 w-7 h-7 flex items-center justify-center text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-md transition-colors disabled:cursor-not-allowed"
|
| onClick={handleClear}
|
| disabled={disabled}
|
| title="Clear"
|
| >
|
| ✕
|
| </button>
|
| )}
|
|
|
| {searchStatus === 'loading' && (
|
| <div className="absolute right-3 w-5 h-5 border-2 border-slate-200 border-t-slate-900 rounded-full animate-spin" title="Resolving chain..."></div>
|
| )}
|
| </div>
|
|
|
| {searchStatus === 'success' && resolvedLabel && (
|
| <div className="flex items-center gap-2 text-sm text-green-700 bg-green-50 px-3 py-2 rounded-lg border border-green-200">
|
| <span className="text-base">✓</span>
|
| <span className="font-medium">{resolvedLabel}</span>
|
| </div>
|
| )}
|
|
|
| {showError && (
|
| <div className="flex items-center gap-2 text-sm text-red-700 bg-red-50 px-3 py-2 rounded-lg border border-red-200">
|
| <span className="text-base">✗</span>
|
| <span>{displayError}</span>
|
| </div>
|
| )}
|
| </div>
|
| );
|
| };
|
|
|