diff --git a/.changeset/honest-mugs-fly.md b/.changeset/honest-mugs-fly.md new file mode 100644 index 0000000..20531ef --- /dev/null +++ b/.changeset/honest-mugs-fly.md @@ -0,0 +1,24 @@ +--- +"@kode-frontend/pathfinder-web": minor +"@kode-frontend/pathfinder-web-example": patch +--- + +- Доработан UI панели: + a. Поддержка мобильной версии + b. Перетаскивание кнопки активации + c. Изменение положения панели и ресайз + d. Темная тема + e. Правки багов UI + +- Явный выбор глобального окружения — добавлена опция "Global" в все селекты, позволяющая явно выбрать использование глобального переопределения + +- Отображение активного baseUrl — под каждым селектом показывается текущий активный baseUrl (глобальный, кастомный или пустой), чтобы пользователь видел, +какой URL будет использоваться +- Синхронизация при смене глобального окружения — когда глобальное переопределение меняется, все эндпоинты с выбранной опцией "Global" автоматически +обновляют отображаемый baseUrl +- Внутренние улучшения — добавлены константы и утилит-функции для безопасной работы с маркерами переопределений, сделан рефакторинг компонентов для +корректной работы с новой логикой +- Правки сборки: +a. Переведена сборка pathfinder-web на Vite с интеграцией в Turbo +b. Добавлен standalone example приложение для локальной разработки и тестирования +c. Обновлены зависимости в pnpm-workspace и package.json diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..86eb4e4 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,102 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +This is a **monorepo** managed with **Turborepo** and **pnpm** containing frontend utility packages from KODE. The repository includes: + +- **Shared configs**: ESLint, Prettier, Commitlint, TypeScript +- **Interceptors & utilities**: Session interceptor (JWT), Timeout interceptor, SVG transformer +- **React packages**: React Native push notifications, Feature toggle React, Pathfinder web + +## Environment & Commands + +**Package manager**: pnpm 9.15.0 (required, enforced in package.json engines) + +**Node version**: >=18 + +### Common Commands + +- **Development**: `pnpm dev` - Start dev mode for all packages (persistent turbo watch) +- **Build**: `pnpm build` - Build all packages with tsup +- **Lint**: `pnpm lint` - Run ESLint across packages +- **Type check**: `pnpm ts:check` - Check TypeScript errors +- **Format**: `pnpm format` - Format with Prettier +- **Clean**: `pnpm clean` - Remove turbo cache and dist folders +- **Package checks**: + - `pnpm lint:packages` - Check package.json dependency consistency (syncpack) + - `pnpm lint:fix:packages` - Fix dependency mismatches and formatting + +### Changesets & Publishing + +- **Add changeset**: `pnpm add-changeset` or `pnpm changeset add` + - For new packages: mark as "major" version, write "new package $name" in description + - Description follows Markdown; only first line is formatted with hyphen +- **Release cycle**: After merge to main, GitHub Actions creates a version PR +- **Publish**: Merge the version PR to publish all non-private packages to npm + +## Architecture & Build System + +### Turbo Configuration (turbo.json) + +Defines task pipelines with dependencies: + +- `build` depends on `^build` (upstream packages must build first), outputs to `dist/**` +- `lint` and `ts:check` are parallel tasks with upstream dependencies +- `dev` runs in persistent mode (no caching) +- `clean` removes build artifacts + +### Package Structure + +Each package in `packages/` follows this pattern: + +- **src/** - TypeScript source code +- **dist/** - Built output (generated by tsup) +- **tsconfig.json** - Extends from `@repo/config-typescript` +- **tsup.config.ts** - Build configuration (format: CJS, dts enabled, tree-shaking) +- **package.json** - Exports field points to `dist/index.js` + +### Internal Packages (internal/) + +- `@repo/config-typescript` - Provides base.json, nextjs.json, react-library.json tsconfigs +- `@repo/config-eslint` - Provides library.js ESLint config (uses Vercel style guide, prettier integration) + +Both are private workspace dependencies (referenced as `workspace:*` in other packages). + +### Root Configuration + +- Root ESLint ignores all workspace packages (each has own config) +- Root tsconfig extends from internal config (base) +- All workspaces use consistent ESLint from internal config + +## Key Development Patterns + +### When Working on a Package + +1. Run `pnpm dev` at repo root for continuous builds +2. Each package is independently buildable: changes in `src/` auto-build to `dist/` +3. Turbo handles dependency resolution (e.g., building internal configs before packages) +4. TypeScript checks are workspace-aware; use `pnpm ts:check` to catch cross-package issues + +### Testing & Validation + +- No Jest/Vitest configured at repo level; test patterns vary by package +- Always run `pnpm ts:check` and `pnpm lint` before committing +- Use `pnpm lint:fix:packages` if dependency versions become inconsistent + +### Adding or Modifying Packages + +- Keep dependencies synchronized across workspace with syncpack +- Use workspace protocol (`workspace:*`) for internal dependencies +- Update package versions only via changesets (not manual edits) + +## Dependency Management + +Syncpack ensures: + +- Consistent version ranges across packages +- Matching semver ranges for shared dependencies +- Proper formatting of package.json files + +Security overrides in root package.json prevent vulnerable versions of ws, braces (auto-enforced on install). diff --git a/package.json b/package.json index 925a5b9..d4aad85 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,9 @@ "build": "turbo build", "clean": "turbo clean", "dev": "turbo dev", + "dev:pathfinder": "turbo run dev --filter=@kode-frontend/pathfinder-web", + "dev:pathfinder-example": "turbo run dev --filter=@kode-frontend/pathfinder-web-example", + "dev:pathfinder-all": "turbo run dev --filter=@kode-frontend/pathfinder-web --filter=@kode-frontend/pathfinder-web-example --parallel", "format": "prettier --write \"**/*.{ts,tsx,js,jsx,md}\"", "lint": "turbo lint", "lint:fix:packages": "npm-run-all --parallel lint:fix:packages:*", @@ -47,6 +50,7 @@ }, "workspaces": [ "packages/*", + "packages/*/example", "internal/*" ] } diff --git a/packages/pathfinder-web/example/index.html b/packages/pathfinder-web/example/index.html index 41d7811..c8c2d20 100644 --- a/packages/pathfinder-web/example/index.html +++ b/packages/pathfinder-web/example/index.html @@ -9,6 +9,6 @@
- + diff --git a/packages/pathfinder-web/example/index.tsx b/packages/pathfinder-web/example/index.tsx index d7f0316..8f59ffb 100644 --- a/packages/pathfinder-web/example/index.tsx +++ b/packages/pathfinder-web/example/index.tsx @@ -1,6 +1,5 @@ -import 'react-app-polyfill/ie11' -import * as React from 'react' -import * as ReactDOM from 'react-dom' +import React from 'react' +import ReactDOM from 'react-dom/client' import { PathfinderProvider } from './pathfinder' import { TryForm } from './try-form' @@ -13,4 +12,8 @@ const App = () => { ) } -ReactDOM.render(, document.getElementById('root')) +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/packages/pathfinder-web/example/package.json b/packages/pathfinder-web/example/package.json index ac99d86..6338f4e 100644 --- a/packages/pathfinder-web/example/package.json +++ b/packages/pathfinder-web/example/package.json @@ -1,30 +1,25 @@ { - "name": "example", + "name": "@kode-frontend/pathfinder-web-example", "version": "1.0.0", - "main": "index.js", + "private": true, "license": "MIT", "scripts": { - "start": "yarn link @kode-frontend/pathfinder-web && parcel index.html", - "build": "parcel build index.html" + "dev": "concurrently \"vite\" \"prism mock ./pathfinder/core.json --port 3100\"", + "build": "vite build", + "preview": "vite preview" }, "dependencies": { - "@kode-frontend/pathfinder-web": "^0.2.0", - "react-app-polyfill": "^1.0.0" - }, - "alias": { - "react": "../node_modules/react", - "react-dom": "../node_modules/react-dom/profiling", - "scheduler/tracing": "../node_modules/scheduler/tracing-profiling" + "@kode-frontend/pathfinder-web": "workspace:*", + "react": "^18.2.0", + "react-dom": "^18.2.0" }, "devDependencies": { - "@babel/core": "^7.17.9", - "@types/react": "^16.9.11", - "@types/react-dom": "^16.8.4", - "parcel": "^1.12.3", - "postcss-modules": "^4.3.1", - "typescript": "^3.4.5" - }, - "resolutions": { - "@babel/preset-env": "7.13.8" + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "@stoplight/prism-cli": "^5.0.0", + "@vitejs/plugin-react": "^4.2.0", + "concurrently": "^9.0.0", + "typescript": "^5.3.0", + "vite": "^5.0.0" } } diff --git a/packages/pathfinder-web/example/pathfinder/core.json b/packages/pathfinder-web/example/pathfinder/core.json index c9abd1a..9d8d54a 100644 --- a/packages/pathfinder-web/example/pathfinder/core.json +++ b/packages/pathfinder-web/example/pathfinder/core.json @@ -1,3 +1,243 @@ { - "todo": "openapi 3.0.0 specification from stoplight" + "openapi": "3.0.0", + "info": { + "title": "Core API", + "version": "1.0.0" + }, + "servers": [ + { + "url": "http://127.0.0.1:3100", + "description": "Local mock" + }, + { + "url": "https://jsonplaceholder.typicode.com", + "description": "backend" + } + ], + "paths": { + "/bo/backoffice/api/v1/customers/search/{id}": { + "get": { + "operationId": "getCustomerById", + "summary": "Get customer by ID", + "tags": ["Customers"], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "page", + "in": "query", + "schema": { "type": "integer" } + }, + { + "name": "pageSize", + "in": "query", + "schema": { "type": "integer" } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "examples": { + "default": { + "value": { + "id": "2323-22", + "firstName": "Ivan", + "lastName": "Petrov", + "email": "ivan.petrov@example.com", + "phone": "+7 999 123-45-67", + "status": "active", + "page": 22, + "pageSize": 2 + } + }, + "happy": { + "value": { + "id": "2323-22", + "firstName": "Happy", + "lastName": "Path", + "email": "ivan.petrov@example.com", + "phone": "+7 999 123-45-67", + "status": "active", + "page": 22, + "pageSize": 2 + } + } + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "examples": { + "default": { + "value": { + "error": "Customer not found", + "code": 404 + } + } + } + } + } + } + } + } + }, + "/bo/backoffice/api/v1/customers": { + "get": { + "operationId": "getCustomers", + "summary": "List customers", + "tags": ["Customers"], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "examples": { + "default": { + "value": { + "items": [ + { + "id": "1111-11", + "firstName": "Ivan", + "lastName": "Petrov", + "email": "ivan.petrov@example.com", + "status": "active" + }, + { + "id": "2222-22", + "firstName": "Maria", + "lastName": "Sidorova", + "email": "maria.sidorova@example.com", + "status": "inactive" + } + ], + "total": 2, + "page": 1, + "pageSize": 20 + } + } + } + } + } + } + } + }, + "post": { + "operationId": "createCustomer", + "summary": "Create customer", + "tags": ["Customers"], + "responses": { + "201": { + "description": "Created", + "content": { + "application/json": { + "examples": { + "default": { + "value": { + "id": "3333-33", + "firstName": "Alexey", + "lastName": "Novikov", + "email": "alexey.novikov@example.com", + "status": "active" + } + } + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "examples": { + "default": { + "value": { + "error": "Validation failed", + "details": ["email is required", "firstName is required"] + } + } + } + } + } + } + } + } + }, + "/posts/{id}": { + "get": { + "operationId": "getPost", + "summary": "Get post by ID", + "tags": ["Posts"], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { "type": "integer" } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "examples": { + "default": { + "value": { + "id": 1, + "userId": 1, + "title": "sunt aut facere repellat provident", + "body": "quia et suscipit suscipit recusandae consequuntur" + } + } + } + } + } + } + } + } + }, + "/posts": { + "get": { + "operationId": "getPosts", + "summary": "List posts", + "tags": ["Posts"], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "examples": { + "default": { + "value": [ + { + "id": 1, + "userId": 1, + "title": "sunt aut facere repellat provident", + "body": "quia et suscipit suscipit recusandae consequuntur" + }, + { + "id": 2, + "userId": 1, + "title": "qui est esse", + "body": "est rerum tempore vitae sequi sint nihil" + } + ] + } + } + } + } + } + } + } + } + } } diff --git a/packages/pathfinder-web/example/pathfinder/provider.tsx b/packages/pathfinder-web/example/pathfinder/provider.tsx index fc6188e..67bc4b5 100644 --- a/packages/pathfinder-web/example/pathfinder/provider.tsx +++ b/packages/pathfinder-web/example/pathfinder/provider.tsx @@ -4,6 +4,7 @@ import { openApiResolver, storage, } from '@kode-frontend/pathfinder-web' +import coreSpec from './core.json' type Props = { children: React.ReactNode @@ -15,6 +16,7 @@ export const PathfinderProvider = ({ children }: Props) => { children={<>{children}} resolver={openApiResolver} storage={storage} + defaultSpecs={[coreSpec]} active={process.env.NODE_ENV !== 'production'} dataKey={'pathfinder-storage-key'} /> diff --git a/packages/pathfinder-web/example/try-form/try-form.tsx b/packages/pathfinder-web/example/try-form/try-form.tsx index 6ffe0b2..1aa475e 100644 --- a/packages/pathfinder-web/example/try-form/try-form.tsx +++ b/packages/pathfinder-web/example/try-form/try-form.tsx @@ -1,55 +1,128 @@ import * as React from 'react' import { useState } from 'react' +import coreSpec from '../pathfinder/core.json' -export const TryForm = () => { - const [value, setValue] = useState( - 'http://127.0.0.1:3100/bo/backoffice/api/v1/customers/search/2323-22?page=22&pageSize=2', - ) +type Endpoint = { + method: string + url: string + summary: string +} - const [result, setResult] = useState('') +const BASE_URL = coreSpec.servers[0].url - const onSubmitFetchHandler = async () => { - const data = await fetch(value, { - headers: { - 'Content-Type': 'application/json; charset=UTF-8', - Authorization: 'Bearer 12322', - }, - }) +function buildEndpoints(): Endpoint[] { + const endpoints: Endpoint[] = [] - const json = await data.json() + for (const [path, item] of Object.entries(coreSpec.paths)) { + for (const [method, operation] of Object.entries(item)) { + const url = BASE_URL + path.replace(/\{[^}]+\}/g, '1') + endpoints.push({ + method: method.toUpperCase(), + url, + summary: (operation as { summary: string }).summary, + }) + } + } - setResult(json) + return endpoints +} + +const ENDPOINTS = buildEndpoints() + +export const TryForm = () => { + const [result, setResult] = useState(null) + const [activeUrl, setActiveUrl] = useState('') + + const runFetch = async (method: string, url: string) => { + setActiveUrl(url) + setResult(null) + try { + const res = await fetch(url, { method }) + const json = await res.json() + setResult(json) + } catch (e) { + setResult({ error: String(e) }) + } } - const onSubmitXMLHttpRequestHandler = async () => { - function reqListener() { + const runXHR = (method: string, url: string) => { + setActiveUrl(url) + setResult(null) + const req = new XMLHttpRequest() + req.onload = function () { try { setResult(JSON.parse(this.responseText)) - } catch (e) { - console.error(onSubmitXMLHttpRequestHandler, e) + } catch { + setResult({ raw: this.responseText }) } } - - var oReq = new XMLHttpRequest() - oReq.onload = reqListener - oReq.open('get', value, true) - oReq.send() + req.onerror = () => setResult({ error: 'XHR error' }) + req.open(method, url, true) + req.send() } return ( - <> - setValue(e.target.value)} - /> - - - - -
{JSON.stringify(result, null, '\t')}
- +
+ + + + + + + + + + + + {ENDPOINTS.map((ep, i) => ( + + + + + + + + ))} + +
MethodURLSummaryFetchXHR
+ {ep.method} + + {ep.url} + {ep.summary} + + + +
+ + {result !== null && ( +
+          {JSON.stringify(result, null, 2)}
+        
+ )} +
) } + +const thStyle: React.CSSProperties = { + textAlign: 'left', + padding: '8px 12px', + borderBottom: '2px solid #ddd', +} + +const tdStyle: React.CSSProperties = { + padding: '6px 12px', + borderBottom: '1px solid #eee', +} diff --git a/packages/pathfinder-web/example/tsconfig.json b/packages/pathfinder-web/example/tsconfig.json index 1e2e4fd..b83bf3f 100644 --- a/packages/pathfinder-web/example/tsconfig.json +++ b/packages/pathfinder-web/example/tsconfig.json @@ -1,6 +1,7 @@ { "compilerOptions": { - "allowSyntheticDefaultImports": false, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, "target": "es5", "module": "commonjs", "jsx": "react", @@ -13,6 +14,11 @@ "preserveConstEnums": true, "sourceMap": true, "lib": ["es2015", "es2016", "dom"], - "types": ["node"] + "types": ["node"], + "resolveJsonModule": true, + "baseUrl": ".", + "paths": { + "@kode-frontend/pathfinder-web": ["../src"] + } } } diff --git a/packages/pathfinder-web/example/vite.config.ts b/packages/pathfinder-web/example/vite.config.ts new file mode 100644 index 0000000..f66c74f --- /dev/null +++ b/packages/pathfinder-web/example/vite.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import path from 'path' + +export default defineConfig({ + plugins: [react()], + server: { + port: 3000, + }, + resolve: { + alias: { + '@kode-frontend/pathfinder-web': path.resolve(__dirname, '../src'), + }, + }, +}) diff --git a/packages/pathfinder-web/package.json b/packages/pathfinder-web/package.json index 9c728e2..6edbf6b 100644 --- a/packages/pathfinder-web/package.json +++ b/packages/pathfinder-web/package.json @@ -16,7 +16,6 @@ "directory": "packages/pathfinder-web" }, "bugs": "https://github.com/appKODE/frontend-depend/issues", - "typings": "dist/index.d.ts", "files": [ "dist", "src" @@ -27,8 +26,7 @@ "scripts": { "dev": "tsup --watch", "build": "tsup", - "test": "tsup test --passWithNoTests", - "lint": "tsup lint", + "test": "jest", "release": "release-it", "prepare": "tsup", "size": "size-limit", @@ -49,11 +47,11 @@ }, "size-limit": [ { - "path": "dist/mytslib.cjs.production.min.js", + "path": "dist/index.js", "limit": "10 KB" }, { - "path": "dist/mytslib.esm.js", + "path": "dist/index.mjs", "limit": "10 KB" } ], @@ -86,6 +84,7 @@ "prettier": "2.6.2", "react-is": "^18.0.0", "release-it": "^14.14.2", + "rollup-plugin-postcss": "3.1.6", "size-limit": "^7.0.8", "tailwindcss": "^3.0.24", "ts-loader": "8.0.14", @@ -97,7 +96,6 @@ }, "dependencies": { "fetch-intercept": "^2.4.0", - "rollup-plugin-postcss": "3.1.6", "styled-components": "^5.3.5", "url-pattern": "^1.0.3" }, diff --git a/packages/pathfinder-web/src/app/pathfinder.tsx b/packages/pathfinder-web/src/app/pathfinder.tsx index b30bcd4..5555c3b 100644 --- a/packages/pathfinder-web/src/app/pathfinder.tsx +++ b/packages/pathfinder-web/src/app/pathfinder.tsx @@ -5,6 +5,7 @@ import React, { useMemo, useState, } from 'react' +import ReactDOM from 'react-dom' import styled, { css, ThemeProvider } from 'styled-components' import { theme } from '../shared/theme' @@ -18,12 +19,12 @@ import { import { addConsoleActivation } from '../features/hidden-activation' import { useRequestInterception } from '../processes' import { parseHeaders } from '../shared/lib' +import { usePanelResize, useDragPosition } from '../shared/hooks' import { DataResolver, DataStorage, EnvSpec, Header, - Schema, Spec, StrRecord, UrlSpec, @@ -31,74 +32,132 @@ import { import { createPathFinder } from '../lib' import { TUrlHeaders } from '../shared/ui/organisms/endpoints-list/types' import { getEndpointsHeaders } from '../shared/lib/helpers' +import { GLOBAL_ENV_MARKER } from '../constants' -type ButtonPosition = { - left?: string - right?: string - top?: string - bottom?: string -} +export type PanelPosition = 'bottom' | 'top' | 'left' | 'right' + +const POSITION_KEY = 'pathfinder-panel-position' type PathfinderProviderProps = { children: JSX.Element storage: DataStorage resolver: DataResolver - defaultSpecs?: Schema[] + defaultSpecs?: unknown[] dataKey: string active?: boolean - buttonPosition?: ButtonPosition } -const ActionWrapper = styled.div` +const ActionWrapper = styled.div<{ $x: number; $y: number; hidden?: boolean }>` position: fixed; - right: ${({ right }) => right || '9px'}; - bottom: ${({ bottom }) => bottom || '9px'}; - ${({ left }) => - left - ? css` - left: ${left}; - ` - : undefined} - ${({ top }) => - top - ? css` - top: ${top}; - ` - : undefined} + left: ${({ $x }) => $x}px; + top: ${({ $y }) => $y}px; z-index: 9999999; + touch-action: none; + user-select: none; display: ${({ hidden }) => (hidden ? 'none' : 'block')}; ` -const Container = styled.div` +const PanelShell = styled.div<{ $position: PanelPosition; $size: number }>` position: fixed; z-index: 9999; - left: 0; - top: 0; - width: 100%; - height: 100%; -` - -const Content = styled.div` - position: relative; - z-index: 25; - margin: 24px; - height: 90%; + background-color: ${({ theme }) => theme.colors.panel.bg}; + display: flex; + flex-direction: column; + overflow: hidden; * { box-sizing: border-box; font-family: sans-serif; } + + ${({ $position, $size }) => { + switch ($position) { + case 'bottom': + return css` + bottom: 0; + left: 0; + right: 0; + height: min(${$size}px, 85vh); + border-top: 1px solid; + padding-bottom: env(safe-area-inset-bottom, 0px); + ` + case 'top': + return css` + top: 0; + left: 0; + right: 0; + height: min(${$size}px, 85vh); + border-bottom: 1px solid; + ` + case 'left': + return css` + top: 0; + left: 0; + bottom: 0; + width: min(${$size}px, 85vw); + border-right: 1px solid; + ` + case 'right': + return css` + top: 0; + right: 0; + bottom: 0; + width: min(${$size}px, 85vw); + border-left: 1px solid; + ` + } + }} + border-color: ${({ theme }) => theme.colors.panel.border}; ` -const Overlay = styled.div` +const ResizeHandle = styled.div<{ $position: PanelPosition }>` position: absolute; - left: 0; - top: 0; - z-index: 20; - width: 100dvw; - height: 100dvh; - background-color: ${({ theme }) => theme.colors.decorative.dark.translucent}; - backdrop-filter: blur(3px); + background: ${({ theme }) => theme.colors.panel.handleBg}; + touch-action: none; + transition: background 0.15s; + z-index: 1; + flex-shrink: 0; + + &:hover { + background: ${({ theme }) => theme.colors.panel.handleHover}; + } + + ${({ $position }) => { + switch ($position) { + case 'bottom': + return css` + top: 0; + left: 0; + right: 0; + height: 4px; + cursor: ns-resize; + ` + case 'top': + return css` + bottom: 0; + left: 0; + right: 0; + height: 4px; + cursor: ns-resize; + ` + case 'left': + return css` + top: 0; + right: 0; + bottom: 0; + width: 4px; + cursor: ew-resize; + ` + case 'right': + return css` + top: 0; + left: 0; + bottom: 0; + width: 4px; + cursor: ew-resize; + ` + } + }} ` const toPanelUrl = (url: UrlSpec): TPanelUrl => ({ @@ -107,11 +166,13 @@ const toPanelUrl = (url: UrlSpec): TPanelUrl => ({ template: url.template, name: url.name, responses: url.responses, + tags: url.tags, }) const toPanelEnv = (env: EnvSpec): TPanelEnv => ({ id: env.id, name: env.name, + baseUrl: env.baseUrl, }) export const Pathfinder = ({ @@ -120,7 +181,6 @@ export const Pathfinder = ({ storage, dataKey, defaultSpecs, - buttonPosition, active, }: PathfinderProviderProps) => { const module = useMemo(() => { @@ -128,6 +188,7 @@ export const Pathfinder = ({ }, [resolver, storage]) const [spec, setSpec] = useState(module.getSpecs()) + const [defaultSpecIds, setDefaultSpecIds] = useState>(new Set()) const [globalHeaders, setGlobalHeaders] = useState>( module.getGlobalHeaders(), ) @@ -142,6 +203,69 @@ export const Pathfinder = ({ const [isOpen, setOpen] = useState(false) const [isActive, setActive] = useState(active) + const [panelPosition, setPanelPosition] = useState(() => { + if (typeof localStorage === 'undefined') return 'bottom' + const stored = localStorage.getItem(POSITION_KEY) + return (stored as PanelPosition) || 'bottom' + }) + + const [portalRoot] = useState(() => { + const el = document.createElement('div') + el.setAttribute('id', 'pathfinder-portal-root') + return el + }) + + useEffect(() => { + document.body.appendChild(portalRoot) + return () => { + document.body.removeChild(portalRoot) + } + }, [portalRoot]) + + const { size: panelSize, onPointerDown: onDragPointerDown } = + usePanelResize(panelPosition) + + const { + pos: buttonPos, + didDrag, + onPointerDown: onButtonPointerDown, + } = useDragPosition() + + useEffect(() => { + // Initialize safe-area-inset CSS variables for mobile support + const updateSafeAreaVars = () => { + const root = document.documentElement + const safeTop = parseInt( + window.getComputedStyle(root).getPropertyValue('safe-area-inset-top') || + '0', + ) + const safeBottom = parseInt( + window + .getComputedStyle(root) + .getPropertyValue('safe-area-inset-bottom') || '0', + ) + const safeLeft = parseInt( + window + .getComputedStyle(root) + .getPropertyValue('safe-area-inset-left') || '0', + ) + const safeRight = parseInt( + window + .getComputedStyle(root) + .getPropertyValue('safe-area-inset-right') || '0', + ) + + root.style.setProperty('--safe-area-inset-top', `${safeTop}px`) + root.style.setProperty('--safe-area-inset-bottom', `${safeBottom}px`) + root.style.setProperty('--safe-area-inset-left', `${safeLeft}px`) + root.style.setProperty('--safe-area-inset-right', `${safeRight}px`) + } + + updateSafeAreaVars() + window.addEventListener('resize', updateSafeAreaVars) + return () => window.removeEventListener('resize', updateSafeAreaVars) + }, []) + useEffect(() => { addConsoleActivation(setActive) }, [setActive]) @@ -152,6 +276,15 @@ export const Pathfinder = ({ setOpen(prevState => !prevState) }, []) + const handleToggleIfNotDragged = useCallback(() => { + if (!didDrag.current) handleToggle() + }, [handleToggle, didDrag]) + + const handleChangePosition = useCallback((pos: PanelPosition) => { + setPanelPosition(pos) + localStorage.setItem(POSITION_KEY, pos) + }, []) + const handleChangeDefaultEnv = (envId: string | null, specId: string) => { module.setGlobalEnv(envId, specId) } @@ -172,9 +305,14 @@ export const Pathfinder = ({ const endpoints = getEndpointsHeaders(getLocalEndpointHeader, specs) setEndpointsHeaders(endpoints) } + useEffect(() => { if (defaultSpecs) { loadSpec(defaultSpecs) + const specs = module.getSpecs() + if (specs) { + setDefaultSpecIds(new Set(specs.map(s => s.id))) + } } }, []) @@ -191,18 +329,40 @@ export const Pathfinder = ({ setSpec(module.getSpecs()) } + const handleRemoveSpec = (specId: string) => { + // Don't allow removing default specs + if (defaultSpecIds.has(specId)) { + return + } + const updatedSpecs = spec?.filter(s => s.id !== specId) || [] + setSpec(updatedSpecs) + const getLocalEndpointHeader = module.getEndpointHeaders + const endpoints = getEndpointsHeaders(getLocalEndpointHeader, updatedSpecs) + setEndpointsHeaders(endpoints) + } + const configs: TConfigs[] = [] const initialUrlValues: StrRecord = {} spec?.forEach(item => { + const globalEnv = module.getGlobalEnv()[item.id] + configs.push({ specId: item.id, config: { urlList: item?.urls.map(url => { const newUrl = toPanelUrl(url) - const envId = module.getUrlEnv(newUrl.id, item.id) - initialUrlValues[newUrl.id] = envId || '' + const storedEnvId = module.getUrlEnv(newUrl.id, item.id) + + // Если нет сохраненного значения или оно совпадает с глобальным, + // используем маркер GLOBAL + if (!storedEnvId || storedEnvId === globalEnv) { + initialUrlValues[newUrl.id] = GLOBAL_ENV_MARKER + } else { + initialUrlValues[newUrl.id] = storedEnvId + } + return newUrl }) || [], envList: item?.envs.map(toPanelEnv) || [], @@ -222,7 +382,6 @@ export const Pathfinder = ({ specId: string, ) => { const headers = parseHeaders(value) - module.setEndpointHeaders(id, headers, specId) setEndpointsHeaders(prev => ({ ...prev, [specId]: { [id]: value } })) } @@ -230,28 +389,44 @@ export const Pathfinder = ({ return (
{children}
-
) } diff --git a/packages/pathfinder-web/src/constants.ts b/packages/pathfinder-web/src/constants.ts new file mode 100644 index 0000000..9f723a2 --- /dev/null +++ b/packages/pathfinder-web/src/constants.ts @@ -0,0 +1,6 @@ +export const GLOBAL_ENV_MARKER = '__GLOBAL__' + +export const isGlobalEnv = (value: string) => value === GLOBAL_ENV_MARKER +export const isNoOverride = (value: string) => value === '' +export const isCustomEnv = (value: string) => + !isGlobalEnv(value) && !isNoOverride(value) diff --git a/packages/pathfinder-web/src/index.tsx b/packages/pathfinder-web/src/index.ts similarity index 88% rename from packages/pathfinder-web/src/index.tsx rename to packages/pathfinder-web/src/index.ts index c9fdb8c..8b8ff40 100644 --- a/packages/pathfinder-web/src/index.tsx +++ b/packages/pathfinder-web/src/index.ts @@ -7,4 +7,4 @@ export { openApiResolver } from './lib' export { storage } from './features/storage' -export { Schema } from './types' +export type { Schema } from './types' diff --git a/packages/pathfinder-web/src/lib.ts b/packages/pathfinder-web/src/lib.ts index 9004853..b5a8fab 100644 --- a/packages/pathfinder-web/src/lib.ts +++ b/packages/pathfinder-web/src/lib.ts @@ -116,7 +116,10 @@ export const createPathFinder: PathfinderBuilder = ({ }) storage.setSpecs(storageSpec) } catch (e) { - console.log(e) + console.error('Failed to parse specs:', e) + throw new Error( + `Failed to parse specifications: ${e instanceof Error ? e.message : 'Unknown error'}`, + ) } } diff --git a/packages/pathfinder-web/src/processes/request-interception/hooks.ts b/packages/pathfinder-web/src/processes/request-interception/hooks.ts index 61a2184..1ed720e 100644 --- a/packages/pathfinder-web/src/processes/request-interception/hooks.ts +++ b/packages/pathfinder-web/src/processes/request-interception/hooks.ts @@ -116,12 +116,15 @@ export function useRequestInterception( XMLHttpRequest.prototype.open = function ( method: string, url: string | URL, + async?: boolean, + user?: string, + password?: string, ) { const urlString = typeof url === 'string' ? url : url.toString() const specs = pathfinder.getSpecs() if (!specs) { - open.apply(this, arguments as any) + open.apply(this, [method, url, async ?? true, user, password]) return } const origin = typeof url === 'string' ? new URL(url).origin : url.origin @@ -129,7 +132,7 @@ export function useRequestInterception( const spec = specs.find(spec => spec.id === specId) if (!spec) { - open.apply(this, arguments as any) + open.apply(this, [method, url, async ?? true, user, password]) return } const basePath = findBaseApi(specs, origin) @@ -164,14 +167,12 @@ export function useRequestInterception( }) : urlString - arguments[1] = newUrl - const newHeaders = mergeGlobalAndEndpointHeaders({ globalHeaders: globalHeaders[specId] || [], endpointHeaders, }) - open.apply(this, arguments as any) + open.apply(this, [method, newUrl, async ?? true, user, password]) Object.getOwnPropertyNames(newHeaders).forEach(header => { if (newHeaders[header]) { diff --git a/packages/pathfinder-web/src/shared/hooks/index.ts b/packages/pathfinder-web/src/shared/hooks/index.ts index 015932c..9b9677c 100644 --- a/packages/pathfinder-web/src/shared/hooks/index.ts +++ b/packages/pathfinder-web/src/shared/hooks/index.ts @@ -1,2 +1,4 @@ export { useClickOutside } from './use-click-outside' export { useClipboard } from './use-clipboard' +export { usePanelResize } from './use-panel-resize' +export { useDragPosition } from './use-drag-position' diff --git a/packages/pathfinder-web/src/shared/hooks/use-drag-position.ts b/packages/pathfinder-web/src/shared/hooks/use-drag-position.ts new file mode 100644 index 0000000..13b9b27 --- /dev/null +++ b/packages/pathfinder-web/src/shared/hooks/use-drag-position.ts @@ -0,0 +1,124 @@ +import { useCallback, useRef, useState } from 'react' +import React from 'react' + +const BUTTON_POSITION_KEY = 'pathfinder-button-position' +const DRAG_THRESHOLD = 5 +const BUTTON_SIZE = 40 + +type Position = { x: number; y: number } + +const getInitialPosition = (): Position => { + if (typeof localStorage !== 'undefined') { + const stored = localStorage.getItem(BUTTON_POSITION_KEY) + if (stored) { + try { + const pos = JSON.parse(stored) as Position + // Ensure button is visible + return ensureInBounds(pos) + } catch { + // ignore + } + } + } + return { + x: window.innerWidth - BUTTON_SIZE - 9, + y: window.innerHeight - BUTTON_SIZE - 9, + } +} + +const ensureInBounds = (pos: Position): Position => { + const root = document.documentElement + const safeTop = parseInt( + getComputedStyle(root).getPropertyValue('--safe-area-inset-top') || '0', + ) + const safeBottom = parseInt( + getComputedStyle(root).getPropertyValue('--safe-area-inset-bottom') || '0', + ) + const safeLeft = parseInt( + getComputedStyle(root).getPropertyValue('--safe-area-inset-left') || '0', + ) + const safeRight = parseInt( + getComputedStyle(root).getPropertyValue('--safe-area-inset-right') || '0', + ) + + const minX = safeLeft + const maxX = window.innerWidth - BUTTON_SIZE - safeRight + const minY = safeTop + const maxY = window.innerHeight - BUTTON_SIZE - safeBottom + + return { + x: Math.max(minX, Math.min(maxX, pos.x)), + y: Math.max(minY, Math.min(maxY, pos.y)), + } +} + +export const useDragPosition = () => { + const [pos, setPos] = useState(() => getInitialPosition()) + const didDrag = useRef(false) + + const onPointerDown = useCallback( + (e: React.PointerEvent) => { + didDrag.current = false + const offsetX = e.clientX - pos.x + const offsetY = e.clientY - pos.y + const startPointerX = e.clientX + const startPointerY = e.clientY + + const clamp = (x: number, y: number): Position => { + const root = document.documentElement + const safeTop = parseInt( + getComputedStyle(root).getPropertyValue('--safe-area-inset-top') || + '0', + ) + const safeBottom = parseInt( + getComputedStyle(root).getPropertyValue('--safe-area-inset-bottom') || + '0', + ) + const safeLeft = parseInt( + getComputedStyle(root).getPropertyValue('--safe-area-inset-left') || + '0', + ) + const safeRight = parseInt( + getComputedStyle(root).getPropertyValue('--safe-area-inset-right') || + '0', + ) + + return { + x: Math.max( + safeLeft, + Math.min(window.innerWidth - BUTTON_SIZE - safeRight, x), + ), + y: Math.max( + safeTop, + Math.min(window.innerHeight - BUTTON_SIZE - safeBottom, y), + ), + } + } + + const onMove = (ev: PointerEvent) => { + const dx = ev.clientX - startPointerX + const dy = ev.clientY - startPointerY + if (Math.abs(dx) > DRAG_THRESHOLD || Math.abs(dy) > DRAG_THRESHOLD) { + didDrag.current = true + } + setPos(clamp(ev.clientX - offsetX, ev.clientY - offsetY)) + } + + const onUp = (ev: PointerEvent) => { + if (didDrag.current) { + const finalPos = clamp(ev.clientX - offsetX, ev.clientY - offsetY) + setPos(finalPos) + localStorage.setItem(BUTTON_POSITION_KEY, JSON.stringify(finalPos)) + } + document.removeEventListener('pointermove', onMove) + document.removeEventListener('pointerup', onUp) + } + + document.addEventListener('pointermove', onMove) + document.addEventListener('pointerup', onUp) + }, + [pos], + ) + + return { pos, didDrag, onPointerDown } +} diff --git a/packages/pathfinder-web/src/shared/hooks/use-panel-resize.ts b/packages/pathfinder-web/src/shared/hooks/use-panel-resize.ts new file mode 100644 index 0000000..1d17c62 --- /dev/null +++ b/packages/pathfinder-web/src/shared/hooks/use-panel-resize.ts @@ -0,0 +1,78 @@ +import React, { useCallback, useEffect, useState } from 'react' + +import { PanelPosition } from '../../app/pathfinder' + +const HEIGHT_KEY = 'pathfinder-panel-height' +const WIDTH_KEY = 'pathfinder-panel-width' +const MIN_SIZE = 200 + +const getInitialSize = (position: PanelPosition): number => { + const isVertical = position === 'bottom' || position === 'top' + const key = isVertical ? HEIGHT_KEY : WIDTH_KEY + + if (typeof localStorage === 'undefined') { + return isVertical + ? Math.round(window.innerHeight * 0.45) + : Math.min(360, Math.round(window.innerWidth * 0.8)) + } + + const stored = localStorage.getItem(key) + if (stored) return Number(stored) + + return isVertical + ? Math.round(window.innerHeight * 0.45) + : Math.min(360, Math.round(window.innerWidth * 0.8)) +} + +export const usePanelResize = (position: PanelPosition) => { + const [size, setSize] = useState(() => getInitialSize(position)) + + useEffect(() => { + setSize(getInitialSize(position)) + }, [position]) + + const onPointerDown = useCallback( + (e: React.PointerEvent) => { + e.preventDefault() + const startX = e.clientX + const startY = e.clientY + const startSize = size + const isVertical = position === 'bottom' || position === 'top' + + const calcDelta = (ev: PointerEvent): number => { + switch (position) { + case 'bottom': + return startY - ev.clientY + case 'top': + return ev.clientY - startY + case 'left': + return ev.clientX - startX + case 'right': + return startX - ev.clientX + } + } + + const clamp = (delta: number): number => { + const max = isVertical ? window.innerHeight : window.innerWidth + return Math.min(max - 48, Math.max(MIN_SIZE, startSize + delta)) + } + + const onMove = (ev: PointerEvent) => { + setSize(clamp(calcDelta(ev))) + } + + const onUp = (ev: PointerEvent) => { + const final = clamp(calcDelta(ev)) + localStorage.setItem(isVertical ? HEIGHT_KEY : WIDTH_KEY, String(final)) + document.removeEventListener('pointermove', onMove) + document.removeEventListener('pointerup', onUp) + } + + document.addEventListener('pointermove', onMove) + document.addEventListener('pointerup', onUp) + }, + [size, position], + ) + + return { size, onPointerDown } +} diff --git a/packages/pathfinder-web/src/shared/theme/theme.ts b/packages/pathfinder-web/src/shared/theme/theme.ts index d1169fd..095f586 100644 --- a/packages/pathfinder-web/src/shared/theme/theme.ts +++ b/packages/pathfinder-web/src/shared/theme/theme.ts @@ -22,6 +22,16 @@ export const theme = { blue: { normal: '#6699CC', translucent: 'rgba(102, 153, 204, 0.5)' }, red: { normal: '#E15A60', translucent: 'rgba(225, 90, 96, 0.5)' }, }, + panel: { + bg: '#141414', + surface: '#1f1f1f', + border: '#2e2e2e', + text: '#e0e0e0', + textMuted: '#888888', + accent: '#6699cc', + handleBg: 'rgba(255,255,255,0.12)', + handleHover: 'rgba(255,255,255,0.25)', + }, }, } diff --git a/packages/pathfinder-web/src/shared/ui/atoms/panel-button/panel-button.tsx b/packages/pathfinder-web/src/shared/ui/atoms/panel-button/panel-button.tsx index ce84e11..0905c60 100644 --- a/packages/pathfinder-web/src/shared/ui/atoms/panel-button/panel-button.tsx +++ b/packages/pathfinder-web/src/shared/ui/atoms/panel-button/panel-button.tsx @@ -1,23 +1,31 @@ import React from 'react' import styled from 'styled-components' +import { GearsIcon } from '../../icons' + const Button = styled.button` appearance: none; - width: 32px; - height: 32px; - background-color: transparent; - border-radius: 8px; - border-color: #fff5f5; - transition: 0.2s linear; + width: 40px; + height: 40px; + border-radius: 50%; + border: none; + background: #1f1f1f; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5); cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: + background 0.15s, + transform 0.15s; - svg path { - transition: 0.2s linear; + &:hover { + background: #2a2a2a; + transform: scale(1.05); } - &:focus, - &:hover { - background-color: #f5f4f4; + &:active { + transform: scale(0.95); } ` @@ -26,7 +34,11 @@ type Props = { } export const PanelButton = ({ onClick }: Props) => { - return + return ( + + ) } export default PanelButton diff --git a/packages/pathfinder-web/src/shared/ui/atoms/radio-input/types.ts b/packages/pathfinder-web/src/shared/ui/atoms/radio-input/types.ts index 4327b37..a507b9f 100644 --- a/packages/pathfinder-web/src/shared/ui/atoms/radio-input/types.ts +++ b/packages/pathfinder-web/src/shared/ui/atoms/radio-input/types.ts @@ -3,6 +3,7 @@ import { theme } from '../../../../shared/theme' export type TRadioOptions = { label: string value: string + description?: string } export type TDigitalColors = keyof typeof theme.colors.digital diff --git a/packages/pathfinder-web/src/shared/ui/atoms/scroll-wrapper/scroll-wrapper.tsx b/packages/pathfinder-web/src/shared/ui/atoms/scroll-wrapper/scroll-wrapper.tsx index 215480a..aa9c7cb 100644 --- a/packages/pathfinder-web/src/shared/ui/atoms/scroll-wrapper/scroll-wrapper.tsx +++ b/packages/pathfinder-web/src/shared/ui/atoms/scroll-wrapper/scroll-wrapper.tsx @@ -1,16 +1,17 @@ import React, { ReactNode } from 'react' import styled from 'styled-components' -const Wrapper = styled.div<{ $height?: string }>` +const Wrapper = styled.div` display: flex; flex-direction: column; align-items: stretch; justify-content: start; width: 100%; - height: 100%; - max-height: ${({ $height }) => ($height ? $height : '75vh')}; + flex: 1; + min-height: 0; padding: 8px; - overflow: scroll; + overflow: auto; + -webkit-overflow-scrolling: touch; scrollbar-width: thin; scrollbar-color: ${({ theme }) => theme.colors.decorative.medium.translucent} transparent; @@ -29,9 +30,8 @@ const Wrapper = styled.div<{ $height?: string }>` type Props = { children: ReactNode - height?: string } -export const ScrollWrapper = ({ children, height }: Props) => { - return {children} +export const ScrollWrapper = ({ children }: Props) => { + return {children} } diff --git a/packages/pathfinder-web/src/shared/ui/atoms/tab/tab.tsx b/packages/pathfinder-web/src/shared/ui/atoms/tab/tab.tsx index ac9aeea..3151b3a 100644 --- a/packages/pathfinder-web/src/shared/ui/atoms/tab/tab.tsx +++ b/packages/pathfinder-web/src/shared/ui/atoms/tab/tab.tsx @@ -7,6 +7,7 @@ type Props = { count?: number isSelected?: boolean onClick?: () => void + onClose?: (e: React.MouseEvent) => void } const StyledButton = styled.button<{ isSelected?: boolean }>` @@ -38,7 +39,30 @@ const StyledButton = styled.button<{ isSelected?: boolean }>` cursor: pointer; ` -export const Tab = ({ children, count, onClick, isSelected }: Props) => { +const CloseButton = styled.button` + background: none; + border: none; + padding: 0; + margin-left: 4px; + cursor: pointer; + font-size: 14px; + line-height: 1; + color: inherit; + opacity: 0.6; + transition: opacity 0.2s; + + &:hover { + opacity: 1; + } +` + +export const Tab = ({ + children, + count, + onClick, + isSelected, + onClose, +}: Props) => { return ( {children} @@ -47,6 +71,11 @@ export const Tab = ({ children, count, onClick, isSelected }: Props) => { )} + {onClose && ( + + x + + )} ) } diff --git a/packages/pathfinder-web/src/shared/ui/flows/search-input/atoms/index.ts b/packages/pathfinder-web/src/shared/ui/flows/search-input/atoms/index.ts index 0d7ee5d..d5bec5b 100644 --- a/packages/pathfinder-web/src/shared/ui/flows/search-input/atoms/index.ts +++ b/packages/pathfinder-web/src/shared/ui/flows/search-input/atoms/index.ts @@ -1 +1,2 @@ export { MethodSelect } from './method-select' +export { TagSelect } from './tag-select' diff --git a/packages/pathfinder-web/src/shared/ui/flows/search-input/atoms/method-select/method-select.tsx b/packages/pathfinder-web/src/shared/ui/flows/search-input/atoms/method-select/method-select.tsx index d884bc5..241cf5f 100644 --- a/packages/pathfinder-web/src/shared/ui/flows/search-input/atoms/method-select/method-select.tsx +++ b/packages/pathfinder-web/src/shared/ui/flows/search-input/atoms/method-select/method-select.tsx @@ -1,9 +1,10 @@ -import React, { useState } from 'react' +import React, { useState, useRef } from 'react' import styled, { css } from 'styled-components' import { ArrowDownIcon } from '../../../../icons' import { Method } from '../../../../atoms' import { UrlMethod } from '../../../../../../types' +import { useClickOutside } from '../../../../../hooks' const Wrapper = styled.div` position: relative; @@ -32,6 +33,7 @@ const StyledText = styled.p` padding: 0; margin: 0; white-space: nowrap; + color: ${({ theme }) => theme.colors.panel.text}; ` const IconWrap = styled.div<{ isDropped: boolean }>` @@ -46,7 +48,7 @@ const IconWrap = styled.div<{ isDropped: boolean }>` const DropDown = styled.div` position: absolute; - background-color: #f5f5f7; + background-color: ${({ theme }) => theme.colors.panel.surface}; top: 43px; border-radius: 0 0 6px 6px; left: -12px; @@ -54,9 +56,11 @@ const DropDown = styled.div` width: 124px; z-index: 10; box-shadow: 0 5px 20px 0 rgba(12, 32, 62, 0.15); + border: 1px solid ${({ theme }) => theme.colors.panel.border}; + border-top: none; ` -const DropDownItem = styled.div` +const DropDownItem = styled.div<{ $active?: boolean }>` height: 40px; padding: 0 10px; width: 100%; @@ -64,35 +68,45 @@ const DropDownItem = styled.div` align-items: center; transition: background-color 0.2s; cursor: pointer; + background-color: ${({ theme, $active }) => + $active ? (theme.colors.panel.accent ?? '#4f8ef7') : 'transparent'}; + color: ${({ theme, $active }) => + $active ? '#fff' : theme.colors.panel.text}; &:hover { - background-color: rgba(255, 255, 255, 0.7); + background-color: ${({ theme }) => theme.colors.panel.bg}; } ` type Props = { methods?: UrlMethod[] + value: UrlMethod | null onSelectMethod: (method: UrlMethod | null) => void } -export const MethodSelect = ({ methods, onSelectMethod }: Props) => { - const [selectedMethod, setSelectedMethod] = useState(null) +export const MethodSelect = ({ methods, value, onSelectMethod }: Props) => { const [isDropped, setIsDropped] = useState(false) + const wrapperRef = useRef(null) + + useClickOutside({ + ref: wrapperRef, + handler: () => setIsDropped(false), + flag: isDropped, + }) const onHandleSelect = (method: UrlMethod | null) => { onSelectMethod(method) - setSelectedMethod(method) setIsDropped(false) } return ( - + { setIsDropped(prevState => !prevState) }}> - {selectedMethod ?? 'All'} + {value ? : All} @@ -100,16 +114,16 @@ export const MethodSelect = ({ methods, onSelectMethod }: Props) => { {isDropped && ( { - onSelectMethod(null) - setSelectedMethod(null) - setIsDropped(false) - }}> + $active={value === null} + onClick={() => onHandleSelect(null)}> All {methods && - methods.map((method, index) => ( - onHandleSelect(method)} key={index}> + methods.map(method => ( + onHandleSelect(method)} + key={method}> ))} diff --git a/packages/pathfinder-web/src/shared/ui/flows/search-input/atoms/tag-select/index.ts b/packages/pathfinder-web/src/shared/ui/flows/search-input/atoms/tag-select/index.ts new file mode 100644 index 0000000..7fd6238 --- /dev/null +++ b/packages/pathfinder-web/src/shared/ui/flows/search-input/atoms/tag-select/index.ts @@ -0,0 +1 @@ +export { TagSelect } from './tag-select' diff --git a/packages/pathfinder-web/src/shared/ui/flows/search-input/atoms/tag-select/tag-select.tsx b/packages/pathfinder-web/src/shared/ui/flows/search-input/atoms/tag-select/tag-select.tsx new file mode 100644 index 0000000..ae89868 --- /dev/null +++ b/packages/pathfinder-web/src/shared/ui/flows/search-input/atoms/tag-select/tag-select.tsx @@ -0,0 +1,140 @@ +import React, { useState, useRef } from 'react' +import styled, { css } from 'styled-components' + +import { ArrowDownIcon } from '../../../../icons' +import { useClickOutside } from '../../../../../hooks' + +const Wrapper = styled.div` + position: relative; + max-width: 100px; + width: 100%; + padding: 8px 0; +` + +const TagButton = styled.button` + background-color: transparent; + outline: none; + padding: 0; + width: 100%; + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + border: none; + cursor: pointer; +` + +const StyledText = styled.p` + font-family: sans-serif; + font-size: 16px; + line-height: 20px; + padding: 0; + margin: 0; + white-space: nowrap; + color: ${({ theme }) => theme.colors.panel.text}; + text-overflow: ellipsis; + overflow: hidden; +` + +const IconWrap = styled.div<{ isDropped: boolean }>` + margin-left: 8px; + ${({ isDropped }) => + isDropped && + css` + transform: rotate(180deg); + `} + transition: transform 0.3s ease; +` + +const DropDown = styled.div` + position: absolute; + background-color: ${({ theme }) => theme.colors.panel.surface}; + top: 43px; + border-radius: 0 0 6px 6px; + left: -12px; + min-height: 50px; + max-height: 200px; + width: 124px; + z-index: 10; + box-shadow: 0 5px 20px 0 rgba(12, 32, 62, 0.15); + border: 1px solid ${({ theme }) => theme.colors.panel.border}; + border-top: none; + overflow-y: auto; +` + +const DropDownItem = styled.div<{ $active?: boolean }>` + height: 40px; + padding: 0 10px; + width: 100%; + display: flex; + align-items: center; + transition: background-color 0.2s; + cursor: pointer; + background-color: ${({ theme, $active }) => + $active ? (theme.colors.panel.accent ?? '#4f8ef7') : 'transparent'}; + color: ${({ theme, $active }) => + $active ? '#fff' : theme.colors.panel.text}; + font-size: 13px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + &:hover { + background-color: ${({ theme }) => theme.colors.panel.bg}; + } +` + +type Props = { + tags?: string[] + value: string | null + onSelectTag: (tag: string | null) => void +} + +export const TagSelect = ({ tags, value, onSelectTag }: Props) => { + const [isDropped, setIsDropped] = useState(false) + const wrapperRef = useRef(null) + + useClickOutside({ + ref: wrapperRef, + handler: () => setIsDropped(false), + flag: isDropped, + }) + + const onHandleSelect = (tag: string | null) => { + onSelectTag(tag) + setIsDropped(false) + } + + return ( + + { + setIsDropped(prevState => !prevState) + }}> + {value ? value : 'Tag'} + + + + + {isDropped && ( + + onHandleSelect(null)}> + All + + {tags && + tags.map(tag => ( + onHandleSelect(tag)} + key={tag}> + {tag} + + ))} + + )} + + ) +} diff --git a/packages/pathfinder-web/src/shared/ui/flows/search-input/molecules/search-input/search-input.tsx b/packages/pathfinder-web/src/shared/ui/flows/search-input/molecules/search-input/search-input.tsx index d425e14..bc49a8d 100644 --- a/packages/pathfinder-web/src/shared/ui/flows/search-input/molecules/search-input/search-input.tsx +++ b/packages/pathfinder-web/src/shared/ui/flows/search-input/molecules/search-input/search-input.tsx @@ -3,14 +3,14 @@ import styled from 'styled-components' import { UrlMethod } from '../../../../../../types' import { BoldCloseIcon } from '../../../../icons' -import { MethodSelect } from '../../atoms' +import { MethodSelect, TagSelect } from '../../atoms' const Wrapper = styled.div` display: flex; position: relative; flex-direction: row; height: 48px; - background-color: ${({}) => '#F5F5F7'}; + background-color: ${({ theme }) => theme.colors.panel.surface}; border-radius: 8px; padding: 0 12px; margin: 8px; @@ -21,11 +21,16 @@ const StyledInput = styled.input` background-color: transparent; border: none; outline: none; - width: 300px; - margin-right: 50px; + flex: 1; + min-width: 100px; height: 25px; font-size: 16px; user-select: none; + color: ${({ theme }) => theme.colors.panel.text}; + + &::placeholder { + color: ${({ theme }) => theme.colors.panel.textMuted}; + } ` const CloseIconWrap = styled.div` @@ -34,10 +39,15 @@ const CloseIconWrap = styled.div` height: 24px; right: 10px; cursor: pointer; + color: ${({ theme }) => theme.colors.panel.textMuted}; + + &:hover { + color: ${({ theme }) => theme.colors.panel.text}; + } ` const Divider = styled.div` - background-color: #8e8e90; + background-color: ${({ theme }) => theme.colors.panel.border}; width: 1px; height: 100%; margin: 0 8px; @@ -47,22 +57,44 @@ const Divider = styled.div` type Props = { value: string methods: UrlMethod[] + selectedMethod: UrlMethod | null + tags?: string[] + selectedTag: string | null onClearHandler: () => void onSelectMethod: (method: UrlMethod | null) => void + onSelectTag: (tag: string | null) => void onHandleChange: (value: string) => void } export const SearchInput = ({ value, methods, + selectedMethod, + tags, + selectedTag, onClearHandler, onSelectMethod, + onSelectTag, onHandleChange, }: Props) => { return ( - + + {tags && tags.length > 0 && ( + <> + + + + )} onHandleChange(e.target.value)} value={value || ''} diff --git a/packages/pathfinder-web/src/shared/ui/icons/gears-icon.tsx b/packages/pathfinder-web/src/shared/ui/icons/gears-icon.tsx index 586625f..a8f597d 100644 --- a/packages/pathfinder-web/src/shared/ui/icons/gears-icon.tsx +++ b/packages/pathfinder-web/src/shared/ui/icons/gears-icon.tsx @@ -2,15 +2,17 @@ import React from 'react' type Props = { size?: number + fill?: string } -export const GearsIcon = ({ size = 16 }: Props) => { +export const GearsIcon = ({ size = 16, fill = 'currentColor' }: Props) => { return ( + height={size} + fill={fill}> diff --git a/packages/pathfinder-web/src/shared/ui/icons/index.ts b/packages/pathfinder-web/src/shared/ui/icons/index.ts index 47bf2e9..882f2ce 100644 --- a/packages/pathfinder-web/src/shared/ui/icons/index.ts +++ b/packages/pathfinder-web/src/shared/ui/icons/index.ts @@ -6,3 +6,9 @@ export { SearchIcon } from './search-icon' export { ThinCloseIcon } from './thin-close-icon' export { ArrowDownIcon } from './arrow-down-icon' export { BoldCloseIcon } from './bold-close-icon' +export { + PanelBottomIcon, + PanelTopIcon, + PanelLeftIcon, + PanelRightIcon, +} from './panel-position-icons' diff --git a/packages/pathfinder-web/src/shared/ui/icons/panel-position-icons.tsx b/packages/pathfinder-web/src/shared/ui/icons/panel-position-icons.tsx new file mode 100644 index 0000000..08f70b4 --- /dev/null +++ b/packages/pathfinder-web/src/shared/ui/icons/panel-position-icons.tsx @@ -0,0 +1,89 @@ +import React from 'react' + +type Props = { + size?: number + fill?: string +} + +export const PanelBottomIcon = ({ + size = 16, + fill = 'currentColor', +}: Props) => ( + + + + +) + +export const PanelTopIcon = ({ size = 16, fill = 'currentColor' }: Props) => ( + + + + +) + +export const PanelLeftIcon = ({ size = 16, fill = 'currentColor' }: Props) => ( + + + + +) + +export const PanelRightIcon = ({ size = 16, fill = 'currentColor' }: Props) => ( + + + + +) diff --git a/packages/pathfinder-web/src/shared/ui/molecules/env-select/env-select.tsx b/packages/pathfinder-web/src/shared/ui/molecules/env-select/env-select.tsx new file mode 100644 index 0000000..5dc17b0 --- /dev/null +++ b/packages/pathfinder-web/src/shared/ui/molecules/env-select/env-select.tsx @@ -0,0 +1,78 @@ +import React from 'react' +import styled from 'styled-components' +import { GLOBAL_ENV_MARKER } from '../../../../constants' + +type TOption = { + label: string + value: string + description?: string +} + +type Props = { + id: string + value: string + options: TOption[] + onChange: (id: string, value: string) => void + activeBaseUrl?: string +} + +const Select = styled.select<{ $isActive: boolean }>` + font-size: 11px; + padding: 4px 8px; + border-radius: 6px; + border: 1px solid + ${({ theme, $isActive }) => + $isActive ? '#f5a623' : theme.colors.panel.border}; + background: ${({ theme }) => theme.colors.panel.bg}; + color: ${({ theme, $isActive }) => + $isActive ? '#f5a623' : theme.colors.panel.text}; + cursor: pointer; + outline: none; + transition: border-color 0.15s; + + &:hover { + border-color: ${({ theme }) => theme.colors.panel.accent}; + } +` + +const Container = styled.div` + display: flex; + flex-direction: column; +` + +const Description = styled.span` + font-size: 10px; + color: ${({ theme }) => theme.colors.panel.textMuted}; + font-family: monospace; + margin-top: 2px; +` + +export const EnvSelect = ({ + id, + value, + options, + onChange, + activeBaseUrl, +}: Props) => { + const selectedOption = options.find(opt => opt.value === value) + + return ( + + + {(activeBaseUrl || selectedOption?.description) && ( + + {activeBaseUrl || selectedOption?.description} + + )} + + ) +} diff --git a/packages/pathfinder-web/src/shared/ui/molecules/env-select/index.ts b/packages/pathfinder-web/src/shared/ui/molecules/env-select/index.ts new file mode 100644 index 0000000..9e88850 --- /dev/null +++ b/packages/pathfinder-web/src/shared/ui/molecules/env-select/index.ts @@ -0,0 +1 @@ +export { EnvSelect } from './env-select' diff --git a/packages/pathfinder-web/src/shared/ui/molecules/header/header.tsx b/packages/pathfinder-web/src/shared/ui/molecules/header/header.tsx index 9c938b9..81686b8 100644 --- a/packages/pathfinder-web/src/shared/ui/molecules/header/header.tsx +++ b/packages/pathfinder-web/src/shared/ui/molecules/header/header.tsx @@ -1,50 +1,145 @@ import React, { memo, MouseEventHandler, ReactNode } from 'react' import styled, { useTheme } from 'styled-components' +import { PanelPosition } from '../../../../app/pathfinder' import { CloseIcon } from '../../icons' -import { Button } from '../../atoms' +import { + PanelBottomIcon, + PanelLeftIcon, + PanelRightIcon, + PanelTopIcon, +} from '../../icons/panel-position-icons' const Wrapper = styled.div` display: flex; - padding: 18px 8px; + padding: 10px 12px; align-items: center; - justify-content: center; + justify-content: space-between; + gap: 8px; + flex-shrink: 0; + border-bottom: 1px solid ${({ theme }) => theme.colors.panel.border}; ` const Title = styled.h1` flex: 1 1 auto; margin: 0; + font-size: 14px; + font-weight: 600; + letter-spacing: 0.5px; + color: ${({ theme }) => theme.colors.panel.text}; ` -const ActionWrapper = styled.div` - flex: 0 0 auto; - align-self: flex-start; - margin-top: 0; - margin-right: 0; +const Controls = styled.div` + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; ` +const PositionButton = styled.button<{ $active: boolean }>` + appearance: none; + border: none; + background: ${({ $active, theme }) => + $active ? theme.colors.panel.surface : 'transparent'}; + border-radius: 6px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + min-width: 44px; + min-height: 44px; + transition: background 0.15s; + + &:hover { + background: ${({ theme }) => theme.colors.panel.surface}; + } + + @media (min-width: 480px) { + min-width: 32px; + min-height: 32px; + } +` + +const CloseButton = styled.button` + appearance: none; + border: none; + background: transparent; + border-radius: 6px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + min-width: 44px; + min-height: 44px; + transition: background 0.15s; + margin-left: 4px; + + &:hover { + background: ${({ theme }) => theme.colors.panel.surface}; + } + + @media (min-width: 480px) { + min-width: 32px; + min-height: 32px; + } +` + +type PositionEntry = { + value: PanelPosition + Icon: React.ComponentType<{ size?: number; fill?: string }> +} + +const POSITIONS: PositionEntry[] = [ + { value: 'bottom', Icon: PanelBottomIcon }, + { value: 'top', Icon: PanelTopIcon }, + { value: 'left', Icon: PanelLeftIcon }, + { value: 'right', Icon: PanelRightIcon }, +] + type Props = { children: ReactNode onClose: MouseEventHandler + position: PanelPosition + onChangePosition: (pos: PanelPosition) => void } -export const Header = memo(({ children, onClose }: Props) => { - const theme = useTheme() - - return ( - - {children} - - - - - ) -}) +export const Header = memo( + ({ children, onClose, position, onChangePosition }: Props) => { + const theme = useTheme() + + return ( + + {children} + + {POSITIONS.map(({ value, Icon }) => ( + onChangePosition(value)} + title={`Dock to ${value}`}> + + + ))} + + + + + + ) + }, +) export default Header diff --git a/packages/pathfinder-web/src/shared/ui/molecules/index.ts b/packages/pathfinder-web/src/shared/ui/molecules/index.ts index e217f75..39a3fc1 100644 --- a/packages/pathfinder-web/src/shared/ui/molecules/index.ts +++ b/packages/pathfinder-web/src/shared/ui/molecules/index.ts @@ -2,3 +2,4 @@ export { Header } from './header' export { RadioGroup } from './radio-group' export { UploadSpec } from './upload-spec' export { Tabs } from './tabs' +export { EnvSelect } from './env-select' diff --git a/packages/pathfinder-web/src/shared/ui/molecules/key-value-field/key-value-field.tsx b/packages/pathfinder-web/src/shared/ui/molecules/key-value-field/key-value-field.tsx index 3c504d8..a01b489 100644 --- a/packages/pathfinder-web/src/shared/ui/molecules/key-value-field/key-value-field.tsx +++ b/packages/pathfinder-web/src/shared/ui/molecules/key-value-field/key-value-field.tsx @@ -2,14 +2,18 @@ import React, { useRef, useState } from 'react' import styled from 'styled-components' import { useClickOutside } from '../../../hooks' -import { Button, Box, Badge } from '../../atoms' -import { RadioGroup } from '../radio-group' +import { Button, Box } from '../../atoms' type TResponse = { code: string examples: string[] } +type THeader = { + key: string + value: string +} + type Props = { title: string id: string @@ -19,6 +23,27 @@ type Props = { onApply: (value: string) => void } +const parseHeadersString = (str?: string): THeader[] => { + if (!str || !str.trim()) return [] + return str + .split('\n') + .map(line => { + const idx = line.indexOf(': ') + if (idx === -1) return null + return { + key: line.slice(0, idx).trim(), + value: line.slice(idx + 2).trim(), + } + }) + .filter((h): h is THeader => h !== null && h.key !== '') +} + +const serializeHeaders = (headers: THeader[]): string => + headers + .filter(h => h.key.trim() !== '') + .map(h => `${h.key}: ${h.value}`) + .join('\n') + const BackGround = styled.div<{ isVisible: boolean }>` position: fixed; height: 100dvh; @@ -26,14 +51,17 @@ const BackGround = styled.div<{ isVisible: boolean }>` left: 0; top: 0; z-index: 1; - background-color: #1c1c1e; - opacity: 0.7; + background-color: rgba(0, 0, 0, 0.7); display: ${({ isVisible }) => (isVisible ? 'flex' : 'none')}; ` const Wrapper = styled.div` position: relative; + display: flex; + align-items: center; + gap: 4px; ` + const DropArea = styled.div` position: fixed; top: 50%; @@ -41,33 +69,115 @@ const DropArea = styled.div` z-index: 100; padding: 16px; box-shadow: 3px 3px 5px rgb(0 0 0 / 21%); - background-color: rgb(255 255 255); + background-color: ${({ theme }) => theme.colors.panel.bg}; + color: ${({ theme }) => theme.colors.panel.text}; transform: translate(-50%, -50%); max-width: 80dvw; max-height: 80dvh; - min-height: 40dvh; - min-width: 40dvh; + min-width: 360px; overflow: auto; border-radius: 16px; + border: 1px solid ${({ theme }) => theme.colors.panel.border}; ` -const RadioWrapper = styled.div` +const ModalTitle = styled.div` + font-size: 16px; + font-weight: 600; + margin-bottom: 12px; + color: ${({ theme }) => theme.colors.panel.text}; +` + +const HeaderRow = styled.div` display: flex; - flex-direction: column; - align-items: flex-start; - width: 100%; - gap: 8px; - margin: 8px 0px; + gap: 6px; + align-items: center; + margin-bottom: 6px; +` + +const KeyInput = styled.input` + flex: 1; + padding: 5px 8px; + border: 1px solid ${({ theme }) => theme.colors.panel.border}; + border-radius: 6px; + background: ${({ theme }) => theme.colors.panel.surface}; + color: ${({ theme }) => theme.colors.panel.text}; + font-size: 12px; + font-family: monospace; + outline: none; +` + +const ValueInput = styled(KeyInput)` + flex: 2; +` + +const DeleteBtn = styled.button` + background: none; + border: none; + cursor: pointer; + color: ${({ theme }) => theme.colors.panel.textMuted}; + font-size: 14px; + padding: 2px 4px; + border-radius: 4px; + &:hover { + color: ${({ theme }) => theme.colors.panel.text}; + } ` -const TextArea = styled.textarea` +const AddBtn = styled.button` + background: none; + border: 1px dashed ${({ theme }) => theme.colors.panel.border}; + border-radius: 6px; + color: ${({ theme }) => theme.colors.panel.textMuted}; + font-size: 12px; + padding: 5px 12px; + cursor: pointer; width: 100%; - min-height: 300px; - min-width: 200px; - margin: 8px 0; - padding: 8px; - border: 1px solid black; - border-radius: 8px; + margin-bottom: 12px; + &:hover { + color: ${({ theme }) => theme.colors.panel.text}; + } +` + +const QuickFillSection = styled.div` + margin-bottom: 12px; +` + +const QuickFillLabel = styled.div` + font-size: 11px; + color: ${({ theme }) => theme.colors.panel.textMuted}; + margin-bottom: 6px; +` + +const QuickFillRow = styled.div` + display: flex; + flex-wrap: wrap; + gap: 4px; + align-items: center; +` + +const QuickFillBtn = styled.button<{ $active?: boolean }>` + padding: 3px 8px; + border-radius: 6px; + border: 1px solid + ${({ theme, $active }) => + $active + ? (theme.colors.panel.accent ?? '#4f8ef7') + : theme.colors.panel.border}; + background: ${({ theme, $active }) => + $active ? (theme.colors.panel.accent ?? '#4f8ef7') : 'none'}; + color: ${({ theme, $active }) => + $active ? '#fff' : theme.colors.panel.text}; + font-size: 11px; + cursor: pointer; +` + +const ExampleSelect = styled.select` + font-size: 11px; + padding: 3px 6px; + border-radius: 6px; + border: 1px solid ${({ theme }) => theme.colors.panel.border}; + background: ${({ theme }) => theme.colors.panel.bg}; + color: ${({ theme }) => theme.colors.panel.text}; ` const ButtonWrapper = styled.div` @@ -79,19 +189,30 @@ const ButtonWrapper = styled.div` } ` -const headersString = (value: string, example?: string) => - `Prefer: code=${value} ${example ? `, example=${example}` : ''}` +const CountBadge = styled.span` + display: inline-flex; + align-items: center; + justify-content: center; + background: ${({ theme }) => theme.colors.digital?.green ?? '#22c55e'}; + color: #fff; + border-radius: 10px; + font-size: 10px; + font-weight: 600; + padding: 1px 6px; + min-width: 18px; +` export const KeyValueField = ({ initialValue, responses, id, title, - placeholder, onApply, }: Props) => { const [isOpen, setIsOpen] = useState(false) - const [value, setValue] = useState(initialValue) + const [headers, setHeaders] = useState(() => + parseHeadersString(initialValue), + ) const [currCode, setCurrCode] = useState(undefined) const wrapperRef = useRef(null) @@ -103,72 +224,113 @@ export const KeyValueField = ({ flag: isOpen, }) - const onApplyHandler = () => { - onApply(value) - setIsOpen(false) + const handleOpen = () => { + setHeaders(parseHeadersString(initialValue)) + setCurrCode(undefined) + setIsOpen(true) } - const renderExamplesBlock = (responses: TResponse[]) => { - return ( - <> - {responses.map(response => { - const selectOption = response.examples.map(item => ( - - )) - const preferCode = response.code - return ( - - { - setValue(headersString(value)) - setCurrCode(preferCode) - }} - value={currCode} - slot={ - response.examples.length > 0 && - currCode === preferCode && ( - <> - - - ) - } - /> - - ) - })} - + + const updateHeader = (index: number, field: 'key' | 'value', val: string) => { + setHeaders(prev => + prev.map((h, i) => (i === index ? { ...h, [field]: val } : h)), ) } + const removeHeader = (index: number) => { + setHeaders(prev => prev.filter((_, i) => i !== index)) + } + + const addHeader = () => { + setHeaders(prev => [...prev, { key: '', value: '' }]) + } + + const setPreferHeader = (code: string, example?: string) => { + const preferValue = example + ? `code=${code}, example=${example}` + : `code=${code}` + setHeaders(prev => { + const idx = prev.findIndex(h => h.key.toLowerCase() === 'prefer') + if (idx !== -1) { + return prev.map((h, i) => + i === idx ? { ...h, value: preferValue } : h, + ) + } + return [...prev, { key: 'Prefer', value: preferValue }] + }) + } + + const onApplyHandler = () => { + onApply(serializeHeaders(headers)) + setIsOpen(false) + } + + const activeCount = headers.filter(h => h.key.trim() !== '').length + return ( - - {value && } + {isOpen && ( -
Headers definition
- {responses && renderExamplesBlock(responses)} -