feat: travel service with 2GIS routing, POI, hotels + finance providers + UI overhaul
- Add travel-svc microservice (Amadeus, TravelPayouts, 2GIS, OpenRouteService) - Add travel orchestrator with parallel collectors (events, POI, hotels, flights) - Add 2GIS road routing with transport cost calculation (car/bus/taxi) - Add TravelMap (2GIS MapGL) and TravelWidgets components - Add useTravelChat hook for streaming travel agent responses - Add finance heatmap providers refactor - Add SearXNG settings, API proxy routes, Docker compose updates - Update Dockerfiles, config, types, and all UI pages for consistency Made-with: Cursor
This commit is contained in:
@@ -168,7 +168,7 @@ export function ChatInput({ onSend, onStop, isLoading, placeholder, autoFocus, v
|
||||
<div className="relative w-full">
|
||||
<motion.div
|
||||
animate={{
|
||||
borderColor: isFocused ? 'hsl(239 84% 67% / 0.4)' : 'hsl(240 4% 16% / 0.8)',
|
||||
borderColor: isFocused ? 'hsl(224 64% 48% / 0.35)' : 'hsl(220 12% 86%)',
|
||||
}}
|
||||
transition={{ duration: 0.15 }}
|
||||
className={`
|
||||
|
||||
@@ -78,7 +78,7 @@ export function ChatMessage({ message }: ChatMessageProps) {
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-gradient hover:opacity-80 underline underline-offset-2 decoration-[hsl(239_84%_74%/0.3)] hover:decoration-[hsl(239_84%_74%/0.5)] transition-all"
|
||||
className="text-gradient hover:opacity-80 underline underline-offset-2 decoration-[hsl(224_64%_48%/0.3)] hover:decoration-[hsl(224_64%_48%/0.5)] transition-all"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
|
||||
@@ -16,7 +16,7 @@ export function Citation({ citation, compact }: CitationProps) {
|
||||
href={citation.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center justify-center w-5 h-5 text-2xs font-medium bg-cream-300/10 hover:bg-cream-300/20 text-cream-300 border border-cream-400/20 rounded transition-colors"
|
||||
className="inline-flex items-center justify-center w-5 h-5 text-2xs font-medium bg-accent/10 hover:bg-accent/18 text-accent border border-accent/25 rounded transition-colors"
|
||||
>
|
||||
{citation.index}
|
||||
</a>
|
||||
@@ -31,9 +31,9 @@ export function Citation({ citation, compact }: CitationProps) {
|
||||
href={citation.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-2.5 py-1.5 bg-navy-800/40 hover:bg-navy-800/60 border border-navy-700/30 hover:border-cream-400/20 rounded-lg transition-all group"
|
||||
className="inline-flex items-center gap-2 px-2.5 py-1.5 bg-elevated/80 hover:bg-elevated border border-border hover:border-accent/25 rounded-lg transition-all group"
|
||||
>
|
||||
<span className="w-4 h-4 rounded bg-cream-300/10 text-cream-300 flex items-center justify-center text-2xs font-medium">
|
||||
<span className="w-4 h-4 rounded bg-accent/10 text-accent flex items-center justify-center text-2xs font-medium">
|
||||
{citation.index}
|
||||
</span>
|
||||
{citation.favicon && (
|
||||
@@ -46,27 +46,27 @@ export function Citation({ citation, compact }: CitationProps) {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span className="text-xs text-cream-400/80 group-hover:text-cream-200 max-w-[120px] truncate transition-colors">
|
||||
<span className="text-xs text-secondary group-hover:text-primary max-w-[120px] truncate transition-colors">
|
||||
{citation.domain}
|
||||
</span>
|
||||
<ExternalLink className="w-2.5 h-2.5 text-cream-500/50 group-hover:text-cream-400/70 transition-colors" />
|
||||
<ExternalLink className="w-2.5 h-2.5 text-muted group-hover:text-secondary transition-colors" />
|
||||
</a>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content
|
||||
side="top"
|
||||
className="max-w-[300px] p-4 bg-navy-800/95 backdrop-blur-xl border border-navy-700/50 rounded-xl shadow-xl z-50"
|
||||
className="max-w-[300px] p-4 bg-elevated backdrop-blur-xl border border-border rounded-xl shadow-dropdown z-50"
|
||||
sideOffset={8}
|
||||
>
|
||||
<p className="font-medium text-sm text-cream-100 line-clamp-2 mb-2">
|
||||
<p className="font-medium text-sm text-primary line-clamp-2 mb-2">
|
||||
{citation.title}
|
||||
</p>
|
||||
{citation.snippet && (
|
||||
<p className="text-xs text-cream-400/70 line-clamp-3 mb-3">
|
||||
<p className="text-xs text-secondary line-clamp-3 mb-3">
|
||||
{citation.snippet}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 text-2xs text-cream-500/60">
|
||||
<div className="flex items-center gap-2 text-2xs text-muted">
|
||||
{citation.favicon && (
|
||||
<img
|
||||
src={citation.favicon}
|
||||
@@ -79,7 +79,7 @@ export function Citation({ citation, compact }: CitationProps) {
|
||||
)}
|
||||
<span className="truncate">{citation.domain}</span>
|
||||
</div>
|
||||
<Tooltip.Arrow className="fill-navy-800" />
|
||||
<Tooltip.Arrow className="fill-elevated" />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
@@ -107,14 +107,14 @@ export function CitationList({ citations, maxVisible = 6 }: CitationListProps) {
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button className="text-xs text-cream-500/70 hover:text-cream-300 px-2.5 py-1.5 rounded-lg hover:bg-navy-800/30 transition-colors">
|
||||
<button className="text-xs text-muted hover:text-secondary px-2.5 py-1.5 rounded-lg hover:bg-surface/60 transition-colors">
|
||||
+{remaining} ещё
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content
|
||||
side="top"
|
||||
className="max-w-[320px] p-3 bg-navy-800/95 backdrop-blur-xl border border-navy-700/50 rounded-xl shadow-xl z-50"
|
||||
className="max-w-[320px] p-3 bg-elevated backdrop-blur-xl border border-border rounded-xl shadow-dropdown z-50"
|
||||
sideOffset={8}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
@@ -124,18 +124,18 @@ export function CitationList({ citations, maxVisible = 6 }: CitationListProps) {
|
||||
href={citation.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 p-2 rounded-lg hover:bg-navy-700/50 transition-colors"
|
||||
className="flex items-center gap-2 p-2 rounded-lg hover:bg-surface/60 transition-colors"
|
||||
>
|
||||
<span className="w-4 h-4 rounded bg-cream-300/10 text-cream-300 flex items-center justify-center text-2xs font-medium flex-shrink-0">
|
||||
<span className="w-4 h-4 rounded bg-accent/10 text-accent flex items-center justify-center text-2xs font-medium flex-shrink-0">
|
||||
{citation.index}
|
||||
</span>
|
||||
<span className="text-xs text-cream-200 truncate">
|
||||
<span className="text-xs text-primary truncate">
|
||||
{citation.title}
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
<Tooltip.Arrow className="fill-navy-800" />
|
||||
<Tooltip.Arrow className="fill-elevated" />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
|
||||
@@ -14,7 +14,7 @@ export function DiscoverCard({ item, variant = 'medium', onSummarize }: Discover
|
||||
|
||||
if (variant === 'large') {
|
||||
return (
|
||||
<article className="group relative overflow-hidden rounded-2xl bg-navy-900/40 border border-navy-700/20 hover:border-cream-400/15 transition-all duration-300">
|
||||
<article className="group relative overflow-hidden rounded-2xl bg-elevated/80 border border-border hover:border-accent/25 transition-all duration-300 shadow-card">
|
||||
{item.thumbnail && (
|
||||
<div className="aspect-video overflow-hidden">
|
||||
<img
|
||||
@@ -34,23 +34,23 @@ export function DiscoverCard({ item, variant = 'medium', onSummarize }: Discover
|
||||
alt=""
|
||||
className="w-4 h-4 rounded"
|
||||
/>
|
||||
<span className="text-xs text-cream-500/60">{domain}</span>
|
||||
<span className="text-xs text-muted">{domain}</span>
|
||||
{item.sourcesCount && item.sourcesCount > 1 && (
|
||||
<span className="text-xs text-cream-600/40">
|
||||
<span className="text-xs text-faint">
|
||||
• {item.sourcesCount} источников
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-cream-100 mb-3 line-clamp-2 group-hover:text-cream-50 transition-colors">
|
||||
<h2 className="text-xl font-semibold text-primary mb-3 line-clamp-2 group-hover:text-accent-hover transition-colors">
|
||||
{item.title}
|
||||
</h2>
|
||||
<p className="text-cream-400/70 text-sm line-clamp-3 mb-5">{item.content}</p>
|
||||
<p className="text-secondary text-sm line-clamp-3 mb-5">{item.content}</p>
|
||||
<div className="flex items-center gap-4">
|
||||
<a
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-sm text-cream-400/80 hover:text-cream-200 transition-colors"
|
||||
className="flex items-center gap-2 text-sm text-secondary hover:text-primary transition-colors"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
Читать
|
||||
@@ -58,7 +58,7 @@ export function DiscoverCard({ item, variant = 'medium', onSummarize }: Discover
|
||||
{onSummarize && (
|
||||
<button
|
||||
onClick={() => onSummarize(item.url)}
|
||||
className="flex items-center gap-2 text-sm text-cream-300 hover:text-cream-100 transition-colors"
|
||||
className="flex items-center gap-2 text-sm text-accent hover:text-accent-hover transition-colors"
|
||||
>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
AI Саммари
|
||||
@@ -72,7 +72,7 @@ export function DiscoverCard({ item, variant = 'medium', onSummarize }: Discover
|
||||
|
||||
if (variant === 'small') {
|
||||
return (
|
||||
<article className="group flex items-start gap-3 p-3 rounded-xl hover:bg-navy-800/30 transition-colors">
|
||||
<article className="group flex items-start gap-3 p-3 rounded-xl hover:bg-surface/60 transition-colors">
|
||||
{item.thumbnail && (
|
||||
<img
|
||||
src={item.thumbnail}
|
||||
@@ -90,13 +90,13 @@ export function DiscoverCard({ item, variant = 'medium', onSummarize }: Discover
|
||||
alt=""
|
||||
className="w-3 h-3 rounded"
|
||||
/>
|
||||
<span className="text-xs text-cream-600/50 truncate">{domain}</span>
|
||||
<span className="text-xs text-faint truncate">{domain}</span>
|
||||
</div>
|
||||
<a
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm font-medium text-cream-200 group-hover:text-cream-100 line-clamp-2 transition-colors"
|
||||
className="text-sm font-medium text-secondary group-hover:text-primary line-clamp-2 transition-colors"
|
||||
>
|
||||
{item.title}
|
||||
</a>
|
||||
@@ -106,7 +106,7 @@ export function DiscoverCard({ item, variant = 'medium', onSummarize }: Discover
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="group p-4 bg-navy-900/30 border border-navy-700/20 rounded-xl hover:border-cream-400/15 hover:bg-navy-900/50 transition-all duration-200">
|
||||
<article className="group p-4 bg-elevated/60 border border-border rounded-xl hover:border-accent/25 hover:bg-elevated/90 transition-all duration-200 shadow-card">
|
||||
{item.thumbnail && (
|
||||
<div className="aspect-video rounded-lg overflow-hidden mb-4 -mx-1 -mt-1">
|
||||
<img
|
||||
@@ -125,7 +125,7 @@ export function DiscoverCard({ item, variant = 'medium', onSummarize }: Discover
|
||||
alt=""
|
||||
className="w-4 h-4 rounded"
|
||||
/>
|
||||
<span className="text-xs text-cream-600/50">{domain}</span>
|
||||
<span className="text-xs text-faint">{domain}</span>
|
||||
</div>
|
||||
<a
|
||||
href={item.url}
|
||||
@@ -133,15 +133,15 @@ export function DiscoverCard({ item, variant = 'medium', onSummarize }: Discover
|
||||
rel="noopener noreferrer"
|
||||
className="block"
|
||||
>
|
||||
<h3 className="font-medium text-cream-100 mb-2 line-clamp-2 group-hover:text-cream-50 transition-colors">
|
||||
<h3 className="font-medium text-primary mb-2 line-clamp-2 group-hover:text-accent-hover transition-colors">
|
||||
{item.title}
|
||||
</h3>
|
||||
</a>
|
||||
<p className="text-sm text-cream-500/60 line-clamp-2">{item.content}</p>
|
||||
<p className="text-sm text-muted line-clamp-2">{item.content}</p>
|
||||
{onSummarize && (
|
||||
<button
|
||||
onClick={() => onSummarize(item.url)}
|
||||
className="flex items-center gap-2 mt-3 text-xs text-cream-400 hover:text-cream-200 transition-colors"
|
||||
className="flex items-center gap-2 mt-3 text-xs text-accent hover:text-accent-hover transition-colors"
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
Саммари
|
||||
|
||||
@@ -220,7 +220,7 @@ export function Sidebar({ onClose }: SidebarProps) {
|
||||
<span className="text-xs font-medium text-primary truncate">{user.name}</span>
|
||||
<span className={`text-[9px] font-medium px-1 py-0.5 rounded ${
|
||||
user.tier === 'business'
|
||||
? 'bg-amber-500/20 text-amber-400'
|
||||
? 'bg-amber-500/20 text-amber-600'
|
||||
: user.tier === 'pro'
|
||||
? 'bg-accent/20 text-accent'
|
||||
: 'bg-surface text-muted'
|
||||
|
||||
582
backend/webui/src/components/TravelMap.tsx
Normal file
582
backend/webui/src/components/TravelMap.tsx
Normal file
@@ -0,0 +1,582 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
MapPin,
|
||||
Navigation,
|
||||
Plane,
|
||||
Hotel,
|
||||
Utensils,
|
||||
Camera,
|
||||
Bus,
|
||||
X,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
Locate,
|
||||
Sparkles,
|
||||
TreePine,
|
||||
Theater,
|
||||
ShoppingBag,
|
||||
Gamepad2,
|
||||
Church,
|
||||
Eye,
|
||||
CalendarDays,
|
||||
Flag,
|
||||
User,
|
||||
} from 'lucide-react';
|
||||
import type { RoutePoint, RouteDirection, GeoLocation } from '@/lib/types';
|
||||
|
||||
interface MapglAPI {
|
||||
Map: new (container: HTMLElement | string, options: Record<string, unknown>) => MapglMapInstance;
|
||||
Marker: new (map: MapglMapInstance, options: Record<string, unknown>) => MapglMarkerInstance;
|
||||
Polyline: new (map: MapglMapInstance, options: Record<string, unknown>) => MapglPolylineInstance;
|
||||
}
|
||||
|
||||
interface MapglMapInstance {
|
||||
destroy: () => void;
|
||||
setCenter: (center: number[], options?: Record<string, unknown>) => void;
|
||||
getCenter: () => number[];
|
||||
setZoom: (zoom: number, options?: Record<string, unknown>) => void;
|
||||
getZoom: () => number;
|
||||
fitBounds: (bounds: { southWest: number[]; northEast: number[] }, options?: Record<string, unknown>) => void;
|
||||
invalidateSize: () => void;
|
||||
on: (type: string, listener: (e: MapglClickEvent) => void) => void;
|
||||
off: (type: string, listener: (e: MapglClickEvent) => void) => void;
|
||||
}
|
||||
|
||||
interface MapglClickEvent {
|
||||
lngLat: number[];
|
||||
}
|
||||
|
||||
interface MapglMarkerInstance {
|
||||
destroy: () => void;
|
||||
on: (type: string, listener: () => void) => void;
|
||||
}
|
||||
|
||||
interface MapglPolylineInstance {
|
||||
destroy: () => void;
|
||||
}
|
||||
|
||||
interface TravelMapProps {
|
||||
route: RoutePoint[];
|
||||
routeDirection?: RouteDirection;
|
||||
center?: GeoLocation;
|
||||
zoom?: number;
|
||||
onPointClick?: (point: RoutePoint) => void;
|
||||
onMapClick?: (location: GeoLocation) => void;
|
||||
className?: string;
|
||||
showControls?: boolean;
|
||||
userLocation?: GeoLocation | null;
|
||||
}
|
||||
|
||||
const TWOGIS_API_KEY = process.env.NEXT_PUBLIC_TWOGIS_API_KEY || '';
|
||||
|
||||
const pointTypeIcons: Record<string, typeof MapPin> = {
|
||||
airport: Plane,
|
||||
hotel: Hotel,
|
||||
restaurant: Utensils,
|
||||
attraction: Camera,
|
||||
transport: Bus,
|
||||
custom: MapPin,
|
||||
museum: Camera,
|
||||
park: TreePine,
|
||||
theater: Theater,
|
||||
shopping: ShoppingBag,
|
||||
entertainment: Gamepad2,
|
||||
religious: Church,
|
||||
viewpoint: Eye,
|
||||
event: CalendarDays,
|
||||
destination: Flag,
|
||||
poi: MapPin,
|
||||
food: Utensils,
|
||||
transfer: Bus,
|
||||
origin: User,
|
||||
};
|
||||
|
||||
const pointTypeColors: Record<string, string> = {
|
||||
airport: '#3B82F6',
|
||||
hotel: '#8B5CF6',
|
||||
restaurant: '#F59E0B',
|
||||
attraction: '#10B981',
|
||||
transport: '#6366F1',
|
||||
custom: '#EC4899',
|
||||
museum: '#14B8A6',
|
||||
park: '#22C55E',
|
||||
theater: '#A855F7',
|
||||
shopping: '#F97316',
|
||||
entertainment: '#EF4444',
|
||||
religious: '#78716C',
|
||||
viewpoint: '#06B6D4',
|
||||
event: '#E11D48',
|
||||
destination: '#3B82F6',
|
||||
poi: '#10B981',
|
||||
food: '#F59E0B',
|
||||
transfer: '#94A3B8',
|
||||
origin: '#10B981',
|
||||
};
|
||||
|
||||
let mapglPromise: Promise<MapglAPI> | null = null;
|
||||
|
||||
function loadMapGL(): Promise<MapglAPI> {
|
||||
if (mapglPromise) return mapglPromise;
|
||||
|
||||
mapglPromise = import('@2gis/mapgl').then((mod) =>
|
||||
(mod.load as (url?: string) => Promise<unknown>)()
|
||||
).then((api) => api as MapglAPI);
|
||||
|
||||
return mapglPromise;
|
||||
}
|
||||
|
||||
function createMarkerSVG(index: number, color: string): string {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28">` +
|
||||
`<circle cx="14" cy="14" r="13" fill="${color}" stroke="white" stroke-width="2"/>` +
|
||||
`<text x="14" y="18" text-anchor="middle" fill="white" font-size="12" font-weight="bold">${index}</text>` +
|
||||
`</svg>`;
|
||||
}
|
||||
|
||||
function createUserLocationSVG(): string {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">` +
|
||||
`<circle cx="16" cy="16" r="14" fill="#3B82F6" fill-opacity="0.2" stroke="#3B82F6" stroke-width="2"/>` +
|
||||
`<circle cx="16" cy="16" r="6" fill="#3B82F6" stroke="white" stroke-width="2"/>` +
|
||||
`</svg>`;
|
||||
}
|
||||
|
||||
export function TravelMap({
|
||||
route,
|
||||
routeDirection,
|
||||
center,
|
||||
zoom = 10,
|
||||
onPointClick,
|
||||
onMapClick,
|
||||
className = '',
|
||||
showControls = true,
|
||||
userLocation,
|
||||
}: TravelMapProps) {
|
||||
const mapRef = useRef<HTMLDivElement>(null);
|
||||
const mapInstanceRef = useRef<MapglMapInstance | null>(null);
|
||||
const mapglRef = useRef<MapglAPI | null>(null);
|
||||
const markersRef = useRef<MapglMarkerInstance[]>([]);
|
||||
const userMarkerRef = useRef<MapglMarkerInstance | null>(null);
|
||||
const polylineRef = useRef<MapglPolylineInstance | null>(null);
|
||||
const onMapClickRef = useRef(onMapClick);
|
||||
const onPointClickRef = useRef(onPointClick);
|
||||
const [selectedPoint, setSelectedPoint] = useState<RoutePoint | null>(null);
|
||||
const [isMapReady, setIsMapReady] = useState(false);
|
||||
const [detectedLocation, setDetectedLocation] = useState<GeoLocation | null>(null);
|
||||
const initDoneRef = useRef(false);
|
||||
|
||||
onMapClickRef.current = onMapClick;
|
||||
onPointClickRef.current = onPointClick;
|
||||
|
||||
const effectiveUserLocation = userLocation ?? detectedLocation;
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapRef.current || initDoneRef.current) return;
|
||||
initDoneRef.current = true;
|
||||
|
||||
let destroyed = false;
|
||||
|
||||
const initCenter = center || { lat: 55.7558, lng: 37.6173 };
|
||||
|
||||
loadMapGL().then((mapgl) => {
|
||||
if (destroyed || !mapRef.current) return;
|
||||
|
||||
mapglRef.current = mapgl;
|
||||
|
||||
try {
|
||||
const map = new mapgl.Map(mapRef.current, {
|
||||
center: [initCenter.lng, initCenter.lat],
|
||||
zoom,
|
||||
key: TWOGIS_API_KEY,
|
||||
lang: 'ru',
|
||||
});
|
||||
|
||||
map.on('click', (e: MapglClickEvent) => {
|
||||
onMapClickRef.current?.({
|
||||
lat: e.lngLat[1],
|
||||
lng: e.lngLat[0],
|
||||
});
|
||||
});
|
||||
|
||||
mapInstanceRef.current = map;
|
||||
setIsMapReady(true);
|
||||
} catch {
|
||||
initDoneRef.current = false;
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
destroyed = true;
|
||||
markersRef.current.forEach((m) => m.destroy());
|
||||
markersRef.current = [];
|
||||
if (userMarkerRef.current) {
|
||||
userMarkerRef.current.destroy();
|
||||
userMarkerRef.current = null;
|
||||
}
|
||||
if (polylineRef.current) {
|
||||
polylineRef.current.destroy();
|
||||
polylineRef.current = null;
|
||||
}
|
||||
if (mapInstanceRef.current) {
|
||||
mapInstanceRef.current.destroy();
|
||||
mapInstanceRef.current = null;
|
||||
}
|
||||
initDoneRef.current = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (userLocation || !navigator.geolocation) return;
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
const loc: GeoLocation = {
|
||||
lat: position.coords.latitude,
|
||||
lng: position.coords.longitude,
|
||||
name: 'Моё местоположение',
|
||||
};
|
||||
setDetectedLocation(loc);
|
||||
|
||||
if (mapInstanceRef.current && route.length === 0) {
|
||||
mapInstanceRef.current.setCenter([loc.lng, loc.lat]);
|
||||
mapInstanceRef.current.setZoom(12);
|
||||
}
|
||||
},
|
||||
() => {
|
||||
// geolocation denied or unavailable
|
||||
},
|
||||
{ enableHighAccuracy: false, timeout: 10000, maximumAge: 300000 },
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const container = mapRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
mapInstanceRef.current?.invalidateSize();
|
||||
});
|
||||
observer.observe(container);
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMapReady || !mapInstanceRef.current || !mapglRef.current) return;
|
||||
|
||||
const map = mapInstanceRef.current;
|
||||
const mapgl = mapglRef.current;
|
||||
|
||||
markersRef.current.forEach((m) => m.destroy());
|
||||
markersRef.current = [];
|
||||
|
||||
if (polylineRef.current) {
|
||||
polylineRef.current.destroy();
|
||||
polylineRef.current = null;
|
||||
}
|
||||
|
||||
if (userMarkerRef.current) {
|
||||
userMarkerRef.current.destroy();
|
||||
userMarkerRef.current = null;
|
||||
}
|
||||
|
||||
if (effectiveUserLocation && effectiveUserLocation.lat !== 0 && effectiveUserLocation.lng !== 0) {
|
||||
try {
|
||||
const userMkr = new mapgl.Marker(map, {
|
||||
coordinates: [effectiveUserLocation.lng, effectiveUserLocation.lat],
|
||||
label: {
|
||||
text: '',
|
||||
offset: [0, -48],
|
||||
image: {
|
||||
url: `data:image/svg+xml,${encodeURIComponent(createUserLocationSVG())}`,
|
||||
size: [32, 32],
|
||||
anchor: [16, 16],
|
||||
},
|
||||
},
|
||||
});
|
||||
userMarkerRef.current = userMkr;
|
||||
} catch {
|
||||
// marker creation failed
|
||||
}
|
||||
}
|
||||
|
||||
if (route.length === 0) {
|
||||
if (effectiveUserLocation && effectiveUserLocation.lat !== 0 && effectiveUserLocation.lng !== 0) {
|
||||
map.setCenter([effectiveUserLocation.lng, effectiveUserLocation.lat]);
|
||||
map.setZoom(12);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
route.forEach((point, index) => {
|
||||
if (!point.lat || !point.lng || point.lat === 0 || point.lng === 0) return;
|
||||
|
||||
const color = pointTypeColors[point.type] || pointTypeColors.custom || '#EC4899';
|
||||
|
||||
try {
|
||||
const marker = new mapgl.Marker(map, {
|
||||
coordinates: [point.lng, point.lat],
|
||||
label: {
|
||||
text: String(index + 1),
|
||||
offset: [0, -48],
|
||||
image: {
|
||||
url: `data:image/svg+xml,${encodeURIComponent(createMarkerSVG(index + 1, color))}`,
|
||||
size: [28, 28],
|
||||
anchor: [14, 14],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
marker.on('click', () => {
|
||||
setSelectedPoint(point);
|
||||
onPointClickRef.current?.(point);
|
||||
});
|
||||
|
||||
markersRef.current.push(marker);
|
||||
} catch {
|
||||
// marker creation failed
|
||||
}
|
||||
});
|
||||
|
||||
const rdCoords = routeDirection?.geometry?.coordinates;
|
||||
if (rdCoords && Array.isArray(rdCoords) && rdCoords.length > 1) {
|
||||
const coords = rdCoords.map(
|
||||
(c: number[]) => [c[0], c[1]] as [number, number]
|
||||
);
|
||||
try {
|
||||
polylineRef.current = new mapgl.Polyline(map, {
|
||||
coordinates: coords,
|
||||
color: '#6366F1',
|
||||
width: 4,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[TravelMap] road polyline failed:', err, 'coords sample:', coords.slice(0, 3));
|
||||
}
|
||||
} else if (route.length > 1) {
|
||||
const coords = route
|
||||
.filter((p) => p.lat !== 0 && p.lng !== 0)
|
||||
.map((p) => [p.lng, p.lat] as [number, number]);
|
||||
if (coords.length > 1) {
|
||||
try {
|
||||
polylineRef.current = new mapgl.Polyline(map, {
|
||||
coordinates: coords,
|
||||
color: '#6366F1',
|
||||
width: 3,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[TravelMap] fallback polyline failed:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const allPoints: { lat: number; lng: number }[] = route
|
||||
.filter((p) => p.lat !== 0 && p.lng !== 0)
|
||||
.map((p) => ({ lat: p.lat, lng: p.lng }));
|
||||
|
||||
if (effectiveUserLocation && effectiveUserLocation.lat !== 0 && effectiveUserLocation.lng !== 0) {
|
||||
allPoints.push({ lat: effectiveUserLocation.lat, lng: effectiveUserLocation.lng });
|
||||
}
|
||||
|
||||
if (allPoints.length > 0) {
|
||||
const lngs = allPoints.map((p) => p.lng);
|
||||
const lats = allPoints.map((p) => p.lat);
|
||||
|
||||
const minLng = Math.min(...lngs);
|
||||
const maxLng = Math.max(...lngs);
|
||||
const minLat = Math.min(...lats);
|
||||
const maxLat = Math.max(...lats);
|
||||
|
||||
const lngSpan = maxLng - minLng;
|
||||
const latSpan = maxLat - minLat;
|
||||
|
||||
if (latSpan < 0.001 && lngSpan < 0.001) {
|
||||
map.setCenter([allPoints[0].lng, allPoints[0].lat]);
|
||||
map.setZoom(14);
|
||||
} else {
|
||||
map.fitBounds(
|
||||
{
|
||||
southWest: [minLng - lngSpan * 0.1, minLat - latSpan * 0.1],
|
||||
northEast: [maxLng + lngSpan * 0.1, maxLat + latSpan * 0.1],
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [isMapReady, route, routeDirection, effectiveUserLocation]);
|
||||
|
||||
const handleZoomIn = useCallback(() => {
|
||||
const map = mapInstanceRef.current;
|
||||
if (map) {
|
||||
map.setZoom(map.getZoom() + 1);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleZoomOut = useCallback(() => {
|
||||
const map = mapInstanceRef.current;
|
||||
if (map) {
|
||||
map.setZoom(map.getZoom() - 1);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleLocate = useCallback(() => {
|
||||
if (!mapInstanceRef.current) return;
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
const loc: GeoLocation = {
|
||||
lat: position.coords.latitude,
|
||||
lng: position.coords.longitude,
|
||||
name: 'Моё местоположение',
|
||||
};
|
||||
setDetectedLocation(loc);
|
||||
mapInstanceRef.current?.setCenter(
|
||||
[position.coords.longitude, position.coords.latitude],
|
||||
);
|
||||
mapInstanceRef.current?.setZoom(14);
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleFitRoute = useCallback(() => {
|
||||
if (!mapInstanceRef.current || route.length === 0) return;
|
||||
|
||||
const allPoints: { lat: number; lng: number }[] = route
|
||||
.filter((p) => p.lat !== 0 && p.lng !== 0)
|
||||
.map((p) => ({ lat: p.lat, lng: p.lng }));
|
||||
|
||||
if (effectiveUserLocation && effectiveUserLocation.lat !== 0 && effectiveUserLocation.lng !== 0) {
|
||||
allPoints.push({ lat: effectiveUserLocation.lat, lng: effectiveUserLocation.lng });
|
||||
}
|
||||
|
||||
if (allPoints.length === 0) return;
|
||||
|
||||
const lngs = allPoints.map((p) => p.lng);
|
||||
const lats = allPoints.map((p) => p.lat);
|
||||
|
||||
const minLng = Math.min(...lngs);
|
||||
const maxLng = Math.max(...lngs);
|
||||
const minLat = Math.min(...lats);
|
||||
const maxLat = Math.max(...lats);
|
||||
|
||||
const lngSpan = maxLng - minLng;
|
||||
const latSpan = maxLat - minLat;
|
||||
|
||||
if (latSpan < 0.001 && lngSpan < 0.001) {
|
||||
mapInstanceRef.current.setCenter([allPoints[0].lng, allPoints[0].lat]);
|
||||
mapInstanceRef.current.setZoom(14);
|
||||
} else {
|
||||
mapInstanceRef.current.fitBounds({
|
||||
southWest: [minLng - lngSpan * 0.1, minLat - latSpan * 0.1],
|
||||
northEast: [maxLng + lngSpan * 0.1, maxLat + latSpan * 0.1],
|
||||
});
|
||||
}
|
||||
}, [route, effectiveUserLocation]);
|
||||
|
||||
const PointIcon = selectedPoint
|
||||
? pointTypeIcons[selectedPoint.type] || MapPin
|
||||
: MapPin;
|
||||
|
||||
return (
|
||||
<div className={`relative rounded-xl overflow-hidden ${className}`}>
|
||||
<div ref={mapRef} className="w-full h-full min-h-[300px]" />
|
||||
|
||||
{showControls && (
|
||||
<div className="absolute top-4 right-4 flex flex-col gap-2 z-[1000]">
|
||||
<button
|
||||
onClick={handleZoomIn}
|
||||
className="w-10 h-10 bg-elevated/90 backdrop-blur-sm border border-border/50 rounded-lg flex items-center justify-center text-secondary hover:text-primary hover:bg-surface transition-all"
|
||||
title="Увеличить"
|
||||
>
|
||||
<ZoomIn className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleZoomOut}
|
||||
className="w-10 h-10 bg-elevated/90 backdrop-blur-sm border border-border/50 rounded-lg flex items-center justify-center text-secondary hover:text-primary hover:bg-surface transition-all"
|
||||
title="Уменьшить"
|
||||
>
|
||||
<ZoomOut className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleLocate}
|
||||
className="w-10 h-10 bg-elevated/90 backdrop-blur-sm border border-border/50 rounded-lg flex items-center justify-center text-secondary hover:text-primary hover:bg-surface transition-all"
|
||||
title="Моё местоположение"
|
||||
>
|
||||
<Locate className="w-5 h-5" />
|
||||
</button>
|
||||
{route.length > 1 && (
|
||||
<button
|
||||
onClick={handleFitRoute}
|
||||
className="w-10 h-10 bg-elevated/90 backdrop-blur-sm border border-border/50 rounded-lg flex items-center justify-center text-secondary hover:text-primary hover:bg-surface transition-all"
|
||||
title="Показать весь маршрут"
|
||||
>
|
||||
<Navigation className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AnimatePresence>
|
||||
{selectedPoint && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 20 }}
|
||||
className="absolute bottom-4 left-4 right-4 bg-elevated/95 backdrop-blur-sm border border-border/50 rounded-xl p-4 z-[1000]"
|
||||
>
|
||||
<button
|
||||
onClick={() => setSelectedPoint(null)}
|
||||
className="absolute top-3 right-3 p-1 rounded-lg hover:bg-surface/50 text-muted hover:text-primary transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className="w-10 h-10 rounded-lg flex items-center justify-center"
|
||||
style={{ backgroundColor: pointTypeColors[selectedPoint.type] + '20' }}
|
||||
>
|
||||
<PointIcon
|
||||
className="w-5 h-5"
|
||||
style={{ color: pointTypeColors[selectedPoint.type] }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="text-sm font-medium text-primary truncate">
|
||||
{selectedPoint.name}
|
||||
</h4>
|
||||
{selectedPoint.address && (
|
||||
<p className="text-xs text-muted mt-0.5 truncate">
|
||||
{selectedPoint.address}
|
||||
</p>
|
||||
)}
|
||||
{selectedPoint.aiComment && (
|
||||
<div className="flex items-start gap-2 mt-2 p-2 bg-surface/50 rounded-lg">
|
||||
<Sparkles className="w-4 h-4 text-accent flex-shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-secondary">{selectedPoint.aiComment}</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedPoint.duration && (
|
||||
<p className="text-xs text-muted mt-2">
|
||||
Рекомендуемое время: {selectedPoint.duration} мин
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{!isMapReady && (
|
||||
<div className="absolute inset-0 bg-surface/80 backdrop-blur-sm flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="w-8 h-8 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
||||
<p className="text-sm text-muted">Загрузка карты 2GIS...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1236
backend/webui/src/components/TravelWidgets.tsx
Normal file
1236
backend/webui/src/components/TravelWidgets.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -34,7 +34,7 @@ export function AuthModal() {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm animate-fade-in"
|
||||
className="absolute inset-0 bg-primary/30 backdrop-blur-sm animate-fade-in"
|
||||
onClick={hideAuthModal}
|
||||
/>
|
||||
|
||||
|
||||
@@ -164,7 +164,7 @@ export function AccountTab() {
|
||||
<h3 className="text-lg font-medium text-primary">{user.name}</h3>
|
||||
<span className={`text-xs font-medium px-2 py-0.5 rounded ${
|
||||
user.tier === 'business'
|
||||
? 'bg-amber-500/20 text-amber-400'
|
||||
? 'bg-amber-500/15 text-amber-600'
|
||||
: user.tier === 'pro'
|
||||
? 'bg-accent/20 text-accent'
|
||||
: 'bg-surface text-muted'
|
||||
|
||||
@@ -55,7 +55,7 @@ const plans: Plan[] = [
|
||||
price: 4990,
|
||||
priceMonthly: 4990,
|
||||
icon: Building2,
|
||||
color: 'text-amber-400',
|
||||
color: 'text-amber-600',
|
||||
features: ['Всё из Pro', 'Безлимитный AI', 'Приоритетная поддержка', 'Команды', 'SLA 99.9%'],
|
||||
limits: {
|
||||
apiRequests: '100,000/день',
|
||||
|
||||
@@ -516,19 +516,19 @@ function ConnectorCard({
|
||||
<div className={`w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 ${
|
||||
isConnected ? 'bg-emerald-500/10' : 'bg-surface/60'
|
||||
}`}>
|
||||
<Icon className={`w-5 h-5 ${isConnected ? 'text-emerald-400' : 'text-secondary'}`} />
|
||||
<Icon className={`w-5 h-5 ${isConnected ? 'text-emerald-600' : 'text-secondary'}`} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0 text-left">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-primary">{connector.name}</span>
|
||||
{isConnected && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-500/10 text-emerald-400">
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-500/10 text-emerald-600">
|
||||
Подключён
|
||||
</span>
|
||||
)}
|
||||
{hasError && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-red-500/10 text-red-400">
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-red-500/10 text-red-600">
|
||||
Ошибка
|
||||
</span>
|
||||
)}
|
||||
@@ -548,7 +548,7 @@ function ConnectorCard({
|
||||
isConnected ? 'bg-emerald-500' : 'bg-surface/80 border border-border'
|
||||
}`}>
|
||||
<div className={`absolute top-1 w-4 h-4 rounded-full transition-all ${
|
||||
isConnected ? 'right-1 bg-white' : 'left-1 bg-secondary'
|
||||
isConnected ? 'right-1 bg-elevated' : 'left-1 bg-secondary'
|
||||
}`} />
|
||||
</div>
|
||||
)}
|
||||
@@ -569,7 +569,7 @@ function ConnectorCard({
|
||||
<div key={field.key}>
|
||||
<label className="block text-xs text-secondary mb-1.5">
|
||||
{field.label}
|
||||
{field.required && <span className="text-red-400 ml-0.5">*</span>}
|
||||
{field.required && <span className="text-red-600 ml-0.5">*</span>}
|
||||
</label>
|
||||
<div className="relative">
|
||||
{field.type === 'textarea' ? (
|
||||
@@ -578,7 +578,7 @@ function ConnectorCard({
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))}
|
||||
placeholder={field.placeholder}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 bg-surface/60 border border-border/50 rounded-lg text-sm text-primary placeholder:text-muted focus:outline-none focus:border-indigo-500/50 resize-none"
|
||||
className="w-full px-3 py-2 bg-surface/60 border border-border/50 rounded-lg text-sm text-primary placeholder:text-muted focus:outline-none focus:border-accent/50 resize-none"
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
@@ -586,7 +586,7 @@ function ConnectorCard({
|
||||
value={formData[field.key] || ''}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))}
|
||||
placeholder={field.placeholder}
|
||||
className="w-full px-3 py-2 bg-surface/60 border border-border/50 rounded-lg text-sm text-primary placeholder:text-muted focus:outline-none focus:border-indigo-500/50 pr-10"
|
||||
className="w-full px-3 py-2 bg-surface/60 border border-border/50 rounded-lg text-sm text-primary placeholder:text-muted focus:outline-none focus:border-accent/50 pr-10"
|
||||
/>
|
||||
)}
|
||||
{field.type === 'password' && (
|
||||
@@ -612,8 +612,8 @@ function ConnectorCard({
|
||||
|
||||
{hasError && userConnector?.errorMessage && (
|
||||
<div className="mt-3 p-2 rounded-lg bg-red-500/10 border border-red-500/20 flex items-start gap-2">
|
||||
<AlertCircle className="w-4 h-4 text-red-400 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-red-400">{userConnector.errorMessage}</p>
|
||||
<AlertCircle className="w-4 h-4 text-red-600 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-red-600">{userConnector.errorMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -621,7 +621,7 @@ function ConnectorCard({
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-indigo-500 hover:bg-indigo-600 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50"
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-accent hover:bg-accent-hover text-accent-foreground text-sm font-medium rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
@@ -653,7 +653,7 @@ function ConnectorCard({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDisconnect}
|
||||
className="px-3 py-2 bg-red-500/10 border border-red-500/20 text-red-400 text-sm rounded-lg hover:bg-red-500/20 transition-colors"
|
||||
className="px-3 py-2 bg-red-500/10 border border-red-500/20 text-red-600 text-sm rounded-lg hover:bg-red-500/20 transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
@@ -666,7 +666,7 @@ function ConnectorCard({
|
||||
href="https://github.com/settings/tokens/new"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 mt-3 text-xs text-indigo-400 hover:text-indigo-300"
|
||||
className="flex items-center gap-1 mt-3 text-xs text-accent hover:text-accent-hover"
|
||||
>
|
||||
Создать токен на GitHub
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
@@ -678,7 +678,7 @@ function ConnectorCard({
|
||||
href="https://t.me/BotFather"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 mt-3 text-xs text-indigo-400 hover:text-indigo-300"
|
||||
className="flex items-center gap-1 mt-3 text-xs text-accent hover:text-accent-hover"
|
||||
>
|
||||
Открыть @BotFather
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
|
||||
@@ -103,8 +103,8 @@ export function PreferencesTab() {
|
||||
className="w-full flex items-center justify-between p-3 sm:p-4 bg-elevated/40 border border-border/40 rounded-xl hover:bg-elevated/60 hover:border-border transition-all"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-indigo-500/10 flex items-center justify-center">
|
||||
<Plug className="w-5 h-5 text-indigo-400" />
|
||||
<div className="w-10 h-10 rounded-xl bg-accent/10 flex items-center justify-center">
|
||||
<Plug className="w-5 h-5 text-accent" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="text-sm font-medium text-primary">Настроить коннекторы</p>
|
||||
|
||||
Reference in New Issue
Block a user