diff --git a/api/v1.yaml b/api/v1.yaml index 7876812..8f7d1ba 100644 --- a/api/v1.yaml +++ b/api/v1.yaml @@ -157,6 +157,32 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' + /api/v1/auth/me: + get: + tags: [auth] + summary: Get info of logged in user + operationId: userInfo + security: + - cookieAuth: [] + responses: + '200': + description: Successfully got user info + content: + application/json: + schema: + $ref: '#/components/schemas/UserInfoResponse' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' /api/v1/user/query: post: tags: [user] @@ -206,14 +232,16 @@ paths: name: offset schema: type: integer + format: uint64 default: 0 - required: true # TODO: проверить можно ли убрать + required: false - in: query name: limit schema: type: integer + format: uint64 default: 20 - required: true + required: false responses: '200': description: Successfully retrieved user search queries @@ -229,6 +257,12 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' + '404': + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' '500': description: Internal server error content: @@ -286,6 +320,10 @@ components: type: string minLength: 1 maxLength: 255 + category: + type: string + minLength: 1 + maxLength: 255 start: type: string format: date-time @@ -298,11 +336,13 @@ components: properties: token: type: string + category: + type: string records: type: array items: $ref: '#/components/schemas/TokenRecord' - required: [token, records] + required: [token, category, records] TokenRecord: type: object properties: @@ -317,10 +357,13 @@ components: interest_normalized: type: number format: float64 + interest_category: + type: number + format: float64 sentiment: type: integer format: int16 - required: [interest, interest_normalized, sentiment] + required: [interest, interest_normalized, interest_category, sentiment] required: [timestamp, features] VkAuthCallbackRequest: type: object @@ -400,6 +443,14 @@ components: type: string format: date-time required: [id, query, searchDate] + UserInfoResponse: + type: object + properties: + username: + type: string + email: + type: string + required: [username, email] Error: type: object diff --git a/app/entities/auth/api/me.api.ts b/app/entities/auth/api/me.api.ts new file mode 100644 index 0000000..34b4ddf --- /dev/null +++ b/app/entities/auth/api/me.api.ts @@ -0,0 +1,6 @@ +import { apiRoutes } from '@app/shared/config/routes'; +import { client } from '@shared/api/client'; + +export const getMe = async () => { + return client.GET(apiRoutes.getMe); +}; diff --git a/app/entities/auth/index.ts b/app/entities/auth/index.ts index 98d4d0e..95978e7 100644 --- a/app/entities/auth/index.ts +++ b/app/entities/auth/index.ts @@ -1 +1,2 @@ +export { getMe } from './api/me.api'; export { useUserStore } from './store/user.store'; diff --git a/app/entities/auth/store/query.store.ts b/app/entities/auth/store/query.store.ts index 1d896e8..ebe1d8e 100644 --- a/app/entities/auth/store/query.store.ts +++ b/app/entities/auth/store/query.store.ts @@ -1,9 +1,17 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; +interface QueryItem { + id: string; + query: string; +} + interface SavedQueriesStore { _items: Record; + queries: QueryItem[]; + setQueries: (queries: QueryItem[]) => void; + getQueryID: (query: string) => string | undefined; isQuerySaved: (query: string) => boolean; saveQuery: (id: string, query: string) => void; @@ -15,6 +23,20 @@ export const useSavedQueriesStore = create()( (set, get) => ({ _items: {}, + queries: [], + setQueries: (queries: QueryItem[]) => { + set({ + queries, + _items: queries.reduce( + (acc, q) => { + acc[q.query] = q.id; + return acc; + }, + {} as Record, + ), + }); + }, + getQueryID: (query: string) => { return get()._items[query]; }, @@ -26,6 +48,7 @@ export const useSavedQueriesStore = create()( saveQuery: (id: string, query: string) => { set((state) => ({ _items: { ...state._items, [query]: id }, + queries: [...state.queries, { id, query }], })); }, @@ -33,7 +56,10 @@ export const useSavedQueriesStore = create()( set((state) => { const newItems = { ...state._items }; delete newItems[query]; - return { _items: newItems }; + return { + _items: newItems, + queries: state.queries.filter((q) => q.query !== query), + }; }); }, }), diff --git a/app/entities/auth/store/user.store.ts b/app/entities/auth/store/user.store.ts index 9e2aff8..db242af 100644 --- a/app/entities/auth/store/user.store.ts +++ b/app/entities/auth/store/user.store.ts @@ -1,13 +1,16 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; +type AuthStatus = 'unknown' | 'authenticated' | 'guest'; + export interface AuthUser { - vkid: number; + vkid?: number; username: string; email: string; } interface UserState { + status: AuthStatus; user?: AuthUser; pendingAuth?: { username: string; @@ -26,11 +29,15 @@ interface UserState { export const useUserStore = create()( persist( (set) => ({ + status: 'unknown', user: undefined, pendingAuth: undefined, - setUser: (user) => set({ user, pendingAuth: undefined }), - setPendingAuth: (pending) => set({ pendingAuth: pending }), - logout: () => set({ user: undefined, pendingAuth: undefined }), + setUser: (user) => + set({ status: 'authenticated', user, pendingAuth: undefined }), + setPendingAuth: (pending) => + set({ status: 'guest', pendingAuth: pending }), + logout: () => + set({ status: 'guest', user: undefined, pendingAuth: undefined }), }), { name: 'user' }, ), diff --git a/app/features/search-token-query/model/eval-query.model.ts b/app/features/search-token-query/model/eval-query.model.ts index 50bfe45..582798c 100644 --- a/app/features/search-token-query/model/eval-query.model.ts +++ b/app/features/search-token-query/model/eval-query.model.ts @@ -102,6 +102,7 @@ const fillMissingDates = ( export const executeQuerySearch = async ( query: string, + start?: string, ): Promise => { try { const tokens = extractTokens(query); @@ -109,9 +110,7 @@ export const executeQuerySearch = async ( const { data, error } = await POST(apiRoutes.searchToken, { body: { token: query, - start: new Date( - new Date().setFullYear(new Date().getFullYear() - 1), - ).toISOString(), + start: start, } as SearchTokenInfoRequest, }); if (error !== undefined) { @@ -132,9 +131,7 @@ export const executeQuerySearch = async ( POST(apiRoutes.searchToken, { body: { token: token, - start: new Date( - new Date().setFullYear(new Date().getFullYear() - 1), - ).toISOString(), + start: start, } as SearchTokenInfoRequest, }), ); diff --git a/app/features/search-token-query/ui/filter-category.ui.tsx b/app/features/search-token-query/ui/filter-category.ui.tsx new file mode 100644 index 0000000..bc09de8 --- /dev/null +++ b/app/features/search-token-query/ui/filter-category.ui.tsx @@ -0,0 +1,40 @@ +import { useQueryParam } from '@shared/lib/hooks/use-query-param'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@shared/ui/select'; + +const CATEGORIES = [ + { value: 'news', label: 'Новости' }, + { value: 'marketplace', label: 'Маркетплейсы' }, + { value: 'review', label: 'Отзывы' }, +]; + +export const FilterCategory = () => { + const [category, setCategory] = useQueryParam('category'); + + return ( +
+ +
+ ); +}; diff --git a/app/features/search-token-query/ui/filters.ui.tsx b/app/features/search-token-query/ui/filters.ui.tsx new file mode 100644 index 0000000..51241df --- /dev/null +++ b/app/features/search-token-query/ui/filters.ui.tsx @@ -0,0 +1,32 @@ +import * as React from 'react'; + +import { cn } from '@app/shared/lib/utils'; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from '@shared/ui/accordion'; + +type SearchTokenFiltersProps = { + children: React.ReactNode; + className?: string; +}; + +export const SearchFilters = ({ + children, + className, +}: SearchTokenFiltersProps) => { + return ( + + + + Расширенные параметры + + +
{children}
+
+
+
+ ); +}; diff --git a/app/features/search-token-query/ui/search-token-query.ui.tsx b/app/features/search-token-query/ui/search-token-query.ui.tsx index 027a057..ab6f73f 100644 --- a/app/features/search-token-query/ui/search-token-query.ui.tsx +++ b/app/features/search-token-query/ui/search-token-query.ui.tsx @@ -1,12 +1,15 @@ import React, { useEffect, useState } from 'react'; import { useNavigate, useNavigation, useSearchParams } from 'react-router'; +import { InputWithTooltip } from '@app/shared/ui/input-with-tooltip'; import { isQuery } from '@entities/token'; import { routes } from '@shared/config/routes'; import { Button } from '@shared/ui/button'; -import { Input } from '@shared/ui/input'; import { Spinner } from '@shared/ui/spinner'; +import { FilterCategory } from './filter-category.ui'; +import { SearchFilters } from './filters.ui'; + export const SearchTokenQuery = () => { const [searchParams] = useSearchParams(); const [query, setQuery] = useState(searchParams.get('query') || ''); @@ -36,7 +39,7 @@ export const SearchTokenQuery = () => { } const formattedQuery = formatQuery(query); - const params = new URLSearchParams(); + const params = new URLSearchParams(searchParams); params.set('query', formattedQuery); navigate({ @@ -46,19 +49,25 @@ export const SearchTokenQuery = () => { }; return ( -
- setQuery(e.target.value)} - required - className="flex-1" - aria-label="Поиск по токену или формуле" - /> - + +
+ setQuery(e.target.value)} + required + className="flex-1" + /> + +
+ + + +
); }; diff --git a/app/pages/landing/ui/landing.ui.tsx b/app/pages/landing/ui/landing.ui.tsx index 46ad404..02a909c 100644 --- a/app/pages/landing/ui/landing.ui.tsx +++ b/app/pages/landing/ui/landing.ui.tsx @@ -1,3 +1,8 @@ +import { useEffect } from 'react'; +import { useSearchParams } from 'react-router'; + +import { toast } from 'sonner'; + import { RegisterDialog } from '@features/vkid-auth'; import { LandingNavbar } from '@widgets/layouts/landing-navbar'; @@ -6,6 +11,18 @@ import { FooterSection } from './footer.ui'; import { HeroSection } from './hero.ui'; export const LandingPage = () => { + const [params, setParams] = useSearchParams(); + + useEffect(() => { + if (params.get('redirect_reason') === 'auth') { + toast.warning( + 'Вы не можете выполнить это действие. Авторизуйтесь и попробуйте снова', + ); + params.delete('redirect_reason'); + setParams(params, { replace: true }); + } + }, [params, setParams]); + return (
diff --git a/app/pages/saved-queries/ui/saved-queries.ui.tsx b/app/pages/saved-queries/ui/saved-queries.ui.tsx index 86700f3..9302ceb 100644 --- a/app/pages/saved-queries/ui/saved-queries.ui.tsx +++ b/app/pages/saved-queries/ui/saved-queries.ui.tsx @@ -1,8 +1,12 @@ -import { useLoaderData } from 'react-router'; +import { useEffect } from 'react'; +import { useLoaderData, useNavigate } from 'react-router'; import { Trash2 } from 'lucide-react'; import { toast } from 'sonner'; +import { isQuery } from '@app/entities/token'; +import { routes } from '@app/shared/config/routes'; +import { Pagination } from '@app/widgets/pagination'; import { useSavedQueriesStore } from '@entities/auth/store/query.store'; import { apiRoutes, @@ -14,27 +18,28 @@ import { Button } from '@shared/ui/button'; import { Label } from '@shared/ui/label'; import type { Route } from './+types/saved-queries'; -import { Pagination } from './pagination.ui'; export const SavedQueriesPage = () => { - const { removeQuery } = useSavedQueriesStore(); - const data = useLoaderData(); - if ('error' in data) { - toast.error( - 'Не получилось загрузить сохраненные запросы. Попробуйте позже', - ); - return null; - } - - const handleDeleteQuery = async (queryID: string, query: string) => { + const { queries, setQueries, removeQuery } = useSavedQueriesStore(); + + const navigate = useNavigate(); + + useEffect(() => { + if (!('error' in data)) { + setQueries(data.queries); + } else { + toast.error( + 'Не получилось загрузить сохраненные запросы. Попробуйте позже', + ); + } + }, [data, setQueries]); + + const handleDeleteQuery = async (queryID: string, queryText: string) => { const { error } = await DELETE(apiRoutes.savedQueries, { - params: { - query: { - id: queryID, - } as DeleteUserQueryParams, - }, + params: { query: { id: queryID } as DeleteUserQueryParams }, }); + if (error) { toast.error( 'Не получилось удалить запрос из сохраненных. Попробуйте позже', @@ -42,7 +47,7 @@ export const SavedQueriesPage = () => { return; } - removeQuery(query); + removeQuery(queryText); }; const formatDate = (dateString: string) => { @@ -56,8 +61,7 @@ export const SavedQueriesPage = () => { }); }; - const { queries, pagination } = data; - const page = pagination?.page ?? 1; + const page = data.pagination?.page ?? 1; return (
@@ -65,7 +69,7 @@ export const SavedQueriesPage = () => { {queries.length === 0 ? ( -
+
Здесь будут появляться сохраненные запросы
) : ( @@ -76,7 +80,24 @@ export const SavedQueriesPage = () => { className="flex items-center justify-between p-4 border rounded-lg" >
-

{query.query}

+

{ + const formattedQuery = isQuery(query.query) + ? query.query + : `token('${query.query.trim()}')`; + + const params = new URLSearchParams(); + params.set('query', formattedQuery); + + navigate({ + pathname: routes.searchResult, + search: params.toString(), + }); + }} + > + {query.query} +

{formatDate(query.searchDate)}

diff --git a/app/pages/search-token-result/api/search-token-result.api.ts b/app/pages/search-token-result/api/search-token-result.api.ts index 4e98887..c595219 100644 --- a/app/pages/search-token-result/api/search-token-result.api.ts +++ b/app/pages/search-token-result/api/search-token-result.api.ts @@ -13,6 +13,13 @@ import type { Route } from './+types/search-token-result'; export async function clientLoader({ request }: Route.ClientLoaderArgs) { const url = new URL(request.url); const query = url.searchParams.get('query'); + const category = url.searchParams.get('category') ?? undefined; + + const start = + url.searchParams.get('start') || + new Date( + new Date().setFullYear(new Date().getFullYear() - 1), + ).toISOString(); if (!query) { toast.warning( @@ -22,7 +29,7 @@ export async function clientLoader({ request }: Route.ClientLoaderArgs) { } if (isQuery(query)) { - const result = await executeQuerySearch(query); + const result = await executeQuerySearch(query, start); if ('error' in result || !result?.records?.length) { toast.warning( 'Мы не смогли ничего найти по вашему запросу. Пробоуйте что-то другое', @@ -32,18 +39,11 @@ export async function clientLoader({ request }: Route.ClientLoaderArgs) { return [result]; } - const start = - url.searchParams.get('start') || - new Date( - new Date().setFullYear(new Date().getFullYear() - 1), - ).toISOString(); - const end = url.searchParams.get('end') ?? undefined; - return await POST(apiRoutes.searchToken, { body: { token: query, + category: category, start: start, - end: end, } as SearchTokenInfoRequest, }); } diff --git a/app/pages/search-token-result/ui/interval-select.ui.tsx b/app/pages/search-token-result/ui/interval-select.ui.tsx new file mode 100644 index 0000000..a55c541 --- /dev/null +++ b/app/pages/search-token-result/ui/interval-select.ui.tsx @@ -0,0 +1,51 @@ +import { useSearchParams } from 'react-router'; + +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@shared/ui/select'; + +const INTERVALS = [ + { value: '7d', label: 'Последние 7 дней', days: 7 }, + { value: '30d', label: 'Последние 30 дней', days: 30 }, + { value: '90d', label: 'Последние 90 дней', days: 90 }, +]; + +export const IntervalSelect = () => { + const [searchParams, setSearchParams] = useSearchParams(); + + const value = searchParams.get('interval') ?? '7d'; + + const onChange = (val: string) => { + const interval = INTERVALS.find((i) => i.value === val); + if (!interval) return; + + const start = new Date( + Date.now() - interval.days * 24 * 60 * 60 * 1000, + ).toISOString(); + + const next = new URLSearchParams(searchParams); + next.set('interval', val); + next.set('start', start); + + setSearchParams(next); + }; + + return ( + + ); +}; diff --git a/app/pages/search-token-result/ui/search-token-result.ui.tsx b/app/pages/search-token-result/ui/search-token-result.ui.tsx index 24eeea2..83377f5 100644 --- a/app/pages/search-token-result/ui/search-token-result.ui.tsx +++ b/app/pages/search-token-result/ui/search-token-result.ui.tsx @@ -34,6 +34,7 @@ import { } from '@widgets/token-sentiment-chart'; import type { Route } from './+types/search-token-result'; +import { IntervalSelect } from './interval-select.ui'; const ResultSkeletons = () => (
@@ -64,6 +65,8 @@ export const SearchTokenResultPage = () => { const query = searchParams.get('query') || ''; const isLoading = navigation.state === 'loading'; + const interval = searchParams.get('interval') ?? '7d'; + const { isQuerySaved, saveQuery, removeQuery, getQueryID } = useSavedQueriesStore(); @@ -145,6 +148,9 @@ export const SearchTokenResultPage = () => { ) : ( <> +
+ +
{ color: 'var(--chart-1)', }, }} + timeRange={interval} className="h-[250px] w-full" /> diff --git a/app/root.tsx b/app/root.tsx index 7914388..06acc7c 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react'; +import React from 'react'; import { isRouteErrorResponse, Links, @@ -6,15 +6,15 @@ import { Outlet, Scripts, ScrollRestoration, - useNavigate, } from 'react-router'; -import { useUserStore } from '@entities/auth'; +import { getMe, useUserStore } from '@entities/auth'; import { SidebarConfigProvider } from '@shared/lib/providers/sidebar-config'; import { ThemeProvider } from '@shared/lib/providers/theme'; import { Notification } from '@widgets/notification'; import type { Route } from './+types/root'; +import type { GetMeResponse } from './shared/api'; import './app.css'; @@ -77,33 +77,27 @@ export const Layout = ({ children }: { children: React.ReactNode }) => { ); }; -export const App = () => { - const navigate = useNavigate(); - const { logout } = useUserStore(); - - useEffect(() => { - const handleRedirect = (event: CustomEvent) => { - const { path } = event.detail; - navigate(path); - }; - - window.addEventListener('redirect', handleRedirect as EventListener); - return () => { - window.removeEventListener('redirect', handleRedirect as EventListener); - }; - }, [navigate]); - - useEffect(() => { - const handleEvent = () => { - logout(); - }; - - window.addEventListener('logout-user', handleEvent as EventListener); - return () => { - window.removeEventListener('logout-user', handleEvent as EventListener); - }; - }, [logout]); +export async function clientLoader() { + const state = useUserStore.getState(); + if (state.status !== 'unknown') { + return null; + } + + const { data, error } = await getMe(); + if (error !== undefined) { + state.logout(); + } else { + const response = data as GetMeResponse; + state.setUser({ + username: response.username, + email: response.email, + }); + } + + return null; +} +export const App = () => { return ; }; diff --git a/app/routes/home-layout.tsx b/app/routes/home-layout.tsx index 3d138b2..8ab9f13 100644 --- a/app/routes/home-layout.tsx +++ b/app/routes/home-layout.tsx @@ -6,7 +6,7 @@ import { AlertTriangle, Clock, Search } from 'lucide-react'; import { routes } from '@shared/config/routes'; import { useSidebarConfig } from '@shared/lib/hooks/use-sidebar-config'; import { SidebarProvider } from '@shared/ui/sidebar'; -import { HomeSidebar } from '@widgets/layouts/home-sidebar'; +import { clientLoader, HomeSidebar } from '@widgets/layouts/home-sidebar'; const navigationItems = [ { title: 'Поиск', url: routes.searchToken, icon: Search }, @@ -38,4 +38,5 @@ const HomeLayout = () => { ); }; +export { clientLoader }; export default HomeLayout; diff --git a/app/shared/api/gen/v1.d.ts b/app/shared/api/gen/v1.d.ts index 0c5b638..0d1aa22 100644 --- a/app/shared/api/gen/v1.d.ts +++ b/app/shared/api/gen/v1.d.ts @@ -72,6 +72,23 @@ export interface paths { patch?: never; trace?: never; }; + '/api/v1/auth/me': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get info of logged in user */ + get: operations['userInfo']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/api/v1/user/query': { parameters: { query?: never; @@ -97,6 +114,7 @@ export interface components { schemas: { SearchTokenInfoRequest: { token: string; + category?: string; /** Format: date-time */ start: string; /** Format: date-time */ @@ -104,6 +122,7 @@ export interface components { }; TokenInfo: { token: string; + category: string; records: components['schemas']['TokenRecord'][]; }; TokenRecord: { @@ -113,6 +132,8 @@ export interface components { interest: number; /** Format: float64 */ interest_normalized: number; + /** Format: float64 */ + interest_category: number; /** Format: int16 */ sentiment: number; }; @@ -150,6 +171,10 @@ export interface components { /** Format: date-time */ searchDate: string; }; + UserInfoResponse: { + username: string; + email: string; + }; Error: { error: string; }; @@ -362,11 +387,49 @@ export interface operations { }; }; }; + userInfo: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully got user info */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['UserInfoResponse']; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; getUserSearchQueries: { parameters: { - query: { - offset: number; - limit: number; + query?: { + offset?: number; + limit?: number; }; header?: never; path?: never; @@ -392,6 +455,15 @@ export interface operations { 'application/json': components['schemas']['Error']; }; }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; + }; + }; /** @description Internal server error */ 500: { headers: { diff --git a/app/shared/api/index.ts b/app/shared/api/index.ts index 831526f..c8dd546 100644 --- a/app/shared/api/index.ts +++ b/app/shared/api/index.ts @@ -2,6 +2,7 @@ export { DELETE, GET, POST, PUT } from './client'; export type { DeleteUserQueryParams, ErrorResponse, + GetMeResponse, GetUserQueriesParams, GetUserQueriesResponse, SaveUserQueryRequest, diff --git a/app/shared/api/middlewares/auth.ts b/app/shared/api/middlewares/auth.ts index 8533e9e..a80dfd5 100644 --- a/app/shared/api/middlewares/auth.ts +++ b/app/shared/api/middlewares/auth.ts @@ -1,7 +1,4 @@ import type { Middleware } from 'openapi-fetch'; -import { toast } from 'sonner'; - -import { routes } from '@shared/config/routes'; export const authMiddleware: Middleware = { async onResponse({ response }) { @@ -9,19 +6,8 @@ export const authMiddleware: Middleware = { return response; } - window.dispatchEvent(new CustomEvent('logout-user')); - - window.dispatchEvent( - new CustomEvent('redirect', { - detail: { - path: routes.landing, - }, - }), - ); - - toast.warning( - 'Вы не можете выполнить это действие. Авторизуйтесь и попробуйте снова', - ); + const { logout } = (await import('@entities/auth')).useUserStore.getState(); + logout(); return response; }, diff --git a/app/shared/api/mocks.ts b/app/shared/api/mocks.ts index 21b01a3..0675d4f 100644 --- a/app/shared/api/mocks.ts +++ b/app/shared/api/mocks.ts @@ -2,7 +2,6 @@ import { apiRoutes } from '@shared/api/routes'; export const mockFetch = async (input: RequestInfo, init?: RequestInit) => { const url = input.url; - const method = input.method; if (url.includes(apiRoutes.searchToken)) { return new Response( @@ -132,45 +131,5 @@ export const mockFetch = async (input: RequestInfo, init?: RequestInit) => { ); } - if (url.includes(apiRoutes.savedQueries) && method === 'GET') { - return new Response( - JSON.stringify([ - { - id: 0, - query: 'telegram', - searchDate: '2025-12-13T16:00:55.217Z', - }, - { - id: 1, - query: 'россия', - searchDate: '2025-12-13T16:01:17.465Z', - }, - ]), - { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }, - ); - } - - if (url.includes(apiRoutes.savedQueries) && method === 'POST') { - return new Response( - JSON.stringify({ - id: '1', - }), - { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }, - ); - } - - if (url.includes(apiRoutes.savedQueries) && method === 'DELETE') { - return new Response(JSON.stringify({}), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }); - } - return fetch(input, init); }; diff --git a/app/shared/api/models.ts b/app/shared/api/models.ts index e3633a2..10cdd84 100644 --- a/app/shared/api/models.ts +++ b/app/shared/api/models.ts @@ -28,3 +28,5 @@ export type SaveUserQueryResponse = export type DeleteUserQueryParams = operations['deleteUserSearchQuery']['parameters']['query']; + +export type GetMeResponse = components['schemas']['UserInfoResponse']; diff --git a/app/shared/api/routes.ts b/app/shared/api/routes.ts index 43d130f..8c52280 100644 --- a/app/shared/api/routes.ts +++ b/app/shared/api/routes.ts @@ -4,4 +4,5 @@ export const apiRoutes = { logoutUser: '/api/v1/auth/logout', searchToken: '/api/v1/token/search', savedQueries: '/api/v1/user/query', + getMe: '/api/v1/auth/me', }; diff --git a/app/shared/config/routes.ts b/app/shared/config/routes.ts index 3b3f327..4859037 100644 --- a/app/shared/config/routes.ts +++ b/app/shared/config/routes.ts @@ -9,6 +9,7 @@ export const BASE_URL = import.meta.env.VXR_BASE_URL; export const API_URL = import.meta.env.VXR_API_BASE_URL; export const apiRoutes = { - searchToken: `${API_URL}/api/v1/token/search`, - logoutUser: `${API_URL}/api/v1/auth/logout`, + searchToken: '/api/v1/token/search', + logoutUser: '/api/v1/auth/logout', + getMe: '/api/v1/auth/me', }; diff --git a/app/shared/lib/hooks/use-query-param/index.ts b/app/shared/lib/hooks/use-query-param/index.ts new file mode 100644 index 0000000..332ab14 --- /dev/null +++ b/app/shared/lib/hooks/use-query-param/index.ts @@ -0,0 +1 @@ +export { useQueryParam } from './use-query-param.lib'; diff --git a/app/shared/lib/hooks/use-query-param/use-query-param.lib.ts b/app/shared/lib/hooks/use-query-param/use-query-param.lib.ts new file mode 100644 index 0000000..64d7e58 --- /dev/null +++ b/app/shared/lib/hooks/use-query-param/use-query-param.lib.ts @@ -0,0 +1,21 @@ +import { useSearchParams } from 'react-router'; + +export const useQueryParam = (key: string, defaultValue?: string) => { + const [searchParams, setSearchParams] = useSearchParams(); + + const value = searchParams.get(key) ?? defaultValue; + + const setValue = (nextValue: string) => { + const next = new URLSearchParams(searchParams); + + if (nextValue === '' || nextValue === defaultValue) { + next.delete(key); + } else { + next.set(key, nextValue); + } + + setSearchParams(next, { replace: true }); + }; + + return [value, setValue] as const; +}; diff --git a/app/shared/ui/accordion.tsx b/app/shared/ui/accordion.tsx new file mode 100644 index 0000000..2496fe4 --- /dev/null +++ b/app/shared/ui/accordion.tsx @@ -0,0 +1,66 @@ +import * as React from 'react'; + +import { ChevronDownIcon } from 'lucide-react'; + +import * as AccordionPrimitive from '@radix-ui/react-accordion'; + +import { cn } from '@shared/lib/utils'; + +function Accordion({ + ...props +}: React.ComponentProps) { + return ; +} + +function AccordionItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AccordionTrigger({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + svg]:rotate-180', + className, + )} + {...props} + > + {children} + + + + ); +} + +function AccordionContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + +
{children}
+
+ ); +} + +export { Accordion, AccordionContent, AccordionItem, AccordionTrigger }; diff --git a/app/shared/ui/chart.tsx b/app/shared/ui/chart.tsx index f9a4157..462d030 100644 --- a/app/shared/ui/chart.tsx +++ b/app/shared/ui/chart.tsx @@ -232,7 +232,7 @@ const ChartTooltipContent = React.forwardRef< )}
diff --git a/app/shared/ui/input-with-tooltip.tsx b/app/shared/ui/input-with-tooltip.tsx new file mode 100644 index 0000000..2c5a9bb --- /dev/null +++ b/app/shared/ui/input-with-tooltip.tsx @@ -0,0 +1,35 @@ +import * as React from 'react'; + +import { Input } from '@shared/ui/input'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@shared/ui/tooltip'; + +interface InputWithTooltipProps extends React.ComponentProps { + tooltip?: React.ReactNode; +} + +export const InputWithTooltip: React.FC = ({ + tooltip, + ...inputProps +}) => { + if (!tooltip) { + return ; + } + + return ( + + + + + + +

{tooltip}

+
+
+
+ ); +}; diff --git a/app/shared/ui/select.tsx b/app/shared/ui/select.tsx new file mode 100644 index 0000000..90fc133 --- /dev/null +++ b/app/shared/ui/select.tsx @@ -0,0 +1,190 @@ +import * as React from 'react'; + +import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react'; + +import * as SelectPrimitive from '@radix-ui/react-select'; + +import { cn } from '@shared/lib/utils'; + +function Select({ + ...props +}: React.ComponentProps) { + return ; +} + +function SelectGroup({ + ...props +}: React.ComponentProps) { + return ; +} + +function SelectValue({ + ...props +}: React.ComponentProps) { + return ; +} + +function SelectTrigger({ + className, + size = 'default', + children, + ...props +}: React.ComponentProps & { + size?: 'sm' | 'default'; +}) { + return ( + + {children} + + + + + ); +} + +function SelectContent({ + className, + children, + position = 'item-aligned', + align = 'center', + ...props +}: React.ComponentProps) { + return ( + + + + + {children} + + + + + ); +} + +function SelectLabel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function SelectItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function SelectSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function SelectScrollUpButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +function SelectScrollDownButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectScrollDownButton, + SelectScrollUpButton, + SelectSeparator, + SelectTrigger, + SelectValue, +}; diff --git a/app/widgets/layouts/home-sidebar/api/me.api.ts b/app/widgets/layouts/home-sidebar/api/me.api.ts new file mode 100644 index 0000000..e7fb977 --- /dev/null +++ b/app/widgets/layouts/home-sidebar/api/me.api.ts @@ -0,0 +1,13 @@ +import { redirect } from 'react-router'; + +import { useUserStore } from '@app/entities/auth'; +import { routes } from '@app/shared/config/routes'; + +export async function clientLoader() { + const { status } = useUserStore.getState(); + if (status === 'unknown' || status === 'guest') { + throw redirect(`${routes.landing}?redirect_reason=auth`); + } + + return null; +} diff --git a/app/widgets/layouts/home-sidebar/index.ts b/app/widgets/layouts/home-sidebar/index.ts index 9458fa3..7170cbb 100644 --- a/app/widgets/layouts/home-sidebar/index.ts +++ b/app/widgets/layouts/home-sidebar/index.ts @@ -1 +1,2 @@ +export { clientLoader } from './api/me.api'; export { HomeSidebar } from './home-sidebar.ui'; diff --git a/app/widgets/layouts/home-sidebar/ui/sidebar-navigation.ui.tsx b/app/widgets/layouts/home-sidebar/ui/sidebar-navigation.ui.tsx index 560c5da..19d527c 100644 --- a/app/widgets/layouts/home-sidebar/ui/sidebar-navigation.ui.tsx +++ b/app/widgets/layouts/home-sidebar/ui/sidebar-navigation.ui.tsx @@ -33,10 +33,10 @@ export const SidebarNavigation: React.FC = ({ isActive={isActive && !item.disabled} tooltip={item.title} disabled={item.disabled} - className="flex items-center gap-2 text-base min-h-[35px]" + className="flex items-center gap-2 text-sm min-h-[35px]" > {item.icon && } - {item.title} + {item.title} {item.disabled && ( )} diff --git a/app/widgets/pagination/index.ts b/app/widgets/pagination/index.ts new file mode 100644 index 0000000..97e5f9c --- /dev/null +++ b/app/widgets/pagination/index.ts @@ -0,0 +1 @@ +export { Pagination } from './ui/pagination.ui'; diff --git a/app/pages/saved-queries/ui/pagination.ui.tsx b/app/widgets/pagination/ui/pagination.ui.tsx similarity index 100% rename from app/pages/saved-queries/ui/pagination.ui.tsx rename to app/widgets/pagination/ui/pagination.ui.tsx diff --git a/app/widgets/token-interest-chart/ui/token-interest-chart.ui.tsx b/app/widgets/token-interest-chart/ui/token-interest-chart.ui.tsx index 7f231d4..cac0e60 100644 --- a/app/widgets/token-interest-chart/ui/token-interest-chart.ui.tsx +++ b/app/widgets/token-interest-chart/ui/token-interest-chart.ui.tsx @@ -39,24 +39,29 @@ export interface TokenInterestChartProps { export const TokenInterestChart: React.FC = ({ data, title = 'График', - timeRange = '90d', + timeRange = '7d', lines, className, }) => { const filteredData = React.useMemo(() => { if (!data?.length) return []; - const latestDate = new Date(data[data.length - 1].date); let days = 90; if (timeRange === '30d') days = 30; else if (timeRange === '7d') days = 7; - const startDate = new Date(latestDate); + const now = new Date(); + const startDate = new Date(now); startDate.setDate(startDate.getDate() - days); - return data.filter((item) => new Date(item.date) >= startDate); + return data.filter((item) => { + const date = new Date(item.date); + return date >= startDate && date <= now; + }); }, [data, timeRange]); + const hasData = filteredData.length > 0; + return ( @@ -64,65 +69,71 @@ export const TokenInterestChart: React.FC = ({ - - - + {!hasData ? ( +
+ Нет данных за указанный период +
+ ) : ( + + + + {Object.entries(lines).map(([dataKey, { color }]) => ( + + + + + ))} + + + + { + const date = new Date(value); + return date.toLocaleDateString('ru-RU', { + month: 'short', + day: 'numeric', + }); + }} + /> + + new Date(value).toLocaleDateString('ru-RU', { + month: 'short', + day: 'numeric', + }) + } + indicator="dot" + /> + } + /> {Object.entries(lines).map(([dataKey, { color }]) => ( - - - - - ))} -
- - - { - const date = new Date(value); - return date.toLocaleDateString('ru-RU', { - month: 'short', - day: 'numeric', - }); - }} - /> - - new Date(value).toLocaleDateString('ru-RU', { - month: 'short', - day: 'numeric', - }) - } - indicator="dot" + dataKey={dataKey} + name={lines[dataKey].label} + type="natural" + stroke={color} + fill={`url(#fill-${dataKey})`} /> - } - /> - {Object.entries(lines).map(([dataKey, { color }]) => ( - - ))} -
-
+ ))} + + + )}
); diff --git a/app/widgets/token-sentiment-chart/ui/token-sentiment-chart.ui.tsx b/app/widgets/token-sentiment-chart/ui/token-sentiment-chart.ui.tsx index 3e9600c..9f15b20 100644 --- a/app/widgets/token-sentiment-chart/ui/token-sentiment-chart.ui.tsx +++ b/app/widgets/token-sentiment-chart/ui/token-sentiment-chart.ui.tsx @@ -1,5 +1,7 @@ 'use client'; +import * as React from 'react'; + import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'; import { Card, CardContent, CardHeader, CardTitle } from '@shared/ui/card'; @@ -15,20 +17,41 @@ export interface SentimentChartDataItem { export interface SentimentChartProps { data: SentimentChartDataItem[]; title: string; + timeRange?: '7d' | '30d' | '90d'; className?: string; } export function SentimentChart({ data, title = 'График', + timeRange = '7d', className, }: SentimentChartProps) { - const chartData = data.map((d) => ({ + const filteredData = React.useMemo(() => { + if (!data?.length) return []; + + let days = 90; + if (timeRange === '30d') days = 30; + else if (timeRange === '7d') days = 7; + + const now = new Date(); + const startDate = new Date(now); + startDate.setDate(startDate.getDate() - days); + + return data.filter((item) => { + const date = new Date(item.date); + return date >= startDate && date <= now; + }); + }, [data, timeRange]); + + const chartData = filteredData.map((d) => ({ date: d.date, positive: d.value > 0 ? d.value : 0, negative: d.value < 0 ? d.value : 0, })); + const hasData = chartData.length > 0; + const chartConfig = { positive: { label: 'Положит', color: 'var(--green)' }, negative: { label: 'Негатив', color: 'var(--red)' }, @@ -40,56 +63,65 @@ export function SentimentChart({ {title} + - - - - - { - const date = new Date(value); - return date.toLocaleDateString('ru-RU', { - month: 'short', - day: 'numeric', - }); - }} - /> - - - - - - - + {!hasData ? ( +
+ Нет данных за указанный период +
+ ) : ( + + + + + + new Date(value).toLocaleDateString('ru-RU', { + month: 'short', + day: 'numeric', + }) + } + /> + + + + + + + + + )}
); diff --git a/bun.lock b/bun.lock index de1b77a..12377c6 100644 --- a/bun.lock +++ b/bun.lock @@ -1,14 +1,17 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "vixar", "dependencies": { + "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-navigation-menu": "^1.2.14", + "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tooltip": "^1.2.8", @@ -216,8 +219,12 @@ "@pkgr/core": ["@pkgr/core@0.2.9", "", {}, "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA=="], + "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], + "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], + "@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA=="], + "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="], "@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA=="], @@ -258,6 +265,8 @@ "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="], + "@radix-ui/react-select": ["@radix-ui/react-select@2.2.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ=="], + "@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g=="], "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], @@ -1304,6 +1313,8 @@ "@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-select/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-separator/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], "@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], diff --git a/package.json b/package.json index 8daef2a..d519937 100644 --- a/package.json +++ b/package.json @@ -12,11 +12,13 @@ "start": "react-router-serve ./build/server/index.js" }, "dependencies": { + "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-navigation-menu": "^1.2.14", + "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tooltip": "^1.2.8", diff --git a/public/favicon.ico b/public/favicon.ico index 5dbdfcd..ae963c9 100644 Binary files a/public/favicon.ico and b/public/favicon.ico differ