Compare commits

...

23 Commits

Author SHA1 Message Date
Ivan
ff18a22b14 B: fix build
Some checks failed
Backend CI / build (3.12) (push) Has been cancelled
Frontend CI / build (22.x) (push) Has been cancelled
2025-03-10 16:19:51 +03:00
Ivan
1c16c19465 update dependencies 2025-03-10 16:12:35 +03:00
Ivan
99507a9b8d F: Improve styling pt2 2025-03-10 16:01:40 +03:00
Ivan
a85112e265 F: Improve styling 2025-03-09 21:57:21 +03:00
Ivan
8d44919303 R: Remove redundant containers 2025-03-07 22:04:56 +03:00
Ivan
38149d7168 F: Refactor z-index and stacking 2025-03-07 20:38:02 +03:00
Ivan
fce995f27d F: Improve layout by reworking dropdowns 2025-03-07 02:45:37 +03:00
Ivan
ad1d1a47d6 T: Add logout test 2025-03-06 22:26:08 +03:00
Ivan
28b147ed28 R: Fix build action sequence 2025-03-06 21:33:51 +03:00
Ivan
39938ff9dd R: Add e2e tests for login 2025-03-06 21:09:09 +03:00
Ivan
b9e5331d91 R: Refactor table usage 2025-03-05 00:57:03 +03:00
Ivan
28c84d90ba M: Update error msg text 2025-03-05 00:16:02 +03:00
Ivan
b63767a401 R: useContext -> use hook 2025-03-05 00:09:00 +03:00
Ivan
bfcaeb1ac5 R: Refactor constituents search 2025-03-04 14:30:08 +03:00
Ivan
181200fcc5 M: Add font perloading and change swap behavior 2025-03-04 12:41:37 +03:00
Ivan
87b8a8d224 R: Add container class for further queries 2025-03-04 12:23:23 +03:00
Ivan
d5386619e5 B: Fix buttons layout 2025-03-04 12:22:39 +03:00
Ivan
e293fdc7ee R: Simplify eslint integration 2025-03-03 12:43:43 +03:00
Ivan
c3cc8f5d89 fix 2025-03-02 21:21:34 +03:00
Ivan
338cb9543c M: Setup testing mocks 2025-03-02 20:27:02 +03:00
Ivan
03578aa0b5 B: Improve typechecks and fix backend API data 2025-03-02 19:07:12 +03:00
Ivan
7dc738d87c R: Improve test runner system and vite config 2025-03-01 22:00:19 +03:00
Ivan
a47cebf456 M: Fix height for tooltip 2025-02-27 14:00:46 +03:00
226 changed files with 2391 additions and 2300 deletions

View File

@ -35,13 +35,14 @@ jobs:
- name: Build - name: Build
run: | run: |
npm install -g typescript vite jest playwright npm install -g typescript vite jest playwright
npx playwright install --with-deps
npm ci npm ci
npx playwright install --with-deps
npm run build --if-present npm run build --if-present
- name: Run CI - name: Run CI
run: | run: |
npm run lint npm run lint
npm test npm test
npm run test:e2e
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
with: with:

29
.vscode/launch.json vendored
View File

