Postgres/Redis/RabbitMQ/MinIO/Ollama теперь общие серверы сети (адреса в .env), локально в докере остаётся только Elasticsearch + app-сервисы. Старый docker-compose.yml (полностью автономный стек) сохранён как docker-compose.selfhosted.yml.example на случай отдельного GPU-сервера в будущем. Добавлен docker-compose.prod.yml (app + свой nginx с TLS, собирающий фронтенд в статику). Makefile переписан под три режима (dev/test-up/prod). Мелкая чистка неиспользуемых импортов во фронтенде.
144 lines
5.1 KiB
TypeScript
144 lines
5.1 KiB
TypeScript
import { ExternalLink, BookmarkPlus, BookmarkCheck, Copy } from 'lucide-react';
|
||
import { clsx } from 'clsx';
|
||
import toast from 'react-hot-toast';
|
||
import { useBibliographyStore } from '../store/bibliography';
|
||
import type { SearchSource } from '../types';
|
||
|
||
interface SourceCardProps {
|
||
source: SearchSource;
|
||
}
|
||
|
||
const SOURCE_DB_LABELS: Record<string, string> = {
|
||
openalex: 'OpenAlex',
|
||
cyberleninka: 'КиберЛенинка',
|
||
arxiv: 'arXiv',
|
||
wikipedia_ru: 'Wikipedia RU',
|
||
wikipedia_en: 'Wikipedia EN',
|
||
};
|
||
|
||
function getRelevanceColor(score: number): string {
|
||
if (score >= 0.7) return 'text-green-700 bg-green-50 border-green-200';
|
||
if (score >= 0.4) return 'text-yellow-700 bg-yellow-50 border-yellow-200';
|
||
return 'text-gray-600 bg-gray-50 border-gray-200';
|
||
}
|
||
|
||
export function SourceCard({ source }: SourceCardProps) {
|
||
const { addSource, removeSource, hasSource } = useBibliographyStore();
|
||
const inBibliography = hasSource(source.id);
|
||
|
||
const handleToggleBibliography = () => {
|
||
if (inBibliography) {
|
||
removeSource(source.id);
|
||
toast.success('Удалено из библиографии');
|
||
} else {
|
||
addSource(source);
|
||
toast.success('Добавлено в библиографию');
|
||
}
|
||
};
|
||
|
||
const handleCopyCitation = () => {
|
||
navigator.clipboard.writeText(source.gost_citation).then(() => {
|
||
toast.success('ГОСТ-цитата скопирована');
|
||
});
|
||
};
|
||
|
||
const authorsStr = source.authors
|
||
.slice(0, 3)
|
||
.map((a) => `${a.last_name} ${a.initials || ''}`.trim())
|
||
.join(', ');
|
||
|
||
return (
|
||
<div className="bg-white rounded-xl border border-gray-100 p-5 hover:border-gray-200 hover:shadow-sm transition-all duration-200">
|
||
{/* Заголовок и значки */}
|
||
<div className="flex items-start justify-between gap-3 mb-3">
|
||
<div className="flex-1 min-w-0">
|
||
<h3 className="text-base font-semibold text-gray-900 leading-snug line-clamp-2 mb-1">
|
||
{source.title}
|
||
</h3>
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
{authorsStr && (
|
||
<span className="text-sm text-gray-500">{authorsStr}</span>
|
||
)}
|
||
{source.year && (
|
||
<span className="text-sm text-gray-400">{source.year}</span>
|
||
)}
|
||
{source.journal && (
|
||
<span className="text-sm text-gray-400 italic">{source.journal}</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Бейдж релевантности */}
|
||
<div className={clsx(
|
||
'flex-shrink-0 px-2 py-1 rounded-lg text-xs font-medium border',
|
||
getRelevanceColor(source.relevance_score)
|
||
)}>
|
||
{(source.relevance_score * 100).toFixed(0)}%
|
||
</div>
|
||
</div>
|
||
|
||
{/* Аннотация */}
|
||
{source.abstract && (
|
||
<p className="text-sm text-gray-600 line-clamp-3 mb-3">
|
||
{source.abstract}
|
||
</p>
|
||
)}
|
||
|
||
{/* ГОСТ-цитата */}
|
||
<div className="bg-gray-50 rounded-lg p-3 mb-3">
|
||
<p className="text-xs text-gray-500 mb-1 font-medium">ГОСТ-цитата:</p>
|
||
<p className="text-xs text-gray-700 leading-relaxed">{source.gost_citation}</p>
|
||
</div>
|
||
|
||
{/* Действия */}
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
{/* Источник */}
|
||
<span className="px-2 py-1 bg-gray-100 text-gray-500 text-xs rounded-md">
|
||
{SOURCE_DB_LABELS[source.source_db] || source.source_db}
|
||
</span>
|
||
|
||
<div className="ml-auto flex items-center gap-1.5">
|
||
{/* Открыть источник */}
|
||
{source.url && (
|
||
<a
|
||
href={source.url}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-gray-600 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
|
||
>
|
||
<ExternalLink className="w-3 h-3" />
|
||
Открыть
|
||
</a>
|
||
)}
|
||
|
||
{/* Копировать цитату */}
|
||
<button
|
||
onClick={handleCopyCitation}
|
||
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-gray-600 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
|
||
>
|
||
<Copy className="w-3 h-3" />
|
||
Цитата
|
||
</button>
|
||
|
||
{/* В библиографию */}
|
||
<button
|
||
onClick={handleToggleBibliography}
|
||
className={clsx(
|
||
'inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-lg transition-colors',
|
||
inBibliography
|
||
? 'bg-brand-600 text-white hover:bg-brand-700'
|
||
: 'bg-white text-brand-600 border border-brand-200 hover:bg-brand-50'
|
||
)}
|
||
>
|
||
{inBibliography ? (
|
||
<><BookmarkCheck className="w-3 h-3" />Добавлено</>
|
||
) : (
|
||
<><BookmarkPlus className="w-3 h-3" />В библиографию</>
|
||
)}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|