@ -28,6 +28,14 @@
"script": "${workspaceFolder}/scripts/dev/RunTests.ps1", "script": "${workspaceFolder}/scripts/dev/RunTests.ps1",
"args": [] "args": []
}, },
{
// Run end-to-end tests
"name": "Test E2E",
"type": "PowerShell",
"request": "launch",
"script": "${workspaceFolder}/scripts/dev/RunE2ETests.ps1",
"args": []
},
{ {
// Run Tests for backend for current file in Debug mode // Run Tests for backend for current file in Debug mode
"name": "BE-DebugTestFile", "name": "BE-DebugTestFile",
@ -38,9 +46,28 @@
"args": ["test", "-k", "${fileBasenameNoExtension}"], "args": ["test", "-k", "${fileBasenameNoExtension}"],
"django": true "django": true
}, },
{
// Run Tests for frontend for current file in Debug mode
"name": "FE-DebugTestFile",
"type": "node",
"request": "launch",
"cwd": "${workspaceFolder}/rsconcept/frontend",
"runtimeExecutable": "npx",
"runtimeArgs": [
"playwright",
"test",
"${fileBasename}",
"--headed",
"--project=Desktop Chrome"
],
"env": {
"PWDEBUG": "1"
},
"console": "integratedTerminal"
},
{ {
// Run Tests for frontned in Debug mode // Run Tests for frontned in Debug mode
"name": "FE-DebugTestAll", "name": "Jest DebugAll",
"type": "node", "type": "node",
"request": "launch", "request": "launch",
"runtimeExecutable": "${workspaceFolder}/rsconcept/frontend/node_modules/.bin/jest", "runtimeExecutable": "${workspaceFolder}/rsconcept/frontend/node_modules/.bin/jest",

View File

@ -4,7 +4,6 @@
".pytest_cache/": true ".pytest_cache/": true
}, },
"typescript.tsdk": "rsconcept/frontend/node_modules/typescript/lib", "typescript.tsdk": "rsconcept/frontend/node_modules/typescript/lib",
"eslint.workingDirectories": ["rsconcept/frontend"],
"isort.args": [ "isort.args": [
"--line-length", "--line-length",
"100", "100",
@ -15,6 +14,13 @@
"--project", "--project",
"shared" "shared"
], ],
"eslint.workingDirectories": [
{
"directory": "rsconcept/frontend",
"overrideConfigFile": "rsconcept/frontend/eslint.config.js",
"changeProcessCWD": true
}
],
"autopep8.args": [ "autopep8.args": [
"--max-line-length", "--max-line-length",
"120", "120",

View File

@ -66,6 +66,7 @@ This readme file is used mostly to document project dependencies and conventions
- eslint-plugin-simple-import-sort - eslint-plugin-simple-import-sort
- eslint-plugin-react-hooks - eslint-plugin-react-hooks
- eslint-plugin-tsdoc - eslint-plugin-tsdoc
- eslint-plugin-playwright
- babel-plugin-react-compiler - babel-plugin-react-compiler
- vite - vite
- jest - jest

View File

@ -203,6 +203,7 @@ class OperationSchemaSerializer(serializers.ModelSerializer):
def to_representation(self, instance: LibraryItem): def to_representation(self, instance: LibraryItem):
result = LibraryItemDetailsSerializer(instance).data result = LibraryItemDetailsSerializer(instance).data
del result['versions']
oss = OperationSchema(instance) oss = OperationSchema(instance)
result['items'] = [] result['items'] = []
for operation in oss.operations().order_by('pk'): for operation in oss.operations().order_by('pk'):

View File

@ -13,10 +13,10 @@ from .basics import (
) )
from .data_access import ( from .data_access import (
CstCreateSerializer, CstCreateSerializer,
CstInfoSerializer,
CstListSerializer, CstListSerializer,
CstMoveSerializer, CstMoveSerializer,
CstRenameSerializer, CstRenameSerializer,
CstSerializer,
CstSubstituteSerializer, CstSubstituteSerializer,
CstTargetSerializer, CstTargetSerializer,
CstUpdateSerializer, CstUpdateSerializer,

View File

@ -30,13 +30,12 @@ class CstBaseSerializer(serializers.ModelSerializer):
read_only_fields = ('id',) read_only_fields = ('id',)
class CstSerializer(serializers.ModelSerializer): class CstInfoSerializer(serializers.ModelSerializer):
''' Serializer: Constituenta data. ''' ''' Serializer: Constituenta public information. '''
class Meta: class Meta:
''' serializer metadata. ''' ''' serializer metadata. '''
model = Constituenta model = Constituenta
exclude = ('order',) exclude = ('order', 'schema')
read_only_fields = ('id', 'schema', 'alias', 'cst_type', 'definition_resolved', 'term_resolved')
class CstUpdateSerializer(serializers.Serializer): class CstUpdateSerializer(serializers.Serializer):
@ -100,7 +99,7 @@ class RSFormSerializer(serializers.ModelSerializer):
child=serializers.IntegerField() child=serializers.IntegerField()
) )
items = serializers.ListField( items = serializers.ListField(
child=CstSerializer() child=CstInfoSerializer()
) )
inheritance = serializers.ListField( inheritance = serializers.ListField(
child=InheritanceDataSerializer() child=InheritanceDataSerializer()
@ -136,8 +135,7 @@ class RSFormSerializer(serializers.ModelSerializer):
result['oss'] = [] result['oss'] = []
result['inheritance'] = [] result['inheritance'] = []
for cst in RSForm(instance).constituents().defer('order').order_by('order'): for cst in RSForm(instance).constituents().defer('order').order_by('order'):
result['items'].append(CstSerializer(cst).data) result['items'].append(CstInfoSerializer(cst).data)
del result['items'][-1]['schema']
for oss in LibraryItem.objects.filter(operations__result=instance).only('alias'): for oss in LibraryItem.objects.filter(operations__result=instance).only('alias'):
result['oss'].append({ result['oss'].append({
'id': oss.pk, 'id': oss.pk,

View File

@ -92,7 +92,7 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
return Response( return Response(
status=c.HTTP_201_CREATED, status=c.HTTP_201_CREATED,
data={ data={
'new_cst': s.CstSerializer(new_cst).data, 'new_cst': s.CstInfoSerializer(new_cst).data,
'schema': s.RSFormParseSerializer(schema.model).data 'schema': s.RSFormParseSerializer(schema.model).data
} }
) )
@ -102,7 +102,7 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
tags=['RSForm'], tags=['RSForm'],
request=s.CstUpdateSerializer, request=s.CstUpdateSerializer,
responses={ responses={
c.HTTP_200_OK: s.CstSerializer, c.HTTP_200_OK: s.CstInfoSerializer,
c.HTTP_400_BAD_REQUEST: None, c.HTTP_400_BAD_REQUEST: None,
c.HTTP_403_FORBIDDEN: None, c.HTTP_403_FORBIDDEN: None,
c.HTTP_404_NOT_FOUND: None c.HTTP_404_NOT_FOUND: None
@ -122,7 +122,7 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
PropagationFacade.after_update_cst(schema, cst, data, old_data) PropagationFacade.after_update_cst(schema, cst, data, old_data)
return Response( return Response(
status=c.HTTP_200_OK, status=c.HTTP_200_OK,
data=s.CstSerializer(m.Constituenta.objects.get(pk=request.data['target'])).data data=s.CstInfoSerializer(m.Constituenta.objects.get(pk=request.data['target'])).data
) )
@extend_schema( @extend_schema(
@ -202,7 +202,7 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
return Response( return Response(
status=c.HTTP_200_OK, status=c.HTTP_200_OK,
data={ data={
'new_cst': s.CstSerializer(cst).data, 'new_cst': s.CstInfoSerializer(cst).data,
'schema': s.RSFormParseSerializer(schema.model).data 'schema': s.RSFormParseSerializer(schema.model).data
} }
) )

View File

@ -77,18 +77,6 @@ class AuthSerializer(serializers.Serializer):
} }
class UserInfoSerializer(serializers.ModelSerializer):
''' Serializer: User data. '''
class Meta:
''' serializer metadata. '''
model = models.User
fields = [
'id',
'first_name',
'last_name',
]
class UserSerializer(serializers.ModelSerializer): class UserSerializer(serializers.ModelSerializer):
''' Serializer: User data. ''' ''' Serializer: User data. '''
id = serializers.IntegerField(read_only=True) id = serializers.IntegerField(read_only=True)
@ -117,6 +105,20 @@ class UserSerializer(serializers.ModelSerializer):
return attrs return attrs
class UserInfoSerializer(serializers.ModelSerializer):
''' Serializer: User open information. '''
id = serializers.IntegerField(read_only=True)
class Meta:
''' serializer metadata. '''
model = models.User
fields = [
'id',
'first_name',
'last_name',
]
class ChangePasswordSerializer(serializers.Serializer): class ChangePasswordSerializer(serializers.Serializer):
''' Serializer: Change password. ''' ''' Serializer: Change password. '''
old_password = serializers.CharField(required=True) old_password = serializers.CharField(required=True)

View File

@ -73,7 +73,7 @@ class AuthAPIView(generics.RetrieveAPIView):
class ActiveUsersView(generics.ListAPIView): class ActiveUsersView(generics.ListAPIView):
''' Endpoint: Get list of active users. ''' ''' Endpoint: Get list of active users. '''
permission_classes = (permissions.AllowAny,) permission_classes = (permissions.AllowAny,)
serializer_class = s.UserSerializer serializer_class = s.UserInfoSerializer
def get_queryset(self): def get_queryset(self):
return m.User.objects.filter(is_active=True) return m.User.objects.filter(is_active=True)

View File

@ -1,10 +1,10 @@
tzdata==2025.1 tzdata==2025.1
Django==5.1.5 Django==5.1.7
djangorestframework==3.15.2 djangorestframework==3.15.2
django-cors-headers==4.6.0 django-cors-headers==4.7.0
django-filter==24.3 django-filter==25.1
drf-spectacular==0.28.0 drf-spectacular==0.28.0
drf-spectacular-sidecar==2024.12.1 drf-spectacular-sidecar==2025.3.1
coreapi==2.3.3 coreapi==2.3.3
django-rest-passwordreset==1.5.0 django-rest-passwordreset==1.5.0
cctext==0.1.4 cctext==0.1.4
@ -12,9 +12,10 @@ pyconcept==0.1.12
psycopg2-binary==2.9.10 psycopg2-binary==2.9.10
gunicorn==23.0.0 gunicorn==23.0.0
djangorestframework-stubs==3.15.2
djangorestframework-stubs==3.15.3
django-extensions==3.2.3 django-extensions==3.2.3
django-stubs==5.1.2 django-stubs==5.1.3
mypy==1.13.0 mypy==1.15.0
pylint==3.3.3 pylint==3.3.5
coverage==7.6.10 coverage==7.6.12

View File

@ -1,10 +1,10 @@
tzdata==2025.1 tzdata==2025.1
Django==5.1.5 Django==5.1.7
djangorestframework==3.15.2 djangorestframework==3.15.2
django-cors-headers==4.6.0 django-cors-headers==4.7.0
django-filter==24.3 django-filter==25.1
drf-spectacular==0.28.0 drf-spectacular==0.28.0
drf-spectacular-sidecar==2024.12.1 drf-spectacular-sidecar==2025.3.1
coreapi==2.3.3 coreapi==2.3.3
django-rest-passwordreset==1.5.0 django-rest-passwordreset==1.5.0
cctext==0.1.4 cctext==0.1.4

View File

@ -1,21 +0,0 @@
# Frontend Developer guidelines
Styling conventions
- static > conditional static > props. All dynamic styling should go in styles props
- dimensions = rectangle + outer layout
<details>
<summary>clsx className grouping and order</summary>
<pre>
- layer: z-position
- outer layout: fixed bottom-1/2 left-0 -translate-x-1/2
- rectangle: mt-3 min-w-fit min-w-10 flex-grow shrink-0
- inner layout: px-3 py-2 flex flex-col gap-3 justify-between items-center
- overflow behavior: overflow-scroll overscroll-contain
- border: borer-2 outline-none shadow-md
- text: text-start text-sm font-semibold whitespace-nowrap bg-prim-200 fg-app-100
- behavior modifiers: select-none disabled:cursor-auto
- transitions:
</pre>
</details>

View File

@ -6,12 +6,21 @@ import reactCompilerPlugin from 'eslint-plugin-react-compiler';
import reactHooksPlugin from 'eslint-plugin-react-hooks'; import reactHooksPlugin from 'eslint-plugin-react-hooks';
import importPlugin from 'eslint-plugin-import'; import importPlugin from 'eslint-plugin-import';
import simpleImportSort from 'eslint-plugin-simple-import-sort'; import simpleImportSort from 'eslint-plugin-simple-import-sort';
import playwright from 'eslint-plugin-playwright';
export default [ export default [
...typescriptPlugin.configs.recommendedTypeChecked, ...typescriptPlugin.configs.recommendedTypeChecked,
...typescriptPlugin.configs.stylisticTypeChecked, ...typescriptPlugin.configs.stylisticTypeChecked,
{ {
ignores: ['**/parser.ts', '**/node_modules/**', '**/public/**', '**/dist/**', 'eslint.config.js'] ignores: [
'**/parser.ts',
'**/node_modules/**',
'**/public/**',
'**/dist/**',
'vite.config.ts',
'eslint.config.js',
'playwright.config.ts'
]
}, },
{ {
languageOptions: { languageOptions: {
@ -20,11 +29,13 @@ export default [
ecmaVersion: 'latest', ecmaVersion: 'latest',
sourceType: 'module', sourceType: 'module',
globals: { ...globals.browser, ...globals.es2020, ...globals.jest }, globals: { ...globals.browser, ...globals.es2020, ...globals.jest },
project: ['./tsconfig.json', './tsconfig.node.json'] project: ['./tsconfig.json', './tsconfig.vite.json', './tsconfig.playwright.json']
} }
} }
}, },
{ {
files: ['src/**/*.ts', 'src/**/*.tsx'],
plugins: { plugins: {
'react': reactPlugin, 'react': reactPlugin,
'react-compiler': reactCompilerPlugin, 'react-compiler': reactCompilerPlugin,
@ -34,7 +45,12 @@ export default [
}, },
settings: { react: { version: 'detect' } }, settings: { react: { version: 'detect' } },
rules: { rules: {
'no-console': 'off',
'require-jsdoc': 'off',
'react-compiler/react-compiler': 'error', 'react-compiler/react-compiler': 'error',
'react-refresh/only-export-components': ['off', { allowConstantExport: true }],
'@typescript-eslint/consistent-type-imports': [ '@typescript-eslint/consistent-type-imports': [
'warn', 'warn',
{ {
@ -54,8 +70,8 @@ export default [
} }
], ],
'react-refresh/only-export-components': ['off', { allowConstantExport: true }], 'simple-import-sort/exports': 'error',
'import/no-duplicates': 'warn',
'simple-import-sort/imports': [ 'simple-import-sort/imports': [
'warn', 'warn',
{ {
@ -81,17 +97,30 @@ export default [
] ]
} }
], ],
'simple-import-sort/exports': 'error',
'import/no-duplicates': 'warn',
...reactHooksPlugin.configs.recommended.rules ...reactHooksPlugin.configs.recommended.rules
} }
}, },
{ {
files: ['**/*.ts', '**/*.tsx'], ...playwright.configs['flat/recommended'],
files: ['tests/**/*.ts'],
plugins: {
'playwright': playwright,
'simple-import-sort': simpleImportSort,
'import': importPlugin
},
rules: { rules: {
...playwright.configs['flat/recommended'].rules,
'no-console': 'off', 'no-console': 'off',
'require-jsdoc': 'off' 'require-jsdoc': 'off',
'simple-import-sort/exports': 'error',
'import/no-duplicates': 'warn',
'simple-import-sort/imports': 'warn'
} }
} }
]; ];

View File

@ -15,8 +15,10 @@
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link <link
href="https://fonts.googleapis.com/css2?family=Rubik:ital,wght@0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,300;1,400;1,500;1,600;1,700;1,800;1,900&family=Fira+Code:wght@300..700&family=Noto+Sans+Math&family=Noto+Sans+Symbols+2&family=Alegreya+Sans+SC:wght@100;300;400;500;700;800;900&family=Noto+Color+Emoji" rel="preload"
rel="stylesheet" as="style"
href="https://fonts.googleapis.com/css2?family=Rubik:ital,wght@0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,300;1,400;1,500;1,600;1,700;1,800;1,900&family=Fira+Code:wght@300..700&family=Noto+Sans+Math&family=Noto+Sans+Symbols+2&family=Alegreya+Sans+SC:wght@100;300;400;500;700;800;900&family=Noto+Color+Emoji&display=block"
onload="this.onload=null;this.rel='stylesheet'"
/> />
<title>Концепт Портал</title> <title>Концепт Портал</title>

File diff suppressed because it is too large Load Diff

View File

@ -5,7 +5,8 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"generate": "lezer-generator src/components/RSInput/rslang/rslangFast.grammar -o src/components/RSInput/rslang/parser.ts && lezer-generator src/components/RSInput/rslang/rslangAST.grammar -o src/components/RSInput/rslang/parserAST.ts && lezer-generator src/components/RefsInput/parse/refsText.grammar -o src/components/RefsInput/parse/parser.ts", "generate": "lezer-generator src/components/RSInput/rslang/rslangFast.grammar -o src/components/RSInput/rslang/parser.ts && lezer-generator src/components/RSInput/rslang/rslangAST.grammar -o src/components/RSInput/rslang/parserAST.ts && lezer-generator src/components/RefsInput/parse/refsText.grammar -o src/components/RefsInput/parse/parser.ts",
"test": "jest && playwright test", "test": "jest",
"test:e2e": "playwright test",
"dev": "vite --host", "dev": "vite --host",
"build": "tsc && vite build", "build": "tsc && vite build",
"lint": "eslint . --report-unused-disable-directives --max-warnings 0", "lint": "eslint . --report-unused-disable-directives --max-warnings 0",
@ -14,14 +15,14 @@
}, },
"dependencies": { "dependencies": {
"@dagrejs/dagre": "^1.1.4", "@dagrejs/dagre": "^1.1.4",
"@hookform/resolvers": "^4.1.2", "@hookform/resolvers": "^4.1.3",
"@lezer/lr": "^1.4.2", "@lezer/lr": "^1.4.2",
"@tanstack/react-query": "^5.66.9", "@tanstack/react-query": "^5.67.2",
"@tanstack/react-query-devtools": "^5.66.9", "@tanstack/react-query-devtools": "^5.67.2",
"@tanstack/react-table": "^8.21.2", "@tanstack/react-table": "^8.21.2",
"@uiw/codemirror-themes": "^4.23.8", "@uiw/codemirror-themes": "^4.23.10",
"@uiw/react-codemirror": "^4.23.8", "@uiw/react-codemirror": "^4.23.10",
"axios": "^1.8.1", "axios": "^1.8.2",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"global": "^4.4.0", "global": "^4.4.0",
"js-file-download": "^0.4.12", "js-file-download": "^0.4.12",
@ -32,9 +33,9 @@
"react-hook-form": "^7.54.2", "react-hook-form": "^7.54.2",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"react-intl": "^7.1.6", "react-intl": "^7.1.6",
"react-router": "^7.2.0", "react-router": "^7.3.0",
"react-scan": "^0.1.4", "react-scan": "^0.2.14",
"react-select": "^5.10.0", "react-select": "^5.10.1",
"react-tabs": "^6.1.0", "react-tabs": "^6.1.0",
"react-toastify": "^11.0.5", "react-toastify": "^11.0.5",
"react-tooltip": "^5.28.0", "react-tooltip": "^5.28.0",
@ -46,29 +47,30 @@
}, },
"devDependencies": { "devDependencies": {
"@lezer/generator": "^1.7.2", "@lezer/generator": "^1.7.2",
"@playwright/test": "^1.50.1", "@playwright/test": "^1.51.0",
"@tailwindcss/vite": "^4.0.9", "@tailwindcss/vite": "^4.0.12",
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
"@types/node": "^22.13.5", "@types/node": "^22.13.10",
"@types/react": "^19.0.10", "@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4", "@types/react-dom": "^19.0.4",
"@typescript-eslint/eslint-plugin": "^8.0.1", "@typescript-eslint/eslint-plugin": "^8.0.1",
"@typescript-eslint/parser": "^8.0.1", "@typescript-eslint/parser": "^8.0.1",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"babel-plugin-react-compiler": "^19.0.0-beta-21e868a-20250216", "babel-plugin-react-compiler": "^19.0.0-beta-21e868a-20250216",
"eslint": "^9.21.0", "eslint": "^9.22.0",
"eslint-plugin-import": "^2.31.0", "eslint-plugin-import": "^2.31.0",
"eslint-plugin-playwright": "^2.2.0",
"eslint-plugin-react": "^7.37.4", "eslint-plugin-react": "^7.37.4",
"eslint-plugin-react-compiler": "^19.0.0-beta-21e868a-20250216", "eslint-plugin-react-compiler": "^19.0.0-beta-21e868a-20250216",
"eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-simple-import-sort": "^12.1.1", "eslint-plugin-simple-import-sort": "^12.1.1",
"globals": "^16.0.0", "globals": "^16.0.0",
"jest": "^29.7.0", "jest": "^29.7.0",
"tailwindcss": "^4.0.7", "tailwindcss": "^4.0.7",
"ts-jest": "^29.2.6", "ts-jest": "^29.2.6",
"typescript": "^5.7.3", "typescript": "^5.8.2",
"typescript-eslint": "^8.25.0", "typescript-eslint": "^8.26.0",
"vite": "^6.2.0" "vite": "^6.2.1"
}, },
"overrides": { "overrides": {
"react": "^19.0.0" "react": "^19.0.0"

View File

@ -5,6 +5,7 @@ export default defineConfig({
forbidOnly: !!process.env.CI, forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0, retries: process.env.CI ? 2 : 0,
reporter: 'list', reporter: 'list',
fullyParallel: true,
projects: [ projects: [
{ {
name: 'Desktop Chrome', name: 'Desktop Chrome',
@ -25,7 +26,7 @@ export default defineConfig({
}, },
webServer: { webServer: {
command: 'npm run dev', command: 'npm run dev',
url: 'http://localhost:3000', port: 3000,
reuseExistingServer: !process.env.CI reuseExistingServer: !process.env.CI
} }
}); });

View File

@ -25,7 +25,7 @@ export function ApplicationLayout() {
return ( return (
<NavigationState> <NavigationState>
<div className='min-w-[20rem] antialiased h-full max-w-[120rem] mx-auto'> <div className='min-w-80 antialiased h-full max-w-480 mx-auto'>
<ToasterThemed <ToasterThemed
className='text-[14px]' className='text-[14px]'
style={{ marginTop: noNavigationAnimation ? '1.5rem' : '3.5rem' }} style={{ marginTop: noNavigationAnimation ? '1.5rem' : '3.5rem' }}

View File

@ -13,15 +13,14 @@ export function Footer() {
'text-xs sm:text-sm select-none whitespace-nowrap text-prim-600 bg-prim-100' 'text-xs sm:text-sm select-none whitespace-nowrap text-prim-600 bg-prim-100'
)} )}
> >
<div className='flex gap-3'> <nav className='flex gap-3' aria-label='Вторичная навигация'>
<TextURL text='Библиотека' href='/library' color='' /> <TextURL text='Библиотека' href='/library' color='' />
<TextURL text='Справка' href='/manuals' color='' /> <TextURL text='Справка' href='/manuals' color='' />
<TextURL text='Центр Концепт' href={external_urls.concept} color='' /> <TextURL text='Центр Концепт' href={external_urls.concept} color='' />
<TextURL text='Экстеор' href='/manuals?topic=exteor' color='' /> <TextURL text='Экстеор' href='/manuals?topic=exteor' color='' />
</div> </nav>
<div>
<p>© 2024 ЦИВТ КОНЦЕПТ</p> <p>© 2024 ЦИВТ КОНЦЕПТ</p>
</div>
</footer> </footer>
); );
} }

View File

@ -1,11 +1,10 @@
import { useNavigation } from 'react-router'; import { useNavigation } from 'react-router';
import clsx from 'clsx';
import { useDebounce } from 'use-debounce'; import { useDebounce } from 'use-debounce';
import { Loader } from '@/components/Loader'; import { Loader } from '@/components/Loader';
import { ModalBackdrop } from '@/components/Modal/ModalBackdrop';
import { PARAMETER } from '@/utils/constants'; import { PARAMETER } from '@/utils/constants';
// TODO: add animation
export function GlobalLoader() { export function GlobalLoader() {
const navigation = useNavigation(); const navigation = useNavigation();
@ -17,16 +16,9 @@ export function GlobalLoader() {
} }
return ( return (
<div className='fixed top-0 left-0 w-full h-full z-modal cursor-default'> <div className='cc-modal-wrapper'>
<div className={clsx('z-navigation', 'fixed top-0 left-0', 'w-full h-full', 'backdrop-blur-[3px] opacity-50')} /> <ModalBackdrop />
<div className={clsx('z-navigation', 'fixed top-0 left-0', 'w-full h-full', 'bg-prim-0 opacity-25')} /> <div className='cc-fade-in px-10 border rounded-xl bg-prim-100'>
<div
className={clsx(
'px-10 mb-10',
'z-modal absolute bottom-1/2 left-1/2 -translate-x-1/2 translate-y-1/2',
'border rounded-xl bg-prim-100'
)}
>
<Loader scale={6} /> <Loader scale={6} />
</div> </div>
</div> </div>

View File

@ -11,7 +11,7 @@ export const GlobalTooltips = () => {
id={globalIDs.tooltip} id={globalIDs.tooltip}
layer='z-topmost' layer='z-topmost'
place='right-start' place='right-start'
className='mt-8 max-w-[20rem] break-words' className='mt-8 max-w-80 break-words'
/> />
<Tooltip <Tooltip
float float

View File

@ -1,8 +1,7 @@
import clsx from 'clsx';
import { useMutationErrors } from '@/backend/useMutationErrors'; import { useMutationErrors } from '@/backend/useMutationErrors';
import { Button } from '@/components/Control'; import { Button } from '@/components/Control';
import { DescribeError } from '@/components/InfoError'; import { DescribeError } from '@/components/InfoError';
import { ModalBackdrop } from '@/components/Modal/ModalBackdrop';
import { useEscapeKey } from '@/hooks/useEscapeKey'; import { useEscapeKey } from '@/hooks/useEscapeKey';
import { useDialogsStore } from '@/stores/dialogs'; import { useDialogsStore } from '@/stores/dialogs';
@ -19,18 +18,11 @@ export function MutationErrors() {
hideDialog(); hideDialog();
return ( return (
<div className='fixed top-0 left-0 w-full h-full z-modal cursor-default'> <div className='cc-modal-wrapper'>
<div className={clsx('z-navigation', 'fixed top-0 left-0', 'w-full h-full', 'backdrop-blur-[3px] opacity-50')} /> <ModalBackdrop onHide={resetErrors} />
<div className={clsx('z-navigation', 'fixed top-0 left-0', 'w-full h-full', 'bg-prim-0 opacity-25')} /> <div className='px-10 py-3 flex flex-col items-center border rounded-xl bg-prim-100' role='alertdialog'>
<div
className={clsx(
'px-10 mb-10 py-3 flex flex-col items-center',
'z-modal absolute bottom-1/2 left-1/2 -translate-x-1/2 translate-y-1/2',
'border rounded-xl bg-prim-100'
)}
>
<h1 className='py-2 select-none'>Ошибка при обработке</h1> <h1 className='py-2 select-none'>Ошибка при обработке</h1>
<div className={clsx('px-3 flex flex-col', 'text-warn-600 text-sm font-semibold', 'select-text')}> <div className='px-3 flex flex-col text-warn-600 text-sm font-semibold select-text'>
<DescribeError error={mutationErrors[0]} /> <DescribeError error={mutationErrors[0]} />
</div> </div>
<Button onClick={resetErrors} className='w-fit' text='Закрыть' /> <Button onClick={resetErrors} className='w-fit' text='Закрыть' />

View File

@ -8,7 +8,8 @@ export function Logo() {
return ( return (
<img <img
alt='' alt=''
className='max-h-[1.6rem] w-fit max-w-[11.4rem]' aria-hidden
className='max-h-7 w-fit max-w-46'
src={size.isSmall ? '/logo_sign.svg' : !darkMode ? '/logo_full.svg' : '/logo_full_dark.svg'} src={size.isSmall ? '/logo_sign.svg' : !darkMode ? '/logo_full.svg' : '/logo_full_dark.svg'}
/> />
); );

View File

@ -1,9 +1,6 @@
import clsx from 'clsx';
import { IconLibrary2, IconManuals, IconNewItem2 } from '@/components/Icons'; import { IconLibrary2, IconManuals, IconNewItem2 } from '@/components/Icons';
import { useWindowSize } from '@/hooks/useWindowSize'; import { useWindowSize } from '@/hooks/useWindowSize';
import { useAppLayoutStore } from '@/stores/appLayout'; import { useAppLayoutStore } from '@/stores/appLayout';
import { PARAMETER } from '@/utils/constants';
import { urls } from '../urls'; import { urls } from '../urls';
@ -28,41 +25,22 @@ export function Navigation() {
router.push({ path: urls.create_schema, newTab: event.ctrlKey || event.metaKey }); router.push({ path: urls.create_schema, newTab: event.ctrlKey || event.metaKey });
return ( return (
<nav <nav className='z-navigation sticky top-0 left-0 right-0 select-none bg-prim-100'>
className={clsx(
'z-navigation', //
'sticky top-0 left-0 right-0',
'select-none',
'bg-prim-100'
)}
>
<ToggleNavigation /> <ToggleNavigation />
<div <div
className={clsx( className='pl-2 pr-6 sm:pr-4 h-12 flex cc-shadow-border'
'pl-2 pr-[1.5rem] sm:pr-[0.9rem] h-[3rem]', //
'flex',
'cc-shadow-border'
)}
style={{ style={{
willChange: 'max-height, translate',
transitionProperty: 'max-height, translate',
transitionDuration: `${PARAMETER.moveDuration}ms`,
maxHeight: noNavigationAnimation ? '0rem' : '3rem', maxHeight: noNavigationAnimation ? '0rem' : '3rem',
translate: noNavigationAnimation ? '0 -1.5rem' : '0' translate: noNavigationAnimation ? '0 -1.5rem' : '0'
}} }}
> >
<div <div className='flex items-center mr-auto cursor-pointer' onClick={!size.isSmall ? navigateHome : undefined}>
tabIndex={-1}
className={clsx('flex items-center mr-auto', !size.isSmall && 'cursor-pointer')}
onClick={!size.isSmall ? navigateHome : undefined}
>
<Logo /> <Logo />
</div> </div>
<div className='flex gap-1 py-[0.3rem]'> <div className='flex gap-2 items-center'>
<NavigationButton text='Новая схема' icon={<IconNewItem2 size='1.5rem' />} onClick={navigateCreateNew} /> <NavigationButton text='Новая схема' icon={<IconNewItem2 size='1.5rem' />} onClick={navigateCreateNew} />
<NavigationButton text='Библиотека' icon={<IconLibrary2 size='1.5rem' />} onClick={navigateLibrary} /> <NavigationButton text='Библиотека' icon={<IconLibrary2 size='1.5rem' />} onClick={navigateLibrary} />
<NavigationButton text='Справка' icon={<IconManuals size='1.5rem' />} onClick={navigateHelp} /> <NavigationButton text='Справка' icon={<IconManuals size='1.5rem' />} onClick={navigateHelp} />
<UserMenu /> <UserMenu />
</div> </div>
</div> </div>

View File

@ -1,49 +1,37 @@
import clsx from 'clsx'; import clsx from 'clsx';
import { type Styling, type Titled } from '@/components/props'; import { type Styling } from '@/components/props';
import { globalIDs } from '@/utils/constants'; import { globalIDs } from '@/utils/constants';
interface NavigationButtonProps extends Titled, Styling { interface NavigationButtonProps extends Styling {
text?: string; text?: string;
title?: string;
hideTitle?: boolean;
icon: React.ReactNode; icon: React.ReactNode;
onClick?: (event: React.MouseEvent<Element>) => void; onClick?: (event: React.MouseEvent<Element>) => void;
} }
export function NavigationButton({ export function NavigationButton({ icon, title, hideTitle, className, style, onClick, text }: NavigationButtonProps) {
icon,
title,
className,
style,
titleHtml,
hideTitle,
onClick,
text
}: NavigationButtonProps) {
return ( return (
<button <button
type='button' type='button'
tabIndex={-1} tabIndex={-1}
data-tooltip-id={!!title || !!titleHtml ? globalIDs.tooltip : undefined} aria-label={title}
data-tooltip-html={titleHtml} data-tooltip-id={!!title ? globalIDs.tooltip : undefined}
data-tooltip-content={title}
data-tooltip-hidden={hideTitle} data-tooltip-hidden={hideTitle}
data-tooltip-content={title}
onClick={onClick} onClick={onClick}
className={clsx( className={clsx(
'mr-1 h-full', 'p-2 flex items-center gap-1',
'flex items-center gap-1',
'cursor-pointer', 'cursor-pointer',
'clr-btn-nav cc-animate-color duration-500', 'clr-btn-nav cc-animate-color duration-500',
'rounded-xl', 'rounded-xl',
'font-controls whitespace-nowrap', 'font-controls whitespace-nowrap',
{
'px-2': text,
'px-4': !text
},
className className
)} )}
style={style} style={style}
> >
{icon ? <span>{icon}</span> : null} {icon ? icon : null}
{text ? <span className='hidden sm:inline'>{text}</span> : null} {text ? <span className='hidden sm:inline'>{text}</span> : null}
</button> </button>
); );

View File

@ -1,6 +1,6 @@
'use client'; 'use client';
import { createContext, useContext, useEffect, useState } from 'react'; import { createContext, use, useEffect, useState } from 'react';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
export interface NavigationProps { export interface NavigationProps {
@ -23,9 +23,9 @@ interface INavigationContext {
setIsBlocked: (value: boolean) => void; setIsBlocked: (value: boolean) => void;
} }
const NavigationContext = createContext<INavigationContext | null>(null); export const NavigationContext = createContext<INavigationContext | null>(null);
export const useConceptNavigation = () => { export const useConceptNavigation = () => {
const context = useContext(NavigationContext); const context = use(NavigationContext);
if (!context) { if (!context) {
throw new Error('useConceptNavigation has to be used within <NavigationState>'); throw new Error('useConceptNavigation has to be used within <NavigationState>');
} }
@ -36,13 +36,14 @@ export const NavigationState = ({ children }: React.PropsWithChildren) => {
const router = useNavigate(); const router = useNavigate();
const [isBlocked, setIsBlocked] = useState(false); const [isBlocked, setIsBlocked] = useState(false);
const [internalNavigation, setInternalNavigation] = useState(false);
function validate() { function validate() {
return !isBlocked || confirm('Изменения не сохранены. Вы уверены что хотите совершить переход?'); return !isBlocked || confirm('Изменения не сохранены. Вы уверены что хотите совершить переход?');
} }
function canBack() { function canBack() {
return !!window.history && window.history?.length !== 0; return internalNavigation && !!window.history && window.history?.length !== 0;
} }
function push(props: NavigationProps) { function push(props: NavigationProps) {
@ -50,6 +51,7 @@ export const NavigationState = ({ children }: React.PropsWithChildren) => {
window.open(`${props.path}`, '_blank'); window.open(`${props.path}`, '_blank');
} else if (props.force || validate()) { } else if (props.force || validate()) {
setIsBlocked(false); setIsBlocked(false);
setInternalNavigation(true);
Promise.resolve(router(props.path, { viewTransition: true })).catch(console.error); Promise.resolve(router(props.path, { viewTransition: true })).catch(console.error);
} }
} }
@ -59,6 +61,7 @@ export const NavigationState = ({ children }: React.PropsWithChildren) => {
window.open(`${props.path}`, '_blank'); window.open(`${props.path}`, '_blank');
} else if (props.force || validate()) { } else if (props.force || validate()) {
setIsBlocked(false); setIsBlocked(false);
setInternalNavigation(true);
return router(props.path, { viewTransition: true }); return router(props.path, { viewTransition: true });
} }
} }

View File

@ -1,34 +1,28 @@
import clsx from 'clsx'; 'use client';
import { IconDarkTheme, IconLightTheme, IconPin, IconUnpin } from '@/components/Icons'; import { IconDarkTheme, IconLightTheme, IconPin, IconUnpin } from '@/components/Icons';
import { useAppLayoutStore } from '@/stores/appLayout'; import { useAppLayoutStore } from '@/stores/appLayout';
import { usePreferencesStore } from '@/stores/preferences'; import { usePreferencesStore } from '@/stores/preferences';
import { globalIDs, PARAMETER } from '@/utils/constants'; import { globalIDs } from '@/utils/constants';
export function ToggleNavigation() { export function ToggleNavigation() {
const darkMode = usePreferencesStore(state => state.darkMode); const darkMode = usePreferencesStore(state => state.darkMode);
const toggleDarkMode = usePreferencesStore(state => state.toggleDarkMode); const toggleDarkMode = usePreferencesStore(state => state.toggleDarkMode);
const noNavigation = useAppLayoutStore(state => state.noNavigation);
const noNavigationAnimation = useAppLayoutStore(state => state.noNavigationAnimation); const noNavigationAnimation = useAppLayoutStore(state => state.noNavigationAnimation);
const toggleNoNavigation = useAppLayoutStore(state => state.toggleNoNavigation); const toggleNoNavigation = useAppLayoutStore(state => state.toggleNoNavigation);
const iconSize = !noNavigationAnimation ? '0.75rem' : '1rem';
return ( return (
<div <div className='absolute top-0 right-0 z-navigation h-12 grid'>
className={clsx( <button
'absolute top-0 right-0 z-navigation', tabIndex={-1}
'min-h-[2rem] min-w-[2rem]', type='button'
'flex items-end justify-center gap-1', className='p-1 cursor-pointer self-start'
'select-none', onClick={toggleNoNavigation}
!noNavigation && 'flex-col-reverse' data-tooltip-id={globalIDs.tooltip}
)} data-tooltip-content={noNavigationAnimation ? 'Показать навигацию' : 'Скрыть навигацию'}
style={{ >
willChange: 'height, width', {!noNavigationAnimation ? <IconPin size='0.75rem' /> : null}
transitionProperty: 'height, width', {noNavigationAnimation ? <IconUnpin size='0.75rem' /> : null}
transitionDuration: `${PARAMETER.moveDuration}ms`, </button>
height: noNavigationAnimation ? '2rem' : '3rem',
width: noNavigationAnimation ? '3rem' : '2rem'
}}
>
{!noNavigationAnimation ? ( {!noNavigationAnimation ? (
<button <button
tabIndex={-1} tabIndex={-1}
@ -42,17 +36,6 @@ export function ToggleNavigation() {
{!darkMode ? <IconLightTheme size='0.75rem' /> : null} {!darkMode ? <IconLightTheme size='0.75rem' /> : null}
</button> </button>
) : null} ) : null}
<button
tabIndex={-1}
type='button'
className='p-1 cursor-pointer'
onClick={toggleNoNavigation}
data-tooltip-id={globalIDs.tooltip}
data-tooltip-content={noNavigationAnimation ? 'Показать навигацию' : 'Скрыть навигацию'}
>
{!noNavigationAnimation ? <IconPin size={iconSize} /> : null}
{noNavigationAnimation ? <IconUnpin size={iconSize} /> : null}
</button>
</div> </div>
); );
} }

View File

@ -2,15 +2,17 @@ import { useAuthSuspense } from '@/features/auth';
import { IconLogin, IconUser2 } from '@/components/Icons'; import { IconLogin, IconUser2 } from '@/components/Icons';
import { usePreferencesStore } from '@/stores/preferences'; import { usePreferencesStore } from '@/stores/preferences';
import { globalIDs } from '@/utils/constants';
import { NavigationButton } from './NavigationButton'; import { NavigationButton } from './NavigationButton';
interface UserButtonProps { interface UserButtonProps {
onLogin: () => void; onLogin: () => void;
onClickUser: () => void; onClickUser: () => void;
isOpen: boolean;
} }
export function UserButton({ onLogin, onClickUser }: UserButtonProps) { export function UserButton({ onLogin, onClickUser, isOpen }: UserButtonProps) {
const { user, isAnonymous } = useAuthSuspense(); const { user, isAnonymous } = useAuthSuspense();
const adminMode = usePreferencesStore(state => state.adminMode); const adminMode = usePreferencesStore(state => state.adminMode);
if (isAnonymous) { if (isAnonymous) {
@ -26,6 +28,11 @@ export function UserButton({ onLogin, onClickUser }: UserButtonProps) {
return ( return (
<NavigationButton <NavigationButton
className='cc-fade-in' className='cc-fade-in'
title='Пользователь'
hideTitle={isOpen}
aria-haspopup='true'
aria-expanded={isOpen}
aria-controls={globalIDs.user_dropdown}
icon={<IconUser2 size='1.5rem' className={adminMode && user.is_staff ? 'icon-primary' : ''} />} icon={<IconUser2 size='1.5rem' className={adminMode && user.is_staff ? 'icon-primary' : ''} />}
onClick={onClickUser} onClick={onClickUser}
/> />

View File

@ -17,6 +17,7 @@ import {
IconUser IconUser
} from '@/components/Icons'; } from '@/components/Icons';
import { usePreferencesStore } from '@/stores/preferences'; import { usePreferencesStore } from '@/stores/preferences';
import { globalIDs } from '@/utils/constants';
import { urls } from '../urls'; import { urls } from '../urls';
@ -75,7 +76,7 @@ export function UserDropdown({ isOpen, hideDropdown }: UserDropdownProps) {
} }
return ( return (
<Dropdown className='mt-[1.5rem] min-w-[18ch] max-w-[12rem]' stretchLeft isOpen={isOpen}> <Dropdown id={globalIDs.user_dropdown} className='min-w-[18ch] max-w-48' stretchLeft isOpen={isOpen}>
<DropdownButton <DropdownButton
text={user.username} text={user.username}
title='Профиль пользователя' title='Профиль пользователя'
@ -104,7 +105,7 @@ export function UserDropdown({ isOpen, hideDropdown }: UserDropdownProps) {
) : null} ) : null}
{user.is_staff ? ( {user.is_staff ? (
<DropdownButton <DropdownButton
text='REST API' // prettier: split-line text='REST API' //
icon={<IconRESTapi size='1rem' />} icon={<IconRESTapi size='1rem' />}
className='border-t' className='border-t'
onClick={gotoRestApi} onClick={gotoRestApi}
@ -112,21 +113,21 @@ export function UserDropdown({ isOpen, hideDropdown }: UserDropdownProps) {
) : null} ) : null}
{user.is_staff ? ( {user.is_staff ? (
<DropdownButton <DropdownButton
text='База данных' // prettier: split-line text='База данных' //
icon={<IconDatabase size='1rem' />} icon={<IconDatabase size='1rem' />}
onClick={gotoAdmin} onClick={gotoAdmin}
/> />
) : null} ) : null}
{user?.is_staff ? ( {user?.is_staff ? (
<DropdownButton <DropdownButton
text='Иконки' // prettier: split-line text='Иконки' //
icon={<IconImage size='1rem' />} icon={<IconImage size='1rem' />}
onClick={gotoIcons} onClick={gotoIcons}
/> />
) : null} ) : null}
{user.is_staff ? ( {user.is_staff ? (
<DropdownButton <DropdownButton
text='Структура БД' // prettier: split-line text='Структура БД' //
icon={<IconDBStructure size='1rem' />} icon={<IconDBStructure size='1rem' />}
onClick={gotoDatabaseSchema} onClick={gotoDatabaseSchema}
className='border-b' className='border-b'

View File

@ -13,9 +13,13 @@ export function UserMenu() {
const router = useConceptNavigation(); const router = useConceptNavigation();
const menu = useDropdown(); const menu = useDropdown();
return ( return (
<div ref={menu.ref} className='h-full w-[4rem] flex items-center justify-center'> <div ref={menu.ref} className='flex items-center justify-start relative h-full pr-2'>
<Suspense fallback={<Loader circular scale={1.5} />}> <Suspense fallback={<Loader circular scale={1.5} />}>
<UserButton onLogin={() => router.push({ path: urls.login, force: true })} onClickUser={menu.toggle} /> <UserButton
onLogin={() => router.push({ path: urls.login, force: true })}
onClickUser={menu.toggle}
isOpen={menu.isOpen}
/>
</Suspense> </Suspense>
<UserDropdown isOpen={menu.isOpen} hideDropdown={() => menu.hide()} /> <UserDropdown isOpen={menu.isOpen} hideDropdown={() => menu.hide()} />
</div> </div>

View File

@ -17,7 +17,7 @@ export function Divider({ vertical, margins = 'mx-2', className, ...restProps }:
return ( return (
<div <div
className={clsx( className={clsx(
margins, //prettier: split-lines margins, //
className, className,
{ {
'border-x': vertical, 'border-x': vertical,

View File

@ -1,13 +0,0 @@
import clsx from 'clsx';
/**
* `flex` column container.
* This component is useful for creating vertical layouts with flexbox.
*/
export function FlexColumn({ className, children, ...restProps }: React.ComponentProps<'div'>) {
return (
<div className={clsx('cc-column', className)} {...restProps}>
{children}
</div>
);
}

View File

@ -1,33 +0,0 @@
import clsx from 'clsx';
import { type Styling } from '../props';
interface OverlayProps extends Styling {
/** Id of the overlay. */
id?: string;
/** Classnames for position of the overlay. */
position?: string;
/** Classname for z-index of the overlay. */
layer?: string;
}
/**
* Displays a transparent overlay over the main content.
*/
export function Overlay({
children,
className,
position = 'top-0 right-0',
layer = 'z-pop',
...restProps
}: React.PropsWithChildren<OverlayProps>) {
return (
<div className='relative'>
<div className={clsx('absolute', className, position, layer)} {...restProps}>
{children}
</div>
</div>
);
}

View File

@ -39,6 +39,7 @@ export function Tooltip({
delayHide={100} delayHide={100}
opacity={1} opacity={1}
className={clsx( className={clsx(
'relative',
'max-h-[calc(100svh-6rem)]', 'max-h-[calc(100svh-6rem)]',
'overflow-y-auto overflow-x-hidden sm:overflow-hidden overscroll-contain', 'overflow-y-auto overflow-x-hidden sm:overflow-hidden overscroll-contain',
'border shadow-md', 'border shadow-md',

View File

@ -1,4 +1,2 @@
export { Divider } from './Divider'; export { Divider } from './Divider';
export { FlexColumn } from './FlexColumn';
export { Overlay } from './Overlay';
export { type PlacesType, Tooltip } from './Tooltip'; export { type PlacesType, Tooltip } from './Tooltip';

View File

@ -41,7 +41,7 @@ export function Button({
disabled={disabled ?? loading} disabled={disabled ?? loading}
className={clsx( className={clsx(
'inline-flex gap-2 items-center justify-center', 'inline-flex gap-2 items-center justify-center',
'select-none disabled:cursor-auto', 'font-medium select-none disabled:cursor-auto',
'clr-btn-default cc-animate-color', 'clr-btn-default cc-animate-color',
{ {
'border rounded-sm': !noBorder, 'border rounded-sm': !noBorder,
@ -61,7 +61,7 @@ export function Button({
{...restProps} {...restProps}
> >
{icon ? icon : null} {icon ? icon : null}
{text ? <span className='font-medium'>{text}</span> : null} {text ? <span>{text}</span> : null}
</button> </button>
); );
} }

View File

@ -32,7 +32,7 @@ export function SubmitButton({ text = 'ОК', icon, disabled, loading, className
disabled={disabled || loading} disabled={disabled || loading}
{...restProps} {...restProps}
> >
{icon ? <span>{icon}</span> : null} {icon ? icon : null}
{text ? <span>{text}</span> : null} {text ? <span>{text}</span> : null}
</button> </button>
); );

View File

@ -3,7 +3,6 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { type Table } from '@tanstack/react-table'; import { type Table } from '@tanstack/react-table';
import clsx from 'clsx';
import { prefixes } from '@/utils/constants'; import { prefixes } from '@/utils/constants';
@ -34,7 +33,7 @@ export function PaginationTools<TData>({
); );
return ( return (
<div className={clsx('flex justify-end items-center', 'my-2', 'text-sm', 'clr-text-controls', 'select-none')}> <div className='flex justify-end items-center my-2 text-sm clr-text-controls select-none'>
<span className='mr-3'> <span className='mr-3'>
{`${table.getState().pagination.pageIndex * table.getState().pagination.pageSize + 1} {`${table.getState().pagination.pageIndex * table.getState().pagination.pageSize + 1}
- -

View File

@ -14,7 +14,7 @@ export function SortingIcon<TData>({ column }: SortingIconProps<TData>) {
{{ {{
desc: <IconSortDesc size='1rem' />, desc: <IconSortDesc size='1rem' />,
asc: <IconSortAsc size='1rem' /> asc: <IconSortAsc size='1rem' />
}[column.getIsSorted() as string] ?? <IconSortDesc size='1rem' className='opacity-0 hover:opacity-50' />} }[column.getIsSorted() as string] ?? <IconSortDesc size='1rem' className='opacity-0 group-hover:opacity-25' />}
</> </>
); );
} }

View File

@ -72,12 +72,12 @@ export function TableBody<TData>({
'cc-scroll-row', 'cc-scroll-row',
'clr-hover cc-animate-color', 'clr-hover cc-animate-color',
!noHeader && 'scroll-mt-[calc(2px+2rem)]', !noHeader && 'scroll-mt-[calc(2px+2rem)]',
row.getIsSelected() ? 'clr-selected' : index % 2 === 0 ? 'bg-prim-200' : 'bg-prim-100' row.getIsSelected() ? 'clr-selected' : 'odd:bg-prim-200 even:bg-prim-100'
)} )}
style={{ ...(conditionalRowStyles ? getRowStyles(row) : []) }} style={{ ...(conditionalRowStyles ? getRowStyles(row) : []) }}
> >
{enableRowSelection ? ( {enableRowSelection ? (
<td key={`select-${row.id}`} className='pl-3 pr-1 align-middle border-y'> <td key={`select-${row.id}`} className='pl-3 pr-1 border-y'>
<SelectRow row={row} onChangeLastSelected={onChangeLastSelected} /> <SelectRow row={row} onChangeLastSelected={onChangeLastSelected} />
</td> </td>
) : null} ) : null}

View File

@ -31,7 +31,7 @@ export function TableHeader<TData>({
{table.getHeaderGroups().map((headerGroup: HeaderGroup<TData>) => ( {table.getHeaderGroups().map((headerGroup: HeaderGroup<TData>) => (
<tr key={headerGroup.id}> <tr key={headerGroup.id}>
{enableRowSelection ? ( {enableRowSelection ? (
<th className='pl-3 pr-1 align-middle'> <th className='pl-3 pr-1' scope='col'>
<SelectAll table={table} resetLastSelected={resetLastSelected} /> <SelectAll table={table} resetLastSelected={resetLastSelected} />
</th> </th>
) : null} ) : null}
@ -39,17 +39,16 @@ export function TableHeader<TData>({
<th <th
key={header.id} key={header.id}
colSpan={header.colSpan} colSpan={header.colSpan}
className='pl-2 py-2 text-xs font-medium select-none whitespace-nowrap' scope='col'
className='cc-table-header group'
style={{ style={{
paddingRight: enableSorting && header.column.getCanSort() ? '0px' : '2px',
textAlign: 'start',
width: `calc(var(--header-${header?.id}-size) * 1px)`, width: `calc(var(--header-${header?.id}-size) * 1px)`,
cursor: enableSorting && header.column.getCanSort() ? 'pointer' : 'auto' cursor: enableSorting && header.column.getCanSort() ? 'pointer' : 'auto'
}} }}
onClick={enableSorting ? header.column.getToggleSortingHandler() : undefined} onClick={enableSorting ? header.column.getToggleSortingHandler() : undefined}
> >
{!header.isPlaceholder ? ( {!header.isPlaceholder ? (
<span className='inline-flex align-middle gap-1'> <span className='inline-flex gap-1'>
{flexRender(header.column.columnDef.header, header.getContext())} {flexRender(header.column.columnDef.header, header.getContext())}
{enableSorting && header.column.getCanSort() ? <SortingIcon column={header.column} /> : null} {enableSorting && header.column.getCanSort() ? <SortingIcon column={header.column} /> : null}
</span> </span>

View File

@ -1,3 +1,4 @@
import React from 'react';
import clsx from 'clsx'; import clsx from 'clsx';
import { PARAMETER } from '@/utils/constants'; import { PARAMETER } from '@/utils/constants';
@ -5,6 +6,15 @@ import { PARAMETER } from '@/utils/constants';
import { type Styling } from '../props'; import { type Styling } from '../props';
interface DropdownProps extends Styling { interface DropdownProps extends Styling {
/** Reference to the dropdown element. */
ref?: React.Ref<HTMLDivElement>;
/** Unique ID for the dropdown. */
id?: string;
/** Margin for the dropdown. */
margin?: string;
/** Indicates whether the dropdown should stretch to the left. */ /** Indicates whether the dropdown should stretch to the left. */
stretchLeft?: boolean; stretchLeft?: boolean;
@ -17,47 +27,49 @@ interface DropdownProps extends Styling {
/** /**
* Animated list of children with optional positioning and visibility control. * Animated list of children with optional positioning and visibility control.
* Note: Dropdown should be inside a relative container.
*/ */
export function Dropdown({ export function Dropdown({
isOpen, isOpen,
stretchLeft, stretchLeft,
stretchTop, stretchTop,
margin,
className, className,
children, children,
style, style,
...restProps ...restProps
}: React.PropsWithChildren<DropdownProps>) { }: React.PropsWithChildren<DropdownProps>) {
return ( return (
<div className='relative'> <div
<div tabIndex={-1}
tabIndex={-1} className={clsx(
className={clsx( 'z-topmost absolute',
'z-topmost', {
'absolute mt-3', 'right-0': stretchLeft,
'flex flex-col', 'left-0': !stretchLeft,
'border rounded-md shadow-lg', 'bottom-0': stretchTop,
'text-sm', 'top-full': !stretchTop
'clr-input', },
{ 'grid',
'right-0': stretchLeft, 'border rounded-md shadow-lg',
'left-0': !stretchLeft, 'clr-input',
'bottom-[2rem]': stretchTop 'text-sm',
}, margin,
className className
)} )}
style={{ style={{
willChange: 'clip-path, transform', willChange: 'clip-path, transform',
transitionProperty: 'clip-path, transform', transitionProperty: 'clip-path, transform',
transitionDuration: `${PARAMETER.dropdownDuration}ms`, transitionDuration: `${PARAMETER.dropdownDuration}ms`,
transitionTimingFunction: 'ease-in-out', transitionTimingFunction: 'ease-in-out',
transform: isOpen ? 'translateY(0)' : 'translateY(-10%)', transform: isOpen ? 'translateY(0)' : 'translateY(-10%)',
clipPath: isOpen ? 'inset(0% 0% 0% 0%)' : 'inset(10% 0% 90% 0%)', clipPath: isOpen ? 'inset(0% 0% 0% 0%)' : 'inset(10% 0% 90% 0%)',
...style ...style
}} }}
{...restProps} aria-hidden={!isOpen}
> {...restProps}
{children} >
</div> {children}
</div> </div>
); );
} }

View File

@ -52,9 +52,9 @@ export function DropdownButton({
data-tooltip-hidden={hideTitle} data-tooltip-hidden={hideTitle}
{...restProps} {...restProps}
> >
{icon ? icon : null}
{text ? <span>{text}</span> : null}
{children ? children : null} {children ? children : null}
{!children && icon ? icon : null}
{!children && text ? <span>{text}</span> : null}
</button> </button>
); );
} }

View File

@ -171,16 +171,9 @@ export interface IconProps {
className?: string; className?: string;
} }
function MetaIconSVG({ viewBox, size = '1.5rem', className, props, children }: React.PropsWithChildren<IconSVGProps>) { function MetaIconSVG({ viewBox, size = '1.5rem', props, children }: React.PropsWithChildren<IconSVGProps>) {
return ( return (
<svg <svg width={size} height={size} fill='currentColor' viewBox={viewBox} {...props}>
width={size}
height={size}
className={`w-[${size}] h-[${size}] ${className}`}
fill='currentColor'
viewBox={viewBox}
{...props}
>
{children} {children}
</svg> </svg>
); );
@ -205,7 +198,7 @@ export function IconLogin(props: IconProps) {
export function CheckboxChecked() { export function CheckboxChecked() {
return ( return (
<svg className='w-3 h-3' viewBox='0 0 512 512' fill='#ffffff'> <svg className='w-4 h-4 p-0.75' viewBox='0 0 512 512' fill='#ffffff'>
<path d='M470.6 105.4c12.5 12.5 12.5 32.8 0 45.3l-256 256c-12.5 12.5-32.8 12.5-45.3 0l-128-128c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0L192 338.7l233.4-233.3c12.5-12.5 32.8-12.5 45.3 0z' /> <path d='M470.6 105.4c12.5 12.5 12.5 32.8 0 45.3l-256 256c-12.5 12.5-32.8 12.5-45.3 0l-128-128c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0L192 338.7l233.4-233.3c12.5-12.5 32.8-12.5 45.3 0z' />
</svg> </svg>
); );
@ -213,7 +206,7 @@ export function CheckboxChecked() {
export function CheckboxNull() { export function CheckboxNull() {
return ( return (
<svg className='w-3 h-3' viewBox='0 0 16 16' fill='#ffffff'> <svg className='w-4 h-4 p-0.25' viewBox='0 0 16 16' fill='#ffffff'>
<path d='M2 7.75A.75.75 0 012.75 7h10a.75.75 0 010 1.5h-10A.75.75 0 012 7.75z' /> <path d='M2 7.75A.75.75 0 012.75 7h10a.75.75 0 010 1.5h-10A.75.75 0 012 7.75z' />
</svg> </svg>
); );

View File

@ -90,7 +90,7 @@ export function InfoError({ error }: InfoErrorProps) {
<div <div
className={clsx( className={clsx(
'cc-fade-in', 'cc-fade-in',
'min-w-[25rem]', 'min-w-100',
'px-3 py-2 flex flex-col', 'px-3 py-2 flex flex-col',
'text-warn-600 text-sm font-semibold', 'text-warn-600 text-sm font-semibold',
'select-text' 'select-text'

View File

@ -64,10 +64,8 @@ export function Checkbox({
> >
<div <div
className={clsx( className={clsx(
'max-w-[1rem] min-w-[1rem] h-4', // 'w-4 h-4', //
'pt-[0.05rem] pl-[0.05rem]',
'border rounded-xs', 'border rounded-xs',
'cc-animate-color',
{ {
'bg-sec-600 text-sec-0': value !== false, 'bg-sec-600 text-sec-0': value !== false,
'bg-prim-100': value === false 'bg-prim-100': value === false

View File

@ -66,9 +66,7 @@ export function CheckboxTristate({
<div <div
className={clsx( className={clsx(
'w-4 h-4', // 'w-4 h-4', //
'pt-[0.05rem] pl-[0.05rem]',
'border rounded-xs', 'border rounded-xs',
'cc-animate-color',
{ {
'bg-sec-600 text-sec-0': value !== false, 'bg-sec-600 text-sec-0': value !== false,
'bg-prim-100': value === false 'bg-prim-100': value === false

View File

@ -41,7 +41,7 @@ export function FileInput({ id, label, acceptType, title, className, style, onCh
}; };
return ( return (
<div className={clsx('py-2', 'flex flex-col gap-2 items-center', className)} style={style}> <div className={clsx('py-2 flex flex-col gap-2 items-center', className)} style={style}>
<input <input
id={id} id={id}
type='file' type='file'

View File

@ -1,6 +1,5 @@
import clsx from 'clsx'; import clsx from 'clsx';
import { Overlay } from '@/components/Container';
import { IconSearch } from '@/components/Icons'; import { IconSearch } from '@/components/Icons';
import { type Styling } from '@/components/props'; import { type Styling } from '@/components/props';
@ -35,15 +34,17 @@ export function SearchBar({
noIcon, noIcon,
onChangeQuery, onChangeQuery,
noBorder, noBorder,
className,
placeholder = 'Поиск', placeholder = 'Поиск',
...restProps ...restProps
}: SearchBarProps) { }: SearchBarProps) {
return ( return (
<div {...restProps}> <div className={clsx('relative', className)} {...restProps}>
{!noIcon ? ( {!noIcon ? (
<Overlay position='top-[-0.125rem] left-3 translate-y-1/2' className='pointer-events-none clr-text-controls'> <IconSearch
<IconSearch size='1.25rem' /> className='absolute -top-0.5 left-3 translate-y-1/2 pointer-events-none clr-text-controls'
</Overlay> size='1.25rem'
/>
) : null} ) : null}
<TextInput <TextInput
id={id} id={id}

View File

@ -109,7 +109,7 @@ export function SelectMulti<Option, Group extends GroupBase<Option> = GroupBase<
...theme, ...theme,
borderRadius: 0, borderRadius: 0,
spacing: { spacing: {
...theme.spacing, // prettier: split-lines ...theme.spacing,
baseUnit: size.isSmall ? 2 : 4, baseUnit: size.isSmall ? 2 : 4,
menuGutter: size.isSmall ? 4 : 8, menuGutter: size.isSmall ? 4 : 8,
controlHeight: size.isSmall ? 28 : 38 controlHeight: size.isSmall ? 28 : 38

View File

@ -107,7 +107,7 @@ export function SelectSingle<Option, Group extends GroupBase<Option> = GroupBase
...theme, ...theme,
borderRadius: 0, borderRadius: 0,
spacing: { spacing: {
...theme.spacing, // prettier: split-lines ...theme.spacing,
baseUnit: size.isSmall ? 2 : 4, baseUnit: size.isSmall ? 2 : 4,
menuGutter: 2, menuGutter: 2,
controlHeight: size.isSmall ? 28 : 38 controlHeight: size.isSmall ? 28 : 38

View File

@ -3,7 +3,6 @@ import clsx from 'clsx';
import { globalIDs, PARAMETER } from '@/utils/constants'; import { globalIDs, PARAMETER } from '@/utils/constants';
import { Overlay } from '../Container';
import { MiniButton } from '../Control'; import { MiniButton } from '../Control';
import { IconDropArrow, IconPageRight } from '../Icons'; import { IconDropArrow, IconPageRight } from '../Icons';
import { type Styling } from '../props'; import { type Styling } from '../props';
@ -51,13 +50,13 @@ export function SelectTree<ItemType>({
setFolded(items.filter(item => getParent(value) !== item && getParent(getParent(value)) !== item)); setFolded(items.filter(item => getParent(value) !== item && getParent(getParent(value)) !== item));
}, [value, getParent, items]); }, [value, getParent, items]);
function onFoldItem(target: ItemType, showChildren: boolean) { function onFoldItem(target: ItemType) {
setFolded(prev => setFolded(prev =>
items.filter(item => { items.filter(item => {
if (item === target) { if (item === target) {
return !showChildren; return !prev.includes(target);
} }
if (!showChildren && (getParent(item) === target || getParent(getParent(item)) === target)) { if (!prev.includes(target) && (getParent(item) === target || getParent(getParent(item)) === target)) {
return true; return true;
} else { } else {
return prev.includes(item); return prev.includes(item);
@ -66,16 +65,20 @@ export function SelectTree<ItemType>({
); );
} }
function handleClickFold(event: React.MouseEvent<Element>, target: ItemType, showChildren: boolean) { function handleClickFold(event: React.MouseEvent<Element>, target: ItemType) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
onFoldItem(target, showChildren); onFoldItem(target);
} }
function handleSetValue(event: React.MouseEvent<Element>, target: ItemType) { function handleClickItem(event: React.MouseEvent<Element>, target: ItemType) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
onChange(target); if (event.ctrlKey || event.metaKey) {
onFoldItem(target);
} else {
onChange(target);
}
} }
return ( return (
@ -86,6 +89,7 @@ export function SelectTree<ItemType>({
<div <div
key={`${prefix}${index}`} key={`${prefix}${index}`}
className={clsx( className={clsx(
'relative',
'pr-3 pl-6 border-b', 'pr-3 pl-6 border-b',
'cc-scroll-row', 'cc-scroll-row',
'bg-prim-200 clr-hover cc-animate-color', 'bg-prim-200 clr-hover cc-animate-color',
@ -95,7 +99,7 @@ export function SelectTree<ItemType>({
)} )}
data-tooltip-id={globalIDs.tooltip} data-tooltip-id={globalIDs.tooltip}
data-tooltip-html={getDescription(item)} data-tooltip-html={getDescription(item)}
onClick={event => handleSetValue(event, item)} onClick={event => handleClickItem(event, item)}
style={{ style={{
borderBottomWidth: isActive ? '1px' : '0px', borderBottomWidth: isActive ? '1px' : '0px',
willChange: 'max-height, opacity, padding', willChange: 'max-height, opacity, padding',
@ -108,14 +112,13 @@ export function SelectTree<ItemType>({
}} }}
> >
{foldable.has(item) ? ( {foldable.has(item) ? (
<Overlay position='left-[-1.3rem]' className={clsx(!folded.includes(item) && 'top-[0.1rem]')}> <MiniButton
<MiniButton className={clsx('absolute left-1', !folded.includes(item) ? 'top-1.5' : 'top-1')}
noPadding noPadding
noHover noHover
icon={!folded.includes(item) ? <IconDropArrow size='1rem' /> : <IconPageRight size='1.25rem' />} icon={!folded.includes(item) ? <IconDropArrow size='1rem' /> : <IconPageRight size='1.25rem' />}
onClick={event => handleClickFold(event, item, folded.includes(item))} onClick={event => handleClickFold(event, item)}
/> />
</Overlay>
) : null} ) : null}
{getParent(item) === item ? getLabel(item) : `- ${getLabel(item).toLowerCase()}`} {getParent(item) === item ? getLabel(item) : `- ${getLabel(item).toLowerCase()}`}
</div> </div>

View File

@ -1,19 +1,14 @@
'use client'; 'use client';
import clsx from 'clsx';
interface ModalBackdropProps { interface ModalBackdropProps {
onHide: () => void; onHide?: () => void;
} }
export function ModalBackdrop({ onHide }: ModalBackdropProps) { export function ModalBackdrop({ onHide }: ModalBackdropProps) {
return ( return (
<> <>
<div className={clsx('z-navigation', 'fixed top-0 left-0', 'w-full h-full', 'backdrop-blur-[3px] opacity-50')} /> <div className='z-bottom fixed inset-0 backdrop-blur-[3px] opacity-50' />
<div <div className='z-bottom fixed inset-0 bg-prim-0 opacity-25' onClick={onHide} />
className={clsx('z-navigation', 'fixed top-0 left-0', 'w-full h-full', 'bg-prim-0 opacity-25')}
onClick={onHide}
/>
</> </>
); );
} }

View File

@ -7,10 +7,8 @@ import { BadgeHelp } from '@/features/help/components';
import { useEscapeKey } from '@/hooks/useEscapeKey'; import { useEscapeKey } from '@/hooks/useEscapeKey';
import { useDialogsStore } from '@/stores/dialogs'; import { useDialogsStore } from '@/stores/dialogs';
import { PARAMETER } from '@/utils/constants';
import { prepareTooltip } from '@/utils/utils'; import { prepareTooltip } from '@/utils/utils';
import { Overlay } from '../Container';
import { Button, MiniButton, SubmitButton } from '../Control'; import { Button, MiniButton, SubmitButton } from '../Control';
import { IconClose } from '../Icons'; import { IconClose } from '../Icons';
import { type Styling } from '../props'; import { type Styling } from '../props';
@ -89,37 +87,43 @@ export function ModalForm({
} }
return ( return (
<div className='fixed top-0 left-0 w-full h-full z-modal cursor-default'> <div className='cc-modal-wrapper'>
<ModalBackdrop onHide={handleCancel} /> <ModalBackdrop onHide={handleCancel} />
<form <form
className={clsx( className='cc-animate-modal grid border rounded-xl bg-prim-100'
'cc-animate-modal', role='dialog'
'z-modal absolute bottom-1/2 left-1/2 -translate-x-1/2 translate-y-1/2',
'border rounded-xl bg-prim-100'
)}
onSubmit={handleSubmit} onSubmit={handleSubmit}
aria-labelledby='modal-title'
> >
{helpTopic && !hideHelpWhen?.() ? ( {helpTopic && !hideHelpWhen?.() ? (
<div className='float-left mt-2 ml-2'> <BadgeHelp
<BadgeHelp topic={helpTopic} className={clsx(PARAMETER.TOOLTIP_WIDTH, 'sm:max-w-[40rem]')} padding='p-0' /> topic={helpTopic}
</div> className='absolute z-pop left-0 mt-2 ml-2'
padding='p-0'
contentClass='sm:max-w-160'
/>
) : null} ) : null}
<Overlay className='z-modalOverlay'> <MiniButton
<MiniButton noPadding
noPadding aria-label='Закрыть'
titleHtml={prepareTooltip('Закрыть диалоговое окно', 'ESC')} titleHtml={prepareTooltip('Закрыть диалоговое окно', 'ESC')}
icon={<IconClose size='1.25rem' />} icon={<IconClose size='1.25rem' />}
className='float-right mt-2 mr-2' className='absolute z-pop right-0 mt-2 mr-2'
onClick={handleCancel} onClick={hideDialog}
/> />
</Overlay>
{header ? <h1 className='px-12 py-2 select-none'>{header}</h1> : null} {header ? (
<h1 id='modal-title' className='px-12 py-2 select-none'>
{header}
</h1>
) : null}
<div <div
className={clsx( className={clsx(
'overscroll-contain max-h-[calc(100svh-8rem)] max-w-[100svw] xs:max-w-[calc(100svw-2rem)] outline-hidden', '@container/modal',
'max-h-[calc(100svh-8rem)] max-w-[100svw] xs:max-w-[calc(100svw-2rem)]',
'overscroll-contain outline-hidden',
{ {
'overflow-auto': !overflowVisible, 'overflow-auto': !overflowVisible,
'overflow-visible': overflowVisible 'overflow-visible': overflowVisible
@ -131,15 +135,15 @@ export function ModalForm({
{children} {children}
</div> </div>
<div className='z-modal-controls my-2 flex gap-12 justify-center text-sm'> <div className='z-pop my-2 flex gap-12 justify-center text-sm'>
<SubmitButton <SubmitButton
autoFocus autoFocus
text={submitText} text={submitText}
title={!canSubmit ? submitInvalidTooltip : ''} title={!canSubmit ? submitInvalidTooltip : ''}
className='min-w-[7rem]' className='min-w-28'
disabled={!canSubmit} disabled={!canSubmit}
/> />
<Button text='Отмена' className='min-w-[7rem]' onClick={handleCancel} /> <Button text='Отмена' aria-label='Закрыть' className='min-w-28' onClick={handleCancel} />
</div> </div>
</form> </form>
</div> </div>

View File

@ -1,19 +1,12 @@
import clsx from 'clsx';
import { Loader } from '@/components/Loader'; import { Loader } from '@/components/Loader';
import { ModalBackdrop } from './ModalBackdrop';
export function ModalLoader() { export function ModalLoader() {
return ( return (
<div className='fixed top-0 left-0 w-full h-full z-modal cursor-default'> <div className='cc-modal-wrapper'>
<div className={clsx('z-navigation', 'fixed top-0 left-0', 'w-full h-full', 'backdrop-blur-[3px] opacity-50')} /> <ModalBackdrop />
<div className={clsx('z-navigation', 'fixed top-0 left-0', 'w-full h-full', 'bg-prim-0 opacity-25')} /> <div className='cc-animate-modal p-20 border rounded-xl bg-prim-100'>
<div
className={clsx(
'cc-animate-modal p-20',
'z-modal absolute bottom-1/2 left-1/2 -translate-x-1/2 translate-y-1/2',
'border rounded-xl bg-prim-100'
)}
>
<Loader circular scale={6} /> <Loader circular scale={6} />
</div> </div>
</div> </div>

View File

@ -6,10 +6,8 @@ import { BadgeHelp } from '@/features/help/components';
import { useEscapeKey } from '@/hooks/useEscapeKey'; import { useEscapeKey } from '@/hooks/useEscapeKey';
import { useDialogsStore } from '@/stores/dialogs'; import { useDialogsStore } from '@/stores/dialogs';
import { PARAMETER } from '@/utils/constants';
import { prepareTooltip } from '@/utils/utils'; import { prepareTooltip } from '@/utils/utils';
import { Overlay } from '../Container';
import { Button, MiniButton } from '../Control'; import { Button, MiniButton } from '../Control';
import { IconClose } from '../Icons'; import { IconClose } from '../Icons';
@ -34,36 +32,34 @@ export function ModalView({
useEscapeKey(hideDialog); useEscapeKey(hideDialog);
return ( return (
<div className='fixed top-0 left-0 w-full h-full z-modal cursor-default'> <div className='cc-modal-wrapper'>
<ModalBackdrop onHide={hideDialog} /> <ModalBackdrop onHide={hideDialog} />
<div <div className='cc-animate-modal grid border rounded-xl bg-prim-100' role='dialog'>
className={clsx(
'cc-animate-modal',
'z-modal absolute bottom-1/2 left-1/2 -translate-x-1/2 translate-y-1/2',
'border rounded-xl bg-prim-100'
)}
>
{helpTopic && !hideHelpWhen?.() ? ( {helpTopic && !hideHelpWhen?.() ? (
<div className='float-left mt-2 ml-2'> <BadgeHelp
<BadgeHelp topic={helpTopic} className={clsx(PARAMETER.TOOLTIP_WIDTH, 'sm:max-w-[40rem]')} padding='p-0' /> topic={helpTopic}
</div> className='absolute z-pop left-0 mt-2 ml-2'
padding='p-0'
contentClass='sm:max-w-160'
/>
) : null} ) : null}
<Overlay className='z-modalOverlay'> <MiniButton
<MiniButton noPadding
noPadding aria-label='Закрыть'
titleHtml={prepareTooltip('Закрыть диалоговое окно', 'ESC')} titleHtml={prepareTooltip('Закрыть диалоговое окно', 'ESC')}
icon={<IconClose size='1.25rem' />} icon={<IconClose size='1.25rem' />}
className='float-right mt-2 mr-2' className='absolute z-pop right-0 mt-2 mr-2'
onClick={hideDialog} onClick={hideDialog}
/> />
</Overlay>
{header ? <h1 className='px-12 py-2 select-none'>{header}</h1> : null} {header ? <h1 className='px-12 py-2 select-none'>{header}</h1> : null}
<div <div
className={clsx( className={clsx(
'overscroll-contain max-h-[calc(100svh-8rem)] max-w-[100svw] xs:max-w-[calc(100svw-2rem)] outline-hidden', '@container/modal',
'max-h-[calc(100svh-8rem)] max-w-[100svw] xs:max-w-[calc(100svw-2rem)]',
'overscroll-contain outline-hidden',
{ {
'overflow-auto': !overflowVisible, 'overflow-auto': !overflowVisible,
'overflow-visible': overflowVisible 'overflow-visible': overflowVisible
@ -75,9 +71,12 @@ export function ModalView({
{children} {children}
</div> </div>
<div className='z-modal-controls my-2 flex gap-12 justify-center text-sm'> <Button
<Button text='Закрыть' className='min-w-[7rem]' onClick={hideDialog} /> text='Закрыть'
</div> aria-label='Закрыть'
className='z-pop my-2 mx-auto text-sm min-w-28'
onClick={hideDialog}
/>
</div> </div>
</div> </div>
); );

View File

@ -18,7 +18,7 @@ export function TabLabel({ label, title, titleHtml, hideTitle, className, ...oth
return ( return (
<TabImpl <TabImpl
className={clsx( className={clsx(
'min-w-[5.5rem] h-full', 'min-w-20 h-full',
'px-2 py-1 flex justify-center', 'px-2 py-1 flex justify-center',
'clr-hover cc-animate-color duration-150', 'clr-hover cc-animate-color duration-150',
'text-sm whitespace-nowrap font-controls', 'text-sm whitespace-nowrap font-controls',

View File

@ -20,5 +20,5 @@ interface ValueStatsProps extends Styling, Titled {
* Displays statistics value with an icon. * Displays statistics value with an icon.
*/ */
export function ValueStats(props: ValueStatsProps) { export function ValueStats(props: ValueStatsProps) {
return <ValueIcon dense smallThreshold={SMALL_THRESHOLD} textClassName='min-w-[1.4rem]' {...props} />; return <ValueIcon dense smallThreshold={SMALL_THRESHOLD} textClassName='min-w-5' {...props} />;
} }

View File

@ -15,7 +15,7 @@ export interface ICurrentUser {
/** /**
* Represents login data, used to authenticate users. * Represents login data, used to authenticate users.
*/ */
export const schemaUserLogin = z.object({ export const schemaUserLogin = z.strictObject({
username: z.string().nonempty(errorMsg.requiredField), username: z.string().nonempty(errorMsg.requiredField),
password: z.string().nonempty(errorMsg.requiredField) password: z.string().nonempty(errorMsg.requiredField)
}); });

View File

@ -2,7 +2,6 @@
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import clsx from 'clsx';
import { urls, useConceptNavigation } from '@/app'; import { urls, useConceptNavigation } from '@/app';
@ -56,11 +55,11 @@ export function LoginPage() {
} }
return ( return (
<form <form
className={clsx('cc-column cc-fade-in', 'w-[24rem] mx-auto', 'pt-12 pb-6 px-6')} className='cc-column cc-fade-in w-96 mx-auto pt-12 pb-6 px-6'
onSubmit={event => void handleSubmit(onSubmit)(event)} onSubmit={event => void handleSubmit(onSubmit)(event)}
onChange={resetErrors} onChange={resetErrors}
> >
<img alt='Концепт Портал' src={resources.logo} className='max-h-[2.5rem] min-w-[2.5rem] mb-3' /> <img alt='Концепт Портал' src={resources.logo} className='max-h-10 min-w-10 mb-3' />
<TextInput <TextInput
id='username' id='username'
autoComplete='username' autoComplete='username'
@ -82,7 +81,7 @@ export function LoginPage() {
error={errors.password} error={errors.password}
/> />
<SubmitButton text='Войти' className='self-center w-[12rem] mt-3' loading={isPending} /> <SubmitButton text='Войти' className='self-center w-48 mt-3' loading={isPending} />
<div className='flex flex-col text-sm'> <div className='flex flex-col text-sm'>
<TextURL text='Восстановить пароль...' href='/restore-password' /> <TextURL text='Восстановить пароль...' href='/restore-password' />
<TextURL text='Нет аккаунта? Зарегистрируйтесь...' href='/signup' /> <TextURL text='Нет аккаунта? Зарегистрируйтесь...' href='/signup' />

View File

@ -1,7 +1,6 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import clsx from 'clsx';
import { urls, useConceptNavigation } from '@/app'; import { urls, useConceptNavigation } from '@/app';
@ -51,7 +50,7 @@ export function Component() {
} }
return ( return (
<form className={clsx('cc-fade-in cc-column', 'w-[24rem] mx-auto', 'px-6 mt-3')} onSubmit={handleSubmit}> <form className='cc-fade-in cc-column w-96 mx-auto px-6 mt-3' onSubmit={handleSubmit}>
<TextInput <TextInput
id='new_password' id='new_password'
type='password' type='password'
@ -77,7 +76,7 @@ export function Component() {
<SubmitButton <SubmitButton
text='Установить пароль' text='Установить пароль'
className='self-center w-[12rem] mt-3' className='self-center w-48 mt-3'
loading={isPending} loading={isPending}
disabled={!canSubmit} disabled={!canSubmit}
/> />

View File

@ -1,7 +1,6 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import clsx from 'clsx';
import { isAxiosError } from '@/backend/apiTransport'; import { isAxiosError } from '@/backend/apiTransport';
import { SubmitButton, TextURL } from '@/components/Control'; import { SubmitButton, TextURL } from '@/components/Control';
@ -33,11 +32,7 @@ export function Component() {
); );
} else { } else {
return ( return (
<form <form className='cc-fade-in cc-column w-96 mx-auto px-6 mt-3' onSubmit={handleSubmit} onChange={clearServerError}>
className={clsx('cc-fade-in cc-column', 'w-[24rem] mx-auto', 'px-6 mt-3')}
onSubmit={handleSubmit}
onChange={clearServerError}
>
<TextInput <TextInput
id='email' id='email'
autoComplete='email' autoComplete='email'
@ -48,12 +43,7 @@ export function Component() {
onChange={event => setEmail(event.target.value)} onChange={event => setEmail(event.target.value)}
/> />
<SubmitButton <SubmitButton text='Запросить пароль' className='self-center w-48 mt-3' loading={isPending} disabled={!email} />
text='Запросить пароль'
className='self-center w-[12rem] mt-3'
loading={isPending}
disabled={!email}
/>
{serverError ? <ServerError error={serverError} /> : null} {serverError ? <ServerError error={serverError} /> : null}
</form> </form>
); );

View File

@ -1,4 +1,5 @@
import React, { Suspense } from 'react'; import React, { Suspense } from 'react';
import clsx from 'clsx';
import { type PlacesType, Tooltip } from '@/components/Container'; import { type PlacesType, Tooltip } from '@/components/Container';
import { TextURL } from '@/components/Control'; import { TextURL } from '@/components/Control';
@ -6,6 +7,7 @@ import { IconHelp } from '@/components/Icons';
import { Loader } from '@/components/Loader'; import { Loader } from '@/components/Loader';
import { type Styling } from '@/components/props'; import { type Styling } from '@/components/props';
import { usePreferencesStore } from '@/stores/preferences'; import { usePreferencesStore } from '@/stores/preferences';
import { PARAMETER } from '@/utils/constants';
import { type HelpTopic } from '../models/helpTopic'; import { type HelpTopic } from '../models/helpTopic';
@ -25,26 +27,33 @@ interface BadgeHelpProps extends Styling {
/** Place of the tooltip in relation to the cursor. */ /** Place of the tooltip in relation to the cursor. */
place?: PlacesType; place?: PlacesType;
/** Classname for content wrapper. */
contentClass?: string;
} }
/** /**
* Display help icon with a manual page tooltip. * Display help icon with a manual page tooltip.
*/ */
export function BadgeHelp({ topic, padding = 'p-1', ...restProps }: BadgeHelpProps) { export function BadgeHelp({ topic, padding = 'p-1', className, contentClass, style, ...restProps }: BadgeHelpProps) {
const showHelp = usePreferencesStore(state => state.showHelp); const showHelp = usePreferencesStore(state => state.showHelp);
if (!showHelp) { if (!showHelp) {
return null; return null;
} }
return ( return (
<div tabIndex={-1} id={`help-${topic}`} className={padding}> <div tabIndex={-1} id={`help-${topic}`} className={clsx(padding, className)} style={style}>
<IconHelp size='1.25rem' className='icon-primary' /> <IconHelp size='1.25rem' className='icon-primary' />
<Tooltip clickable anchorSelect={`#help-${topic}`} layer='z-modal-tooltip' {...restProps}> <Tooltip
clickable
anchorSelect={`#help-${topic}`}
layer='z-topmost'
className={clsx(PARAMETER.TOOLTIP_WIDTH, contentClass)}
{...restProps}
>
<Suspense fallback={<Loader />}> <Suspense fallback={<Loader />}>
<div className='relative' onClick={event => event.stopPropagation()}> <div className='absolute right-1 text-sm top-2 clr-input' onClick={event => event.stopPropagation()}>
<div className='absolute right-0 text-sm top-[0.4rem] clr-input'> <TextURL text='Справка...' href={`/manuals?topic=${topic}`} />
<TextURL text='Справка...' href={`/manuals?topic=${topic}`} />
</div>
</div> </div>
<TopicPage topic={topic} /> <TopicPage topic={topic} />
</Suspense> </Suspense>

View File

@ -1,5 +1,3 @@
import clsx from 'clsx';
import { CstClass } from '@/features/rsform'; import { CstClass } from '@/features/rsform';
import { colorBgCstClass } from '@/features/rsform/colors'; import { colorBgCstClass } from '@/features/rsform/colors';
import { describeCstClass, labelCstClass } from '@/features/rsform/labels'; import { describeCstClass, labelCstClass } from '@/features/rsform/labels';
@ -18,7 +16,7 @@ export function InfoCstClass({ header }: InfoCstClassProps) {
return ( return (
<p key={`${prefixes.cst_status_list}${index}`}> <p key={`${prefixes.cst_status_list}${index}`}>
<span <span
className={clsx('inline-block', 'min-w-[7rem]', 'px-1', 'border', 'text-center text-sm font-controls')} className='inline-block min-w-28 px-1 border text-center text-sm font-controls'
style={{ backgroundColor: colorBgCstClass(cstClass) }} style={{ backgroundColor: colorBgCstClass(cstClass) }}
> >
{labelCstClass(cstClass)} {labelCstClass(cstClass)}

View File

@ -21,7 +21,7 @@ export function InfoCstStatus({ title }: InfoCstStatusProps) {
<span <span
className={clsx( className={clsx(
'inline-block', // 'inline-block', //
'min-w-[7rem]', 'min-w-28',
'px-1', 'px-1',
'border', 'border',
'text-center text-sm font-controls' 'text-center text-sm font-controls'

View File

@ -7,11 +7,12 @@ import { HelpTopic } from '../models/helpTopic';
export function HelpRSLang() { export function HelpRSLang() {
const windowSize = useWindowSize(); const windowSize = useWindowSize();
const isSmall = windowSize.isSmall;
const videoHeight = (() => { const videoHeight = (() => {
const viewH = windowSize.height ?? 0; const viewH = windowSize.height ?? 0;
const viewW = windowSize.width ?? 0; const viewW = windowSize.width ?? 0;
const availableWidth = viewW - (windowSize.isSmall ? 35 : 310); const availableWidth = viewW - (isSmall ? 35 : 310);
return Math.min(1080, Math.max(viewH - 450, 300), Math.floor((availableWidth * 9) / 16)); return Math.min(1080, Math.max(viewH - 450, 300), Math.floor((availableWidth * 9) / 16));
})(); })();

View File

@ -25,9 +25,9 @@ import { HelpTopic } from '../../models/helpTopic';
export function HelpOssGraph() { export function HelpOssGraph() {
return ( return (
<div className='flex flex-col'> <div className='flex flex-col'>
<h1 className='sm:pr-[6rem]'>Граф синтеза</h1> <h1 className='sm:pr-24'>Граф синтеза</h1>
<div className='flex flex-col sm:flex-row'> <div className='flex flex-col sm:flex-row'>
<div className='sm:w-[14rem]'> <div className='sm:w-56'>
<h1>Настройка графа</h1> <h1>Настройка графа</h1>
<li> <li>
<IconFitImage className='inline-icon' /> Вписать в экран <IconFitImage className='inline-icon' /> Вписать в экран
@ -51,7 +51,7 @@ export function HelpOssGraph() {
<Divider vertical margins='mx-3 mt-3' className='hidden sm:block' /> <Divider vertical margins='mx-3 mt-3' className='hidden sm:block' />
<div className='sm:w-[21rem]'> <div className='sm:w-84'>
<h1>Изменение узлов</h1> <h1>Изменение узлов</h1>
<li>Клик на операцию выделение</li> <li>Клик на операцию выделение</li>
<li>Esc сбросить выделение</li> <li>Esc сбросить выделение</li>
@ -73,7 +73,7 @@ export function HelpOssGraph() {
<Divider margins='my-3' className='hidden sm:block' /> <Divider margins='my-3' className='hidden sm:block' />
<div className='flex flex-col-reverse mb-3 sm:flex-row'> <div className='flex flex-col-reverse mb-3 sm:flex-row'>
<div className='sm:w-[14rem]'> <div className='sm:w-56'>
<h1>Общие</h1> <h1>Общие</h1>
<li> <li>
<IconReset className='inline-icon' /> Сбросить изменения <IconReset className='inline-icon' /> Сбросить изменения
@ -85,7 +85,7 @@ export function HelpOssGraph() {
<Divider vertical margins='mx-3' className='hidden sm:block' /> <Divider vertical margins='mx-3' className='hidden sm:block' />
<div className='dense w-[21rem]'> <div className='dense w-84'>
<h1>Контекстное меню</h1> <h1>Контекстное меню</h1>
<li> <li>
<IconRSForm className='inline-icon icon-green' /> Статус связанной{' '} <IconRSForm className='inline-icon icon-green' /> Статус связанной{' '}

View File

@ -29,7 +29,7 @@ export function HelpRSGraphTerm() {
<div className='flex flex-col'> <div className='flex flex-col'>
<h1>Граф термов</h1> <h1>Граф термов</h1>
<div className='flex flex-col sm:flex-row'> <div className='flex flex-col sm:flex-row'>
<div className='sm:w-[14rem]'> <div className='sm:w-56'>
<h1>Настройка графа</h1> <h1>Настройка графа</h1>
<li>Цвет покраска узлов</li> <li>Цвет покраска узлов</li>
<li> <li>
@ -45,7 +45,7 @@ export function HelpRSGraphTerm() {
<Divider vertical margins='mx-3 mt-3' className='hidden sm:block' /> <Divider vertical margins='mx-3 mt-3' className='hidden sm:block' />
<div className='sm:w-[21rem]'> <div className='sm:w-84'>
<h1>Изменение узлов</h1> <h1>Изменение узлов</h1>
<li>Клик на узел выделение</li> <li>Клик на узел выделение</li>
<li> <li>
@ -69,7 +69,7 @@ export function HelpRSGraphTerm() {
<Divider margins='my-3' className='hidden sm:block' /> <Divider margins='my-3' className='hidden sm:block' />
<div className='flex flex-col-reverse mb-3 sm:flex-row'> <div className='flex flex-col-reverse mb-3 sm:flex-row'>
<div className='sm:w-[14rem]'> <div className='sm:w-56'>
<h1>Общие</h1> <h1>Общие</h1>
<li> <li>
<IconOSS className='inline-icon' /> переход к связанной <LinkTopic text='ОСС' topic={HelpTopic.CC_OSS} /> <IconOSS className='inline-icon' /> переход к связанной <LinkTopic text='ОСС' topic={HelpTopic.CC_OSS} />
@ -88,7 +88,7 @@ export function HelpRSGraphTerm() {
<Divider vertical margins='mx-3' className='hidden sm:block' /> <Divider vertical margins='mx-3' className='hidden sm:block' />
<div className='dense w-[21rem]'> <div className='dense w-84'>
<h1>Выделение</h1> <h1>Выделение</h1>
<li> <li>
<IconGraphCollapse className='inline-icon' /> все влияющие <IconGraphCollapse className='inline-icon' /> все влияющие

View File

@ -67,7 +67,7 @@ export function HelpRSMenu() {
<Divider vertical margins='mx-3' /> <Divider vertical margins='mx-3' />
<div className='w-[18rem]'> <div className='w-72'>
<h2>Режимы работы</h2> <h2>Режимы работы</h2>
<li> <li>
<IconAlert size='1.25rem' className='inline-icon icon-red' /> работа в анонимном режиме. Переход на страницу <IconAlert size='1.25rem' className='inline-icon icon-red' /> работа в анонимном режиме. Переход на страницу

View File

@ -27,7 +27,7 @@ export function ManualsPage() {
} }
return ( return (
<div className='flex mx-auto max-w-[80rem]' role='manuals' style={{ minHeight: mainHeight }}> <div className='flex mx-auto max-w-320' role='manuals' style={{ minHeight: mainHeight }}>
<TopicsList activeTopic={activeTopic} onChangeTopic={topic => onSelectTopic(topic)} /> <TopicsList activeTopic={activeTopic} onChangeTopic={topic => onSelectTopic(topic)} />
<ViewTopic topic={activeTopic} /> <ViewTopic topic={activeTopic} />
</div> </div>

View File

@ -31,14 +31,14 @@ export function TopicsDropdown({ activeTopic, onChangeTopic }: TopicsDropdownPro
<div <div
ref={menu.ref} ref={menu.ref}
className={clsx( className={clsx(
'absolute left-0 w-[13.5rem]', // prettier: split-lines 'absolute left-0 w-54', //
'flex flex-col', 'flex flex-col',
'z-modal-tooltip', 'z-topmost',
'text-xs sm:text-sm', 'text-xs sm:text-sm',
'select-none', 'select-none',
{ {
'top-0': noNavigation, 'top-0': noNavigation,
'top-[3rem]': !noNavigation 'top-12': !noNavigation
} }
)} )}
> >
@ -48,7 +48,7 @@ export function TopicsDropdown({ activeTopic, onChangeTopic }: TopicsDropdownPro
title='Список тем' title='Список тем'
hideTitle={menu.isOpen} hideTitle={menu.isOpen}
icon={!menu.isOpen ? <IconMenuUnfold size='1.25rem' /> : <IconMenuFold size='1.25rem' />} icon={!menu.isOpen ? <IconMenuUnfold size='1.25rem' /> : <IconMenuFold size='1.25rem' />}
className={clsx('w-[3rem] h-7 rounded-none border-l-0', menu.isOpen && 'border-b-0')} className={clsx('w-12 h-7 rounded-none border-l-0', menu.isOpen && 'border-b-0')}
onClick={menu.toggle} onClick={menu.toggle}
/> />
<SelectTree <SelectTree
@ -59,11 +59,7 @@ export function TopicsDropdown({ activeTopic, onChangeTopic }: TopicsDropdownPro
getParent={item => topicParent.get(item) ?? item} getParent={item => topicParent.get(item) ?? item}
getLabel={labelHelpTopic} getLabel={labelHelpTopic}
getDescription={describeHelpTopic} getDescription={describeHelpTopic}
className={clsx( className='border-r border-t rounded-none cc-scroll-y bg-prim-200'
'border-r border-t rounded-none', // prettier: split-lines
'cc-scroll-y',
'bg-prim-200'
)}
style={{ style={{
maxHeight: treeHeight, maxHeight: treeHeight,
willChange: 'clip-path', willChange: 'clip-path',

View File

@ -25,7 +25,7 @@ export function TopicsStatic({ activeTopic, onChangeTopic }: TopicsStaticProps)
getDescription={describeHelpTopic} getDescription={describeHelpTopic}
className={clsx( className={clsx(
'sticky top-0 left-0', 'sticky top-0 left-0',
'min-w-[14.5rem] max-w-[14.5rem] sm:min-w-[12.5rem] sm:max-w-[12.5rem] md:min-w-[14.5rem] md:max-w-[14.5rem]', 'min-w-58 max-w-58 sm:min-w-50 sm:max-w-50 md:min-w-58 md:max-w-58',
'cc-scroll-y', 'cc-scroll-y',
'self-start', 'self-start',
'border-x border-t rounded-none', 'border-x border-t rounded-none',

View File

@ -19,7 +19,7 @@ import {
type IRenameLocationDTO, type IRenameLocationDTO,
type IUpdateLibraryItemDTO, type IUpdateLibraryItemDTO,
type IVersionCreateDTO, type IVersionCreateDTO,
type IVersionInfo, type IVersionExInfo,
type IVersionUpdateDTO, type IVersionUpdateDTO,
schemaLibraryItem, schemaLibraryItem,
schemaLibraryItemArray, schemaLibraryItemArray,
@ -154,7 +154,7 @@ export const libraryApi = {
} }
}), }),
versionUpdate: (data: { itemID: number; version: IVersionUpdateDTO }) => versionUpdate: (data: { itemID: number; version: IVersionUpdateDTO }) =>
axiosPatch<IVersionUpdateDTO, IVersionInfo>({ axiosPatch<IVersionUpdateDTO, IVersionExInfo>({
schema: schemaVersionInfo, schema: schemaVersionInfo,
endpoint: `/api/versions/${data.version.id}`, endpoint: `/api/versions/${data.version.id}`,
request: { request: {

View File

@ -34,6 +34,9 @@ export interface IRenameLocationDTO {
/** Represents library item version information. */ /** Represents library item version information. */
export type IVersionInfo = z.infer<typeof schemaVersionInfo>; export type IVersionInfo = z.infer<typeof schemaVersionInfo>;
/** Represents library item version extended information. */
export type IVersionExInfo = z.infer<typeof schemaVersionExInfo>;
/** Represents data, used for cloning {@link IRSForm}. */ /** Represents data, used for cloning {@link IRSForm}. */
export type ICloneLibraryItemDTO = z.infer<typeof schemaCloneLibraryItem>; export type ICloneLibraryItemDTO = z.infer<typeof schemaCloneLibraryItem>;
@ -51,7 +54,7 @@ export type IVersionUpdateDTO = z.infer<typeof schemaVersionUpdate>;
// ======= SCHEMAS ========= // ======= SCHEMAS =========
export const schemaLibraryItem = z.object({ export const schemaLibraryItem = z.strictObject({
id: z.coerce.number(), id: z.coerce.number(),
item_type: z.nativeEnum(LibraryItemType), item_type: z.nativeEnum(LibraryItemType),
title: z.string(), title: z.string(),
@ -112,7 +115,7 @@ export const schemaCreateLibraryItem = z
message: errorMsg.requiredField message: errorMsg.requiredField
}); });
export const schemaUpdateLibraryItem = z.object({ export const schemaUpdateLibraryItem = z.strictObject({
id: z.number(), id: z.number(),
item_type: z.nativeEnum(LibraryItemType), item_type: z.nativeEnum(LibraryItemType),
title: z.string().nonempty(errorMsg.requiredField), title: z.string().nonempty(errorMsg.requiredField),
@ -122,20 +125,24 @@ export const schemaUpdateLibraryItem = z.object({
read_only: z.boolean() read_only: z.boolean()
}); });
export const schemaVersionInfo = z.object({ export const schemaVersionInfo = z.strictObject({
id: z.coerce.number(), id: z.coerce.number(),
version: z.string(), version: z.string(),
description: z.string(), description: z.string(),
time_create: z.string().datetime({ offset: true }) time_create: z.string().datetime({ offset: true })
}); });
export const schemaVersionUpdate = z.object({ export const schemaVersionExInfo = schemaVersionInfo.extend({
item: z.number()
});
export const schemaVersionUpdate = z.strictObject({
id: z.number(), id: z.number(),
version: z.string().nonempty(errorMsg.requiredField), version: z.string().nonempty(errorMsg.requiredField),
description: z.string() description: z.string()
}); });
export const schemaVersionCreate = z.object({ export const schemaVersionCreate = z.strictObject({
version: z.string(), version: z.string(),
description: z.string(), description: z.string(),
items: z.array(z.number()) items: z.array(z.number())

View File

@ -5,7 +5,7 @@ import { urls, useConceptNavigation } from '@/app';
import { useLabelUser, useRoleStore, UserRole } from '@/features/users'; import { useLabelUser, useRoleStore, UserRole } from '@/features/users';
import { InfoUsers, SelectUser } from '@/features/users/components'; import { InfoUsers, SelectUser } from '@/features/users/components';
import { Overlay, Tooltip } from '@/components/Container'; import { Tooltip } from '@/components/Container';
import { MiniButton } from '@/components/Control'; import { MiniButton } from '@/components/Control';
import { useDropdown } from '@/components/Dropdown'; import { useDropdown } from '@/components/Dropdown';
import { import {
@ -83,7 +83,7 @@ export function EditorLibraryItem({ schema, isAttachedToOSS }: EditorLibraryItem
return ( return (
<div className='flex flex-col'> <div className='flex flex-col'>
<div className='flex justify-stretch sm:mb-1 max-w-[30rem] gap-3'> <div className='relative flex justify-stretch sm:mb-1 max-w-120 gap-3'>
<MiniButton <MiniButton
noHover noHover
noPadding noPadding
@ -101,21 +101,21 @@ export function EditorLibraryItem({ schema, isAttachedToOSS }: EditorLibraryItem
/> />
</div> </div>
{ownerSelector.isOpen ? ( <div className='relative'>
<Overlay position='top-[-0.5rem] left-[4rem] cc-icons'> {ownerSelector.isOpen ? (
{ownerSelector.isOpen ? ( <div className='absolute -top-2 right-0'>
<SelectUser className='w-[25rem] sm:w-[26rem] text-sm' value={schema.owner} onChange={onSelectUser} /> <SelectUser className='w-100 text-sm' value={schema.owner} onChange={onSelectUser} />
) : null} </div>
</Overlay> ) : null}
) : null} <ValueIcon
<ValueIcon className='sm:mb-1'
className='sm:mb-1' icon={<IconOwner size='1.25rem' className='icon-primary' />}
icon={<IconOwner size='1.25rem' className='icon-primary' />} value={getUserLabel(schema.owner)}
value={getUserLabel(schema.owner)} title={isAttachedToOSS ? 'Владелец наследуется от ОСС' : 'Владелец'}
title={isAttachedToOSS ? 'Владелец наследуется от ОСС' : 'Владелец'} onClick={ownerSelector.toggle}
onClick={ownerSelector.toggle} disabled={isModified || isProcessing || isAttachedToOSS || role < UserRole.OWNER}
disabled={isModified || isProcessing || isAttachedToOSS || role < UserRole.OWNER} />
/> </div>
<div className='sm:mb-1 flex justify-between items-center'> <div className='sm:mb-1 flex justify-between items-center'>
<ValueIcon <ValueIcon
@ -126,7 +126,7 @@ export function EditorLibraryItem({ schema, isAttachedToOSS }: EditorLibraryItem
onClick={handleEditEditors} onClick={handleEditEditors}
disabled={isModified || isProcessing || role < UserRole.OWNER} disabled={isModified || isProcessing || role < UserRole.OWNER}
/> />
<Tooltip anchorSelect='#editor_stats' layer='z-modal-tooltip'> <Tooltip anchorSelect='#editor_stats'>
<Suspense fallback={<Loader scale={2} />}> <Suspense fallback={<Loader scale={2} />}>
<InfoUsers items={schema.editors} prefix={prefixes.user_editors} header='Редакторы' /> <InfoUsers items={schema.editors} prefix={prefixes.user_editors} header='Редакторы' />
</Suspense> </Suspense>

View File

@ -44,7 +44,7 @@ export function MenuRole({ isOwned, isEditor }: MenuRoleProps) {
} }
return ( return (
<div ref={accessMenu.ref}> <div ref={accessMenu.ref} className='relative'>
<Button <Button
dense dense
noBorder noBorder
@ -56,7 +56,7 @@ export function MenuRole({ isOwned, isEditor }: MenuRoleProps) {
icon={<IconRole role={role} size='1.25rem' />} icon={<IconRole role={role} size='1.25rem' />}
onClick={accessMenu.toggle} onClick={accessMenu.toggle}
/> />
<Dropdown isOpen={accessMenu.isOpen}> <Dropdown isOpen={accessMenu.isOpen} margin='mt-3'>
<DropdownButton <DropdownButton
text={labelUserRole(UserRole.READER)} text={labelUserRole(UserRole.READER)}
title={describeUserRole(UserRole.READER)} title={describeUserRole(UserRole.READER)}

View File

@ -28,7 +28,7 @@ export function MiniSelectorOSS({ items, onSelect, className, ...restProps }: Mi
} }
return ( return (
<div ref={ossMenu.ref} className={clsx('flex items-center', className)} {...restProps}> <div ref={ossMenu.ref} className={clsx('relative flex items-center', className)} {...restProps}>
<MiniButton <MiniButton
icon={<IconOSS size='1.25rem' className='icon-primary' />} icon={<IconOSS size='1.25rem' className='icon-primary' />}
title='Операционные схемы' title='Операционные схемы'
@ -36,11 +36,11 @@ export function MiniSelectorOSS({ items, onSelect, className, ...restProps }: Mi
onClick={onToggle} onClick={onToggle}
/> />
{items.length > 1 ? ( {items.length > 1 ? (
<Dropdown isOpen={ossMenu.isOpen}> <Dropdown isOpen={ossMenu.isOpen} margin='mt-1'>
<Label text='Список ОСС' className='border-b px-3 py-1' /> <Label text='Список ОСС' className='border-b px-3 py-1' />
{items.map((reference, index) => ( {items.map((reference, index) => (
<DropdownButton <DropdownButton
className='min-w-[5rem]' className='min-w-20'
key={`${prefixes.oss_list}${index}`} key={`${prefixes.oss_list}${index}`}
text={reference.alias} text={reference.alias}
onClick={event => onSelect(event, reference)} onClick={event => onSelect(event, reference)}

View File

@ -2,7 +2,6 @@ import { useState } from 'react';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import clsx from 'clsx'; import clsx from 'clsx';
import { FlexColumn } from '@/components/Container';
import { MiniButton } from '@/components/Control'; import { MiniButton } from '@/components/Control';
import { createColumnHelper, DataTable, type IConditionalStyle } from '@/components/DataTable'; import { createColumnHelper, DataTable, type IConditionalStyle } from '@/components/DataTable';
import { Dropdown, useDropdown } from '@/components/Dropdown'; import { Dropdown, useDropdown } from '@/components/Dropdown';
@ -113,14 +112,14 @@ export function PickSchema({
query={filterText} query={filterText}
onChangeQuery={newValue => setFilterText(newValue)} onChangeQuery={newValue => setFilterText(newValue)}
/> />
<div ref={locationMenu.ref}> <div className='relative' ref={locationMenu.ref}>
<MiniButton <MiniButton
icon={<IconFolderTree size='1.25rem' className={!!filterLocation ? 'icon-green' : 'icon-primary'} />} icon={<IconFolderTree size='1.25rem' className={!!filterLocation ? 'icon-green' : 'icon-primary'} />}
title='Фильтр по расположению' title='Фильтр по расположению'
className='mt-1' className='mt-1'
onClick={() => locationMenu.toggle()} onClick={() => locationMenu.toggle()}
/> />
<Dropdown isOpen={locationMenu.isOpen} stretchLeft className='w-[20rem] h-[12.5rem] z-modal-tooltip mt-0'> <Dropdown isOpen={locationMenu.isOpen} stretchLeft className='w-80 h-50'>
<SelectLocation <SelectLocation
value={filterLocation} value={filterLocation}
prefix={prefixes.folders_list} prefix={prefixes.folders_list}
@ -148,10 +147,10 @@ export function PickSchema({
columns={columns} columns={columns}
conditionalRowStyles={conditionalRowStyles} conditionalRowStyles={conditionalRowStyles}
noDataComponent={ noDataComponent={
<FlexColumn className='dense p-3 items-center min-h-[6rem]'> <div className='cc-column dense p-3 items-center min-h-24'>
<p>Список схем пуст</p> <p>Список схем пуст</p>
<p>Измените параметры фильтра</p> <p>Измените параметры фильтра</p>
</FlexColumn> </div>
} }
onRowClicked={rowData => onChange(rowData.id)} onRowClicked={rowData => onChange(rowData.id)}
/> />

View File

@ -1,5 +1,7 @@
'use client'; 'use client';
import clsx from 'clsx';
import { MiniButton } from '@/components/Control'; import { MiniButton } from '@/components/Control';
import { Dropdown, DropdownButton, useDropdown } from '@/components/Dropdown'; import { Dropdown, DropdownButton, useDropdown } from '@/components/Dropdown';
import { type Styling } from '@/components/props'; import { type Styling } from '@/components/props';
@ -18,7 +20,14 @@ interface SelectAccessPolicyProps extends Styling {
stretchLeft?: boolean; stretchLeft?: boolean;
} }
export function SelectAccessPolicy({ value, disabled, stretchLeft, onChange, ...restProps }: SelectAccessPolicyProps) { export function SelectAccessPolicy({
value,
disabled,
className,
stretchLeft,
onChange,
...restProps
}: SelectAccessPolicyProps) {
const menu = useDropdown(); const menu = useDropdown();
function handleChange(newValue: AccessPolicy) { function handleChange(newValue: AccessPolicy) {
@ -29,7 +38,7 @@ export function SelectAccessPolicy({ value, disabled, stretchLeft, onChange, ...
} }
return ( return (
<div ref={menu.ref} {...restProps}> <div ref={menu.ref} className={clsx('relative', className)} {...restProps}>
<MiniButton <MiniButton
title={`Доступ: ${labelAccessPolicy(value)}`} title={`Доступ: ${labelAccessPolicy(value)}`}
hideTitle={menu.isOpen} hideTitle={menu.isOpen}
@ -38,7 +47,7 @@ export function SelectAccessPolicy({ value, disabled, stretchLeft, onChange, ...
onClick={menu.toggle} onClick={menu.toggle}
disabled={disabled} disabled={disabled}
/> />
<Dropdown isOpen={menu.isOpen} stretchLeft={stretchLeft}> <Dropdown isOpen={menu.isOpen} stretchLeft={stretchLeft} margin='mt-1'>
{Object.values(AccessPolicy).map((item, index) => ( {Object.values(AccessPolicy).map((item, index) => (
<DropdownButton <DropdownButton
key={`${prefixes.policy_list}${index}`} key={`${prefixes.policy_list}${index}`}

View File

@ -1,5 +1,7 @@
'use client'; 'use client';
import clsx from 'clsx';
import { SelectorButton } from '@/components/Control'; import { SelectorButton } from '@/components/Control';
import { Dropdown, DropdownButton, useDropdown } from '@/components/Dropdown'; import { Dropdown, DropdownButton, useDropdown } from '@/components/Dropdown';
import { type Styling } from '@/components/props'; import { type Styling } from '@/components/props';
@ -17,7 +19,14 @@ interface SelectItemTypeProps extends Styling {
stretchLeft?: boolean; stretchLeft?: boolean;
} }
export function SelectItemType({ value, disabled, stretchLeft, onChange, ...restProps }: SelectItemTypeProps) { export function SelectItemType({
value,
disabled,
className,
stretchLeft,
onChange,
...restProps
}: SelectItemTypeProps) {
const menu = useDropdown(); const menu = useDropdown();
function handleChange(newValue: LibraryItemType) { function handleChange(newValue: LibraryItemType) {
@ -28,7 +37,7 @@ export function SelectItemType({ value, disabled, stretchLeft, onChange, ...rest
} }
return ( return (
<div ref={menu.ref} {...restProps}> <div ref={menu.ref} className={clsx('relative', className)} {...restProps}>
<SelectorButton <SelectorButton
transparent transparent
title={describeLibraryItemType(value)} title={describeLibraryItemType(value)}
@ -39,7 +48,7 @@ export function SelectItemType({ value, disabled, stretchLeft, onChange, ...rest
onClick={menu.toggle} onClick={menu.toggle}
disabled={disabled} disabled={disabled}
/> />
<Dropdown isOpen={menu.isOpen} stretchLeft={stretchLeft}> <Dropdown isOpen={menu.isOpen} stretchLeft={stretchLeft} margin='mt-1'>
{Object.values(LibraryItemType).map((item, index) => ( {Object.values(LibraryItemType).map((item, index) => (
<DropdownButton <DropdownButton
key={`${prefixes.policy_list}${index}`} key={`${prefixes.policy_list}${index}`}

View File

@ -51,14 +51,14 @@ export function SelectLocation({ value, dense, prefix, onClick, className, style
} }
return ( return (
<div className={clsx('flex flex-col', 'cc-scroll-y', className)} style={style}> <div className={clsx('flex flex-col cc-scroll-y', className)} style={style}>
{items.map((item, index) => {items.map((item, index) =>
!item.parent || !folded.includes(item.parent) ? ( !item.parent || !folded.includes(item.parent) ? (
<div <div
tabIndex={-1} tabIndex={-1}
key={`${prefix}${index}`} key={`${prefix}${index}`}
className={clsx( className={clsx(
!dense && 'min-h-[2.0825rem] sm:min-h-[2.3125rem]', !dense && 'h-7 sm:h-8',
'pr-3 py-1 flex items-center gap-2', 'pr-3 py-1 flex items-center gap-2',
'cc-scroll-row', 'cc-scroll-row',
'clr-hover cc-animate-color', 'clr-hover cc-animate-color',

View File

@ -14,7 +14,7 @@ interface SelectLocationContextProps extends Styling {
value: string; value: string;
onChange: (newValue: string) => void; onChange: (newValue: string) => void;
title?: string; title?: string;
stretchTop?: boolean; dropdownHeight?: string;
} }
export function SelectLocationContext({ export function SelectLocationContext({
@ -22,7 +22,8 @@ export function SelectLocationContext({
title = 'Проводник...', title = 'Проводник...',
onChange, onChange,
className, className,
style dropdownHeight,
...restProps
}: SelectLocationContextProps) { }: SelectLocationContextProps) {
const menu = useDropdown(); const menu = useDropdown();
@ -34,18 +35,14 @@ export function SelectLocationContext({
} }
return ( return (
<div ref={menu.ref} className='h-full text-right self-start mt-[-0.25rem] ml-[-1.5rem]'> <div ref={menu.ref} className={clsx('relative h-full -mt-1 -ml-6 text-right self-start', className)} {...restProps}>
<MiniButton <MiniButton
title={title} title={title}
hideTitle={menu.isOpen} hideTitle={menu.isOpen}
icon={<IconFolderTree size='1.25rem' className='icon-green' />} icon={<IconFolderTree size='1.25rem' className='icon-green' />}
onClick={() => menu.toggle()} onClick={() => menu.toggle()}
/> />
<Dropdown <Dropdown isOpen={menu.isOpen} className={clsx('w-80 h-50 z-tooltip', dropdownHeight)}>
isOpen={menu.isOpen}
className={clsx('w-[20rem] h-[12.5rem] z-modal-tooltip mt-[-0.25rem]', className)}
style={style}
>
<SelectLocation <SelectLocation
value={value} value={value}
prefix={prefixes.folders_list} prefix={prefixes.folders_list}

View File

@ -33,7 +33,7 @@ export function SelectLocationHead({
} }
return ( return (
<div ref={menu.ref} className={clsx('h-full text-right', className)} {...restProps}> <div ref={menu.ref} className={clsx('text-right relative', className)} {...restProps}>
<SelectorButton <SelectorButton
transparent transparent
tabIndex={-1} tabIndex={-1}
@ -45,22 +45,18 @@ export function SelectLocationHead({
onClick={menu.toggle} onClick={menu.toggle}
/> />
<Dropdown isOpen={menu.isOpen} className='z-modal-tooltip'> <Dropdown isOpen={menu.isOpen} margin='mt-2'>
{Object.values(LocationHead) {Object.values(LocationHead)
.filter(head => !excluded.includes(head)) .filter(head => !excluded.includes(head))
.map((head, index) => { .map((head, index) => {
return ( return (
<DropdownButton <DropdownButton
className='w-[10rem]'
key={`${prefixes.location_head_list}${index}`} key={`${prefixes.location_head_list}${index}`}
onClick={() => handleChange(head)} onClick={() => handleChange(head)}
title={describeLocationHead(head)} title={describeLocationHead(head)}
> icon={<IconLocationHead value={head} size='1rem' />}
<div className='inline-flex items-center gap-3'> text={labelLocationHead(head)}
<IconLocationHead value={head} size='1rem' /> />
{labelLocationHead(head)}
</div>
</DropdownButton>
); );
})} })}
</Dropdown> </Dropdown>

View File

@ -34,7 +34,7 @@ export function SelectVersion({ id, className, items, value, onChange, ...restPr
return ( return (
<SelectSingle <SelectSingle
id={id} id={id}
className={clsx('min-w-[12rem] text-ellipsis', className)} className={clsx('min-w-48 text-ellipsis', className)}
options={options} options={options}
value={{ value: value, label: labelVersion(value, items) }} value={{ value: value, label: labelVersion(value, items) }}
onChange={data => onChange(data?.value ?? 'latest')} onChange={data => onChange(data?.value ?? 'latest')}

View File

@ -2,11 +2,9 @@ import { HelpTopic } from '@/features/help';
import { BadgeHelp } from '@/features/help/components'; import { BadgeHelp } from '@/features/help/components';
import { useRoleStore, UserRole } from '@/features/users'; import { useRoleStore, UserRole } from '@/features/users';
import { Overlay } from '@/components/Container';
import { MiniButton } from '@/components/Control'; import { MiniButton } from '@/components/Control';
import { IconImmutable, IconMutable } from '@/components/Icons'; import { IconImmutable, IconMutable } from '@/components/Icons';
import { Label } from '@/components/Input'; import { Label } from '@/components/Input';
import { PARAMETER } from '@/utils/constants';
import { type AccessPolicy, type ILibraryItem } from '../backend/types'; import { type AccessPolicy, type ILibraryItem } from '../backend/types';
import { useMutatingLibrary } from '../backend/useMutatingLibrary'; import { useMutatingLibrary } from '../backend/useMutatingLibrary';
@ -42,7 +40,7 @@ export function ToolbarItemAccess({
} }
return ( return (
<Overlay position='top-[4.5rem] right-0 w-[12rem] pr-2' className='flex' layer='z-bottom'> <div className='absolute z-bottom top-18 right-0 w-48 flex pr-2'>
<Label text='Доступ' className='self-center select-none' /> <Label text='Доступ' className='self-center select-none' />
<div className='ml-auto cc-icons'> <div className='ml-auto cc-icons'>
<SelectAccessPolicy <SelectAccessPolicy
@ -70,9 +68,8 @@ export function ToolbarItemAccess({
onClick={toggleReadOnly} onClick={toggleReadOnly}
disabled={role === UserRole.READER || isProcessing} disabled={role === UserRole.READER || isProcessing}
/> />
<BadgeHelp topic={HelpTopic.ACCESS} className='mt-0.5' offset={4} />
<BadgeHelp topic={HelpTopic.ACCESS} className={PARAMETER.TOOLTIP_WIDTH} offset={4} />
</div> </div>
</Overlay> </div>
); );
} }

View File

@ -3,29 +3,28 @@
import { urls, useConceptNavigation } from '@/app'; import { urls, useConceptNavigation } from '@/app';
import { HelpTopic } from '@/features/help'; import { HelpTopic } from '@/features/help';
import { BadgeHelp } from '@/features/help/components'; import { BadgeHelp } from '@/features/help/components';
import { AccessPolicy, type ILibraryItem, LibraryItemType } from '@/features/library'; import { type IRSForm } from '@/features/rsform';
import { useMutatingLibrary } from '@/features/library/backend/useMutatingLibrary';
import { MiniSelectorOSS } from '@/features/library/components';
import { useRoleStore, UserRole } from '@/features/users'; import { useRoleStore, UserRole } from '@/features/users';
import { Overlay } from '@/components/Container';
import { MiniButton } from '@/components/Control'; import { MiniButton } from '@/components/Control';
import { IconDestroy, IconSave, IconShare } from '@/components/Icons'; import { IconDestroy, IconSave, IconShare } from '@/components/Icons';
import { useModificationStore } from '@/stores/modification'; import { useModificationStore } from '@/stores/modification';
import { PARAMETER } from '@/utils/constants';
import { tooltipText } from '@/utils/labels'; import { tooltipText } from '@/utils/labels';
import { prepareTooltip, sharePage } from '@/utils/utils'; import { prepareTooltip, sharePage } from '@/utils/utils';
import { type IRSForm } from '../models/rsform'; import { AccessPolicy, type ILibraryItem, LibraryItemType } from '../backend/types';
import { useMutatingLibrary } from '../backend/useMutatingLibrary';
interface ToolbarRSFormCardProps { import { MiniSelectorOSS } from './MiniSelectorOSS';
interface ToolbarItemCardProps {
onSubmit: () => void; onSubmit: () => void;
isMutable: boolean; isMutable: boolean;
schema: ILibraryItem; schema: ILibraryItem;
deleteSchema: () => void; deleteSchema: () => void;
} }
export function ToolbarRSFormCard({ schema, onSubmit, isMutable, deleteSchema }: ToolbarRSFormCardProps) { export function ToolbarItemCard({ schema, onSubmit, isMutable, deleteSchema }: ToolbarItemCardProps) {
const role = useRoleStore(state => state.role); const role = useRoleStore(state => state.role);
const router = useConceptNavigation(); const router = useConceptNavigation();
const { isModified } = useModificationStore(); const { isModified } = useModificationStore();
@ -49,7 +48,7 @@ export function ToolbarRSFormCard({ schema, onSubmit, isMutable, deleteSchema }:
})(); })();
return ( return (
<Overlay position='cc-tab-tools' className='cc-icons'> <div className='cc-tab-tools cc-icons'>
{ossSelector} {ossSelector}
{isMutable || isModified ? ( {isMutable || isModified ? (
<MiniButton <MiniButton
@ -73,7 +72,7 @@ export function ToolbarRSFormCard({ schema, onSubmit, isMutable, deleteSchema }:
onClick={deleteSchema} onClick={deleteSchema}
/> />
) : null} ) : null}
<BadgeHelp topic={HelpTopic.UI_RS_CARD} offset={4} className={PARAMETER.TOOLTIP_WIDTH} /> <BadgeHelp topic={HelpTopic.UI_RS_CARD} offset={4} />
</Overlay> </div>
); );
} }

View File

@ -5,3 +5,4 @@ export { PickSchema } from './PickSchema';
export { SelectLibraryItem } from './SelectLibraryItem'; export { SelectLibraryItem } from './SelectLibraryItem';
export { SelectVersion } from './SelectVersion'; export { SelectVersion } from './SelectVersion';
export { ToolbarItemAccess } from './ToolbarItemAccess'; export { ToolbarItemAccess } from './ToolbarItemAccess';
export { ToolbarItemCard } from './ToolbarItemCard';

View File

@ -2,7 +2,6 @@
import { Controller, useForm } from 'react-hook-form'; import { Controller, useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import clsx from 'clsx';
import { z } from 'zod'; import { z } from 'zod';
import { useAuthSuspense } from '@/features/auth'; import { useAuthSuspense } from '@/features/auth';
@ -18,7 +17,7 @@ import { SelectLocationHead } from '../components/SelectLocationHead';
import { LocationHead } from '../models/library'; import { LocationHead } from '../models/library';
import { combineLocation, validateLocation } from '../models/libraryAPI'; import { combineLocation, validateLocation } from '../models/libraryAPI';
const schemaLocation = z.object({ const schemaLocation = z.strictObject({
location: z.string().refine(data => validateLocation(data), { message: errorMsg.invalidLocation }) location: z.string().refine(data => validateLocation(data), { message: errorMsg.invalidLocation })
}); });
@ -57,9 +56,9 @@ export function DlgChangeLocation() {
submitInvalidTooltip={`Допустимы буквы, цифры, подчерк, пробел и "!". Сегмент пути не может начинаться и заканчиваться пробелом. Общая длина (с корнем) не должна превышать ${limits.location_len}`} submitInvalidTooltip={`Допустимы буквы, цифры, подчерк, пробел и "!". Сегмент пути не может начинаться и заканчиваться пробелом. Общая длина (с корнем) не должна превышать ${limits.location_len}`}
canSubmit={isValid && isDirty} canSubmit={isValid && isDirty}
onSubmit={event => void handleSubmit(onSubmit)(event)} onSubmit={event => void handleSubmit(onSubmit)(event)}
className={clsx('w-[35rem]', 'pb-3 px-6 flex gap-3 h-[9rem]')} className='w-140 pb-3 px-6 flex gap-3 h-36'
> >
<div className='flex flex-col gap-2 min-w-[7rem] h-min'> <div className='flex flex-col gap-2 min-w-28'>
<Label className='select-none' text='Корень' /> <Label className='select-none' text='Корень' />
<Controller <Controller
control={control} control={control}
@ -77,11 +76,11 @@ export function DlgChangeLocation() {
control={control} control={control}
name='location' name='location'
render={({ field }) => ( render={({ field }) => (
<SelectLocationContext className='max-h-[9.2rem]' value={field.value} onChange={field.onChange} /> <SelectLocationContext dropdownHeight='max-h-36' value={field.value} onChange={field.onChange} />
)} )}
/> />
<Controller <Controller
control={control} // control={control}
name='location' name='location'
render={({ field }) => ( render={({ field }) => (
<TextArea <TextArea

View File

@ -2,7 +2,6 @@
import { Controller, useForm } from 'react-hook-form'; import { Controller, useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import clsx from 'clsx';
import { urls, useConceptNavigation } from '@/app'; import { urls, useConceptNavigation } from '@/app';
import { useAuthSuspense } from '@/features/auth'; import { useAuthSuspense } from '@/features/auth';
@ -69,7 +68,7 @@ export function DlgCloneLibraryItem() {
submitText='Создать' submitText='Создать'
canSubmit={isValid} canSubmit={isValid}
onSubmit={event => void handleSubmit(onSubmit)(event)} onSubmit={event => void handleSubmit(onSubmit)(event)}
className={clsx('px-6 py-2', 'cc-column', 'max-h-full w-[30rem]')} className='px-6 py-2 cc-column h-fit w-120'
> >
<TextInput <TextInput
id='dlg_full_name' // id='dlg_full_name' //
@ -77,14 +76,9 @@ export function DlgCloneLibraryItem() {
{...register('title')} {...register('title')}
error={errors.title} error={errors.title}
/> />
<div className='flex justify-between gap-3'> <div className='flex justify-between gap-3'>
<TextInput <TextInput id='dlg_alias' label='Сокращение' className='w-64' {...register('alias')} error={errors.alias} />
id='dlg_alias'
label='Сокращение'
className='w-[16rem]'
{...register('alias')}
error={errors.alias}
/>
<div className='flex flex-col gap-2'> <div className='flex flex-col gap-2'>
<Label text='Доступ' className='self-center select-none' /> <Label text='Доступ' className='self-center select-none' />
<div className='ml-auto cc-icons'> <div className='ml-auto cc-icons'>
@ -114,8 +108,8 @@ export function DlgCloneLibraryItem() {
</div> </div>
</div> </div>
<div className='flex justify-between gap-3'> <div className='flex gap-3'>
<div className='flex flex-col gap-2 w-[7rem] h-min'> <div className='flex flex-col gap-2 w-28'>
<Label text='Корень' /> <Label text='Корень' />
<Controller <Controller
control={control} // control={control} //
@ -155,7 +149,7 @@ export function DlgCloneLibraryItem() {
/> />
</div> </div>
<TextArea id='dlg_comment' {...register('comment')} label='Описание' error={errors.comment} /> <TextArea id='dlg_comment' {...register('comment')} label='Описание' rows={4} error={errors.comment} />
{selected.length > 0 ? ( {selected.length > 0 ? (
<Controller <Controller

View File

@ -2,7 +2,6 @@
import { Controller, useForm, useWatch } from 'react-hook-form'; import { Controller, useForm, useWatch } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import clsx from 'clsx';
import { Checkbox, TextArea, TextInput } from '@/components/Input'; import { Checkbox, TextArea, TextInput } from '@/components/Input';
import { ModalForm } from '@/components/Modal'; import { ModalForm } from '@/components/Modal';
@ -45,13 +44,13 @@ export function DlgCreateVersion() {
return ( return (
<ModalForm <ModalForm
header='Создание версии' header='Создание версии'
className={clsx('cc-column', 'w-[30rem]', 'py-2 px-6')} className='cc-column w-120 py-2 px-6'
canSubmit={canSubmit} canSubmit={canSubmit}
submitInvalidTooltip={errorMsg.versionTaken} submitInvalidTooltip={errorMsg.versionTaken}
submitText='Создать' submitText='Создать'
onSubmit={event => void handleSubmit(onSubmit)(event)} onSubmit={event => void handleSubmit(onSubmit)(event)}
> >
<TextInput id='dlg_version' {...register('version')} dense label='Версия' className='w-[16rem]' /> <TextInput id='dlg_version' {...register('version')} dense label='Версия' className='w-64' />
<TextArea id='dlg_description' {...register('description')} spellCheck label='Описание' rows={3} /> <TextArea id='dlg_description' {...register('description')} spellCheck label='Описание' rows={3} />
{selected.length > 0 ? ( {selected.length > 0 ? (
<Controller <Controller

View File

@ -1,7 +1,6 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import clsx from 'clsx';
import { useUsers } from '@/features/users'; import { useUsers } from '@/features/users';
import { SelectUser, TableUsers } from '@/features/users/components'; import { SelectUser, TableUsers } from '@/features/users/components';
@ -42,15 +41,15 @@ export function DlgEditEditors() {
<ModalForm <ModalForm
header='Список редакторов' header='Список редакторов'
submitText='Сохранить список' submitText='Сохранить список'
className='flex flex-col w-[35rem] px-6 gap-3 pb-6' className='flex flex-col w-140 px-6 gap-3 pb-6'
onSubmit={handleSubmit} onSubmit={handleSubmit}
> >
<div className={clsx('flex self-center items-center', 'text-sm font-semibold')}> <div className='self-center text-sm font-semibold'>
<span>Всего редакторов [{selected.length}]</span> <span>Всего редакторов [{selected.length}]</span>
<MiniButton <MiniButton
noHover noHover
title='Очистить список' title='Очистить список'
className='py-0' className='py-0 align-middle'
icon={<IconRemove size='1.5rem' className='icon-red' />} icon={<IconRemove size='1.5rem' className='icon-red' />}
disabled={selected.length === 0} disabled={selected.length === 0}
onClick={() => setSelected([])} onClick={() => setSelected([])}
@ -65,7 +64,7 @@ export function DlgEditEditors() {
filter={id => !selected.includes(id)} // filter={id => !selected.includes(id)} //
value={null} value={null}
onChange={onAddEditor} onChange={onAddEditor}
className='w-[25rem]' className='w-100'
/> />
</div> </div>
</ModalForm> </ModalForm>

View File

@ -85,7 +85,7 @@ export function DlgEditVersions() {
} }
return ( return (
<ModalView header='Редактирование версий' className='flex flex-col w-[40rem] px-6 gap-3 pb-6'> <ModalView header='Редактирование версий' className='flex flex-col w-160 px-6 gap-3 pb-3'>
<TableVersions <TableVersions
processing={isProcessing} processing={isProcessing}
items={schema.versions.reverse()} items={schema.versions.reverse()}
@ -100,7 +100,7 @@ export function DlgEditVersions() {
{...register('version')} {...register('version')}
dense dense
label='Версия' label='Версия'
className='w-[16rem] mr-3' className='w-64 mr-3'
error={formErrors.version} error={formErrors.version}
/> />
<div className='cc-icons h-fit'> <div className='cc-icons h-fit'>

View File

@ -1,7 +1,6 @@
'use client'; 'use client';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import clsx from 'clsx';
import { MiniButton } from '@/components/Control'; import { MiniButton } from '@/components/Control';
import { createColumnHelper, DataTable, type IConditionalStyle } from '@/components/DataTable'; import { createColumnHelper, DataTable, type IConditionalStyle } from '@/components/DataTable';
@ -33,7 +32,7 @@ export function TableVersions({ processing, items, onDelete, selected, onSelect
columnHelper.accessor('version', { columnHelper.accessor('version', {
id: 'version', id: 'version',
header: 'Версия', header: 'Версия',
cell: props => <div className='min-w-[6rem] max-w-[6rem] text-ellipsis'>{props.getValue()}</div> cell: props => <div className='w-24 text-ellipsis'>{props.getValue()}</div>
}), }),
columnHelper.accessor('description', { columnHelper.accessor('description', {
id: 'description', id: 'description',
@ -62,16 +61,15 @@ export function TableVersions({ processing, items, onDelete, selected, onSelect
id: 'actions', id: 'actions',
size: 0, size: 0,
cell: props => ( cell: props => (
<div className='h-[1.25rem] w-[1.25rem]'> <MiniButton
<MiniButton title='Удалить версию'
title='Удалить версию' className='align-middle'
noHover noHover
noPadding noPadding
disabled={processing} disabled={processing}
icon={<IconRemove size='1.25rem' className='icon-red' />} icon={<IconRemove size='1.25rem' className='icon-red' />}
onClick={event => handleDeleteVersion(event, props.row.original.id)} onClick={event => handleDeleteVersion(event, props.row.original.id)}
/> />
</div>
) )
}) })
]; ];
@ -90,7 +88,7 @@ export function TableVersions({ processing, items, onDelete, selected, onSelect
dense dense
noFooter noFooter
headPosition='0' headPosition='0'
className={clsx('mb-2', 'max-h-[17.4rem] min-h-[17.4rem]', 'border', 'cc-scroll-y')} className='mb-2 h-70 border cc-scroll-y'
data={items} data={items}
columns={columns} columns={columns}
onRowClicked={rowData => onSelect(rowData.id)} onRowClicked={rowData => onSelect(rowData.id)}

View File

@ -3,12 +3,10 @@
import { useRef } from 'react'; import { useRef } from 'react';
import { Controller, useForm, useWatch } from 'react-hook-form'; import { Controller, useForm, useWatch } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import clsx from 'clsx';
import { urls, useConceptNavigation } from '@/app'; import { urls, useConceptNavigation } from '@/app';
import { useAuthSuspense } from '@/features/auth'; import { useAuthSuspense } from '@/features/auth';
import { Overlay } from '@/components/Container';
import { Button, MiniButton, SubmitButton } from '@/components/Control'; import { Button, MiniButton, SubmitButton } from '@/components/Control';
import { IconDownload } from '@/components/Icons'; import { IconDownload } from '@/components/Icons';
import { InfoError } from '@/components/InfoError'; import { InfoError } from '@/components/InfoError';
@ -104,13 +102,13 @@ export function FormCreateItem() {
return ( return (
<form <form
className={clsx('cc-fade-in cc-column', 'min-w-[30rem] max-w-[30rem] mx-auto', 'px-6 py-3')} className='cc-fade-in cc-column min-w-120 max-w-120 mx-auto px-6 py-3'
onSubmit={event => void handleSubmit(onSubmit)(event)} onSubmit={event => void handleSubmit(onSubmit)(event)}
onChange={resetErrors} onChange={resetErrors}
> >
<h1 className='select-none'> <h1 className='select-none relative'>
{itemType == LibraryItemType.RSFORM ? ( {itemType == LibraryItemType.RSFORM ? (
<Overlay position='top-0 right-[0.5rem]'> <>
<Controller <Controller
control={control} control={control}
name='file' name='file'
@ -127,10 +125,11 @@ export function FormCreateItem() {
/> />
<MiniButton <MiniButton
title='Загрузить из Экстеор' title='Загрузить из Экстеор'
className='absolute top-0 right-0'
icon={<IconDownload size='1.25rem' className='icon-primary' />} icon={<IconDownload size='1.25rem' className='icon-primary' />}
onClick={() => inputRef.current?.click()} onClick={() => inputRef.current?.click()}
/> />
</Overlay> </>
) : null} ) : null}
Создание схемы Создание схемы
</h1> </h1>
@ -151,7 +150,7 @@ export function FormCreateItem() {
{...register('alias')} {...register('alias')}
label='Сокращение' label='Сокращение'
placeholder={file && 'Загрузить из файла'} placeholder={file && 'Загрузить из файла'}
className='w-[16rem]' className='w-64'
error={errors.alias} error={errors.alias}
/> />
<div className='flex flex-col items-center gap-2'> <div className='flex flex-col items-center gap-2'>
@ -206,7 +205,7 @@ export function FormCreateItem() {
/> />
<div className='flex justify-between gap-3 grow'> <div className='flex justify-between gap-3 grow'>
<div className='flex flex-col gap-2 min-w-[7rem] h-min'> <div className='flex flex-col gap-2 min-w-28'>
<Label text='Корень' /> <Label text='Корень' />
<Controller <Controller
control={control} // control={control} //
@ -247,8 +246,8 @@ export function FormCreateItem() {
</div> </div>
<div className='flex justify-around gap-6 py-3'> <div className='flex justify-around gap-6 py-3'>
<SubmitButton text='Создать схему' loading={isPending} className='min-w-[10rem]' /> <SubmitButton text='Создать схему' loading={isPending} className='min-w-40' />
<Button text='Отмена' className='min-w-[10rem]' onClick={() => handleCancel()} /> <Button text='Отмена' className='min-w-40' onClick={() => handleCancel()} />
</div> </div>
{serverError ? <InfoError error={serverError} /> : null} {serverError ? <InfoError error={serverError} /> : null}
</form> </form>

View File

@ -3,10 +3,8 @@
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import fileDownload from 'js-file-download'; import fileDownload from 'js-file-download';
import { Overlay } from '@/components/Container';
import { MiniButton } from '@/components/Control'; import { MiniButton } from '@/components/Control';
import { IconCSV } from '@/components/Icons'; import { IconCSV } from '@/components/Icons';
import { useAppLayoutStore } from '@/stores/appLayout';
import { useDialogsStore } from '@/stores/dialogs'; import { useDialogsStore } from '@/stores/dialogs';
import { infoMsg } from '@/utils/labels'; import { infoMsg } from '@/utils/labels';
import { convertToCSV } from '@/utils/utils'; import { convertToCSV } from '@/utils/utils';
@ -24,8 +22,6 @@ export function LibraryPage() {
const { items: libraryItems } = useLibrarySuspense(); const { items: libraryItems } = useLibrarySuspense();
const { renameLocation } = useRenameLocation(); const { renameLocation } = useRenameLocation();
const noNavigation = useAppLayoutStore(state => state.noNavigation);
const folderMode = useLibrarySearchStore(state => state.folderMode); const folderMode = useLibrarySearchStore(state => state.folderMode);
const location = useLibrarySearchStore(state => state.location); const location = useLibrarySearchStore(state => state.location);
const setLocation = useLibrarySearchStore(state => state.setLocation); const setLocation = useLibrarySearchStore(state => state.setLocation);
@ -57,20 +53,15 @@ export function LibraryPage() {
return ( return (
<> <>
<Overlay <ToolbarSearch total={libraryItems.length} filtered={filtered.length} />
position={noNavigation ? 'top-[0.25rem] right-[3rem]' : 'top-[0.25rem] right-0'} <div className='relative cc-fade-in flex'>
layer='z-tooltip'
className='cc-animate-position'
>
<MiniButton <MiniButton
className='absolute z-tooltip top-1 right-0 cc-animate-position'
title='Выгрузить в формате CSV' title='Выгрузить в формате CSV'
icon={<IconCSV size='1.25rem' className='icon-green' />} icon={<IconCSV size='1.25rem' className='icon-green' />}
onClick={handleDownloadCSV} onClick={handleDownloadCSV}
/> />
</Overlay>
<ToolbarSearch total={libraryItems.length} filtered={filtered.length} />
<div className='cc-fade-in flex'>
<ViewSideLocation <ViewSideLocation
isVisible={folderMode} isVisible={folderMode}
onRenameLocation={() => showChangeLocation({ initial: location, onChangeLocation: handleRenameLocation })} onRenameLocation={() => showChangeLocation({ initial: location, onChangeLocation: handleRenameLocation })}

View File

@ -1,43 +1,47 @@
'use client'; 'use client';
import { useLayoutEffect, useState } from 'react';
import { useIntl } from 'react-intl';
import clsx from 'clsx'; import clsx from 'clsx';
import { urls, useConceptNavigation } from '@/app'; import { urls, useConceptNavigation } from '@/app';
import { useLabelUser } from '@/features/users';
import { FlexColumn } from '@/components/Container'; import { TextURL } from '@/components/Control';
import { MiniButton, TextURL } from '@/components/Control'; import { DataTable, type IConditionalStyle, type VisibilityState } from '@/components/DataTable';
import { createColumnHelper, DataTable, type IConditionalStyle, type VisibilityState } from '@/components/DataTable';
import { IconFolderTree } from '@/components/Icons';
import { useWindowSize } from '@/hooks/useWindowSize'; import { useWindowSize } from '@/hooks/useWindowSize';
import { useFitHeight } from '@/stores/appLayout'; import { useFitHeight } from '@/stores/appLayout';
import { usePreferencesStore } from '@/stores/preferences'; import { usePreferencesStore } from '@/stores/preferences';
import { APP_COLORS } from '@/styling/colors'; import { APP_COLORS } from '@/styling/colors';
import { type ILibraryItem, LibraryItemType } from '../../backend/types'; import { type ILibraryItem, LibraryItemType } from '../../backend/types';
import { BadgeLocation } from '../../components/BadgeLocation';
import { useLibrarySearchStore } from '../../stores/librarySearch'; import { useLibrarySearchStore } from '../../stores/librarySearch';
import { useLibraryColumns } from './useLibraryColumns';
interface TableLibraryItemsProps { interface TableLibraryItemsProps {
items: ILibraryItem[]; items: ILibraryItem[];
} }
const columnHelper = createColumnHelper<ILibraryItem>();
export function TableLibraryItems({ items }: TableLibraryItemsProps) { export function TableLibraryItems({ items }: TableLibraryItemsProps) {
const router = useConceptNavigation(); const router = useConceptNavigation();
const intl = useIntl(); const { isSmall } = useWindowSize();
const getUserLabel = useLabelUser();
const folderMode = useLibrarySearchStore(state => state.folderMode); const folderMode = useLibrarySearchStore(state => state.folderMode);
const toggleFolderMode = useLibrarySearchStore(state => state.toggleFolderMode);
const resetFilter = useLibrarySearchStore(state => state.resetFilter); const resetFilter = useLibrarySearchStore(state => state.resetFilter);
const itemsPerPage = usePreferencesStore(state => state.libraryPagination); const itemsPerPage = usePreferencesStore(state => state.libraryPagination);
const setItemsPerPage = usePreferencesStore(state => state.setLibraryPagination); const setItemsPerPage = usePreferencesStore(state => state.setLibraryPagination);
const columns = useLibraryColumns();
const columnVisibility: VisibilityState = { owner: !isSmall };
const conditionalRowStyles: IConditionalStyle<ILibraryItem>[] = [
{
when: (item: ILibraryItem) => item.item_type === LibraryItemType.OSS,
style: {
color: APP_COLORS.fgGreen
}
}
];
const tableHeight = useFitHeight('2.25rem');
function handleOpenItem(item: ILibraryItem, event: React.MouseEvent<Element>) { function handleOpenItem(item: ILibraryItem, event: React.MouseEvent<Element>) {
const selection = window.getSelection(); const selection = window.getSelection();
if (!!selection && selection.toString().length > 0) { if (!!selection && selection.toString().length > 0) {
@ -50,108 +54,6 @@ export function TableLibraryItems({ items }: TableLibraryItemsProps) {
} }
} }
const windowSize = useWindowSize();
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
useLayoutEffect(() => {
setColumnVisibility({
owner: !windowSize.isSmall
});
}, [windowSize]);
function handleToggleFolder(event: React.MouseEvent<Element>) {
event.preventDefault();
event.stopPropagation();
toggleFolderMode();
}
const columns = [
...(folderMode
? []
: [
columnHelper.accessor('location', {
id: 'location',
header: () => (
<MiniButton
noPadding
noHover
className='pl-2 max-h-[1rem] translate-y-[-0.125rem]'
onClick={handleToggleFolder}
titleHtml='Переключение в режим Проводник'
icon={<IconFolderTree size='1.25rem' className='clr-text-controls' />}
/>
),
size: 50,
minSize: 50,
maxSize: 50,
enableSorting: true,
cell: props => <BadgeLocation location={props.getValue()} />,
sortingFn: 'text'
})
]),
columnHelper.accessor('alias', {
id: 'alias',
header: 'Шифр',
size: 150,
minSize: 80,
maxSize: 150,
enableSorting: true,
cell: props => <div className='min-w-[5rem]'>{props.getValue()}</div>,
sortingFn: 'text'
}),
columnHelper.accessor('title', {
id: 'title',
header: 'Название',
size: 1200,
minSize: 200,
maxSize: 1200,
enableSorting: true,
sortingFn: 'text'
}),
columnHelper.accessor(item => item.owner ?? 0, {
id: 'owner',
header: 'Владелец',
size: 400,
minSize: 100,
maxSize: 400,
cell: props => getUserLabel(props.getValue()),
enableSorting: true,
sortingFn: 'text'
}),
columnHelper.accessor('time_update', {
id: 'time_update',
header: windowSize.isSmall ? 'Дата' : 'Обновлена',
cell: props => (
<div className='whitespace-nowrap'>
{new Date(props.getValue()).toLocaleString(intl.locale, {
year: '2-digit',
month: '2-digit',
day: '2-digit',
...(!windowSize.isSmall && {
hour: '2-digit',
minute: '2-digit'
})
})}
</div>
),
enableSorting: true,
sortingFn: 'datetime',
sortDescFirst: true
})
];
const tableHeight = useFitHeight('2.2rem');
const conditionalRowStyles: IConditionalStyle<ILibraryItem>[] = [
{
when: (item: ILibraryItem) => item.item_type === LibraryItemType.OSS,
style: {
color: APP_COLORS.fgGreen
}
}
];
return ( return (
<DataTable <DataTable
id='library_data' id='library_data'
@ -161,13 +63,13 @@ export function TableLibraryItems({ items }: TableLibraryItemsProps) {
className={clsx('text-xs sm:text-sm cc-scroll-y h-fit border-b', { 'border-l': folderMode })} className={clsx('text-xs sm:text-sm cc-scroll-y h-fit border-b', { 'border-l': folderMode })}
style={{ maxHeight: tableHeight }} style={{ maxHeight: tableHeight }}
noDataComponent={ noDataComponent={
<FlexColumn className='dense p-3 items-center min-h-[6rem]'> <div className='cc-column dense p-3 items-center min-h-24'>
<p>Список схем пуст</p> <p>Список схем пуст</p>
<p className='flex gap-6'> <p className='flex gap-6'>
<TextURL text='Создать схему' href='/library/create' /> <TextURL text='Создать схему' href='/library/create' />
<TextURL text='Очистить фильтр' onClick={resetFilter} /> <TextURL text='Очистить фильтр' onClick={resetFilter} />
</p> </p>
</FlexColumn> </div>
} }
columnVisibility={columnVisibility} columnVisibility={columnVisibility}
onRowClicked={handleOpenItem} onRowClicked={handleOpenItem}

View File

@ -75,35 +75,19 @@ export function ToolbarSearch({ total, filtered }: ToolbarSearchProps) {
} }
return ( return (
<div <div className='sticky top-0 h-9 flex gap-3 border-b text-sm clr-input items-center'>
className={clsx( <div className='ml-3 min-w-18 sm:min-w-30 select-none whitespace-nowrap'>
'sticky top-0', //
'h-[2.2rem]',
'flex items-center gap-3',
'border-b',
'text-sm',
'clr-input'
)}
>
<div
className={clsx(
'ml-3 pt-1 self-center',
'min-w-[4.5rem] sm:min-w-[7.4rem]',
'select-none',
'whitespace-nowrap'
)}
>
{filtered} из {total} {filtered} из {total}
</div> </div>
<div className='cc-icons'> <div className='cc-icons h-full items-center'>
<MiniButton <MiniButton
title='Видимость' title='Видимость'
icon={<IconItemVisibility value={true} className={tripleToggleColor(isVisible)} />} icon={<IconItemVisibility value={true} className={tripleToggleColor(isVisible)} />}
onClick={toggleVisible} onClick={toggleVisible}
/> />
<div ref={userMenu.ref} className='flex'> <div ref={userMenu.ref} className='relative flex'>
<MiniButton <MiniButton
title='Поиск пользователя' title='Поиск пользователя'
hideTitle={userMenu.isOpen} hideTitle={userMenu.isOpen}
@ -124,7 +108,7 @@ export function ToolbarSearch({ total, filtered }: ToolbarSearchProps) {
<SelectUser <SelectUser
noBorder noBorder
placeholder='Выберите владельца' placeholder='Выберите владельца'
className='min-w-[15rem] text-sm mx-1 mb-1' className='min-w-60 text-sm mx-1 mb-1'
value={filterUser} value={filterUser}
onChange={setFilterUser} onChange={setFilterUser}
/> />
@ -144,15 +128,15 @@ export function ToolbarSearch({ total, filtered }: ToolbarSearchProps) {
id='library_search' id='library_search'
placeholder='Поиск' placeholder='Поиск'
noBorder noBorder
className={clsx('min-w-[7rem] sm:min-w-[10rem] max-w-[20rem]', folderMode && 'grow')} className={clsx('min-w-28 sm:min-w-40 max-w-80', folderMode && 'grow')}
query={query} query={query}
onChangeQuery={setQuery} onChangeQuery={setQuery}
/> />
{!folderMode ? ( {!folderMode ? (
<div ref={headMenu.ref} className='flex items-center h-full py-1 select-none'> <div ref={headMenu.ref} className='relative flex items-center h-full select-none'>
<SelectorButton <SelectorButton
transparent transparent
className='h-full rounded-lg' className='rounded-lg py-1'
titleHtml={(head ? describeLocationHead(head) : 'Выберите каталог') + '<br/>Ctrl + клик - Проводник'} titleHtml={(head ? describeLocationHead(head) : 'Выберите каталог') + '<br/>Ctrl + клик - Проводник'}
hideTitle={headMenu.isOpen} hideTitle={headMenu.isOpen}
icon={ icon={
@ -166,32 +150,27 @@ export function ToolbarSearch({ total, filtered }: ToolbarSearchProps) {
text={head ?? '//'} text={head ?? '//'}
/> />
<Dropdown isOpen={headMenu.isOpen} stretchLeft className='z-modal-tooltip'> <Dropdown isOpen={headMenu.isOpen} stretchLeft>
<DropdownButton title='Переключение в режим Проводник' onClick={handleToggleFolder}> <DropdownButton
<div className='inline-flex items-center gap-3'> title='Переключение в режим Проводник'
<IconFolderTree size='1rem' className='clr-text-controls' /> text='проводник...'
<span>проводник...</span> icon={<IconFolderTree size='1rem' className='clr-text-controls' />}
</div> onClick={handleToggleFolder}
</DropdownButton> />
<DropdownButton className='w-[10rem]' onClick={() => handleChange(null)}> <DropdownButton
<div className='inline-flex items-center gap-3'> text='отображать все'
<IconFolder size='1rem' className='clr-text-controls' /> icon={<IconFolder size='1rem' className='clr-text-controls' />}
<span>отображать все</span> onClick={() => handleChange(null)}
</div> />
</DropdownButton>
{Object.values(LocationHead).map((head, index) => { {Object.values(LocationHead).map((head, index) => {
return ( return (
<DropdownButton <DropdownButton
className='w-[10rem]'
key={`${prefixes.location_head_list}${index}`} key={`${prefixes.location_head_list}${index}`}
onClick={() => handleChange(head)} onClick={() => handleChange(head)}
title={describeLocationHead(head)} title={describeLocationHead(head)}
> text={labelLocationHead(head)}
<div className='inline-flex items-center gap-3'> icon={<IconLocationHead value={head} size='1rem' />}
<IconLocationHead value={head} size='1rem' /> />
{labelLocationHead(head)}
</div>
</DropdownButton>
); );
})} })}
</Dropdown> </Dropdown>
@ -203,7 +182,7 @@ export function ToolbarSearch({ total, filtered }: ToolbarSearchProps) {
placeholder='Путь' placeholder='Путь'
noIcon noIcon
noBorder noBorder
className='w-[4.5rem] sm:w-[5rem] grow' className='w-18 sm:w-20 grow'
query={path} query={path}
onChangeQuery={setPath} onChangeQuery={setPath}
/> />

View File

@ -26,7 +26,7 @@ interface ViewSideLocationProps {
export function ViewSideLocation({ isVisible, onRenameLocation }: ViewSideLocationProps) { export function ViewSideLocation({ isVisible, onRenameLocation }: ViewSideLocationProps) {
const { user, isAnonymous } = useAuthSuspense(); const { user, isAnonymous } = useAuthSuspense();
const { items } = useLibrary(); const { items } = useLibrary();
const windowSize = useWindowSize(); const { isSmall } = useWindowSize();
const location = useLibrarySearchStore(state => state.location); const location = useLibrarySearchStore(state => state.location);
const setLocation = useLibrarySearchStore(state => state.setLocation); const setLocation = useLibrarySearchStore(state => state.setLocation);
@ -63,23 +63,18 @@ export function ViewSideLocation({ isVisible, onRenameLocation }: ViewSideLocati
return ( return (
<div <div
className={clsx('max-w-[10rem] sm:max-w-[15rem]', 'flex flex-col', 'text:xs sm:text-sm', 'select-none')} className={clsx('max-w-40 sm:max-w-60', 'flex flex-col', 'text:xs sm:text-sm', 'select-none')}
style={{ style={{
transitionProperty: 'width, min-width, opacity', transitionProperty: 'width, min-width, opacity',
transitionDuration: `${PARAMETER.moveDuration}ms`, transitionDuration: `${PARAMETER.moveDuration}ms`,
transitionTimingFunction: 'ease-out', transitionTimingFunction: 'ease-out',
minWidth: isVisible ? (windowSize.isSmall ? '10rem' : '15rem') : '0', minWidth: isVisible ? (isSmall ? '10rem' : '15rem') : '0',
width: isVisible ? '100%' : '0', width: isVisible ? '100%' : '0',
opacity: isVisible ? 1 : 0 opacity: isVisible ? 1 : 0
}} }}
> >
<div className='h-[2.08rem] flex justify-between items-center pr-1 pl-[0.125rem]'> <div className='h-8 flex justify-between items-center pr-1 pl-0.5'>
<BadgeHelp <BadgeHelp topic={HelpTopic.UI_LIBRARY} contentClass='text-sm' offset={5} place='right-start' />
topic={HelpTopic.UI_LIBRARY}
className={clsx(PARAMETER.TOOLTIP_WIDTH, 'text-sm')}
offset={5}
place='right-start'
/>
<div className='cc-icons'> <div className='cc-icons'>
{canRename ? ( {canRename ? (
<MiniButton <MiniButton
@ -90,7 +85,7 @@ export function ViewSideLocation({ isVisible, onRenameLocation }: ViewSideLocati
) : null} ) : null}
{!!location ? ( {!!location ? (
<MiniButton <MiniButton
title={subfolders ? 'Вложенные папки: Вкл' : 'Вложенные папки: Выкл'} // prettier: split-lines title={subfolders ? 'Вложенные папки: Вкл' : 'Вложенные папки: Выкл'}
icon={<IconShowSubfolders value={subfolders} />} icon={<IconShowSubfolders value={subfolders} />}
onClick={toggleSubfolders} onClick={toggleSubfolders}
/> />

View File

@ -0,0 +1,104 @@
import { useIntl } from 'react-intl';
import { useLabelUser } from '@/features/users';
import { MiniButton } from '@/components/Control';
import { createColumnHelper } from '@/components/DataTable';
import { IconFolderTree } from '@/components/Icons';
import { useWindowSize } from '@/hooks/useWindowSize';
import { type ILibraryItem } from '../../backend/types';
import { BadgeLocation } from '../../components/BadgeLocation';
import { useLibrarySearchStore } from '../../stores/librarySearch';
const columnHelper = createColumnHelper<ILibraryItem>();
export function useLibraryColumns() {
const { isSmall } = useWindowSize();
const intl = useIntl();
const getUserLabel = useLabelUser();
const folderMode = useLibrarySearchStore(state => state.folderMode);
const toggleFolderMode = useLibrarySearchStore(state => state.toggleFolderMode);
function handleToggleFolder(event: React.MouseEvent<Element>) {
event.preventDefault();
event.stopPropagation();
toggleFolderMode();
}
return [
...(folderMode
? []
: [
columnHelper.accessor('location', {
id: 'location',
header: () => (
<MiniButton
noPadding
noHover
className='pl-2 max-h-4 -translate-y-0.5'
onClick={handleToggleFolder}
titleHtml='Переключение в режим Проводник'
icon={<IconFolderTree size='1.25rem' className='clr-text-controls' />}
/>
),
size: 50,
minSize: 50,
maxSize: 50,
enableSorting: true,
cell: props => <BadgeLocation location={props.getValue()} />,
sortingFn: 'text'
})
]),
columnHelper.accessor('alias', {
id: 'alias',
header: 'Шифр',
size: 150,
minSize: 80,
maxSize: 150,
enableSorting: true,
cell: props => <span className='min-w-20'>{props.getValue()}</span>,
sortingFn: 'text'
}),
columnHelper.accessor('title', {
id: 'title',
header: 'Название',
size: 1200,
minSize: 200,
maxSize: 1200,
enableSorting: true,
sortingFn: 'text'
}),
columnHelper.accessor(item => item.owner ?? 0, {
id: 'owner',
header: 'Владелец',
size: 400,
minSize: 100,
maxSize: 400,
cell: props => getUserLabel(props.getValue()),
enableSorting: true,
sortingFn: 'text'
}),
columnHelper.accessor('time_update', {
id: 'time_update',
header: isSmall ? 'Дата' : 'Обновлена',
cell: props => (
<span className='whitespace-nowrap'>
{new Date(props.getValue()).toLocaleString(intl.locale, {
year: '2-digit',
month: '2-digit',
day: '2-digit',
...(!isSmall && {
hour: '2-digit',
minute: '2-digit'
})
})}
</span>
),
enableSorting: true,
sortingFn: 'datetime',
sortDescFirst: true
})
];
}

Some files were not shown because too many files have changed in this diff Show More