From 472c64667560791d6cd03fef976b4f15ae796ba3 Mon Sep 17 00:00:00 2001 From: mvoof Date: Sun, 15 Mar 2026 03:58:07 +0500 Subject: [PATCH 01/48] feat: add language selection to project creation and settings --- backend/app/routers/projects.py | 3 ++- backend/app/schemas.py | 1 + frontend/src/constants/languages.ts | 24 +++++++++++++++++++ frontend/src/pages/Dashboard.tsx | 32 ++++++++++++++++++++++++-- frontend/src/pages/ProjectSettings.tsx | 25 +++++++++++++++----- 5 files changed, 76 insertions(+), 9 deletions(-) create mode 100644 frontend/src/constants/languages.ts diff --git a/backend/app/routers/projects.py b/backend/app/routers/projects.py index 1d20a3f..3d6b3f7 100644 --- a/backend/app/routers/projects.py +++ b/backend/app/routers/projects.py @@ -44,6 +44,7 @@ async def create_project( name=data.name, type=data.type, created_by=user.id, + config=data.config or {}, ) db.add(project) await db.commit() @@ -374,7 +375,7 @@ def _parse_ini_lines(text: str) -> list[tuple[str, str]]: async def export_data( project_id: int, format: str = "json", - language: str = Query("ru", description="Target language code for translation export"), + language: str = Query("en", description="Target language code for translation export"), user: models.User = Depends( RoleChecker(allowed_project_roles=[RoleProject.MANAGER]) ), diff --git a/backend/app/schemas.py b/backend/app/schemas.py index f5776d2..f7878cc 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -43,6 +43,7 @@ class RefreshTokenRequest(BaseModel): class ProjectCreate(BaseModel): name: str type: str # "NER" or "TRANSLATION" + config: Optional[dict] = None class ProjectResponse(BaseModel): diff --git a/frontend/src/constants/languages.ts b/frontend/src/constants/languages.ts new file mode 100644 index 0000000..afaceae --- /dev/null +++ b/frontend/src/constants/languages.ts @@ -0,0 +1,24 @@ +export const LANGUAGE_OPTIONS = [ + { value: 'en', label: 'English' }, + { value: 'ru', label: 'Russian' }, + { value: 'fr', label: 'French' }, + { value: 'de', label: 'German' }, + { value: 'es', label: 'Spanish' }, + { value: 'it', label: 'Italian' }, + { value: 'pt', label: 'Portuguese' }, + { value: 'zh', label: 'Chinese' }, + { value: 'ja', label: 'Japanese' }, + { value: 'ko', label: 'Korean' }, + { value: 'pl', label: 'Polish' }, + { value: 'tr', label: 'Turkish' }, + { value: 'uk', label: 'Ukrainian' }, + { value: 'ar', label: 'Arabic' }, + { value: 'nl', label: 'Dutch' }, + { value: 'sv', label: 'Swedish' }, + { value: 'cs', label: 'Czech' }, + { value: 'da', label: 'Danish' }, + { value: 'fi', label: 'Finnish' }, + { value: 'hu', label: 'Hungarian' }, + { value: 'no', label: 'Norwegian' }, + { value: 'ro', label: 'Romanian' }, +]; diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index c4d0054..782116d 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -32,6 +32,7 @@ import { authStore } from '../store/authStore'; import api from '../api/client'; import type { Project, ProjectType, OverviewStats, ProjectStats } from '../types/api'; import { tokens } from '../styles/design-tokens'; +import { LANGUAGE_OPTIONS } from '../constants/languages'; import { motion } from 'framer-motion'; const Dashboard: React.FC = observer(() => { @@ -74,8 +75,13 @@ const Dashboard: React.FC = observer(() => { }); const createProject = useMutation({ - mutationFn: (values: { name: string; type: ProjectType }) => - api.post('/projects', values), + mutationFn: (values: { name: string; type: ProjectType; source_language?: string; target_languages?: string[] }) => { + const { source_language, target_languages, ...rest } = values; + const config: Record = {}; + if (source_language) config.source_language = source_language; + if (target_languages?.length) config.target_languages = target_languages; + return api.post('/projects', { ...rest, config }); + }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['projects'] }); setModalOpen(false); @@ -351,6 +357,28 @@ const Dashboard: React.FC = observer(() => { Named Entity Recognition (NER) + prev.type !== cur.type}> + {({ getFieldValue }) => + getFieldValue('type') === 'TRANSLATION' && ( + <> + + + + + + + + ) + } + diff --git a/frontend/src/pages/ProjectSettings.tsx b/frontend/src/pages/ProjectSettings.tsx index 5d141dc..061528e 100644 --- a/frontend/src/pages/ProjectSettings.tsx +++ b/frontend/src/pages/ProjectSettings.tsx @@ -33,6 +33,7 @@ import { import { observer } from 'mobx-react-lite'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import api from '../api/client'; +import { LANGUAGE_OPTIONS } from '../constants/languages'; import type { ProjectMember, RoleProject, @@ -398,15 +399,27 @@ const ProjectSettings: React.FC = observer(() => { <>
Source Language - {project?.config?.source_language || 'en'} + updateProjectConfig({ ...project?.config, target_languages: val })} + options={LANGUAGE_OPTIONS} + />
)} From 0f39e46699977850d580da5d6798b1ee713d5c27 Mon Sep 17 00:00:00 2001 From: mvoof Date: Sun, 15 Mar 2026 04:01:31 +0500 Subject: [PATCH 02/48] docs: add CC BY-NC 4.0 license and contributing guidelines --- CONTRIBUTING.md | 66 +++++++++++++++++++++++++++++++++++++++++++++++++ LICENSE | 32 ++++++++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..74fbd93 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,66 @@ +> [!NOTE] +> Please prefer English language for all communication. + +# Contributing to VerseLab + +Thank you for considering contributing to VerseLab! Before opening an issue, please check that a similar one hasn't already been reported. + +## How to Contribute + +1. **Fork** the repository +2. **Create** a feature branch: `git checkout -b feat/my-feature` +3. **Install dependencies:** + ```bash + # Backend + cd backend && uv sync + + # Frontend + cd frontend && npm install + ``` +4. **Make your changes** and test them locally +5. **Submit a pull request** with a clear description of what you changed and why + +## Commit Messages + +We follow [Conventional Commits](https://www.conventionalcommits.org/): + +``` +[optional scope]: +``` + +Allowed types: + +| Type | Description | +|------------|--------------------------------------| +| `feat` | New feature | +| `fix` | Bug fix | +| `refactor` | Code change (no new feature or fix) | +| `perf` | Performance improvement | +| `docs` | Documentation only | +| `style` | Formatting, missing semicolons, etc. | +| `test` | Adding or correcting tests | +| `ci` | CI/CD changes | +| `chore` | Repository maintenance | +| `revert` | Revert a previous commit | + +## Development + +```bash +# Run backend +cd backend && uv run uvicorn app.main:app --reload + +# Run frontend +cd frontend && npm run dev + +# Run tests +cd backend && uv run pytest +``` + +## Guidelines + +- All communication is in **English** +- Keep pull requests focused — one feature or fix per PR +- Add tests for new backend functionality when possible +- Ensure `tsc --noEmit` passes before submitting frontend changes + +If you have questions, feel free to open an issue or start a discussion. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b66a10b --- /dev/null +++ b/LICENSE @@ -0,0 +1,32 @@ +Creative Commons Attribution-NonCommercial 4.0 International License + +Copyright (c) 2026 mvoof + +This work is licensed under the Creative Commons Attribution-NonCommercial 4.0 +International License. To view a copy of this license, visit +https://creativecommons.org/licenses/by-nc/4.0/ or send a letter to Creative +Commons, PO Box 1866, Mountain View, CA 94042, USA. + +You are free to: + + Share — copy and redistribute the material in any medium or format + Adapt — remix, transform, and build upon the material + +Under the following terms: + + Attribution — You must give appropriate credit, provide a link to the license, + and indicate if changes were made. You may do so in any reasonable manner, but + not in any way that suggests the licensor endorses you or your use. + + NonCommercial — You may not use the material for commercial purposes. + + No additional restrictions — You may not apply legal terms or technological + measures that legally restrict others from doing anything the license permits. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. From 770568d05638937eeb9fc56bbffffb4430967130 Mon Sep 17 00:00:00 2001 From: mvoof Date: Sun, 15 Mar 2026 13:06:19 +0500 Subject: [PATCH 03/48] refactor: extract Glossary and Review from Settings into standalone pages --- frontend/e2e/full-flow.spec.ts | 75 ---- frontend/package.json | 1 - frontend/playwright.config.ts | 20 - .../components/editors/TranslationEditor.tsx | 85 +++- frontend/src/components/layout/AppLayout.tsx | 16 + frontend/src/pages/ProjectGlossary.tsx | 168 ++++++++ frontend/src/pages/ProjectReview.tsx | 300 +++++++++++++ frontend/src/pages/ProjectSettings.tsx | 396 ------------------ frontend/src/routes/index.tsx | 4 + 9 files changed, 570 insertions(+), 495 deletions(-) delete mode 100644 frontend/e2e/full-flow.spec.ts delete mode 100644 frontend/playwright.config.ts create mode 100644 frontend/src/pages/ProjectGlossary.tsx create mode 100644 frontend/src/pages/ProjectReview.tsx diff --git a/frontend/e2e/full-flow.spec.ts b/frontend/e2e/full-flow.spec.ts deleted file mode 100644 index c3c2373..0000000 --- a/frontend/e2e/full-flow.spec.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { test, expect } from "@playwright/test"; - -test.describe("Full Flow: Setup -> Login -> Create -> Import -> Annotate -> Review -> Vote -> Export", () => { - test.describe("Setup", () => { - test.skip("first user registration creates admin account", async ({ page }) => { - // TODO: Navigate to /register, fill form, verify redirect to dashboard - }); - }); - - test.describe("Login", () => { - test.skip("admin can log in with valid credentials", async ({ page }) => { - // TODO: Navigate to /login, enter credentials, verify dashboard loads - }); - - test.skip("login fails with invalid credentials", async ({ page }) => { - // TODO: Navigate to /login, enter wrong password, verify error message - }); - }); - - test.describe("Create Project", () => { - test.skip("create a new NER project", async ({ page }) => { - // TODO: Navigate to project creation, fill name/type, submit, verify project appears - }); - - test.skip("create a new TRANSLATION project", async ({ page }) => { - // TODO: Same flow but with TRANSLATION type - }); - }); - - test.describe("Import", () => { - test.skip("import VerseBridge JSON into NER project", async ({ page }) => { - // TODO: Open project, go to import, upload ner_unannotated.json, verify tasks created - }); - - test.skip("import preview shows sample and counts before commit", async ({ page }) => { - // TODO: Upload file, verify preview modal, confirm import - }); - }); - - test.describe("Annotate", () => { - test.skip("annotate a task with NER entities", async ({ page }) => { - // TODO: Open task, select text, assign label, save annotation - }); - - test.skip("undo/redo entity selection", async ({ page }) => { - // TODO: Add entity, undo, verify removed, redo, verify restored - }); - }); - - test.describe("Review", () => { - test.skip("reviewer can approve an annotation", async ({ page }) => { - // TODO: Switch to reviewer, open review tab, approve annotation - }); - - test.skip("reviewer can reject with reason", async ({ page }) => { - // TODO: Open review tab, reject annotation, provide reason - }); - }); - - test.describe("Vote", () => { - test.skip("vote on competing annotations", async ({ page }) => { - // TODO: Open voting panel, compare annotations, cast vote - }); - }); - - test.describe("Export", () => { - test.skip("export reviewed annotations as VerseBridge JSON", async ({ page }) => { - // TODO: Navigate to export, download, verify JSON format matches JSONDataNERType - }); - - test.skip("export translation as INI format", async ({ page }) => { - // TODO: Export translation project in INI-game format - }); - }); -}); diff --git a/frontend/package.json b/frontend/package.json index cc6225b..017a750 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -45,7 +45,6 @@ "prettier": "3.6.2", "typescript": "~5.9.3", "typescript-eslint": "^8.45.0", - "@playwright/test": "^1.52.0", "vite": "^7.1.7" } } diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts deleted file mode 100644 index 04c3971..0000000 --- a/frontend/playwright.config.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { defineConfig, devices } from '@playwright/test'; - -export default defineConfig({ - testDir: './e2e', - fullyParallel: false, - forbidOnly: !!process.env.CI, - retries: process.env.CI ? 2 : 0, - workers: 1, - reporter: 'html', - use: { - baseURL: 'http://localhost:5173', - trace: 'on-first-retry', - }, - projects: [ - { - name: 'chromium', - use: { ...devices['Desktop Chrome'] }, - }, - ], -}); diff --git a/frontend/src/components/editors/TranslationEditor.tsx b/frontend/src/components/editors/TranslationEditor.tsx index 17d0aa8..b80e6f3 100644 --- a/frontend/src/components/editors/TranslationEditor.tsx +++ b/frontend/src/components/editors/TranslationEditor.tsx @@ -1,13 +1,15 @@ import { useState, useEffect, useMemo } from 'react'; -import { Typography, Tabs, Button, Input, Space, Tooltip, Tag } from 'antd'; -import { SendOutlined, CopyOutlined, ForwardOutlined } from '@ant-design/icons'; +import { Typography, Tabs, Button, Input, Space, Tooltip, Tag, theme } from 'antd'; +import { SendOutlined, CopyOutlined, ForwardOutlined, UserOutlined } from '@ant-design/icons'; import { useQuery } from '@tanstack/react-query'; import api from '../../api/client'; +import { authStore } from '../../store/authStore'; import type { TranslationAnnotationResult, Task, ProjectConfig, GlossaryTerm, + TaskAnnotation, } from '../../types/api'; interface Props { @@ -17,6 +19,7 @@ interface Props { } const TranslationEditor: React.FC = ({ task, config, onSubmit }) => { + const { token: themeToken } = theme.useToken(); const targetLangs = useMemo( () => config?.target_languages || [], [config?.target_languages] @@ -27,6 +30,19 @@ const TranslationEditor: React.FC = ({ task, config, onSubmit }) => { const sourceText = 'source' in task.data ? (task.data as { source: string }).source : ''; const iniKey = 'key' in task.data ? (task.data as { key: string }).key : ''; + // Fetch existing annotations for this task + const { data: existingAnnotations = [] } = useQuery({ + queryKey: ['task-annotations', task.id], + queryFn: () => api.get(`/api/tasks/${task.id}/annotations`).then((r) => r.data), + enabled: !!task.id, + }); + + // Other users' suggestions (exclude current user) + const otherSuggestions = useMemo( + () => existingAnnotations.filter((a) => a.user_id !== authStore.user?.id), + [existingAnnotations] + ); + // Fetch glossary const { data: glossary = [] } = useQuery({ queryKey: ['glossary', task.project_id], @@ -39,11 +55,24 @@ const TranslationEditor: React.FC = ({ task, config, onSubmit }) => { [glossary, sourceText] ); + // Pre-fill with existing translation (own first, then latest) useEffect(() => { const initial: Record = {}; targetLangs.forEach((lang) => (initial[lang] = '')); + + if (existingAnnotations.length > 0) { + const own = existingAnnotations.find((a) => a.user_id === authStore.user?.id); + const source = own || existingAnnotations[0]; + const result = source.result as Record; + if (result && typeof result === 'object') { + targetLangs.forEach((lang) => { + if (result[lang]) initial[lang] = result[lang]; + }); + } + } + setTranslations(initial); - }, [task, targetLangs]); + }, [task.id, targetLangs, existingAnnotations]); const handleTextChange = (text: string) => { setTranslations((prev) => ({ ...prev, [activeTab]: text })); @@ -222,6 +251,56 @@ const TranslationEditor: React.FC = ({ task, config, onSubmit }) => { /> + {/* Other users' suggestions */} + {otherSuggestions.length > 0 && ( +
+ + Other suggestions ({otherSuggestions.length}) + +
+ {otherSuggestions.map((ann) => { + const result = ann.result as Record; + const text = result?.[activeTab]; + if (!text) return null; + return ( +
handleTextChange(text)} + title="Click to use this translation" + > + + + {ann.user_full_name || ann.user_email} + + + {ann.status} + + +
{text}
+
+ ); + })} +
+
+ )} +
{ icon: , label: 'Stats', }, + { + key: `/projects/${projectId}/glossary`, + icon: , + label: 'Glossary', + }, + { + key: `/projects/${projectId}/review`, + icon: , + label: 'Review', + }, { key: `/projects/${projectId}/settings`, icon: , @@ -154,6 +166,10 @@ const AppLayout: React.FC = observer(() => { breadcrumbItems.push({ title: Workspace }); else if (location.pathname.includes('/stats')) breadcrumbItems.push({ title: Stats }); + else if (location.pathname.includes('/glossary')) + breadcrumbItems.push({ title: Glossary }); + else if (location.pathname.includes('/review')) + breadcrumbItems.push({ title: Review }); else if (location.pathname.includes('/settings')) breadcrumbItems.push({ title: Settings }); } diff --git a/frontend/src/pages/ProjectGlossary.tsx b/frontend/src/pages/ProjectGlossary.tsx new file mode 100644 index 0000000..572fd39 --- /dev/null +++ b/frontend/src/pages/ProjectGlossary.tsx @@ -0,0 +1,168 @@ +import { useState } from 'react'; +import { useParams } from 'react-router'; +import { + Typography, + Card, + Button, + Table, + Space, + Modal, + Form, + Input, + Tag, + Popconfirm, + App, +} from 'antd'; +import { PlusOutlined, DeleteOutlined } from '@ant-design/icons'; +import { observer } from 'mobx-react-lite'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import api from '../api/client'; +import type { GlossaryTerm } from '../types/api'; + +const ProjectGlossary: React.FC = observer(() => { + const { projectId } = useParams(); + const { message } = App.useApp(); + const queryClient = useQueryClient(); + + const [glossaryForm] = Form.useForm(); + const [glossaryModalOpen, setGlossaryModalOpen] = useState(false); + const [editingTerm, setEditingTerm] = useState(null); + + const { data: glossary = [] } = useQuery({ + queryKey: ['glossary', projectId], + queryFn: () => api.get(`/api/projects/${projectId}/glossary`).then((r) => r.data), + enabled: !!projectId, + }); + + const handleSaveGlossaryTerm = async (values: { source_term: string; translations: string; notes?: string }) => { + const translationsMap: Record = {}; + (values.translations || '').split('\n').forEach((line: string) => { + const [lang, ...rest] = line.split(':'); + if (lang && rest.length > 0) translationsMap[lang.trim()] = rest.join(':').trim(); + }); + + try { + if (editingTerm) { + await api.patch(`/api/projects/${projectId}/glossary/${editingTerm.id}`, { + translations: translationsMap, + notes: values.notes || null, + }); + } else { + await api.post(`/api/projects/${projectId}/glossary`, { + source_term: values.source_term, + translations: translationsMap, + notes: values.notes || null, + }); + } + setGlossaryModalOpen(false); + setEditingTerm(null); + glossaryForm.resetFields(); + queryClient.invalidateQueries({ queryKey: ['glossary', projectId] }); + } catch { + message.error('Failed to save glossary term'); + } + }; + + const handleDeleteGlossaryTerm = async (termId: number) => { + try { + await api.delete(`/api/projects/${projectId}/glossary/${termId}`); + queryClient.invalidateQueries({ queryKey: ['glossary', projectId] }); + } catch { + message.error('Failed to delete term'); + } + }; + + return ( +
+
+ Glossary + +
+ + + + {glossary.length} term{glossary.length !== 1 ? 's' : ''} + + {v} }, + { + title: 'Translations', + dataIndex: 'translations', + render: (t: Record) => ( + + {Object.entries(t).map(([lang, trans]) => ( + + {lang} + {trans} + + ))} + + ), + }, + { title: 'Notes', dataIndex: 'notes', render: (v: string | null) => v || '\u2014' }, + { + title: '', width: 80, + render: (_: unknown, record: GlossaryTerm) => ( + + + handleDeleteGlossaryTerm(record.id)}> + + + + )} + +
({ + disabled: record.status !== 'SUBMITTED', + }), + }} + expandable={{ + expandedRowRender: (record: AnnotationListItem) => ( +
+ + Content: + + {project?.type === 'NER' + ? renderNERAnnotation(record.result) + : renderTranslationAnnotation(record.result) + } + {record.task_data && ( +
+ + Source text: + + + {'text' in record.task_data ? (record.task_data as { text: string }).text + : 'source' in record.task_data ? (record.task_data as { source: string }).source + : ''} + +
+ )} + {record.review_note && ( +
+ + Review note: {record.review_note} + +
+ )} +
+ ), + }} + columns={[ + { title: 'ID', dataIndex: 'id', width: 60 }, + { title: 'Task', dataIndex: 'task_id', width: 80, render: (v: number) => `#${v}` }, + { + title: 'Annotator', + render: (_: unknown, r: AnnotationListItem) => r.user_full_name || r.user_email, + }, + { + title: 'Preview', + render: (_: unknown, r: AnnotationListItem) => { + if (project?.type === 'NER') { + const entities = r.result as unknown as NEREntity[]; + return Array.isArray(entities) ? ( + + {entities.slice(0, 3).map((e, i) => ( + + {e.text} + + ))} + {entities.length > 3 && +{entities.length - 3}} + + ) : null; + } + const vals = Object.values(r.result).filter(Boolean); + return vals.length > 0 ? ( + + {String(vals[0]).slice(0, 60)} + + ) : null; + }, + }, + { + title: 'Status', dataIndex: 'status', width: 120, + render: (s: string) => ( + {s} + ), + }, + { title: 'Updated', dataIndex: 'updated_at', render: (v: string) => new Date(v).toLocaleString() }, + { + title: 'Actions', width: 120, + render: (_: unknown, r: AnnotationListItem) => + r.status === 'SUBMITTED' ? ( + + + - - - )} - -
({ - disabled: record.status !== 'SUBMITTED', - }), - }} - expandable={{ - expandedRowRender: (record: AnnotationListItem) => ( -
- - Content: - - {project?.type === 'NER' - ? renderNERAnnotation(record.result) - : renderTranslationAnnotation(record.result) - } - {record.task_data && ( -
- - Source text: - - - {'text' in record.task_data ? (record.task_data as { text: string }).text - : 'source' in record.task_data ? (record.task_data as { source: string }).source - : ''} - -
- )} - {record.review_note && ( -
- - Review note: {record.review_note} - -
- )} -
- ), - }} - columns={[ - { title: 'ID', dataIndex: 'id', width: 60 }, - { title: 'Task', dataIndex: 'task_id', width: 80, render: (v: number) => `#${v}` }, - { - title: 'Annotator', - render: (_: unknown, r: AnnotationListItem) => r.user_full_name || r.user_email, - }, - { - title: 'Preview', - render: (_: unknown, r: AnnotationListItem) => { - if (project?.type === 'NER') { - const entities = r.result as unknown as NEREntity[]; - return Array.isArray(entities) ? ( - - {entities.slice(0, 3).map((e, i) => ( - - {e.text} - - ))} - {entities.length > 3 && +{entities.length - 3}} - - ) : null; - } - const vals = Object.values(r.result).filter(Boolean); - return vals.length > 0 ? ( - - {String(vals[0]).slice(0, 60)} - - ) : null; - }, - }, - { - title: 'Status', dataIndex: 'status', width: 120, - render: (s: string) => ( - {s} - ), - }, - { title: 'Updated', dataIndex: 'updated_at', render: (v: string) => new Date(v).toLocaleString() }, - { - title: 'Actions', width: 120, - render: (_: unknown, r: AnnotationListItem) => - r.status === 'SUBMITTED' ? ( - - - - -
{v} }, - { - title: 'Translations', - dataIndex: 'translations', - render: (t: Record) => ( - - {Object.entries(t).map(([lang, trans]) => ( - - {lang} - {trans} - - ))} - - ), - }, - { title: 'Notes', dataIndex: 'notes', render: (v: string | null) => v || '\u2014' }, - { - title: '', width: 80, - render: (_: unknown, record: GlossaryTerm) => ( - - - handleDeleteGlossaryTerm(record.id)}> - , - , ]} diff --git a/frontend/src/components/editors/NEREditor.tsx b/frontend/src/components/editors/NEREditor.tsx index e733446..d0ea684 100644 --- a/frontend/src/components/editors/NEREditor.tsx +++ b/frontend/src/components/editors/NEREditor.tsx @@ -47,8 +47,17 @@ interface HistoryState { const NEREditor: React.FC = ({ task, config, onSubmit }) => { const labels = config?.labels || [ - 'PER', 'LOC', 'ORG', 'MISC', 'GPE', 'FAC', 'PRODUCT', - 'EVENT', 'SHIP', 'ARMOR', 'WEAPON', + 'PER', + 'LOC', + 'ORG', + 'MISC', + 'GPE', + 'FAC', + 'PRODUCT', + 'EVENT', + 'SHIP', + 'ARMOR', + 'WEAPON', ]; const [selectedLabel, setSelectedLabel] = useState(labels[0]); @@ -70,7 +79,10 @@ const NEREditor: React.FC = ({ task, config, onSubmit }) => { // Pre-populated entities from task data const preEntities = useMemo(() => { - if ('entities' in task.data && Array.isArray((task.data as Record).entities)) { + if ( + 'entities' in task.data && + Array.isArray((task.data as Record).entities) + ) { return (task.data as { entities: NEREntity[] }).entities; } return []; @@ -84,14 +96,17 @@ const NEREditor: React.FC = ({ task, config, onSubmit }) => { setHistoryIndex(0); }, [task.id, preEntities]); - const pushHistory = useCallback((newEntities: NEREntity[]) => { - setHistory((prev) => { - const sliced = prev.slice(0, historyIndex + 1); - return [...sliced, { entities: newEntities }]; - }); - setHistoryIndex((prev) => prev + 1); - setEntities(newEntities); - }, [historyIndex]); + const pushHistory = useCallback( + (newEntities: NEREntity[]) => { + setHistory((prev) => { + const sliced = prev.slice(0, historyIndex + 1); + return [...sliced, { entities: newEntities }]; + }); + setHistoryIndex((prev) => prev + 1); + setEntities(newEntities); + }, + [historyIndex] + ); const undo = useCallback(() => { if (historyIndex > 0) { @@ -171,8 +186,14 @@ const NEREditor: React.FC = ({ task, config, onSubmit }) => { } // Adjust for leading/trailing whitespace - const trimmedStart = charStart + (text.slice(charStart, charEnd).length - text.slice(charStart, charEnd).trimStart().length); - const trimmedEnd = charEnd - (text.slice(charStart, charEnd).length - text.slice(charStart, charEnd).trimEnd().length); + const trimmedStart = + charStart + + (text.slice(charStart, charEnd).length - + text.slice(charStart, charEnd).trimStart().length); + const trimmedEnd = + charEnd - + (text.slice(charStart, charEnd).length - + text.slice(charStart, charEnd).trimEnd().length); // Remove overlapping entities const filtered = entities.filter( @@ -200,7 +221,11 @@ const NEREditor: React.FC = ({ task, config, onSubmit }) => { }; const handleEntityClick = (entity: NEREntity) => { - pushHistory(entities.filter((e) => !(e.start === entity.start && e.end === entity.end))); + pushHistory( + entities.filter( + (e) => !(e.start === entity.start && e.end === entity.end) + ) + ); }; const handleEntityLabelChange = (entity: NEREntity, newLabel: string) => { @@ -240,9 +265,13 @@ const NEREditor: React.FC = ({ task, config, onSubmit }) => { : labels; // Determine drag highlight range - const dragRange = isDragging && dragStart !== null && dragEnd !== null - ? { start: Math.min(dragStart, dragEnd), end: Math.max(dragStart, dragEnd) } - : null; + const dragRange = + isDragging && dragStart !== null && dragEnd !== null + ? { + start: Math.min(dragStart, dragEnd), + end: Math.max(dragStart, dragEnd), + } + : null; return (
= ({ task, config, onSubmit }) => { }} >
- + Labels {labels.length > 10 && ( @@ -284,7 +316,11 @@ const NEREditor: React.FC = ({ task, config, onSubmit }) => { return ( setSelectedLabel(label)} style={{ cursor: 'pointer', @@ -313,7 +349,10 @@ const NEREditor: React.FC = ({ task, config, onSubmit }) => { overflow: 'auto', }} > - + Entities ({entities.length}) {entities.map((e, idx) => ( @@ -329,7 +368,12 @@ const NEREditor: React.FC = ({ task, config, onSubmit }) => { > { const idx = labels.indexOf(e.label); const nextLabel = labels[(idx + 1) % labels.length]; @@ -354,9 +398,21 @@ const NEREditor: React.FC = ({ task, config, onSubmit }) => {
{/* Actions */} -
+
{preEntities.length > 0 && ( - )} @@ -420,10 +476,14 @@ const NEREditor: React.FC = ({ task, config, onSubmit }) => { ); const isInDragRange = - dragRange && i >= dragRange.start && i <= dragRange.end && token.trim(); + dragRange && + i >= dragRange.start && + i <= dragRange.end && + token.trim(); if (activeEntity) { - const color = LABEL_COLORS[activeEntity.label] || tokens.colors.primary; + const color = + LABEL_COLORS[activeEntity.label] || tokens.colors.primary; return ( = ({ task, config, onSubmit }) => { onMouseEnter={() => handleMouseEnter(i)} style={{ cursor: token.trim() ? 'text' : undefined, - backgroundColor: isInDragRange ? 'rgba(59, 130, 246, 0.15)' : undefined, + backgroundColor: isInDragRange + ? 'rgba(59, 130, 246, 0.15)' + : undefined, borderRadius: isInDragRange ? 3 : undefined, }} > diff --git a/frontend/src/components/editors/TranslationEditor.tsx b/frontend/src/components/editors/TranslationEditor.tsx index b80e6f3..b992d64 100644 --- a/frontend/src/components/editors/TranslationEditor.tsx +++ b/frontend/src/components/editors/TranslationEditor.tsx @@ -1,6 +1,11 @@ -import { useState, useEffect, useMemo } from 'react'; -import { Typography, Tabs, Button, Input, Space, Tooltip, Tag, theme } from 'antd'; -import { SendOutlined, CopyOutlined, ForwardOutlined, UserOutlined } from '@ant-design/icons'; +import React, { useState, useEffect, useMemo } from 'react'; +import { Tabs, Button, Input, Space, Tooltip, Tag, theme } from 'antd'; +import { + SendOutlined, + CopyOutlined, + ForwardOutlined, + UserOutlined, +} from '@ant-design/icons'; import { useQuery } from '@tanstack/react-query'; import api from '../../api/client'; import { authStore } from '../../store/authStore'; @@ -27,13 +32,15 @@ const TranslationEditor: React.FC = ({ task, config, onSubmit }) => { const [activeTab, setActiveTab] = useState(targetLangs[0]); const [translations, setTranslations] = useState>({}); - const sourceText = 'source' in task.data ? (task.data as { source: string }).source : ''; + const sourceText = + 'source' in task.data ? (task.data as { source: string }).source : ''; const iniKey = 'key' in task.data ? (task.data as { key: string }).key : ''; // Fetch existing annotations for this task const { data: existingAnnotations = [] } = useQuery({ queryKey: ['task-annotations', task.id], - queryFn: () => api.get(`/api/tasks/${task.id}/annotations`).then((r) => r.data), + queryFn: () => + api.get(`/api/tasks/${task.id}/annotations`).then((r) => r.data), enabled: !!task.id, }); @@ -46,12 +53,16 @@ const TranslationEditor: React.FC = ({ task, config, onSubmit }) => { // Fetch glossary const { data: glossary = [] } = useQuery({ queryKey: ['glossary', task.project_id], - queryFn: () => api.get(`/api/projects/${task.project_id}/glossary`).then((r) => r.data), + queryFn: () => + api.get(`/api/projects/${task.project_id}/glossary`).then((r) => r.data), staleTime: 60_000, }); const matchedTerms = useMemo( - () => glossary.filter((g) => sourceText.toLowerCase().includes(g.source_term.toLowerCase())), + () => + glossary.filter((g) => + sourceText.toLowerCase().includes(g.source_term.toLowerCase()) + ), [glossary, sourceText] ); @@ -61,7 +72,9 @@ const TranslationEditor: React.FC = ({ task, config, onSubmit }) => { targetLangs.forEach((lang) => (initial[lang] = '')); if (existingAnnotations.length > 0) { - const own = existingAnnotations.find((a) => a.user_id === authStore.user?.id); + const own = existingAnnotations.find( + (a) => a.user_id === authStore.user?.id + ); const source = own || existingAnnotations[0]; const result = source.result as Record; if (result && typeof result === 'object') { @@ -83,7 +96,10 @@ const TranslationEditor: React.FC = ({ task, config, onSubmit }) => { }; const handleInsertTerm = (term: GlossaryTerm) => { - const translation = term.translations[activeTab] || Object.values(term.translations)[0] || term.source_term; + const translation = + term.translations[activeTab] || + Object.values(term.translations)[0] || + term.source_term; setTranslations((prev) => ({ ...prev, [activeTab]: (prev[activeTab] || '') + translation, @@ -98,14 +114,23 @@ const TranslationEditor: React.FC = ({ task, config, onSubmit }) => { const renderSourceText = () => { if (matchedTerms.length === 0) { return ( - +
{sourceText} - +
); } // Sort terms by length (longest first) to avoid partial matches - const sortedTerms = [...matchedTerms].sort((a, b) => b.source_term.length - a.source_term.length); + const sortedTerms = [...matchedTerms].sort( + (a, b) => b.source_term.length - a.source_term.length + ); // Simple split approach const regex = new RegExp( @@ -115,7 +140,14 @@ const TranslationEditor: React.FC = ({ task, config, onSubmit }) => { const segments = sourceText.split(regex); return ( - +
{segments.map((seg, i) => { const matchedTerm = sortedTerms.find( (t) => t.source_term.toLowerCase() === seg.toLowerCase() @@ -126,13 +158,21 @@ const TranslationEditor: React.FC = ({ task, config, onSubmit }) => { key={i} title={
- {Object.entries(matchedTerm.translations).map(([lang, trans]) => ( -
- {lang}: {trans} + {Object.entries(matchedTerm.translations).map( + ([lang, trans]) => ( +
+ {lang}: {trans} +
+ ) + )} + {matchedTerm.notes && ( +
+ {matchedTerm.notes}
- ))} - {matchedTerm.notes &&
{matchedTerm.notes}
} -
Click to insert
+ )} +
+ Click to insert +
} > @@ -153,14 +193,21 @@ const TranslationEditor: React.FC = ({ task, config, onSubmit }) => { } return {seg}; })} - +
); }; return (
{/* Source panel */} -
+
= ({ task, config, onSubmit }) => { }} > - + SOURCE ({config?.source_language || 'en'}) - - + + {sourceWordCount} words - +
{iniKey && ( -
- +
+ {iniKey} - +
)}
@@ -261,9 +324,16 @@ const TranslationEditor: React.FC = ({ task, config, onSubmit }) => { overflow: 'auto', }} > - + Other suggestions ({otherSuggestions.length}) - +
{otherSuggestions.map((ann) => { const result = ann.result as Record; @@ -283,11 +353,22 @@ const TranslationEditor: React.FC = ({ task, config, onSubmit }) => { title="Click to use this translation" > - + {ann.user_full_name || ann.user_email} - + {ann.status} @@ -311,9 +392,11 @@ const TranslationEditor: React.FC = ({ task, config, onSubmit }) => { }} > - + {targetWordCount} words - +
- navigate(key)} - style={{ border: 'none', padding: '8px 4px' }} - /> +
+ navigate(key)} + style={{ border: 'none', padding: '8px 4px' }} + /> +
- +
{ - + {(user?.full_name || user?.email || '?')[0].toUpperCase()} diff --git a/frontend/src/components/workspace/ContextPanel.tsx b/frontend/src/components/workspace/ContextPanel.tsx index 7551fec..77e9df3 100644 --- a/frontend/src/components/workspace/ContextPanel.tsx +++ b/frontend/src/components/workspace/ContextPanel.tsx @@ -17,9 +17,7 @@ interface Props { } function parseIniKey(key: string): string { - return key - .replace(/_/g, ' > ') - .replace(/\b\w/g, (c) => c.toUpperCase()); + return key.replace(/_/g, ' > ').replace(/\b\w/g, (c) => c.toUpperCase()); } const ContextPanel: React.FC = ({ task, project, themeToken }) => { @@ -32,7 +30,8 @@ const ContextPanel: React.FC = ({ task, project, themeToken }) => { const { data: glossary = [] } = useQuery({ queryKey: ['glossary', project.id], - queryFn: () => api.get(`/api/projects/${project.id}/glossary`).then((r) => r.data), + queryFn: () => + api.get(`/api/projects/${project.id}/glossary`).then((r) => r.data), staleTime: 60_000, }); @@ -44,12 +43,16 @@ const ContextPanel: React.FC = ({ task, project, themeToken }) => { : ''; // Translation memory - fuzzy search similar approved translations - const { data: tmMatches = [], isLoading: tmLoading } = useQuery({ + const { data: tmMatches = [], isLoading: tmLoading } = useQuery< + TranslationMemoryMatch[] + >({ queryKey: ['translation-memory', project.id, sourceText.slice(0, 50)], queryFn: () => - api.get(`/api/projects/${project.id}/translation-memory`, { - params: { query: sourceText.slice(0, 100), limit: 5 }, - }).then((r) => r.data), + api + .get(`/api/projects/${project.id}/translation-memory`, { + params: { query: sourceText.slice(0, 100), limit: 5 }, + }) + .then((r) => r.data), enabled: !!sourceText && project.type === 'TRANSLATION', staleTime: 120_000, }); @@ -73,11 +76,21 @@ const ContextPanel: React.FC = ({ task, project, themeToken }) => { {/* INI Key */} {iniKey && ( <> - + Key
- + {iniKey}
@@ -89,7 +102,10 @@ const ContextPanel: React.FC = ({ task, project, themeToken }) => { )} {/* Glossary matches */} - + Glossary
@@ -113,14 +129,25 @@ const ContextPanel: React.FC = ({ task, project, themeToken }) => { {Object.entries(term.translations).map(([lang, trans]) => (
- + {lang} - {trans} + + {trans} +
))} {term.notes && ( - + {term.notes} )} @@ -133,7 +160,14 @@ const ContextPanel: React.FC = ({ task, project, themeToken }) => { {project.type === 'TRANSLATION' && ( <> - + Translation Memory
@@ -154,16 +188,33 @@ const ContextPanel: React.FC = ({ task, project, themeToken }) => { background: themeToken.colorBgTextHover, }} > - - {match.source.slice(0, 80)}{match.source.length > 80 ? '...' : ''} + + {match.source.slice(0, 80)} + {match.source.length > 80 ? '...' : ''} {Object.entries(match.result).map(([lang, text]) => (
- {lang} - {String(text)} + + {lang} + + + {String(text)} +
))} - + Match: {Math.round(match.score * 100)}%
@@ -176,21 +227,35 @@ const ContextPanel: React.FC = ({ task, project, themeToken }) => { {/* Task metadata */} - + Task Info
- ID: + + ID:{' '} + #{task.id}
- Annotations: - {task.annotation_count} + + Annotations:{' '} + + + {task.annotation_count} +
- Final: - + + Final:{' '} + + {task.has_final ? 'Yes' : 'No'}
diff --git a/frontend/src/components/workspace/VotingPanel.tsx b/frontend/src/components/workspace/VotingPanel.tsx index 99232a7..f4de7be 100644 --- a/frontend/src/components/workspace/VotingPanel.tsx +++ b/frontend/src/components/workspace/VotingPanel.tsx @@ -1,5 +1,15 @@ import { useState } from 'react'; -import { Typography, Button, Space, Tag, Input, Tooltip, Empty, Spin, theme } from 'antd'; +import { + Typography, + Button, + Space, + Tag, + Input, + Tooltip, + Empty, + Spin, + theme, +} from 'antd'; import { LikeOutlined, LikeFilled, @@ -19,10 +29,20 @@ interface Props { } const LABEL_COLORS: Record = { - PER: '#3b82f6', ORG: '#8b5cf6', LOC: '#10b981', GPE: '#06b6d4', - FAC: '#f59e0b', MISC: '#6b7280', PRODUCT: '#ec4899', EVENT: '#f97316', - SHIP: '#0ea5e9', ARMOR: '#84cc16', WEAPON: '#ef4444', - QUANTITY: '#14b8a6', DATE: '#a855f7', MONEY: '#eab308', + PER: '#3b82f6', + ORG: '#8b5cf6', + LOC: '#10b981', + GPE: '#06b6d4', + FAC: '#f59e0b', + MISC: '#6b7280', + PRODUCT: '#ec4899', + EVENT: '#f97316', + SHIP: '#0ea5e9', + ARMOR: '#84cc16', + WEAPON: '#ef4444', + QUANTITY: '#14b8a6', + DATE: '#a855f7', + MONEY: '#eab308', }; const VotingPanel: React.FC = ({ taskId, projectType }) => { @@ -33,7 +53,8 @@ const VotingPanel: React.FC = ({ taskId, projectType }) => { const { data: annotations = [], isLoading } = useQuery({ queryKey: ['task-annotations', taskId], - queryFn: () => api.get(`/api/tasks/${taskId}/annotations`).then((r) => r.data), + queryFn: () => + api.get(`/api/tasks/${taskId}/annotations`).then((r) => r.data), enabled: !!taskId, }); @@ -44,7 +65,9 @@ const VotingPanel: React.FC = ({ taskId, projectType }) => { const handleComment = async (annotationId: number) => { if (!commentText.trim()) return; - await api.post(`/api/annotations/${annotationId}/comments`, { text: commentText }); + await api.post(`/api/annotations/${annotationId}/comments`, { + text: commentText, + }); setCommentText(''); setCommentingId(null); }; @@ -55,12 +78,20 @@ const VotingPanel: React.FC = ({ taskId, projectType }) => { const renderNERResult = (result: unknown) => { const entities = result as NEREntity[]; if (!Array.isArray(entities) || entities.length === 0) { - return No entities; + return ( + + No entities + + ); } return (
{entities.map((e, i) => ( - + {e.text} {e.label} ))} @@ -74,7 +105,11 @@ const VotingPanel: React.FC = ({ taskId, projectType }) => {
{Object.entries(translations).map(([lang, text]) => (
- {lang} + + {lang} + {text}
))} @@ -90,12 +125,18 @@ const VotingPanel: React.FC = ({ taskId, projectType }) => { background: themeToken.colorBgContainer, }} > - + Annotations ({annotations.length}) {annotations.length === 0 ? ( - + ) : (
{annotations.map((ann) => ( @@ -105,27 +146,42 @@ const VotingPanel: React.FC = ({ taskId, projectType }) => { padding: 10, borderRadius: 8, border: `1px solid ${ann.is_final ? themeToken.colorSuccess : themeToken.colorBorderSecondary}`, - background: ann.is_final ? `${themeToken.colorSuccess}08` : themeToken.colorBgLayout, + background: ann.is_final + ? `${themeToken.colorSuccess}08` + : themeToken.colorBgLayout, }} > {/* Header */} -
+
{ann.user_full_name || ann.user_email} {ann.status} {ann.is_final && ( - + )} @@ -137,13 +193,19 @@ const VotingPanel: React.FC = ({ taskId, projectType }) => {
{projectType === 'NER' ? renderNERResult(ann.result) - : renderTranslationResult(ann.result) - } + : renderTranslationResult(ann.result)}
{/* Review note */} {ann.review_note && ( -
+
Review: {ann.review_note} @@ -156,7 +218,15 @@ const VotingPanel: React.FC = ({ taskId, projectType }) => {
@@ -195,7 +275,11 @@ const VotingPanel: React.FC = ({ taskId, projectType }) => { onPressEnter={() => handleComment(ann.id)} style={{ fontSize: 12 }} /> -
diff --git a/frontend/src/hooks/usePresence.ts b/frontend/src/hooks/usePresence.ts index cad6848..12a4b98 100644 --- a/frontend/src/hooks/usePresence.ts +++ b/frontend/src/hooks/usePresence.ts @@ -30,19 +30,23 @@ export function usePresence(projectId: string | undefined): PresenceState & { ws.onopen = () => { setConnected(true); - ws.send(JSON.stringify({ - user: { - id: authStore.user!.id, - name: authStore.user!.full_name || authStore.user!.email, - }, - })); + ws.send( + JSON.stringify({ + user: { + id: authStore.user!.id, + name: authStore.user!.full_name || authStore.user!.email, + }, + }) + ); }; ws.onmessage = (event) => { try { const data = JSON.parse(event.data); if (data.type === 'presence') { - setUsers(data.users.filter((u: PresenceUser) => u.id !== authStore.user?.id)); + setUsers( + data.users.filter((u: PresenceUser) => u.id !== authStore.user?.id) + ); } } catch { // ignore @@ -66,7 +70,9 @@ export function usePresence(projectId: string | undefined): PresenceState & { const sendSubmitted = useCallback((taskId: number) => { if (wsRef.current?.readyState === WebSocket.OPEN) { - wsRef.current.send(JSON.stringify({ type: 'submitted', task_id: taskId })); + wsRef.current.send( + JSON.stringify({ type: 'submitted', task_id: taskId }) + ); } }, []); diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 782116d..b153aa1 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -30,7 +30,12 @@ import { import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { authStore } from '../store/authStore'; import api from '../api/client'; -import type { Project, ProjectType, OverviewStats, ProjectStats } from '../types/api'; +import type { + Project, + ProjectType, + OverviewStats, + ProjectStats, +} from '../types/api'; import { tokens } from '../styles/design-tokens'; import { LANGUAGE_OPTIONS } from '../constants/languages'; import { motion } from 'framer-motion'; @@ -75,7 +80,12 @@ const Dashboard: React.FC = observer(() => { }); const createProject = useMutation({ - mutationFn: (values: { name: string; type: ProjectType; source_language?: string; target_languages?: string[] }) => { + mutationFn: (values: { + name: string; + type: ProjectType; + source_language?: string; + target_languages?: string[]; + }) => { const { source_language, target_languages, ...rest } = values; const config: Record = {}; if (source_language) config.source_language = source_language; @@ -90,25 +100,37 @@ const Dashboard: React.FC = observer(() => { }); const filteredProjects = projects.filter((p) => { - if (searchText && !p.name.toLowerCase().includes(searchText.toLowerCase())) return false; + if (searchText && !p.name.toLowerCase().includes(searchText.toLowerCase())) + return false; if (typeFilter && p.type !== typeFilter) return false; return true; }); - const totalTasks = Object.values(projectStats).reduce((sum, s) => sum + s.total_tasks, 0); - const totalPending = Object.values(projectStats).reduce((sum, s) => sum + s.pending_review, 0); + const totalTasks = Object.values(projectStats).reduce( + (sum, s) => sum + s.total_tasks, + 0 + ); + const totalPending = Object.values(projectStats).reduce( + (sum, s) => sum + s.pending_review, + 0 + ); // Find the project with the most pending work for "Continue" button - const lastActiveProject = projects.length > 0 - ? projects.reduce((best, p) => { - const s = projectStats[p.id]; - const bS = projectStats[best.id]; - if (!s) return best; - if (!bS) return p; - if (s.total_tasks - s.approved_tasks > bS.total_tasks - bS.approved_tasks) return p; - return best; - }) - : null; + const lastActiveProject = + projects.length > 0 + ? projects.reduce((best, p) => { + const s = projectStats[p.id]; + const bS = projectStats[best.id]; + if (!s) return best; + if (!bS) return p; + if ( + s.total_tasks - s.approved_tasks > + bS.total_tasks - bS.approved_tasks + ) + return p; + return best; + }) + : null; return (
@@ -119,7 +141,9 @@ const Dashboard: React.FC = observer(() => { type="primary" size="large" icon={} - onClick={() => navigate(`/projects/${lastActiveProject.id}/workspace`)} + onClick={() => + navigate(`/projects/${lastActiveProject.id}/workspace`) + } > Continue: {lastActiveProject.name} @@ -132,12 +156,24 @@ const Dashboard: React.FC = observer(() => { {[ ...(authStore.isAdmin ? [ - { title: 'Users', value: overview.total_users, color: tokens.colors.primary }, + { + title: 'Users', + value: overview.total_users, + color: tokens.colors.primary, + }, ] : []), - { title: 'Projects', value: projects.length, color: tokens.colors.accent }, + { + title: 'Projects', + value: projects.length, + color: tokens.colors.accent, + }, { title: 'Tasks', value: totalTasks, color: tokens.colors.success }, - { title: 'Pending Review', value: totalPending, color: tokens.colors.warning }, + { + title: 'Pending Review', + value: totalPending, + color: tokens.colors.warning, + }, ].map((stat, i) => (
{ > + {stat.title} } @@ -226,7 +264,11 @@ const Dashboard: React.FC = observer(() => { ) : filteredProjects.length === 0 ? ( - + {authStore.isAdmin && !searchText && ( @@ -263,7 +307,9 @@ const Dashboard: React.FC = observer(() => { key="stats" type="link" icon={} - onClick={() => navigate(`/projects/${project.id}/stats`)} + onClick={() => + navigate(`/projects/${project.id}/stats`) + } > Stats , @@ -271,7 +317,9 @@ const Dashboard: React.FC = observer(() => { key="settings" type="link" icon={} - onClick={() => navigate(`/projects/${project.id}/settings`)} + onClick={() => + navigate(`/projects/${project.id}/settings`) + } > Settings , @@ -290,13 +338,18 @@ const Dashboard: React.FC = observer(() => { justifyContent: 'center', }} > - + } title={ {project.name} - + {project.type} @@ -305,11 +358,23 @@ const Dashboard: React.FC = observer(() => {
{stats && (
-
- +
+ {stats.total_tasks} tasks - + {progress}%
@@ -321,7 +386,10 @@ const Dashboard: React.FC = observer(() => { />
)} - + {new Date(project.created_at).toLocaleDateString()}
@@ -348,30 +416,60 @@ const Dashboard: React.FC = observer(() => { onFinish={(values) => createProject.mutate(values)} initialValues={{ type: 'TRANSLATION' }} > - + - prev.type !== cur.type}> + prev.type !== cur.type} + > {({ getFieldValue }) => getFieldValue('type') === 'TRANSLATION' && ( <> - + - - {LANGUAGE_OPTIONS.map((l) => ( - {l.label} + + {l.label} + ))} diff --git a/frontend/src/pages/Profile.tsx b/frontend/src/pages/Profile.tsx index c40857d..dc03912 100644 --- a/frontend/src/pages/Profile.tsx +++ b/frontend/src/pages/Profile.tsx @@ -1,14 +1,6 @@ import React, { useState } from 'react'; import { observer } from 'mobx-react-lite'; -import { - Typography, - Card, - Form, - Input, - Button, - message, - Divider, -} from 'antd'; +import { Typography, Card, Form, Input, Button, message, Divider } from 'antd'; import { authStore } from '../store/authStore'; import api from '../api/client'; diff --git a/frontend/src/pages/ProjectGlossary.tsx b/frontend/src/pages/ProjectGlossary.tsx index 572fd39..746ba1b 100644 --- a/frontend/src/pages/ProjectGlossary.tsx +++ b/frontend/src/pages/ProjectGlossary.tsx @@ -30,23 +30,32 @@ const ProjectGlossary: React.FC = observer(() => { const { data: glossary = [] } = useQuery({ queryKey: ['glossary', projectId], - queryFn: () => api.get(`/api/projects/${projectId}/glossary`).then((r) => r.data), + queryFn: () => + api.get(`/api/projects/${projectId}/glossary`).then((r) => r.data), enabled: !!projectId, }); - const handleSaveGlossaryTerm = async (values: { source_term: string; translations: string; notes?: string }) => { + const handleSaveGlossaryTerm = async (values: { + source_term: string; + translations: string; + notes?: string; + }) => { const translationsMap: Record = {}; (values.translations || '').split('\n').forEach((line: string) => { const [lang, ...rest] = line.split(':'); - if (lang && rest.length > 0) translationsMap[lang.trim()] = rest.join(':').trim(); + if (lang && rest.length > 0) + translationsMap[lang.trim()] = rest.join(':').trim(); }); try { if (editingTerm) { - await api.patch(`/api/projects/${projectId}/glossary/${editingTerm.id}`, { - translations: translationsMap, - notes: values.notes || null, - }); + await api.patch( + `/api/projects/${projectId}/glossary/${editingTerm.id}`, + { + translations: translationsMap, + notes: values.notes || null, + } + ); } else { await api.post(`/api/projects/${projectId}/glossary`, { source_term: values.source_term, @@ -74,8 +83,17 @@ const ProjectGlossary: React.FC = observer(() => { return (
-
- Glossary +
+ + Glossary +
- + {glossary.length} term{glossary.length !== 1 ? 's' : ''}
{ pagination={false} size="small" columns={[ - { title: 'Source Term', dataIndex: 'source_term', render: (v: string) => {v} }, + { + title: 'Source Term', + dataIndex: 'source_term', + render: (v: string) => ( + {v} + ), + }, { title: 'Translations', dataIndex: 'translations', @@ -107,25 +134,39 @@ const ProjectGlossary: React.FC = observer(() => { {Object.entries(t).map(([lang, trans]) => ( - {lang} - {trans} + + {lang} + + + {trans} + ))} ), }, - { title: 'Notes', dataIndex: 'notes', render: (v: string | null) => v || '\u2014' }, { - title: '', width: 80, + title: 'Notes', + dataIndex: 'notes', + render: (v: string | null) => v || '\u2014', + }, + { + title: '', + width: 80, render: (_: unknown, record: GlossaryTerm) => ( - handleDeleteGlossaryTerm(record.id)}> - @@ -330,48 +406,76 @@ const ProjectSettings: React.FC = observer(() => { onPressEnter={handleAddLabel} style={{ width: 160 }} /> -
- {(project?.config?.labels || []).map((label: string, idx: number) => ( -
- {label} - - Hotkey: {idx + 1 <= 9 ? idx + 1 : '-'} - -
- ))} +
+ ) + )} {(project?.config?.labels || []).length === 0 && ( - No labels configured. Default labels will be used. + + No labels configured. Default labels will be used. + )} )} @@ -386,7 +490,10 @@ const ProjectSettings: React.FC = observer(() => { children: (
- + Upload INI files for Translation or JSON for NER. { showUploadList={false} disabled={uploading} > -

+

+ +

Click or drag file to upload

- {uploading && } + {uploading && ( + + )} {/* Import preview */} { setImportPreview(null); setPendingFile(null); }} + onCancel={() => { + setImportPreview(null); + setPendingFile(null); + }} onOk={handleConfirmImport} okText={`Import ${importPreview?.total_tasks || 0} tasks`} confirmLoading={uploading} > {importPreview && (
- + File: {importPreview.filename} - - {importPreview.total_tasks} tasks found + + + {importPreview.total_tasks} tasks found + {importPreview.with_entities > 0 && ( - {importPreview.with_entities} tasks with entities ({importPreview.entities_count} total) + + {importPreview.with_entities} tasks with entities ( + {importPreview.entities_count} total) + )} - + Sample (first {Math.min(10, importPreview.sample.length)}):
{importPreview.sample.map((s, i) => ( -
- {s.id && {s.id}} - {s.key && {s.key}} - {s.text} +
+ {s.id && ( + + {s.id} + + )} + {s.key && ( + + {s.key} + + )} + + {s.text} + {(s.entities_count ?? 0) > 0 && ( - {s.entities_count} entities + + {s.entities_count} entities + )}
))} @@ -453,12 +619,25 @@ const ProjectSettings: React.FC = observer(() => { - + Download approved annotations for VerseBridge training. - - + +
@@ -471,8 +650,21 @@ const ProjectSettings: React.FC = observer(() => { label: 'Members', children: ( <> -
-
@@ -482,25 +674,52 @@ const ProjectSettings: React.FC = observer(() => { loading={membersLoading} pagination={false} columns={[ - { title: 'Name', dataIndex: 'full_name', render: (v: string) => v || '\u2014' }, + { + title: 'Name', + dataIndex: 'full_name', + render: (v: string) => v || '\u2014', + }, { title: 'Email', dataIndex: 'email' }, { - title: 'Role', dataIndex: 'role', - render: (role: string) => {role}, + title: 'Role', + dataIndex: 'role', + render: (role: string) => ( + + {role} + + ), }, { - title: '', width: 60, + title: '', + width: 60, render: (_: unknown, record: ProjectMember) => ( - handleRemoveMember(record.user_id)}> -
); -}; +}); export default NEREditor; diff --git a/frontend/src/components/editors/TranslationEditor.tsx b/frontend/src/components/editors/TranslationEditor.tsx index b992d64..c12ea02 100644 --- a/frontend/src/components/editors/TranslationEditor.tsx +++ b/frontend/src/components/editors/TranslationEditor.tsx @@ -16,6 +16,7 @@ import type { GlossaryTerm, TaskAnnotation, } from '../../types/api'; +import { observer } from 'mobx-react-lite'; interface Props { task: Task; @@ -23,96 +24,127 @@ interface Props { onSubmit: (result: TranslationAnnotationResult) => void; } -const TranslationEditor: React.FC = ({ task, config, onSubmit }) => { - const { token: themeToken } = theme.useToken(); - const targetLangs = useMemo( - () => config?.target_languages || [], - [config?.target_languages] - ); - const [activeTab, setActiveTab] = useState(targetLangs[0]); - const [translations, setTranslations] = useState>({}); +const TranslationEditor: React.FC = observer( + ({ task, config, onSubmit }) => { + const { token: themeToken } = theme.useToken(); + const targetLangs = useMemo( + () => config?.target_languages || [], + [config?.target_languages] + ); + const [activeTab, setActiveTab] = useState(targetLangs[0]); + const [translations, setTranslations] = useState>( + {} + ); - const sourceText = - 'source' in task.data ? (task.data as { source: string }).source : ''; - const iniKey = 'key' in task.data ? (task.data as { key: string }).key : ''; + const sourceText = + 'source' in task.data ? (task.data as { source: string }).source : ''; + const iniKey = 'key' in task.data ? (task.data as { key: string }).key : ''; - // Fetch existing annotations for this task - const { data: existingAnnotations = [] } = useQuery({ - queryKey: ['task-annotations', task.id], - queryFn: () => - api.get(`/api/tasks/${task.id}/annotations`).then((r) => r.data), - enabled: !!task.id, - }); + // Fetch existing annotations for this task + const { data: existingAnnotations = [] } = useQuery({ + queryKey: ['task-annotations', task.id], + queryFn: () => + api.get(`/api/tasks/${task.id}/annotations`).then((r) => r.data), + enabled: !!task.id, + }); - // Other users' suggestions (exclude current user) - const otherSuggestions = useMemo( - () => existingAnnotations.filter((a) => a.user_id !== authStore.user?.id), - [existingAnnotations] - ); + // Other users' suggestions (exclude current user) + const otherSuggestions = useMemo( + () => existingAnnotations.filter((a) => a.user_id !== authStore.user?.id), + [existingAnnotations] + ); - // Fetch glossary - const { data: glossary = [] } = useQuery({ - queryKey: ['glossary', task.project_id], - queryFn: () => - api.get(`/api/projects/${task.project_id}/glossary`).then((r) => r.data), - staleTime: 60_000, - }); + // Fetch glossary + const { data: glossary = [] } = useQuery({ + queryKey: ['glossary', task.project_id], + queryFn: () => + api + .get(`/api/projects/${task.project_id}/glossary`) + .then((r) => r.data), + staleTime: 60_000, + }); - const matchedTerms = useMemo( - () => - glossary.filter((g) => - sourceText.toLowerCase().includes(g.source_term.toLowerCase()) - ), - [glossary, sourceText] - ); + const matchedTerms = useMemo( + () => + glossary.filter((g) => + sourceText.toLowerCase().includes(g.source_term.toLowerCase()) + ), + [glossary, sourceText] + ); - // Pre-fill with existing translation (own first, then latest) - useEffect(() => { - const initial: Record = {}; - targetLangs.forEach((lang) => (initial[lang] = '')); + // Pre-fill with existing translation (own first, then latest) + useEffect(() => { + const initial: Record = {}; + targetLangs.forEach((lang) => (initial[lang] = '')); - if (existingAnnotations.length > 0) { - const own = existingAnnotations.find( - (a) => a.user_id === authStore.user?.id - ); - const source = own || existingAnnotations[0]; - const result = source.result as Record; - if (result && typeof result === 'object') { - targetLangs.forEach((lang) => { - if (result[lang]) initial[lang] = result[lang]; - }); + if (existingAnnotations.length > 0) { + const own = existingAnnotations.find( + (a) => a.user_id === authStore.user?.id + ); + const source = own || existingAnnotations[0]; + const result = source.result as Record; + if (result && typeof result === 'object') { + targetLangs.forEach((lang) => { + if (result[lang]) initial[lang] = result[lang]; + }); + } } - } - setTranslations(initial); - }, [task.id, targetLangs, existingAnnotations]); + setTranslations(initial); + }, [task.id, targetLangs, existingAnnotations]); + + const handleTextChange = (text: string) => { + setTranslations((prev) => ({ ...prev, [activeTab]: text })); + }; + + const handleCopySource = () => { + setTranslations((prev) => ({ ...prev, [activeTab]: sourceText })); + }; - const handleTextChange = (text: string) => { - setTranslations((prev) => ({ ...prev, [activeTab]: text })); - }; + const handleInsertTerm = (term: GlossaryTerm) => { + const translation = + term.translations[activeTab] || + Object.values(term.translations)[0] || + term.source_term; + setTranslations((prev) => ({ + ...prev, + [activeTab]: (prev[activeTab] || '') + translation, + })); + }; - const handleCopySource = () => { - setTranslations((prev) => ({ ...prev, [activeTab]: sourceText })); - }; + const currentText = translations[activeTab] || ''; + const sourceWordCount = sourceText.split(/\s+/).filter(Boolean).length; + const targetWordCount = currentText.split(/\s+/).filter(Boolean).length; + + // Highlight glossary terms in source text + const renderSourceText = () => { + if (matchedTerms.length === 0) { + return ( +
+ {sourceText} +
+ ); + } - const handleInsertTerm = (term: GlossaryTerm) => { - const translation = - term.translations[activeTab] || - Object.values(term.translations)[0] || - term.source_term; - setTranslations((prev) => ({ - ...prev, - [activeTab]: (prev[activeTab] || '') + translation, - })); - }; + // Sort terms by length (longest first) to avoid partial matches + const sortedTerms = [...matchedTerms].sort( + (a, b) => b.source_term.length - a.source_term.length + ); - const currentText = translations[activeTab] || ''; - const sourceWordCount = sourceText.split(/\s+/).filter(Boolean).length; - const targetWordCount = currentText.split(/\s+/).filter(Boolean).length; + // Simple split approach + const regex = new RegExp( + `(${sortedTerms.map((t) => t.source_term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})`, + 'gi' + ); + const segments = sourceText.split(regex); - // Highlight glossary terms in source text - const renderSourceText = () => { - if (matchedTerms.length === 0) { return (
= ({ task, config, onSubmit }) => { margin: 0, }} > - {sourceText} -
- ); - } - - // Sort terms by length (longest first) to avoid partial matches - const sortedTerms = [...matchedTerms].sort( - (a, b) => b.source_term.length - a.source_term.length - ); - - // Simple split approach - const regex = new RegExp( - `(${sortedTerms.map((t) => t.source_term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})`, - 'gi' - ); - const segments = sourceText.split(regex); - - return ( -
- {segments.map((seg, i) => { - const matchedTerm = sortedTerms.find( - (t) => t.source_term.toLowerCase() === seg.toLowerCase() - ); - if (matchedTerm) { - return ( - - {Object.entries(matchedTerm.translations).map( - ([lang, trans]) => ( -
- {lang}: {trans} + {segments.map((seg, i) => { + const matchedTerm = sortedTerms.find( + (t) => t.source_term.toLowerCase() === seg.toLowerCase() + ); + if (matchedTerm) { + return ( + + {Object.entries(matchedTerm.translations).map( + ([lang, trans]) => ( +
+ {lang}: {trans} +
+ ) + )} + {matchedTerm.notes && ( +
+ {matchedTerm.notes}
- ) - )} - {matchedTerm.notes && ( -
- {matchedTerm.notes} + )} +
+ Click to insert
- )} -
- Click to insert
-
- } - > - handleInsertTerm(matchedTerm)} - style={{ - backgroundColor: 'rgba(59, 130, 246, 0.15)', - borderBottom: '2px dotted #3b82f6', - cursor: 'pointer', - padding: '1px 2px', - borderRadius: 2, - }} + } > - {seg} - -
- ); - } - return {seg}; - })} -
- ); - }; + handleInsertTerm(matchedTerm)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleInsertTerm(matchedTerm); + }} + style={{ + backgroundColor: 'rgba(59, 130, 246, 0.15)', + borderBottom: '2px dotted #3b82f6', + cursor: 'pointer', + padding: '1px 2px', + borderRadius: 2, + }} + > + {seg} + +
+ ); + } + return {seg}; + })} +
+ ); + }; - return ( -
- {/* Source panel */} -
+ return ( +
+ {/* Source panel */}
- - - SOURCE ({config?.source_language || 'en'}) - - - {sourceWordCount} words - - -
- {iniKey && (
- + + SOURCE ({config?.source_language || 'en'}) + + + {sourceWordCount} words + + +
+ {iniKey && ( +
- {iniKey} - + + {iniKey} + +
+ )} +
+ {renderSourceText()}
- )} -
- {renderSourceText()} + + {/* Glossary terms bar */} + {matchedTerms.length > 0 && ( +
+ {matchedTerms.map((term) => ( + handleInsertTerm(term)} + > + {term.source_term} + + ))} +
+ )}
- {/* Glossary terms bar */} - {matchedTerms.length > 0 && ( + {/* Target panel */} +
+ ({ + key: lang, + label: lang.toUpperCase(), + }))} + tabBarExtraContent={ + +
- - {/* Target panel */} -
- ({ - key: lang, - label: lang.toUpperCase(), - }))} - tabBarExtraContent={ - + {targetWordCount} words + + + + +
- )} - -
- - - {targetWordCount} words - - - - - -
-
- ); -}; + ); + } +); export default TranslationEditor; diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx index a4184f0..20ac4ef 100644 --- a/frontend/src/components/layout/AppLayout.tsx +++ b/frontend/src/components/layout/AppLayout.tsx @@ -164,7 +164,21 @@ const AppLayout: React.FC = observer(() => { // Breadcrumbs const breadcrumbItems: Array<{ title: React.ReactNode }> = [ - { title: navigate('/dashboard')}>Projects }, + { + title: ( + navigate('/dashboard')} + onKeyDown={(e) => { + if (e.key === 'Enter') navigate('/dashboard'); + }} + > + Projects + + ), + }, ]; if (projectId) { breadcrumbItems.push({ title: Project #{projectId} }); diff --git a/frontend/src/components/workspace/ContextPanel.tsx b/frontend/src/components/workspace/ContextPanel.tsx index 77e9df3..6e53f00 100644 --- a/frontend/src/components/workspace/ContextPanel.tsx +++ b/frontend/src/components/workspace/ContextPanel.tsx @@ -1,4 +1,6 @@ +import React from 'react'; import { Typography, Divider, Tag, Spin } from 'antd'; +import { observer } from 'mobx-react-lite'; import { useQuery } from '@tanstack/react-query'; import api from '../../api/client'; import type { TaskListItem, Project, GlossaryTerm } from '../../types/api'; @@ -20,248 +22,264 @@ function parseIniKey(key: string): string { return key.replace(/_/g, ' > ').replace(/\b\w/g, (c) => c.toUpperCase()); } -const ContextPanel: React.FC = ({ task, project, themeToken }) => { - const iniKey = - 'original_id' in task.data - ? (task.data as { original_id?: string }).original_id - : 'key' in task.data - ? (task.data as { key?: string }).key - : null; +const ContextPanel: React.FC = observer( + ({ task, project, themeToken }) => { + const iniKey = + 'original_id' in task.data + ? (task.data as { original_id?: string }).original_id + : 'key' in task.data + ? (task.data as { key?: string }).key + : null; - const { data: glossary = [] } = useQuery({ - queryKey: ['glossary', project.id], - queryFn: () => - api.get(`/api/projects/${project.id}/glossary`).then((r) => r.data), - staleTime: 60_000, - }); + const { data: glossary = [] } = useQuery({ + queryKey: ['glossary', project.id], + queryFn: () => + api.get(`/api/projects/${project.id}/glossary`).then((r) => r.data), + staleTime: 60_000, + }); - const sourceText = - 'text' in task.data - ? (task.data as { text: string }).text - : 'source' in task.data - ? (task.data as { source: string }).source - : ''; + const sourceText = + 'text' in task.data + ? (task.data as { text: string }).text + : 'source' in task.data + ? (task.data as { source: string }).source + : ''; - // Translation memory - fuzzy search similar approved translations - const { data: tmMatches = [], isLoading: tmLoading } = useQuery< - TranslationMemoryMatch[] - >({ - queryKey: ['translation-memory', project.id, sourceText.slice(0, 50)], - queryFn: () => - api - .get(`/api/projects/${project.id}/translation-memory`, { - params: { query: sourceText.slice(0, 100), limit: 5 }, - }) - .then((r) => r.data), - enabled: !!sourceText && project.type === 'TRANSLATION', - staleTime: 120_000, - }); + // Translation memory - fuzzy search similar approved translations + const { data: tmMatches = [], isLoading: tmLoading } = useQuery< + TranslationMemoryMatch[] + >({ + queryKey: ['translation-memory', project.id, sourceText.slice(0, 50)], + queryFn: () => + api + .get(`/api/projects/${project.id}/translation-memory`, { + params: { query: sourceText.slice(0, 100), limit: 5 }, + }) + .then((r) => r.data), + enabled: !!sourceText && project.type === 'TRANSLATION', + staleTime: 120_000, + }); - const matchedTerms = glossary.filter((g) => - sourceText.toLowerCase().includes(g.source_term.toLowerCase()) - ); + const matchedTerms = glossary.filter((g) => + sourceText.toLowerCase().includes(g.source_term.toLowerCase()) + ); - return ( -
- {/* INI Key */} - {iniKey && ( - <> - - Key - -
- - {iniKey} - -
- - {parseIniKey(iniKey)} - - - - )} - - {/* Glossary matches */} - - Glossary - -
- {matchedTerms.length === 0 ? ( - - No matching terms - - ) : ( - matchedTerms.map((term) => ( -
+ - - {term.source_term} + Key + +
+ + {iniKey} - {Object.entries(term.translations).map(([lang, trans]) => ( -
- - {lang} - - - {trans} - -
- ))} - {term.notes && ( - - {term.notes} - - )}
- )) + + {parseIniKey(iniKey)} + + + )} -
- {/* Translation Memory */} - {project.type === 'TRANSLATION' && ( - <> - - - Translation Memory - -
- {tmLoading ? ( - - ) : tmMatches.length === 0 ? ( - - No similar translations - - ) : ( - tmMatches.map((match, i) => ( -
+ {/* Glossary matches */} + + Glossary + +
+ {matchedTerms.length === 0 ? ( + + No matching terms + + ) : ( + matchedTerms.map((term) => ( +
+ + {term.source_term} + + {Object.entries(term.translations).map(([lang, trans]) => ( +
+ + {lang} + + + {trans} + +
+ ))} + {term.notes && ( - {match.source.slice(0, 80)} - {match.source.length > 80 ? '...' : ''} + {term.notes} - {Object.entries(match.result).map(([lang, text]) => ( -
- - {lang} - - - {String(text)} - -
- ))} - + )) + )} +
+ + {/* Translation Memory */} + {project.type === 'TRANSLATION' && ( + <> + + + Translation Memory + +
+ {tmLoading ? ( + + ) : tmMatches.length === 0 ? ( + + No similar translations + + ) : ( + tmMatches.map((match, i) => ( +
- Match: {Math.round(match.score * 100)}% - -
- )) - )} -
- - )} + + {match.source.slice(0, 80)} + {match.source.length > 80 ? '...' : ''} + + {Object.entries(match.result).map(([lang, text]) => ( +
+ + {lang} + + + {String(text)} + +
+ ))} + + Match: {Math.round(match.score * 100)}% + +
+ )) + )} +
+ + )} - + - {/* Task metadata */} - - Task Info - -
-
- - ID:{' '} - - #{task.id} -
-
- - Annotations:{' '} - - - {task.annotation_count} - -
-
- - Final:{' '} - - - {task.has_final ? 'Yes' : 'No'} - + {/* Task metadata */} + + Task Info + +
+
+ + ID:{' '} + + + #{task.id} + +
+
+ + Annotations:{' '} + + + {task.annotation_count} + +
+
+ + Final:{' '} + + + {task.has_final ? 'Yes' : 'No'} + +
-
- ); -}; + ); + } +); export default ContextPanel; diff --git a/frontend/src/components/workspace/VotingPanel.tsx b/frontend/src/components/workspace/VotingPanel.tsx index f4de7be..c5e8d7b 100644 --- a/frontend/src/components/workspace/VotingPanel.tsx +++ b/frontend/src/components/workspace/VotingPanel.tsx @@ -1,4 +1,5 @@ -import { useState } from 'react'; +import React, { useState } from 'react'; +import { observer } from 'mobx-react-lite'; import { Typography, Button, @@ -45,7 +46,7 @@ const LABEL_COLORS: Record = { MONEY: '#eab308', }; -const VotingPanel: React.FC = ({ taskId, projectType }) => { +const VotingPanel: React.FC = observer(({ taskId, projectType }) => { const { token: themeToken } = theme.useToken(); const queryClient = useQueryClient(); const [commentingId, setCommentingId] = useState(null); @@ -290,6 +291,6 @@ const VotingPanel: React.FC = ({ taskId, projectType }) => { )}
); -}; +}); export default VotingPanel; diff --git a/frontend/src/pages/ProjectSettings.tsx b/frontend/src/pages/ProjectSettings.tsx index a780373..8a6704c 100644 --- a/frontend/src/pages/ProjectSettings.tsx +++ b/frontend/src/pages/ProjectSettings.tsx @@ -17,7 +17,6 @@ import { Popconfirm, Checkbox, Input, - Tooltip, theme, } from 'antd'; import { diff --git a/frontend/src/pages/ProjectStats.tsx b/frontend/src/pages/ProjectStats.tsx index 84fc400..70fb839 100644 --- a/frontend/src/pages/ProjectStats.tsx +++ b/frontend/src/pages/ProjectStats.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { observer } from 'mobx-react-lite'; import { useParams } from 'react-router'; import { Typography, @@ -56,7 +57,7 @@ const LABEL_COLORS: Record = { MONEY: '#eab308', }; -const ProjectStats: React.FC = () => { +const ProjectStats: React.FC = observer(() => { const { projectId } = useParams(); const { token: themeToken } = theme.useToken(); @@ -370,6 +371,6 @@ const ProjectStats: React.FC = () => { )}
); -}; +}); export default ProjectStats; diff --git a/frontend/src/pages/TaskBrowser.tsx b/frontend/src/pages/TaskBrowser.tsx index 6791608..af680be 100644 --- a/frontend/src/pages/TaskBrowser.tsx +++ b/frontend/src/pages/TaskBrowser.tsx @@ -1,11 +1,12 @@ -import { useState } from 'react'; +import React, { useState } from 'react'; +import { observer } from 'mobx-react-lite'; import { useParams } from 'react-router'; import { Typography, Tag, Card, Table } from 'antd'; import { useQuery } from '@tanstack/react-query'; import api from '../api/client'; import type { TaskListItem, TaskListResponse } from '../types/api'; -const TaskBrowser: React.FC = () => { +const TaskBrowser: React.FC = observer(() => { const { projectId } = useParams(); const [page, setPage] = useState(1); const pageSize = 50; @@ -101,6 +102,6 @@ const TaskBrowser: React.FC = () => { />
); -}; +}); export default TaskBrowser; diff --git a/frontend/src/pages/Workspace.tsx b/frontend/src/pages/Workspace.tsx index 11f62ad..aee8125 100644 --- a/frontend/src/pages/Workspace.tsx +++ b/frontend/src/pages/Workspace.tsx @@ -94,7 +94,7 @@ const Workspace: React.FC = observer(() => { enabled: !!projectId, }); - const tasks = tasksData?.items || []; + const tasks = useMemo(() => tasksData?.items || [], [tasksData?.items]); const totalTasks = tasksData?.total || 0; // Filter tasks client-side for search and status diff --git a/frontend/src/pages/admin/AuditLog.tsx b/frontend/src/pages/admin/AuditLog.tsx index af6e071..f8561d6 100644 --- a/frontend/src/pages/admin/AuditLog.tsx +++ b/frontend/src/pages/admin/AuditLog.tsx @@ -1,10 +1,11 @@ -import { useState } from 'react'; +import React, { useState } from 'react'; +import { observer } from 'mobx-react-lite'; import { Typography, Tag, Table } from 'antd'; import { useQuery } from '@tanstack/react-query'; import api from '../../api/client'; import type { AuditLogItem, AuditLogListResponse } from '../../types/api'; -const AuditLog: React.FC = () => { +const AuditLog: React.FC = observer(() => { const [page, setPage] = useState(1); const pageSize = 50; @@ -80,6 +81,6 @@ const AuditLog: React.FC = () => { />
); -}; +}); export default AuditLog; diff --git a/frontend/src/pages/admin/InvitationsManagement.tsx b/frontend/src/pages/admin/InvitationsManagement.tsx index 4cc5746..1205050 100644 --- a/frontend/src/pages/admin/InvitationsManagement.tsx +++ b/frontend/src/pages/admin/InvitationsManagement.tsx @@ -1,4 +1,5 @@ import React, { useState } from 'react'; +import { observer } from 'mobx-react-lite'; import { Typography, Button, @@ -18,7 +19,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import api from '../../api/client'; import type { Invitation } from '../../types/api'; -const InvitationsManagement: React.FC = () => { +const InvitationsManagement: React.FC = observer(() => { const queryClient = useQueryClient(); const [modalOpen, setModalOpen] = useState(false); const [form] = Form.useForm(); @@ -163,6 +164,6 @@ const InvitationsManagement: React.FC = () => {
); -}; +}); export default InvitationsManagement; diff --git a/frontend/src/pages/admin/MembersManagement.tsx b/frontend/src/pages/admin/MembersManagement.tsx index 35fb7c3..2e35e5f 100644 --- a/frontend/src/pages/admin/MembersManagement.tsx +++ b/frontend/src/pages/admin/MembersManagement.tsx @@ -1,9 +1,11 @@ +import React from 'react'; +import { observer } from 'mobx-react-lite'; import { Typography, Tag, Skeleton, Table } from 'antd'; import { useQuery } from '@tanstack/react-query'; import api from '../../api/client'; import type { UserListItem } from '../../types/api'; -const MembersManagement: React.FC = () => { +const MembersManagement: React.FC = observer(() => { const { data: users = [], isLoading } = useQuery({ queryKey: ['users'], queryFn: () => api.get('/users/').then((r) => r.data), @@ -58,6 +60,6 @@ const MembersManagement: React.FC = () => { />
); -}; +}); export default MembersManagement; From babc05fcd5fb204e71c97ef7fd500b8e16584177 Mon Sep 17 00:00:00 2001 From: mvoof Date: Sun, 15 Mar 2026 17:46:07 +0500 Subject: [PATCH 08/48] fix: correct InputRef type in CommandPalette to fix frontend build --- frontend/src/components/CommandPalette.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/CommandPalette.tsx b/frontend/src/components/CommandPalette.tsx index 3b7d43e..4631da2 100644 --- a/frontend/src/components/CommandPalette.tsx +++ b/frontend/src/components/CommandPalette.tsx @@ -2,6 +2,7 @@ import React, { useState, useEffect, useMemo, useRef } from 'react'; import { observer } from 'mobx-react-lite'; import { useNavigate } from 'react-router'; import { Modal, Input, Typography, Tag, theme } from 'antd'; +import type { InputRef } from 'antd'; import { HomeOutlined, AppstoreOutlined, @@ -33,7 +34,7 @@ const CommandPalette: React.FC = observer(() => { const [search, setSearch] = useState(''); const [selectedIndex, setSelectedIndex] = useState(0); const navigate = useNavigate(); - const inputRef = useRef(null); + const inputRef = useRef(null); const { token: themeToken } = theme.useToken(); const { data: projects = [] } = useQuery({ From 968df203f468258841c9a040b08afb5f2fb3edba Mon Sep 17 00:00:00 2001 From: mvoof Date: Sun, 15 Mar 2026 17:46:14 +0500 Subject: [PATCH 09/48] fix: add port 5174 to CORS origins for Vite dev server --- backend/app/config.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/app/config.py b/backend/app/config.py index fc25d2b..5bcb7e7 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -25,8 +25,10 @@ class Settings(BaseSettings): CORS_ORIGINS: list[str] = [ "http://localhost:3000", "http://localhost:5173", + "http://localhost:5174", "http://127.0.0.1:3000", "http://127.0.0.1:5173", + "http://127.0.0.1:5174", ] # Rate limiting From 510e22a6c1f30f3a48e239c06c6b0855949c6f82 Mon Sep 17 00:00:00 2001 From: mvoof Date: Sun, 15 Mar 2026 18:27:47 +0500 Subject: [PATCH 10/48] fix: resolve timezone-aware datetime crash in invitation creation The POST /api/invitations endpoint returned 500 because datetime.now(timezone.utc) produced a tz-aware datetime incompatible with the TIMESTAMP WITHOUT TIME ZONE column. Switched to datetime.utcnow() to match the DB schema --- backend/app/routers/invitations.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/app/routers/invitations.py b/backend/app/routers/invitations.py index 2e8d1f3..05eb2d8 100644 --- a/backend/app/routers/invitations.py +++ b/backend/app/routers/invitations.py @@ -1,5 +1,5 @@ import uuid -from datetime import datetime, timedelta, timezone +from datetime import datetime, timedelta from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.ext.asyncio import AsyncSession @@ -36,7 +36,7 @@ async def create_invitation( invitation = models.Invitation( email=data.email, token=str(uuid.uuid4()), - expires_at=datetime.now(timezone.utc) + timedelta(days=7), + expires_at=datetime.utcnow() + timedelta(days=7), ) db.add(invitation) await db.commit() From 97e6a574d0f0f917866d621b3431fa56796caef3 Mon Sep 17 00:00:00 2001 From: mvoof Date: Sun, 15 Mar 2026 18:50:57 +0500 Subject: [PATCH 11/48] fix: use Vite dev proxy to eliminate CORS and port issues --- backend/.env.example | 4 +++ backend/app/config.py | 25 ++++++++++++------- frontend/.env.development | 2 +- frontend/src/api/client.ts | 4 +-- frontend/src/components/layout/AppLayout.tsx | 4 +-- frontend/src/pages/Dashboard.tsx | 2 +- .../src/pages/admin/InvitationsManagement.tsx | 5 ++-- frontend/vite.config.ts | 10 ++++++++ 8 files changed, 38 insertions(+), 18 deletions(-) diff --git a/backend/.env.example b/backend/.env.example index 873d0a6..3196652 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,3 +1,7 @@ # Copy to .env and fill in your values DATABASE_URL=postgresql+asyncpg://postgres:YOUR_PASSWORD@localhost:5432/verselab_db SECRET_KEY=generate-a-strong-random-string-here + +# CORS origins (optional, comma-separated or JSON array) +# Leave empty when using Vite proxy (dev) or nginx (prod) +# CORS_ORIGINS=https://verselab.example.com diff --git a/backend/app/config.py b/backend/app/config.py index 5bcb7e7..9b83e64 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -1,3 +1,4 @@ +import json import re import secrets @@ -21,15 +22,21 @@ class Settings(BaseSettings): ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 REFRESH_TOKEN_EXPIRE_DAYS: int = 30 - # CORS - CORS_ORIGINS: list[str] = [ - "http://localhost:3000", - "http://localhost:5173", - "http://localhost:5174", - "http://127.0.0.1:3000", - "http://127.0.0.1:5173", - "http://127.0.0.1:5174", - ] + # CORS — empty by default (Vite proxy / nginx handle same-origin in dev/prod). + # Set CORS_ORIGINS env var as comma-separated or JSON array for external access. + CORS_ORIGINS: list[str] = [] + + @field_validator("CORS_ORIGINS", mode="before") + @classmethod + def parse_cors_origins(cls, v: str | list[str]) -> list[str]: + if isinstance(v, list): + return v + if not v or not v.strip(): + return [] + v = v.strip() + if v.startswith("["): + return json.loads(v) + return [origin.strip() for origin in v.split(",") if origin.strip()] # Rate limiting RATE_LIMIT_DEFAULT: str = "60/minute" diff --git a/frontend/.env.development b/frontend/.env.development index ad82eb2..292a14c 100644 --- a/frontend/.env.development +++ b/frontend/.env.development @@ -1 +1 @@ -VITE_API_URL=http://127.0.0.1:8000 +VITE_API_URL= diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 8f8c844..4527a4c 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,7 +1,7 @@ import axios from 'axios'; import { authStore } from '../store/authStore'; -const API_URL = import.meta.env.VITE_API_URL || ''; +const API_URL = import.meta.env.VITE_API_URL || '/api/v1'; const api = axios.create({ baseURL: API_URL, @@ -64,7 +64,7 @@ api.interceptors.response.use( isRefreshing = true; try { - const res = await axios.post(`${API_URL}/api/v1/auth/refresh`, { + const res = await axios.post(`${API_URL}/auth/refresh`, { refresh_token: refreshToken, }); const newAccessToken = res.data.access_token; diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx index 20ac4ef..d782a41 100644 --- a/frontend/src/components/layout/AppLayout.tsx +++ b/frontend/src/components/layout/AppLayout.tsx @@ -71,7 +71,7 @@ const AppLayout: React.FC = observer(() => { const fetchNotifications = async () => { try { - const res = await api.get('/api/notifications?page_size=10'); + const res = await api.get('/notifications?page_size=10'); setNotifications(res.data.items); setUnreadCount(res.data.unread_count); } catch { @@ -81,7 +81,7 @@ const AppLayout: React.FC = observer(() => { const markAllRead = async () => { try { - await api.patch('/api/notifications/read-all'); + await api.patch('/notifications/read-all'); setUnreadCount(0); setNotifications((prev) => prev.map((n) => ({ ...n, is_read: true }))); } catch { diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index b153aa1..cbca2d0 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -55,7 +55,7 @@ const Dashboard: React.FC = observer(() => { const { data: overview } = useQuery({ queryKey: ['overview-stats'], - queryFn: () => api.get('/api/overview-stats').then((r) => r.data), + queryFn: () => api.get('/overview-stats').then((r) => r.data), }); // Fetch stats for each project for progress bars diff --git a/frontend/src/pages/admin/InvitationsManagement.tsx b/frontend/src/pages/admin/InvitationsManagement.tsx index 1205050..a8db055 100644 --- a/frontend/src/pages/admin/InvitationsManagement.tsx +++ b/frontend/src/pages/admin/InvitationsManagement.tsx @@ -27,12 +27,11 @@ const InvitationsManagement: React.FC = observer(() => { const { data: invitations = [], isLoading } = useQuery({ queryKey: ['invitations'], - queryFn: () => api.get('/api/invitations').then((r) => r.data), + queryFn: () => api.get('/invitations').then((r) => r.data), }); const createInvitation = useMutation({ - mutationFn: (values: { email: string }) => - api.post('/api/invitations', values), + mutationFn: (values: { email: string }) => api.post('/invitations', values), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['invitations'] }); setModalOpen(false); diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 4a5def4..9242cd6 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -4,4 +4,14 @@ import react from '@vitejs/plugin-react'; // https://vite.dev/config/ export default defineConfig({ plugins: [react()], + server: { + proxy: { + '/api': 'http://127.0.0.1:8000', + '/ws': { + target: 'http://127.0.0.1:8000', + ws: true, + }, + '/health': 'http://127.0.0.1:8000', + }, + }, }); From fcdfd50b8494b790c6350837cd6864247ddb0bae Mon Sep 17 00:00:00 2001 From: mvoof Date: Sun, 15 Mar 2026 19:21:23 +0500 Subject: [PATCH 12/48] fix: use CORS origin regex to allow any localhost port in dev MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vite picks the next free port (5173→5174→5175...), so hardcoded origins break. Use allow_origin_regex for localhost/127.0.0.1 on any port by default. In prod, set CORS_ORIGINS env var to disable regex and use explicit origins only --- backend/app/config.py | 5 +++-- backend/app/main.py | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/backend/app/config.py b/backend/app/config.py index 9b83e64..d4b3685 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -22,9 +22,10 @@ class Settings(BaseSettings): ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 REFRESH_TOKEN_EXPIRE_DAYS: int = 30 - # CORS — empty by default (Vite proxy / nginx handle same-origin in dev/prod). - # Set CORS_ORIGINS env var as comma-separated or JSON array for external access. + # CORS — in dev, allow any localhost port via regex (Vite picks a free port). + # In prod, set CORS_ORIGINS to explicit origins (comma-separated or JSON array). CORS_ORIGINS: list[str] = [] + CORS_ORIGIN_REGEX: str = r"^https?://(localhost|127\.0\.0\.1)(:\d+)?$" @field_validator("CORS_ORIGINS", mode="before") @classmethod diff --git a/backend/app/main.py b/backend/app/main.py index 0b8713c..00b32e6 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -43,7 +43,8 @@ async def lifespan(app: FastAPI): app.add_middleware( CORSMiddleware, - allow_origins=settings.CORS_ORIGINS, + allow_origins=settings.CORS_ORIGINS or [], + allow_origin_regex=settings.CORS_ORIGIN_REGEX if not settings.CORS_ORIGINS else None, allow_credentials=True, allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], allow_headers=["Authorization", "Content-Type"], From dc7664e899cbbb64017ddc59bf6da37b1a1de718 Mon Sep 17 00:00:00 2001 From: mvoof Date: Sun, 15 Mar 2026 19:35:51 +0500 Subject: [PATCH 13/48] fix: resolve CORS and trailing-slash redirect issues breaking auth flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add CORS_ORIGIN_REGEX to allow any localhost port (Vite auto-increments) - Disable redirect_slashes to prevent 307→cross-origin redirect losing Auth header - Fix duplicate /api prefix in 15+ frontend API calls (/api/v1/api/... → /api/v1/...) --- backend/app/main.py | 1 + backend/app/routers/projects.py | 4 ++-- backend/app/routers/users.py | 2 +- .../components/editors/TranslationEditor.tsx | 6 ++---- .../src/components/workspace/ContextPanel.tsx | 4 ++-- .../src/components/workspace/VotingPanel.tsx | 7 +++---- frontend/src/pages/Dashboard.tsx | 2 +- frontend/src/pages/ProjectGlossary.tsx | 17 +++++++---------- frontend/src/pages/ProjectReview.tsx | 4 ++-- frontend/src/pages/ProjectStats.tsx | 9 ++++----- frontend/src/pages/Workspace.tsx | 6 ++---- .../src/pages/admin/InvitationsManagement.tsx | 2 +- 12 files changed, 28 insertions(+), 36 deletions(-) diff --git a/backend/app/main.py b/backend/app/main.py index 00b32e6..a3c0c1f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -35,6 +35,7 @@ async def lifespan(app: FastAPI): description="Backend for VerseLab", version="0.3.0", lifespan=lifespan, + redirect_slashes=False, ) app.state.limiter = limiter diff --git a/backend/app/routers/projects.py b/backend/app/routers/projects.py index 3d6b3f7..d80b4dd 100644 --- a/backend/app/routers/projects.py +++ b/backend/app/routers/projects.py @@ -14,7 +14,7 @@ # --- CRUD --- -@router.get("/", response_model=list[schemas.ProjectResponse]) +@router.get("", response_model=list[schemas.ProjectResponse]) async def list_projects( user: models.User = Depends(security.get_current_user), db: AsyncSession = Depends(database.get_db), @@ -33,7 +33,7 @@ async def list_projects( return result.scalars().all() -@router.post("/", response_model=schemas.ProjectResponse, status_code=201) +@router.post("", response_model=schemas.ProjectResponse, status_code=201) async def create_project( data: schemas.ProjectCreate, user: models.User = Depends(AdminRequired()), diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py index 56d645c..2f37877 100644 --- a/backend/app/routers/users.py +++ b/backend/app/routers/users.py @@ -47,7 +47,7 @@ async def update_me( ) -@router.get("/", response_model=list[schemas.UserListItem]) +@router.get("", response_model=list[schemas.UserListItem]) async def list_users( user: models.User = Depends(AdminRequired()), db: AsyncSession = Depends(database.get_db), diff --git a/frontend/src/components/editors/TranslationEditor.tsx b/frontend/src/components/editors/TranslationEditor.tsx index c12ea02..614c37e 100644 --- a/frontend/src/components/editors/TranslationEditor.tsx +++ b/frontend/src/components/editors/TranslationEditor.tsx @@ -44,7 +44,7 @@ const TranslationEditor: React.FC = observer( const { data: existingAnnotations = [] } = useQuery({ queryKey: ['task-annotations', task.id], queryFn: () => - api.get(`/api/tasks/${task.id}/annotations`).then((r) => r.data), + api.get(`/tasks/${task.id}/annotations`).then((r) => r.data), enabled: !!task.id, }); @@ -58,9 +58,7 @@ const TranslationEditor: React.FC = observer( const { data: glossary = [] } = useQuery({ queryKey: ['glossary', task.project_id], queryFn: () => - api - .get(`/api/projects/${task.project_id}/glossary`) - .then((r) => r.data), + api.get(`/projects/${task.project_id}/glossary`).then((r) => r.data), staleTime: 60_000, }); diff --git a/frontend/src/components/workspace/ContextPanel.tsx b/frontend/src/components/workspace/ContextPanel.tsx index 6e53f00..ee0336e 100644 --- a/frontend/src/components/workspace/ContextPanel.tsx +++ b/frontend/src/components/workspace/ContextPanel.tsx @@ -34,7 +34,7 @@ const ContextPanel: React.FC = observer( const { data: glossary = [] } = useQuery({ queryKey: ['glossary', project.id], queryFn: () => - api.get(`/api/projects/${project.id}/glossary`).then((r) => r.data), + api.get(`/projects/${project.id}/glossary`).then((r) => r.data), staleTime: 60_000, }); @@ -52,7 +52,7 @@ const ContextPanel: React.FC = observer( queryKey: ['translation-memory', project.id, sourceText.slice(0, 50)], queryFn: () => api - .get(`/api/projects/${project.id}/translation-memory`, { + .get(`/projects/${project.id}/translation-memory`, { params: { query: sourceText.slice(0, 100), limit: 5 }, }) .then((r) => r.data), diff --git a/frontend/src/components/workspace/VotingPanel.tsx b/frontend/src/components/workspace/VotingPanel.tsx index c5e8d7b..ee56f0a 100644 --- a/frontend/src/components/workspace/VotingPanel.tsx +++ b/frontend/src/components/workspace/VotingPanel.tsx @@ -54,19 +54,18 @@ const VotingPanel: React.FC = observer(({ taskId, projectType }) => { const { data: annotations = [], isLoading } = useQuery({ queryKey: ['task-annotations', taskId], - queryFn: () => - api.get(`/api/tasks/${taskId}/annotations`).then((r) => r.data), + queryFn: () => api.get(`/tasks/${taskId}/annotations`).then((r) => r.data), enabled: !!taskId, }); const handleVote = async (annotationId: number, value: 1 | -1) => { - await api.post(`/api/annotations/${annotationId}/vote`, { value }); + await api.post(`/annotations/${annotationId}/vote`, { value }); queryClient.invalidateQueries({ queryKey: ['task-annotations', taskId] }); }; const handleComment = async (annotationId: number) => { if (!commentText.trim()) return; - await api.post(`/api/annotations/${annotationId}/comments`, { + await api.post(`/annotations/${annotationId}/comments`, { text: commentText, }); setCommentText(''); diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index cbca2d0..a7f97d5 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -66,7 +66,7 @@ const Dashboard: React.FC = observer(() => { await Promise.all( projects.map(async (p) => { try { - const res = await api.get(`/api/projects/${p.id}/stats`); + const res = await api.get(`/projects/${p.id}/stats`); stats[p.id] = res.data; } catch { // ignore diff --git a/frontend/src/pages/ProjectGlossary.tsx b/frontend/src/pages/ProjectGlossary.tsx index 746ba1b..a37f259 100644 --- a/frontend/src/pages/ProjectGlossary.tsx +++ b/frontend/src/pages/ProjectGlossary.tsx @@ -31,7 +31,7 @@ const ProjectGlossary: React.FC = observer(() => { const { data: glossary = [] } = useQuery({ queryKey: ['glossary', projectId], queryFn: () => - api.get(`/api/projects/${projectId}/glossary`).then((r) => r.data), + api.get(`/projects/${projectId}/glossary`).then((r) => r.data), enabled: !!projectId, }); @@ -49,15 +49,12 @@ const ProjectGlossary: React.FC = observer(() => { try { if (editingTerm) { - await api.patch( - `/api/projects/${projectId}/glossary/${editingTerm.id}`, - { - translations: translationsMap, - notes: values.notes || null, - } - ); + await api.patch(`/projects/${projectId}/glossary/${editingTerm.id}`, { + translations: translationsMap, + notes: values.notes || null, + }); } else { - await api.post(`/api/projects/${projectId}/glossary`, { + await api.post(`/projects/${projectId}/glossary`, { source_term: values.source_term, translations: translationsMap, notes: values.notes || null, @@ -74,7 +71,7 @@ const ProjectGlossary: React.FC = observer(() => { const handleDeleteGlossaryTerm = async (termId: number) => { try { - await api.delete(`/api/projects/${projectId}/glossary/${termId}`); + await api.delete(`/projects/${projectId}/glossary/${termId}`); queryClient.invalidateQueries({ queryKey: ['glossary', projectId] }); } catch { message.error('Failed to delete term'); diff --git a/frontend/src/pages/ProjectReview.tsx b/frontend/src/pages/ProjectReview.tsx index 75b41ed..7102894 100644 --- a/frontend/src/pages/ProjectReview.tsx +++ b/frontend/src/pages/ProjectReview.tsx @@ -67,7 +67,7 @@ const ProjectReview: React.FC = observer(() => { try { const params = statusFilter ? `?status=${statusFilter}` : ''; const res = await api.get( - `/api/projects/${projectId}/annotations${params}` + `/projects/${projectId}/annotations${params}` ); setAnnotations(res.data.items); setAnnotationsTotal(res.data.total); @@ -88,7 +88,7 @@ const ProjectReview: React.FC = observer(() => { reviewNote?: string ) => { try { - await api.post(`/api/annotations/${annotationId}/review`, { + await api.post(`/annotations/${annotationId}/review`, { status, review_note: reviewNote || null, }); diff --git a/frontend/src/pages/ProjectStats.tsx b/frontend/src/pages/ProjectStats.tsx index 70fb839..eaa157a 100644 --- a/frontend/src/pages/ProjectStats.tsx +++ b/frontend/src/pages/ProjectStats.tsx @@ -63,26 +63,25 @@ const ProjectStats: React.FC = observer(() => { const { data: stats, isLoading } = useQuery({ queryKey: ['project-stats', projectId], - queryFn: () => - api.get(`/api/projects/${projectId}/stats`).then((r) => r.data), + queryFn: () => api.get(`/projects/${projectId}/stats`).then((r) => r.data), }); const { data: annotatorStats = [] } = useQuery({ queryKey: ['annotator-stats', projectId], queryFn: () => - api.get(`/api/projects/${projectId}/annotator-stats`).then((r) => r.data), + api.get(`/projects/${projectId}/annotator-stats`).then((r) => r.data), }); const { data: timeline = [] } = useQuery({ queryKey: ['stats-timeline', projectId], queryFn: () => - api.get(`/api/projects/${projectId}/stats/timeline`).then((r) => r.data), + api.get(`/projects/${projectId}/stats/timeline`).then((r) => r.data), }); const { data: labelStats = [] } = useQuery({ queryKey: ['stats-labels', projectId], queryFn: () => - api.get(`/api/projects/${projectId}/stats/labels`).then((r) => r.data), + api.get(`/projects/${projectId}/stats/labels`).then((r) => r.data), }); if (isLoading) { diff --git a/frontend/src/pages/Workspace.tsx b/frontend/src/pages/Workspace.tsx index aee8125..396c717 100644 --- a/frontend/src/pages/Workspace.tsx +++ b/frontend/src/pages/Workspace.tsx @@ -145,10 +145,8 @@ const Workspace: React.FC = observer(() => { const handleSubmit = async (result: unknown) => { if (!selectedTask) return; - await api.post(`/api/tasks/${selectedTask.id}/annotations`, { result }); - await api - .post(`/api/annotations/${selectedTask.id}/submit`) - .catch(() => {}); + await api.post(`/tasks/${selectedTask.id}/annotations`, { result }); + await api.post(`/annotations/${selectedTask.id}/submit`).catch(() => {}); sendSubmitted(selectedTask.id); queryClient.invalidateQueries({ queryKey: ['workspace-tasks', projectId] }); // Move to next task diff --git a/frontend/src/pages/admin/InvitationsManagement.tsx b/frontend/src/pages/admin/InvitationsManagement.tsx index a8db055..c2f4786 100644 --- a/frontend/src/pages/admin/InvitationsManagement.tsx +++ b/frontend/src/pages/admin/InvitationsManagement.tsx @@ -42,7 +42,7 @@ const InvitationsManagement: React.FC = observer(() => { }); const deleteInvitation = useMutation({ - mutationFn: (id: number) => api.delete(`/api/invitations/${id}`), + mutationFn: (id: number) => api.delete(`/invitations/${id}`), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['invitations'] }); message.success('Invitation deleted'); From 80b72e4630b2e585e4be81c6481311c1403b3a23 Mon Sep 17 00:00:00 2001 From: mvoof Date: Sun, 15 Mar 2026 19:55:23 +0500 Subject: [PATCH 14/48] fix: resolve members not loading and improve invitations management Fix trailing slash mismatch causing 404 on /users/ endpoint (backend has redirect_slashes=false). Add status filter and regenerate endpoint to invitations API. Replace flat invitation list with Pending/Expired segmented view and regenerate support for expired invitations. --- backend/app/routers/invitations.py | 46 +++++++++- frontend/src/pages/ProjectSettings.tsx | 2 +- .../src/pages/admin/InvitationsManagement.tsx | 86 ++++++++++++++----- .../src/pages/admin/MembersManagement.tsx | 2 +- 4 files changed, 108 insertions(+), 28 deletions(-) diff --git a/backend/app/routers/invitations.py b/backend/app/routers/invitations.py index 05eb2d8..817d8b6 100644 --- a/backend/app/routers/invitations.py +++ b/backend/app/routers/invitations.py @@ -1,9 +1,10 @@ import uuid from datetime import datetime, timedelta +from typing import Optional -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select +from sqlalchemy import select, func from .. import schemas, models, database, security from ..permissions import AdminRequired @@ -46,13 +47,50 @@ async def create_invitation( @router.get("/invitations", response_model=list[schemas.InvitationResponse]) async def list_invitations( + status: Optional[str] = Query(None, regex="^(pending|expired|used)$"), user: models.User = Depends(AdminRequired()), db: AsyncSession = Depends(database.get_db), ): + query = select(models.Invitation) + + if status == "pending": + query = query.where( + models.Invitation.used_by.is_(None), + models.Invitation.expires_at > func.now(), + ) + elif status == "expired": + query = query.where( + models.Invitation.used_by.is_(None), + models.Invitation.expires_at <= func.now(), + ) + elif status == "used": + query = query.where(models.Invitation.used_by.isnot(None)) + + result = await db.execute(query.order_by(models.Invitation.created_at.desc())) + return result.scalars().all() + + +@router.post("/invitations/{invitation_id}/regenerate", response_model=schemas.InvitationResponse) +async def regenerate_invitation( + invitation_id: int, + user: models.User = Depends(AdminRequired()), + db: AsyncSession = Depends(database.get_db), +): + """Regenerate an expired/pending invitation with a new token and expiry.""" result = await db.execute( - select(models.Invitation).order_by(models.Invitation.created_at.desc()) + select(models.Invitation).where(models.Invitation.id == invitation_id) ) - return result.scalars().all() + invitation = result.scalar_one_or_none() + if not invitation: + raise HTTPException(404, "Invitation not found") + if invitation.used_by is not None: + raise HTTPException(400, "Cannot regenerate a used invitation") + + invitation.token = str(uuid.uuid4()) + invitation.expires_at = datetime.utcnow() + timedelta(days=7) + await db.commit() + await db.refresh(invitation) + return invitation @router.delete("/invitations/{invitation_id}", status_code=204) diff --git a/frontend/src/pages/ProjectSettings.tsx b/frontend/src/pages/ProjectSettings.tsx index 8a6704c..502d305 100644 --- a/frontend/src/pages/ProjectSettings.tsx +++ b/frontend/src/pages/ProjectSettings.tsx @@ -178,7 +178,7 @@ const ProjectSettings: React.FC = observer(() => { const fetchUsers = async () => { try { - const res = await api.get('/users/'); + const res = await api.get('/users'); setAllUsers(res.data); } catch { /* only admins */ diff --git a/frontend/src/pages/admin/InvitationsManagement.tsx b/frontend/src/pages/admin/InvitationsManagement.tsx index c2f4786..8e838f7 100644 --- a/frontend/src/pages/admin/InvitationsManagement.tsx +++ b/frontend/src/pages/admin/InvitationsManagement.tsx @@ -13,21 +13,31 @@ import { Table, Space, Skeleton, + Segmented, } from 'antd'; -import { PlusOutlined, DeleteOutlined, CopyOutlined } from '@ant-design/icons'; +import { + PlusOutlined, + DeleteOutlined, + CopyOutlined, + ReloadOutlined, +} from '@ant-design/icons'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import api from '../../api/client'; import type { Invitation } from '../../types/api'; +type StatusFilter = 'pending' | 'expired'; + const InvitationsManagement: React.FC = observer(() => { const queryClient = useQueryClient(); const [modalOpen, setModalOpen] = useState(false); const [form] = Form.useForm(); const [copiedId, setCopiedId] = useState(null); + const [statusFilter, setStatusFilter] = useState('pending'); const { data: invitations = [], isLoading } = useQuery({ - queryKey: ['invitations'], - queryFn: () => api.get('/invitations').then((r) => r.data), + queryKey: ['invitations', statusFilter], + queryFn: () => + api.get(`/invitations?status=${statusFilter}`).then((r) => r.data), }); const createInvitation = useMutation({ @@ -50,6 +60,16 @@ const InvitationsManagement: React.FC = observer(() => { onError: () => message.error('Failed to delete invitation'), }); + const regenerateInvitation = useMutation({ + mutationFn: (id: number) => api.post(`/invitations/${id}/regenerate`), + onSuccess: () => { + setStatusFilter('pending'); + queryClient.invalidateQueries({ queryKey: ['invitations'] }); + message.success('Invitation regenerated'); + }, + onError: () => message.error('Failed to regenerate invitation'), + }); + const copyLink = (inv: Invitation) => { const url = `${window.location.origin}/auth?token=${inv.token}`; navigator.clipboard.writeText(url); @@ -82,6 +102,16 @@ const InvitationsManagement: React.FC = observer(() => { + setStatusFilter(val as StatusFilter)} + options={[ + { label: 'Pending', value: 'pending' }, + { label: 'Expired', value: 'expired' }, + ]} + style={{ marginBottom: 16 }} + /> +
{ { title: 'Status', width: 100, - render: (_: unknown, inv: Invitation) => { - if (inv.used_by) return Used; - if (new Date(inv.expires_at) < new Date()) - return Expired; - return Pending; - }, + render: () => + statusFilter === 'pending' ? ( + Pending + ) : ( + Expired + ), }, { title: 'Expires', @@ -106,10 +136,10 @@ const InvitationsManagement: React.FC = observer(() => { }, { title: '', - width: 100, - render: (_: unknown, inv: Invitation) => - !inv.used_by ? ( - + width: 120, + render: (_: unknown, inv: Invitation) => ( + + {statusFilter === 'pending' && ( @@ -120,19 +150,31 @@ const InvitationsManagement: React.FC = observer(() => { size="small" /> - deleteInvitation.mutate(inv.id)} - > + )} + {statusFilter === 'expired' && ( + + + ) : ( + + + + ), + }, ]} /> + + { + setModalOpen(false); + setSelectedUser(null); + form.resetFields(); + }} + onOk={() => form.submit()} + confirmLoading={addToProject.isPending} + > + addToProject.mutate(v)} + > + + + + + + + + ); }); diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 997320a..d04c46d 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -237,6 +237,12 @@ export interface OverviewStats { // --- User list (admin) --- +export interface UserProjectAssignment { + project_id: number; + project_name: string; + role: RoleProject; +} + export interface UserListItem { id: number; email: string; @@ -246,6 +252,10 @@ export interface UserListItem { created_at: string; } +export interface UserListItemWithProjects extends UserListItem { + project_assignments: UserProjectAssignment[]; +} + // --- Glossary --- export interface GlossaryTerm { From 714ebbbd79800b5ffd1d4b7097bfef5a3ab23a58 Mon Sep 17 00:00:00 2001 From: mvoof Date: Sun, 15 Mar 2026 22:50:06 +0500 Subject: [PATCH 16/48] fix: resolve CI test failures from trailing slashes and audit DB sessions - Remove trailing slashes from test URLs (redirect_slashes=False means /api/v1/projects/ doesn't match /api/v1/projects) - Make audit log_audit() session factory overridable so tests use SQLite instead of trying to connect to PostgreSQL --- backend/app/middleware/audit.py | 17 +++++++++++++++-- backend/tests/conftest.py | 3 +++ backend/tests/test_permissions.py | 4 ++-- backend/tests/test_projects.py | 8 ++++---- 4 files changed, 24 insertions(+), 8 deletions(-) diff --git a/backend/app/middleware/audit.py b/backend/app/middleware/audit.py index c678f6a..2f29e01 100644 --- a/backend/app/middleware/audit.py +++ b/backend/app/middleware/audit.py @@ -1,7 +1,19 @@ -from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from ..models import AuditLog from ..database import AsyncSessionLocal +# Overridable session factory for testing +_session_factory: async_sessionmaker | None = None + + +def set_audit_session_factory(factory: async_sessionmaker | None): + global _session_factory + _session_factory = factory + + +def get_audit_session_factory() -> async_sessionmaker: + return _session_factory or AsyncSessionLocal + async def log_audit( user_id: int | None, @@ -11,7 +23,8 @@ async def log_audit( details: dict | None = None, ): """Log an audit event. Designed to be called via BackgroundTasks.""" - async with AsyncSessionLocal() as db: + factory = get_audit_session_factory() + async with factory() as db: log = AuditLog( user_id=user_id, action=action, diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index ab73549..e57e493 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -10,6 +10,7 @@ from app.database import Base, get_db from app.main import app from app.security import get_password_hash, create_access_token +from app.middleware.audit import set_audit_session_factory from app import models @@ -69,10 +70,12 @@ async def override_get_db(): yield session app.dependency_overrides[get_db] = override_get_db + set_audit_session_factory(session_factory) transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as c: yield c app.dependency_overrides.clear() + set_audit_session_factory(None) @pytest_asyncio.fixture diff --git a/backend/tests/test_permissions.py b/backend/tests/test_permissions.py index 371285d..6e25527 100644 --- a/backend/tests/test_permissions.py +++ b/backend/tests/test_permissions.py @@ -8,7 +8,7 @@ @pytest.mark.asyncio async def test_admin_can_create_project(client: AsyncClient, admin_user): - res = await client.post("/api/v1/projects/", json={ + res = await client.post("/api/v1/projects", json={ "name": "Admin Project", "type": "NER", }, headers=auth_headers(admin_user)) assert res.status_code == 201 @@ -16,7 +16,7 @@ async def test_admin_can_create_project(client: AsyncClient, admin_user): @pytest.mark.asyncio async def test_member_cannot_create_project(client: AsyncClient, member_user): - res = await client.post("/api/v1/projects/", json={ + res = await client.post("/api/v1/projects", json={ "name": "Member Project", "type": "NER", }, headers=auth_headers(member_user)) assert res.status_code == 403 diff --git a/backend/tests/test_projects.py b/backend/tests/test_projects.py index 25ab0ad..4d20919 100644 --- a/backend/tests/test_projects.py +++ b/backend/tests/test_projects.py @@ -9,7 +9,7 @@ @pytest.mark.asyncio async def test_create_project(client: AsyncClient, admin_user): - res = await client.post("/api/v1/projects/", json={ + res = await client.post("/api/v1/projects", json={ "name": "My NER Project", "type": "NER", }, headers=auth_headers(admin_user)) @@ -21,7 +21,7 @@ async def test_create_project(client: AsyncClient, admin_user): @pytest.mark.asyncio async def test_create_project_non_admin_fails(client: AsyncClient, member_user): - res = await client.post("/api/v1/projects/", json={ + res = await client.post("/api/v1/projects", json={ "name": "Unauthorized", "type": "NER", }, headers=auth_headers(member_user)) @@ -30,7 +30,7 @@ async def test_create_project_non_admin_fails(client: AsyncClient, member_user): @pytest.mark.asyncio async def test_list_projects(client: AsyncClient, admin_user, ner_project): - res = await client.get("/api/v1/projects/", headers=auth_headers(admin_user)) + res = await client.get("/api/v1/projects", headers=auth_headers(admin_user)) assert res.status_code == 200 projects = res.json() assert len(projects) >= 1 @@ -55,7 +55,7 @@ async def test_update_project(client: AsyncClient, admin_user, ner_project): @pytest.mark.asyncio async def test_delete_project(client: AsyncClient, admin_user): - create_res = await client.post("/api/v1/projects/", json={ + create_res = await client.post("/api/v1/projects", json={ "name": "To Delete", "type": "NER", }, headers=auth_headers(admin_user)) pid = create_res.json()["id"] From c8742ad4b6745487fc2473e4564404c01c5e0741 Mon Sep 17 00:00:00 2001 From: mvoof Date: Sun, 15 Mar 2026 23:05:57 +0500 Subject: [PATCH 17/48] feat: add member role change and replace theme switcher with toggle --- backend/app/routers/projects.py | 39 +++++++++++++++++++ backend/app/schemas.py | 4 ++ frontend/src/App.tsx | 16 +------- frontend/src/components/layout/AppLayout.tsx | 23 +++++------ frontend/src/pages/ProjectSettings.tsx | 25 ++++++++++-- .../src/pages/admin/MembersManagement.tsx | 36 +++++++++++++++-- frontend/src/store/themeStore.ts | 13 ++----- 7 files changed, 113 insertions(+), 43 deletions(-) diff --git a/backend/app/routers/projects.py b/backend/app/routers/projects.py index 4cdca69..128786c 100644 --- a/backend/app/routers/projects.py +++ b/backend/app/routers/projects.py @@ -188,6 +188,45 @@ async def add_project_member( ) +@router.patch("/{project_id}/members/{user_id}", response_model=schemas.ProjectMemberResponse) +async def update_project_member( + project_id: int, + user_id: int, + data: schemas.ProjectMemberUpdate, + background_tasks: BackgroundTasks, + user: models.User = Depends( + RoleChecker(allowed_project_roles=[RoleProject.MANAGER]) + ), + db: AsyncSession = Depends(database.get_db), +): + """Update a project member's role.""" + result = await db.execute( + select(models.ProjectMember).where( + models.ProjectMember.user_id == user_id, + models.ProjectMember.project_id == project_id, + ) + ) + pm = result.scalar_one_or_none() + if not pm: + raise HTTPException(404, "Project member not found") + old_role = pm.role + pm.role = data.role + await db.commit() + + target_user = await db.get(models.User, user_id) + background_tasks.add_task( + log_audit, user.id, "update_member_role", "project", project_id, + {"target_user_id": user_id, "target_email": target_user.email if target_user else None, "old_role": old_role, "new_role": data.role.value}, + ) + + return schemas.ProjectMemberResponse( + user_id=pm.user_id, + email=target_user.email if target_user else "", + full_name=target_user.full_name if target_user else None, + role=pm.role, + ) + + @router.delete("/{project_id}/members/{user_id}", status_code=204) async def remove_project_member( project_id: int, diff --git a/backend/app/schemas.py b/backend/app/schemas.py index e747ae3..ab4218d 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -152,6 +152,10 @@ class ProjectMemberAdd(BaseModel): role: RoleProject = RoleProject.MEMBER +class ProjectMemberUpdate(BaseModel): + role: RoleProject + + class ProjectMemberResponse(BaseModel): user_id: int email: str diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fe96fd8..dab3510 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect } from 'react'; import { RouterProvider } from 'react-router'; import router from './routes'; import { observer } from 'mobx-react-lite'; @@ -15,19 +15,7 @@ const queryClient = new QueryClient({ }); const App: React.FC = observer(function App() { - const [systemDark, setSystemDark] = useState( - window.matchMedia('(prefers-color-scheme: dark)').matches - ); - - useEffect(() => { - const mq = window.matchMedia('(prefers-color-scheme: dark)'); - const handler = (e: MediaQueryListEvent) => setSystemDark(e.matches); - mq.addEventListener('change', handler); - return () => mq.removeEventListener('change', handler); - }, []); - - const isDark = - themeStore.mode === 'dark' || (themeStore.mode === 'auto' && systemDark); + const isDark = themeStore.mode === 'dark'; useEffect(() => { document.body.style.backgroundColor = isDark diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx index d782a41..f954bba 100644 --- a/frontend/src/components/layout/AppLayout.tsx +++ b/frontend/src/components/layout/AppLayout.tsx @@ -16,6 +16,7 @@ import { Tag, Breadcrumb, Tooltip, + Switch, theme, } from 'antd'; import { @@ -27,8 +28,8 @@ import { AuditOutlined, MenuFoldOutlined, MenuUnfoldOutlined, - BulbOutlined, - BulbFilled, + SunOutlined, + MoonOutlined, AppstoreOutlined, BarChartOutlined, SettingOutlined, @@ -94,7 +95,7 @@ const AppLayout: React.FC = observer(() => { navigate('/auth'); }; - const isDark = themeStore.resolvedMode === 'dark'; + const isDark = themeStore.mode === 'dark'; const menuItems = [ { @@ -343,16 +344,12 @@ const AppLayout: React.FC = observer(() => { - - - - - ), - }, + // --- Danger Zone (Admin only) --- + ...(authStore.isAdmin + ? [ + { + key: 'danger', + label: 'Danger Zone', + children: ( + + + + Deleting a project removes all tasks, annotations, and + members permanently. + + + + + ), + }, + ] + : []), ]; return ( diff --git a/frontend/src/pages/admin/MembersManagement.tsx b/frontend/src/pages/admin/MembersManagement.tsx index ebd65bd..64c0c5e 100644 --- a/frontend/src/pages/admin/MembersManagement.tsx +++ b/frontend/src/pages/admin/MembersManagement.tsx @@ -28,8 +28,14 @@ import type { UserProjectAssignment, Project, RoleProject, + GlobalRole, } from '../../types/api'; +const ROLE_COLOR: Record = { + ADMIN: 'blue', + USER: 'default', +}; + const MembersManagement: React.FC = observer(() => { const queryClient = useQueryClient(); const [modalOpen, setModalOpen] = useState(false); @@ -101,6 +107,16 @@ const MembersManagement: React.FC = observer(() => { onError: () => message.error('Failed to update user status'), }); + const changeGlobalRole = useMutation({ + mutationFn: (vars: { userId: number; role: GlobalRole }) => + api.patch(`/users/${vars.userId}/role`, { role: vars.role }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['users'] }); + message.success('Global role updated'); + }, + onError: () => message.error('Failed to update global role'), + }); + const openAddToProject = (user: UserListItemWithProjects) => { setSelectedUser(user); setModalOpen(true); @@ -118,7 +134,7 @@ const MembersManagement: React.FC = observer(() => { if (isLoading) return ; const expandedRowRender = (record: UserListItemWithProjects) => { - if (record.is_admin) { + if (record.role === 'ADMIN') { return ( Admins have full access to all projects. @@ -158,7 +174,7 @@ const MembersManagement: React.FC = observer(() => { style={{ width: 120 }} > Manager - Member + Editor ), }, @@ -218,19 +234,39 @@ const MembersManagement: React.FC = observer(() => { { title: 'Email', dataIndex: 'email' }, { title: 'Role', - dataIndex: 'is_admin', - width: 100, - render: (_: unknown, record: UserListItemWithProjects) => ( - - {record.is_admin ? 'Admin' : 'User'} - - ), + dataIndex: 'role', + width: 160, + render: (_: unknown, record: UserListItemWithProjects) => + record.role === 'ADMIN' ? ( + {record.role} + ) : ( + + ), }, { title: 'Projects', width: 120, render: (_: unknown, record: UserListItemWithProjects) => - record.is_admin ? ( + record.role === 'ADMIN' ? ( Full access ) : ( `${record.project_assignments.length} project${record.project_assignments.length !== 1 ? 's' : ''}` @@ -246,7 +282,7 @@ const MembersManagement: React.FC = observer(() => { checkedChildren="Active" unCheckedChildren="Inactive" onChange={() => toggleActive.mutate(record.id)} - disabled={record.is_admin} + disabled={record.role === 'ADMIN'} /> ), }, @@ -260,7 +296,7 @@ const MembersManagement: React.FC = observer(() => { title: '', width: 160, render: (_: unknown, record: UserListItemWithProjects) => - record.is_admin ? ( + record.role === 'ADMIN' ? ( - + {canManage && ( +
+ setNewLabel(e.target.value)} + onPressEnter={handleAddLabel} + style={{ width: 160 }} + /> + +
+ )}
{(project?.config?.labels || []).map( (label: string, idx: number) => ( @@ -449,36 +456,42 @@ const ProjectSettings: React.FC = observer(() => { > Hotkey: {idx + 1 <= 9 ? idx + 1 : '-'} -
) )} @@ -494,284 +507,311 @@ const ProjectSettings: React.FC = observer(() => { ), }, - // --- Import/Export --- - { - key: 'import-export', - label: 'Import / Export', - children: ( -
- - - Upload INI files for Translation or JSON for NER. - - {isProjectManager && !authStore.isAdmin && ( - - Note: As a Manager, your imports will require admin approval - before being applied. - - )} - setReplaceOnImport(e.target.checked)} - style={{ marginBottom: 12 }} - > - Replace existing tasks - - -

- -

-

Click or drag file to upload

-
- {uploading && ( - - )} - - {/* Import preview */} - { - setImportPreview(null); - setPendingFile(null); - }} - onOk={handleConfirmImport} - okText={`Import ${importPreview?.total_tasks || 0} tasks`} - confirmLoading={uploading} - > - {importPreview && ( -
+ // --- Import/Export (Manager/Admin only) --- + ...(canManage + ? [ + { + key: 'import-export', + label: 'Import / Export', + children: ( +
+ - File: {importPreview.filename} + Upload INI files for Translation or JSON for NER. - - - {importPreview.total_tasks} tasks found + {isProjectManager && !authStore.isAdmin && ( + + Note: As a Manager, your imports will require admin + approval before being applied. - {importPreview.with_entities > 0 && ( - - {importPreview.with_entities} tasks with entities ( - {importPreview.entities_count} total) - - )} - - - Sample (first {Math.min(10, importPreview.sample.length)}): - -
- {importPreview.sample.map((s, i) => ( -
- {s.id && ( - - {s.id} - - )} - {s.key && ( - - {s.key} - - )} - - {s.text} - - {(s.entities_count ?? 0) > 0 && ( - - {s.entities_count} entities - - )} -
- ))} -
+ )} setReplaceOnImport(e.target.checked)} - style={{ marginTop: 12 }} + style={{ marginBottom: 12 }} > Replace existing tasks -
- )} - - - - - Download approved annotations for VerseBridge training. - - - - - - -
- ), - }, + +

+ +

+

+ Click or drag file to upload +

+
+ {uploading && ( + + )} - // --- Members --- - { - key: 'members', - label: 'Members', - children: ( - <> -
- -
-
v || '\u2014', - }, - { title: 'Email', dataIndex: 'email' }, - { - title: 'Role', - dataIndex: 'role', - render: (_: unknown, record: ProjectMember) => ( - - ), - }, - { - title: '', - width: 60, - render: (_: unknown, record: ProjectMember) => ( - handleRemoveMember(record.user_id)} + {importPreview && ( +
+ + File: {importPreview.filename} + + + + {importPreview.total_tasks} tasks + found + + {importPreview.with_entities > 0 && ( + + {importPreview.with_entities} tasks with entities + ({importPreview.entities_count} total) + + )} + + + Sample (first{' '} + {Math.min(10, importPreview.sample.length)}): + +
+ {importPreview.sample.map((s, i) => ( +
+ {s.id && ( + + {s.id} + + )} + {s.key && ( + + {s.key} + + )} + + {s.text} + + {(s.entities_count ?? 0) > 0 && ( + + {s.entities_count} entities + + )} +
+ ))} +
+ setReplaceOnImport(e.target.checked)} + style={{ marginTop: 12 }} + > + Replace existing tasks + +
+ )} + + + + + Download approved annotations for VerseBridge training. + + + + + + + ), + }, + ] + : []), + + // --- Members (Manager/Admin only) --- + ...(canManage + ? [ + { + key: 'members', + label: 'Members', + children: ( + <> +
+ +
+
v || '\u2014', + }, + { title: 'Email', dataIndex: 'email' }, + { + title: 'Role', + dataIndex: 'role', + render: (_: unknown, record: ProjectMember) => ( + + ), + }, + { + title: '', + width: 60, + render: (_: unknown, record: ProjectMember) => ( + handleRemoveMember(record.user_id)} + > + , - , + ...(authStore.isAdmin || project.my_role === 'MANAGER' + ? [ + , + ] + : []), ]} > Date: Mon, 16 Mar 2026 23:06:02 +0500 Subject: [PATCH 21/48] fix: prevent non-admin managers from modifying/removing other managers - Backend: add role checks in update_project_member and remove_project_member to block non-admin users from demoting or removing MANAGER-role members - Frontend: hide Manager role option in selects for non-admins, disable ole change and remove buttons for MANAGER rows when user is not admin --- backend/app/routers/projects.py | 10 ++++ frontend/src/pages/ProjectSettings.tsx | 71 ++++++++++++++++---------- 2 files changed, 54 insertions(+), 27 deletions(-) diff --git a/backend/app/routers/projects.py b/backend/app/routers/projects.py index 2f19190..8d74300 100644 --- a/backend/app/routers/projects.py +++ b/backend/app/routers/projects.py @@ -263,6 +263,11 @@ async def update_project_member( pm = result.scalar_one_or_none() if not pm: raise HTTPException(404, "Project member not found") + + # Only ADMIN can modify another MANAGER's role + if pm.role == RoleProject.MANAGER and user.role != GlobalRole.ADMIN: + raise HTTPException(403, "Only admin can modify another MANAGER's role") + old_role = pm.role pm.role = data.role await db.commit() @@ -302,6 +307,11 @@ async def remove_project_member( pm = result.scalar_one_or_none() if not pm: raise HTTPException(404, "Project member not found") + + # Only ADMIN can remove a MANAGER from the project + if pm.role == RoleProject.MANAGER and user.role != GlobalRole.ADMIN: + raise HTTPException(403, "Only admin can remove a MANAGER from the project") + await db.delete(pm) await db.commit() background_tasks.add_task( diff --git a/frontend/src/pages/ProjectSettings.tsx b/frontend/src/pages/ProjectSettings.tsx index fca3669..5d058c0 100644 --- a/frontend/src/pages/ProjectSettings.tsx +++ b/frontend/src/pages/ProjectSettings.tsx @@ -737,36 +737,51 @@ const ProjectSettings: React.FC = observer(() => { { title: 'Role', dataIndex: 'role', - render: (_: unknown, record: ProjectMember) => ( - - ), + render: (_: unknown, record: ProjectMember) => { + const isTargetManager = record.role === 'MANAGER'; + const locked = isTargetManager && !authStore.isAdmin; + return ( + + ); + }, }, { title: '', width: 60, - render: (_: unknown, record: ProjectMember) => ( - handleRemoveMember(record.user_id)} - > - - {!isSetupMode && !isInviteMode && ( + {formMode !== 'setup' && formMode !== 'invite' && (
- {isLogin + {formMode === 'login' ? 'Have an invite link? Use it to ' : 'Already have an account? '} { - setMode(isLogin ? 'register' : 'login'); + setMode(formMode === 'login' ? 'register' : 'login'); setError(null); }} > - {isLogin ? 'register' : 'Sign in'} + {formMode === 'login' ? 'register' : 'Sign in'}
)} - {!isSetupMode && !isLogin && !inviteToken && ( + {formMode === 'register' && !inviteToken && ( void; } -const LABEL_COLORS: Record = { - PER: '#3b82f6', - ORG: '#8b5cf6', - LOC: '#10b981', - GPE: '#06b6d4', - FAC: '#f59e0b', - MISC: '#6b7280', - PRODUCT: '#ec4899', - EVENT: '#f97316', - SHIP: '#0ea5e9', - ARMOR: '#84cc16', - WEAPON: '#ef4444', - QUANTITY: '#14b8a6', - DATE: '#a855f7', - MONEY: '#eab308', -}; - interface HistoryState { entities: NEREntity[]; } diff --git a/frontend/src/components/workspace/VotingPanel.tsx b/frontend/src/components/workspace/VotingPanel.tsx index ee56f0a..9cbb44f 100644 --- a/frontend/src/components/workspace/VotingPanel.tsx +++ b/frontend/src/components/workspace/VotingPanel.tsx @@ -21,7 +21,13 @@ import { } from '@ant-design/icons'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import api from '../../api/client'; -import type { TaskAnnotation, NEREntity, ProjectType } from '../../types/api'; +import type { + TaskAnnotation, + NERAnnotationResult, + TranslationAnnotationResult, + ProjectType, +} from '../../types/api'; +import { LABEL_COLORS } from '../../types/domain'; interface Props { taskId: number; @@ -29,23 +35,6 @@ interface Props { labels?: string[]; } -const LABEL_COLORS: Record = { - PER: '#3b82f6', - ORG: '#8b5cf6', - LOC: '#10b981', - GPE: '#06b6d4', - FAC: '#f59e0b', - MISC: '#6b7280', - PRODUCT: '#ec4899', - EVENT: '#f97316', - SHIP: '#0ea5e9', - ARMOR: '#84cc16', - WEAPON: '#ef4444', - QUANTITY: '#14b8a6', - DATE: '#a855f7', - MONEY: '#eab308', -}; - const VotingPanel: React.FC = observer(({ taskId, projectType }) => { const { token: themeToken } = theme.useToken(); const queryClient = useQueryClient(); @@ -75,8 +64,8 @@ const VotingPanel: React.FC = observer(({ taskId, projectType }) => { if (isLoading) return ; if (annotations.length <= 1) return null; - const renderNERResult = (result: unknown) => { - const entities = result as NEREntity[]; + const renderNERResult = (result: NERAnnotationResult) => { + const entities = result; if (!Array.isArray(entities) || entities.length === 0) { return ( @@ -99,8 +88,8 @@ const VotingPanel: React.FC = observer(({ taskId, projectType }) => { ); }; - const renderTranslationResult = (result: unknown) => { - const translations = result as Record; + const renderTranslationResult = (result: TranslationAnnotationResult) => { + const translations = result; return (
{Object.entries(translations).map(([lang, text]) => ( @@ -192,8 +181,10 @@ const VotingPanel: React.FC = observer(({ taskId, projectType }) => { {/* Content */}
{projectType === 'NER' - ? renderNERResult(ann.result) - : renderTranslationResult(ann.result)} + ? renderNERResult(ann.result as NERAnnotationResult) + : renderTranslationResult( + ann.result as TranslationAnnotationResult + )}
{/* Review note */} diff --git a/frontend/src/constants/languages.ts b/frontend/src/constants/languages.ts index afaceae..c934ee7 100644 --- a/frontend/src/constants/languages.ts +++ b/frontend/src/constants/languages.ts @@ -1,4 +1,6 @@ -export const LANGUAGE_OPTIONS = [ +import type { LanguageCode } from '../types/domain'; + +export const LANGUAGE_OPTIONS: Array<{ value: LanguageCode; label: string }> = [ { value: 'en', label: 'English' }, { value: 'ru', label: 'Russian' }, { value: 'fr', label: 'French' }, diff --git a/frontend/src/contexts/ProjectContext.tsx b/frontend/src/contexts/ProjectContext.tsx index e2349ce..a09cf09 100644 --- a/frontend/src/contexts/ProjectContext.tsx +++ b/frontend/src/contexts/ProjectContext.tsx @@ -1,8 +1,9 @@ import { createContext, useContext } from 'react'; import { observer } from 'mobx-react-lite'; +import type { RoleProject, GlobalRole } from '../types/domain'; interface ProjectRoleContextValue { - role: string | null; + role: RoleProject | GlobalRole | null; isManager: boolean; isEditor: boolean; isAdmin: boolean; @@ -16,7 +17,7 @@ const ProjectRoleContext = createContext({ }); export const ProjectRoleProvider: React.FC<{ - role: string; + role: RoleProject | GlobalRole; children: React.ReactNode; }> = observer(({ role, children }) => { const value: ProjectRoleContextValue = { diff --git a/frontend/src/pages/ProjectReview.tsx b/frontend/src/pages/ProjectReview.tsx index 7102894..79ce3f0 100644 --- a/frontend/src/pages/ProjectReview.tsx +++ b/frontend/src/pages/ProjectReview.tsx @@ -22,25 +22,12 @@ import type { AnnotationListItem, AnnotationListResponse, NEREntity, + NERAnnotationResult, + TranslationAnnotationResult, Project, } from '../types/api'; -const LABEL_COLORS: Record = { - PER: '#3b82f6', - ORG: '#8b5cf6', - LOC: '#10b981', - GPE: '#06b6d4', - FAC: '#f59e0b', - MISC: '#6b7280', - PRODUCT: '#ec4899', - EVENT: '#f97316', - SHIP: '#0ea5e9', - ARMOR: '#84cc16', - WEAPON: '#ef4444', - QUANTITY: '#14b8a6', - DATE: '#a855f7', - MONEY: '#eab308', -}; +import { LABEL_COLORS } from '../types/domain'; const ProjectReview: React.FC = observer(() => { const { projectId } = useParams(); @@ -118,8 +105,8 @@ const ProjectReview: React.FC = observer(() => { setRejectingId(null); }; - const renderNERAnnotation = (result: Record) => { - const entities = result as unknown as NEREntity[]; + const renderNERAnnotation = (result: NERAnnotationResult) => { + const entities = result; if (!Array.isArray(entities) || entities.length === 0) { return ( @@ -142,7 +129,7 @@ const ProjectReview: React.FC = observer(() => { ); }; - const renderTranslationAnnotation = (result: Record) => { + const renderTranslationAnnotation = (result: TranslationAnnotationResult) => { return (
{Object.entries(result).map(([lang, text]) => ( @@ -240,8 +227,10 @@ const ProjectReview: React.FC = observer(() => { Content: {project?.type === 'NER' - ? renderNERAnnotation(record.result) - : renderTranslationAnnotation(record.result)} + ? renderNERAnnotation(record.result as NERAnnotationResult) + : renderTranslationAnnotation( + record.result as TranslationAnnotationResult + )} {record.task_data && (
= { - PER: '#3b82f6', - ORG: '#8b5cf6', - LOC: '#10b981', - GPE: '#06b6d4', - FAC: '#f59e0b', - MISC: '#6b7280', - PRODUCT: '#ec4899', - EVENT: '#f97316', - SHIP: '#0ea5e9', - ARMOR: '#84cc16', - WEAPON: '#ef4444', - QUANTITY: '#14b8a6', - DATE: '#a855f7', - MONEY: '#eab308', -}; +import { LABEL_COLORS } from '../types/domain'; const ProjectSettings: React.FC = observer(() => { const { projectId } = useParams(); diff --git a/frontend/src/pages/ProjectStats.tsx b/frontend/src/pages/ProjectStats.tsx index eaa157a..b920df0 100644 --- a/frontend/src/pages/ProjectStats.tsx +++ b/frontend/src/pages/ProjectStats.tsx @@ -27,6 +27,7 @@ import type { AnnotatorStats, } from '../types/api'; import { tokens } from '../styles/design-tokens'; +import { LABEL_COLORS } from '../types/domain'; interface TimelineEntry { date: string; @@ -40,23 +41,6 @@ interface LabelEntry { count: number; } -const LABEL_COLORS: Record = { - PER: '#3b82f6', - ORG: '#8b5cf6', - LOC: '#10b981', - GPE: '#06b6d4', - FAC: '#f59e0b', - MISC: '#6b7280', - PRODUCT: '#ec4899', - EVENT: '#f97316', - SHIP: '#0ea5e9', - ARMOR: '#84cc16', - WEAPON: '#ef4444', - QUANTITY: '#14b8a6', - DATE: '#a855f7', - MONEY: '#eab308', -}; - const ProjectStats: React.FC = observer(() => { const { projectId } = useParams(); const { token: themeToken } = theme.useToken(); diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 595ddb1..0232e6f 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -2,13 +2,26 @@ // Must match backend Pydantic schemas exactly. import type { AxiosError } from 'axios'; - -// Enums matching backend -export type GlobalRole = 'ADMIN' | 'USER'; -export type RoleProject = 'MANAGER' | 'EDITOR'; -export type ProjectType = 'NER' | 'TRANSLATION'; -export type AnnotationStatus = 'DRAFT' | 'SUBMITTED' | 'APPROVED' | 'REJECTED'; -export type ImportStatus = 'PENDING' | 'APPROVED' | 'REJECTED'; +import type { + GlobalRole, + RoleProject, + ProjectType, + AnnotationStatus, + ImportStatus, + NotificationType, + NotificationResourceType, + AuditAction, + AuditResourceType, +} from './domain'; + +// Re-export enums for consumers that import from api.ts +export type { + GlobalRole, + RoleProject, + ProjectType, + AnnotationStatus, + ImportStatus, +}; // --- Auth --- @@ -82,7 +95,6 @@ export interface NEREntity { export type NERAnnotationResult = NEREntity[]; export type TranslationAnnotationResult = Record; - // --- Invitation --- export interface Invitation { @@ -105,7 +117,7 @@ export interface ProjectMember { // --- Project role (from my-role endpoint) --- export interface ProjectRoleInfo { - role: string; + role: RoleProject | GlobalRole; } // --- Task list item --- @@ -131,7 +143,7 @@ export interface AnnotationListItem { user_id: number; user_email: string; user_full_name: string | null; - result: Record; + result: NERAnnotationResult | TranslationAnnotationResult; status: AnnotationStatus; review_note: string | null; is_final: boolean; @@ -147,7 +159,7 @@ export interface TaskAnnotation { user_id: number; user_email: string; user_full_name: string | null; - result: Record; + result: NERAnnotationResult | TranslationAnnotationResult; status: AnnotationStatus; review_note: string | null; is_final: boolean; @@ -182,8 +194,8 @@ export interface AuditLogItem { id: number; user_id: number | null; user_email: string | null; - action: string; - resource_type: string; + action: AuditAction; + resource_type: AuditResourceType; resource_id: number | null; details: Record | null; created_at: string; @@ -198,10 +210,10 @@ export interface AuditLogListResponse { export interface Notification { id: number; - type: string; + type: NotificationType; title: string; message: string; - resource_type: string | null; + resource_type: NotificationResourceType | null; resource_id: number | null; is_read: boolean; created_at: string; @@ -341,8 +353,6 @@ export function isProject(data: unknown): data is Project { ); } - - // --- User-facing error mapper --- export function toUserError(e: unknown): string { diff --git a/frontend/src/types/domain.ts b/frontend/src/types/domain.ts new file mode 100644 index 0000000..f0b2775 --- /dev/null +++ b/frontend/src/types/domain.ts @@ -0,0 +1,87 @@ +// ─── Domain types ─── +// Typed unions mirroring backend enums. Single source of truth for the frontend. + +// Role & project enums + +export type GlobalRole = 'ADMIN' | 'USER'; +export type RoleProject = 'MANAGER' | 'EDITOR'; +export type ProjectType = 'NER' | 'TRANSLATION'; +export type AnnotationStatus = 'DRAFT' | 'SUBMITTED' | 'APPROVED' | 'REJECTED'; +export type ImportStatus = 'PENDING' | 'APPROVED' | 'REJECTED'; + +//Notification typed strings + +export type NotificationType = 'review' | 'comment'; +export type NotificationResourceType = 'annotation'; + +// Audit log typed strings + +export type AuditAction = + | 'setup' + | 'register' + | 'login' + | 'password_change' + | 'create' + | 'delete' + | 'add_member' + | 'update_member_role' + | 'remove_member' + | 'import_pending' + | 'import' + | 'import_approved' + | 'import_rejected' + | 'export' + | 'toggle_active' + | 'change_role' + | 'regenerate'; + +export type AuditResourceType = 'user' | 'invitation' | 'project'; + +// Auth form mode (replaces boolean flags) + +export type AuthFormMode = 'setup' | 'login' | 'register' | 'invite'; + +// NER label colors + +export const LABEL_COLORS: Readonly> = { + PER: '#3b82f6', + ORG: '#8b5cf6', + LOC: '#10b981', + GPE: '#06b6d4', + FAC: '#f59e0b', + MISC: '#6b7280', + PRODUCT: '#ec4899', + EVENT: '#f97316', + SHIP: '#0ea5e9', + ARMOR: '#84cc16', + WEAPON: '#ef4444', + QUANTITY: '#14b8a6', + DATE: '#a855f7', + MONEY: '#eab308', +} as const; + +// Language codes + +export type LanguageCode = + | 'en' + | 'ru' + | 'de' + | 'fr' + | 'es' + | 'pt' + | 'it' + | 'zh' + | 'ja' + | 'ko' + | 'pl' + | 'nl' + | 'tr' + | 'cs' + | 'uk' + | 'ar' + | 'sv' + | 'da' + | 'fi' + | 'hu' + | 'no' + | 'ro'; From 3d6e942ea456f67f89c97ead5f0ea86dfdf97ea2 Mon Sep 17 00:00:00 2001 From: mvoof Date: Mon, 16 Mar 2026 23:54:23 +0500 Subject: [PATCH 24/48] refactor: datetime.now(timezone.utc) instead deprecated datetime.utcnow() --- backend/app/routers/invitations.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/app/routers/invitations.py b/backend/app/routers/invitations.py index 3f87439..33f25c6 100644 --- a/backend/app/routers/invitations.py +++ b/backend/app/routers/invitations.py @@ -1,5 +1,5 @@ import uuid -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Query, BackgroundTasks @@ -39,7 +39,7 @@ async def create_invitation( invitation = models.Invitation( email=data.email, token=str(uuid.uuid4()), - expires_at=datetime.utcnow() + timedelta(days=7), + expires_at=datetime.now(timezone.utc)+ timedelta(days=7), ) db.add(invitation) await db.commit() @@ -96,7 +96,7 @@ async def regenerate_invitation( raise HTTPException(400, "Cannot regenerate a used invitation") invitation.token = str(uuid.uuid4()) - invitation.expires_at = datetime.utcnow() + timedelta(days=7) + invitation.expires_at = datetime.now(timezone.utc) + timedelta(days=7) await db.commit() await db.refresh(invitation) From dfc4d01529c78b4841786df320fb80a14fe00e01 Mon Sep 17 00:00:00 2001 From: mvoof Date: Mon, 16 Mar 2026 23:55:26 +0500 Subject: [PATCH 25/48] chore: @playwright/test package is a testing framework and should be a development dependency --- frontend/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/package.json b/frontend/package.json index 68a29e7..2c45d81 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,7 +14,6 @@ "dependencies": { "@ant-design/charts": "^2.6.7", "@ant-design/icons": "^6.1.0", - "@playwright/test": "^1.58.2", "@tanstack/react-query": "^5.90.21", "antd": "^5.29.3", "axios": "^1.13.2", @@ -34,6 +33,7 @@ "@types/react-dom": "^19.1.9", "@typescript-eslint/parser": "^8.46.0", "@vitejs/plugin-react": "^5.0.4", + "@playwright/test": "^1.58.2", "eslint": "^9.37.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-jsx-a11y": "^6.10.2", From cea9c633429ff1e9bfc4400d266d51bb3ebda33b Mon Sep 17 00:00:00 2001 From: mvoof Date: Tue, 17 Mar 2026 22:37:04 +0500 Subject: [PATCH 26/48] fix: resolve all ESLint errors and replace any/unknown with proper TypeScript types --- frontend/src/api/client.ts | 65 ++++--- frontend/src/components/AuthForm.tsx | 16 +- frontend/src/components/CommandPalette.tsx | 46 +++-- frontend/src/components/ErrorBoundary.tsx | 2 +- frontend/src/components/editors/NEREditor.tsx | 8 +- .../components/editors/TranslationEditor.tsx | 22 ++- frontend/src/components/layout/AppLayout.tsx | 40 +++-- .../src/components/workspace/ContextPanel.tsx | 10 +- .../src/components/workspace/VotingPanel.tsx | 23 ++- frontend/src/constants/languages.ts | 2 +- frontend/src/hooks/usePresence.ts | 22 ++- frontend/src/main.tsx | 3 +- frontend/src/pages/Dashboard.tsx | 50 ++++-- frontend/src/pages/Profile.tsx | 10 +- frontend/src/pages/ProjectGlossary.tsx | 39 +++-- frontend/src/pages/ProjectReview.tsx | 39 ++--- frontend/src/pages/ProjectSettings.tsx | 163 ++++++++++-------- frontend/src/pages/ProjectStats.tsx | 34 ++-- frontend/src/pages/TaskBrowser.tsx | 14 +- frontend/src/pages/Workspace.tsx | 51 ++++-- frontend/src/pages/admin/AuditLog.tsx | 16 +- .../src/pages/admin/InvitationsManagement.tsx | 21 ++- .../src/pages/admin/MembersManagement.tsx | 69 +++++--- frontend/src/routes/ManagerRoute.tsx | 2 +- frontend/src/routes/ProjectRoute.tsx | 4 +- frontend/src/store/authStore.ts | 14 +- frontend/src/store/themeStore.ts | 3 +- frontend/src/types/api.ts | 2 +- 28 files changed, 490 insertions(+), 300 deletions(-) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 4527a4c..5094fb9 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,7 +1,17 @@ -import axios from 'axios'; +import axios, { type AxiosError, type AxiosRequestConfig } from 'axios'; import { authStore } from '../store/authStore'; -const API_URL = import.meta.env.VITE_API_URL || '/api/v1'; +const API_URL = + (import.meta.env.VITE_API_URL as string | undefined) ?? '/api/v1'; + +interface RefreshTokenResponse { + access_token: string; + refresh_token: string; +} + +interface OriginalRequestConfig extends AxiosRequestConfig { + _retry?: boolean; +} const api = axios.create({ baseURL: API_URL, @@ -19,43 +29,47 @@ api.interceptors.request.use((config) => { }); let isRefreshing = false; -let failedQueue: Array<{ +let failedQueue: { resolve: (token: string) => void; - reject: (error: unknown) => void; -}> = []; + reject: (error: Error) => void; +}[] = []; -const processQueue = (error: unknown, token: string | null = null) => { +const processQueue = (error: Error | null, token: string | null = null) => { failedQueue.forEach((prom) => { if (token) prom.resolve(token); - else prom.reject(error); + else prom.reject(error ?? new Error('Token refresh failed')); }); failedQueue = []; }; api.interceptors.response.use( (response) => response, - async (error) => { - const originalRequest = error.config; - const url = originalRequest?.url || ''; + async (error: AxiosError & { config?: OriginalRequestConfig }) => { + const axiosError = error; + const originalRequest = axiosError.config; + const url = originalRequest?.url ?? ''; const isAuthRoute = url.includes('/auth/'); if ( - error.response?.status === 401 && + axiosError.response?.status === 401 && !isAuthRoute && + originalRequest && !originalRequest._retry ) { const refreshToken = localStorage.getItem('refreshToken'); if (!refreshToken) { authStore.logout(); - return Promise.reject(error); + return Promise.reject( + error instanceof Error ? error : new Error(String(error)) + ); } if (isRefreshing) { return new Promise((resolve, reject) => { failedQueue.push({ resolve, reject }); }).then((token) => { - originalRequest.headers.Authorization = `Bearer ${token}`; + originalRequest.headers.Authorization = `Bearer ${String(token)}`; return api(originalRequest); }); } @@ -64,30 +78,39 @@ api.interceptors.response.use( isRefreshing = true; try { - const res = await axios.post(`${API_URL}/auth/refresh`, { - refresh_token: refreshToken, - }); + const res = await axios.post( + `${API_URL}/auth/refresh`, + { + refresh_token: refreshToken, + } + ); const newAccessToken = res.data.access_token; const newRefreshToken = res.data.refresh_token; localStorage.setItem('authToken', newAccessToken); localStorage.setItem('refreshToken', newRefreshToken); processQueue(null, newAccessToken); originalRequest.headers.Authorization = `Bearer ${newAccessToken}`; - return api(originalRequest); + return await api(originalRequest); } catch (refreshError) { - processQueue(refreshError, null); + const refreshErr = + refreshError instanceof Error + ? refreshError + : new Error(String(refreshError)); + processQueue(refreshErr, null); authStore.logout(); - return Promise.reject(refreshError); + throw refreshErr; } finally { isRefreshing = false; } } - if (error.response?.status === 401 && !isAuthRoute) { + if (axiosError.response?.status === 401 && !isAuthRoute) { authStore.logout(); } - return Promise.reject(error); + return Promise.reject( + error instanceof Error ? error : new Error(String(error)) + ); } ); diff --git a/frontend/src/components/AuthForm.tsx b/frontend/src/components/AuthForm.tsx index 7d3a785..9b89c7b 100644 --- a/frontend/src/components/AuthForm.tsx +++ b/frontend/src/components/AuthForm.tsx @@ -29,7 +29,7 @@ const AuthForm: React.FC = observer(() => { const loading = authStore.isLoading; useEffect(() => { - authStore.checkSetupStatus(); + void authStore.checkSetupStatus(); }, []); const handleSubmit = async (values: Record) => { @@ -42,11 +42,11 @@ const AuthForm: React.FC = observer(() => { values.fullName ); if (err) setError(err); - else navigate('/dashboard'); + else void navigate('/dashboard'); } else if (formMode === 'login') { const err = await authStore.login(values.email, values.password); if (err) setError(err); - else navigate('/dashboard'); + else void navigate('/dashboard'); } else { if (!inviteToken) { setError('No invitation token. You need an invite link to register.'); @@ -59,7 +59,7 @@ const AuthForm: React.FC = observer(() => { inviteToken ); if (err) setError(err); - else navigate('/dashboard'); + else void navigate('/dashboard'); } }; @@ -125,7 +125,13 @@ const AuthForm: React.FC = observer(() => { /> )} - + ) => { + void handleSubmit(values); + }} + layout="vertical" + size="large" + > {formMode !== 'login' && ( { const { data: projects = [] } = useQuery({ queryKey: ['projects'], - queryFn: () => api.get('/projects').then((r) => r.data), + queryFn: () => api.get('/projects').then((r) => r.data), enabled: open, }); @@ -66,7 +66,9 @@ const CommandPalette: React.FC = observer(() => { id: 'dashboard', title: 'Dashboard', icon: , - action: () => navigate('/dashboard'), + action: () => { + void navigate('/dashboard'); + }, keywords: ['home', 'projects'], section: 'Navigation', }, @@ -74,7 +76,9 @@ const CommandPalette: React.FC = observer(() => { id: 'profile', title: 'Profile', icon: , - action: () => navigate('/profile'), + action: () => { + void navigate('/profile'); + }, keywords: ['account', 'settings'], section: 'Navigation', }, @@ -83,27 +87,33 @@ const CommandPalette: React.FC = observer(() => { // Project commands for (const p of projects) { items.push({ - id: `project-${p.id}-workspace`, + id: `project-${String(p.id)}-workspace`, title: `${p.name} - Workspace`, description: p.type, icon: , - action: () => navigate(`/projects/${p.id}/workspace`), + action: () => { + void navigate(`/projects/${String(p.id)}/workspace`); + }, keywords: [p.name.toLowerCase(), 'workspace', p.type.toLowerCase()], section: 'Projects', }); items.push({ - id: `project-${p.id}-stats`, + id: `project-${String(p.id)}-stats`, title: `${p.name} - Stats`, icon: , - action: () => navigate(`/projects/${p.id}/stats`), + action: () => { + void navigate(`/projects/${String(p.id)}/stats`); + }, keywords: [p.name.toLowerCase(), 'stats', 'statistics'], section: 'Projects', }); items.push({ - id: `project-${p.id}-settings`, + id: `project-${String(p.id)}-settings`, title: `${p.name} - Settings`, icon: , - action: () => navigate(`/projects/${p.id}/settings`), + action: () => { + void navigate(`/projects/${String(p.id)}/settings`); + }, keywords: [p.name.toLowerCase(), 'settings', 'config'], section: 'Projects', }); @@ -116,7 +126,9 @@ const CommandPalette: React.FC = observer(() => { id: 'admin-members', title: 'Manage Members', icon: , - action: () => navigate('/admin/members'), + action: () => { + void navigate('/admin/members'); + }, keywords: ['users', 'members', 'admin'], section: 'Admin', }, @@ -124,7 +136,9 @@ const CommandPalette: React.FC = observer(() => { id: 'admin-invitations', title: 'Invitations', icon: , - action: () => navigate('/admin/invitations'), + action: () => { + void navigate('/admin/invitations'); + }, keywords: ['invite', 'invitations'], section: 'Admin', }, @@ -132,7 +146,9 @@ const CommandPalette: React.FC = observer(() => { id: 'admin-audit', title: 'Audit Log', icon: , - action: () => navigate('/admin/audit-log'), + action: () => { + void navigate('/admin/audit-log'); + }, keywords: ['audit', 'log', 'history'], section: 'Admin', } @@ -148,8 +164,8 @@ const CommandPalette: React.FC = observer(() => { return commands.filter( (cmd) => cmd.title.toLowerCase().includes(q) || - cmd.description?.toLowerCase().includes(q) || - cmd.keywords?.some((k) => k.includes(q)) + (cmd.description?.toLowerCase().includes(q) ?? false) || + (cmd.keywords?.some((k) => k.includes(q)) ?? false) ); }, [commands, search]); @@ -179,7 +195,7 @@ const CommandPalette: React.FC = observer(() => { const sections = useMemo(() => { const map = new Map(); for (const cmd of filtered) { - const list = map.get(cmd.section) || []; + const list = map.get(cmd.section) ?? []; list.push(cmd); map.set(cmd.section, list); } diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx index fe881c6..00be2c5 100644 --- a/frontend/src/components/ErrorBoundary.tsx +++ b/frontend/src/components/ErrorBoundary.tsx @@ -28,7 +28,7 @@ class ErrorBoundary extends Component { Try Again diff --git a/frontend/src/components/editors/NEREditor.tsx b/frontend/src/components/editors/NEREditor.tsx index 9f553da..cfcbabc 100644 --- a/frontend/src/components/editors/NEREditor.tsx +++ b/frontend/src/components/editors/NEREditor.tsx @@ -52,8 +52,8 @@ const DEFAULT_LABELS = [ const NEREditor: React.FC = observer(({ task, config, onSubmit }) => { const labels = useMemo( - () => config?.labels || DEFAULT_LABELS, - [config?.labels] + () => config.labels ?? DEFAULT_LABELS, + [config.labels] ); const [selectedLabel, setSelectedLabel] = useState(labels[0]); @@ -355,7 +355,7 @@ const NEREditor: React.FC = observer(({ task, config, onSubmit }) => { {entities.map((e, idx) => (
= observer(({ task, config, onSubmit }) => { LABEL_COLORS[activeEntity.label] || tokens.colors.primary; return ( = observer( ({ task, config, onSubmit }) => { const { token: themeToken } = theme.useToken(); const targetLangs = useMemo( - () => config?.target_languages || [], - [config?.target_languages] + () => config.target_languages ?? [], + [config.target_languages] ); const [activeTab, setActiveTab] = useState(targetLangs[0]); const [translations, setTranslations] = useState>( @@ -44,7 +44,9 @@ const TranslationEditor: React.FC = observer( const { data: existingAnnotations = [] } = useQuery({ queryKey: ['task-annotations', task.id], queryFn: () => - api.get(`/tasks/${task.id}/annotations`).then((r) => r.data), + api + .get(`/tasks/${String(task.id)}/annotations`) + .then((r) => r.data as TaskAnnotation[]), enabled: !!task.id, }); @@ -58,7 +60,9 @@ const TranslationEditor: React.FC = observer( const { data: glossary = [] } = useQuery({ queryKey: ['glossary', task.project_id], queryFn: () => - api.get(`/projects/${task.project_id}/glossary`).then((r) => r.data), + api + .get(`/projects/${String(task.project_id)}/glossary`) + .then((r) => r.data as GlossaryTerm[]), staleTime: 60_000, }); @@ -79,9 +83,9 @@ const TranslationEditor: React.FC = observer( const own = existingAnnotations.find( (a) => a.user_id === authStore.user?.id ); - const source = own || existingAnnotations[0]; + const source = own ?? existingAnnotations[0]; const result = source.result as Record; - if (result && typeof result === 'object') { + if (typeof result === 'object') { targetLangs.forEach((lang) => { if (result[lang]) initial[lang] = result[lang]; }); @@ -228,7 +232,7 @@ const TranslationEditor: React.FC = observer( > - SOURCE ({config?.source_language || 'en'}) + SOURCE ({config.source_language ?? 'en'}) = observer(
{otherSuggestions.map((ann) => { const result = ann.result as Record; - const text = result?.[activeTab]; + const text = result[activeTab]; if (!text) return null; return (
= observer( color: 'var(--ant-color-text-secondary)', }} > - {ann.user_full_name || ann.user_email} + {ann.user_full_name ?? ann.user_email} { const user = authStore.user; // Detect project context (deferred to avoid antd Menu layout thrashing) - const projectMatch = location.pathname.match(/\/projects\/(\d+)/); + const projectMatch = /\/projects\/(\d+)/.exec(location.pathname); const currentProjectId = projectMatch ? projectMatch[1] : null; const [projectId, setProjectId] = useState(null); useEffect(() => { @@ -66,14 +66,18 @@ const AppLayout: React.FC = observer(() => { }, [currentProjectId]); useEffect(() => { - fetchNotifications(); - const interval = setInterval(fetchNotifications, 30000); + void fetchNotifications(); + const interval = setInterval(() => { + void fetchNotifications(); + }, 30000); return () => clearInterval(interval); }, []); const fetchNotifications = async () => { try { - const res = await api.get('/notifications?page_size=10'); + const res = await api.get( + '/notifications?page_size=10' + ); setNotifications(res.data.items); setUnreadCount(res.data.unread_count); } catch { @@ -93,7 +97,7 @@ const AppLayout: React.FC = observer(() => { const handleLogout = () => { authStore.logout(); - navigate('/auth'); + void navigate('/auth'); }; const isDark = themeStore.mode === 'dark'; @@ -173,16 +177,18 @@ const AppLayout: React.FC = observer(() => { ]; // Breadcrumbs - const breadcrumbItems: Array<{ title: React.ReactNode }> = [ + const breadcrumbItems: { title: React.ReactNode }[] = [ { title: ( navigate('/dashboard')} + onClick={() => { + void navigate('/dashboard'); + }} onKeyDown={(e) => { - if (e.key === 'Enter') navigate('/dashboard'); + if (e.key === 'Enter') void navigate('/dashboard'); }} > Projects @@ -217,7 +223,13 @@ const AppLayout: React.FC = observer(() => { > Notifications {unreadCount > 0 && ( - )} @@ -324,7 +336,9 @@ const AppLayout: React.FC = observer(() => { mode="inline" selectedKeys={[location.pathname]} items={menuItems} - onClick={({ key }) => navigate(key)} + onClick={({ key }) => { + void navigate(key); + }} style={{ border: 'none', padding: '8px 4px' }} />
@@ -378,10 +392,10 @@ const AppLayout: React.FC = observer(() => { size="small" style={{ background: themeToken.colorPrimary }} > - {(user?.full_name || user?.email || '?')[0].toUpperCase()} + {(user?.full_name ?? user?.email ?? '?')[0].toUpperCase()} - {user?.full_name || user?.email} + {user?.full_name ?? user?.email} {user?.role === 'ADMIN' && ( diff --git a/frontend/src/components/workspace/ContextPanel.tsx b/frontend/src/components/workspace/ContextPanel.tsx index ee0336e..ad23549 100644 --- a/frontend/src/components/workspace/ContextPanel.tsx +++ b/frontend/src/components/workspace/ContextPanel.tsx @@ -34,7 +34,9 @@ const ContextPanel: React.FC = observer( const { data: glossary = [] } = useQuery({ queryKey: ['glossary', project.id], queryFn: () => - api.get(`/projects/${project.id}/glossary`).then((r) => r.data), + api + .get(`/projects/${String(project.id)}/glossary`) + .then((r) => r.data as GlossaryTerm[]), staleTime: 60_000, }); @@ -52,10 +54,10 @@ const ContextPanel: React.FC = observer( queryKey: ['translation-memory', project.id, sourceText.slice(0, 50)], queryFn: () => api - .get(`/projects/${project.id}/translation-memory`, { + .get(`/projects/${String(project.id)}/translation-memory`, { params: { query: sourceText.slice(0, 100), limit: 5 }, }) - .then((r) => r.data), + .then((r) => r.data as TranslationMemoryMatch[]), enabled: !!sourceText && project.type === 'TRANSLATION', staleTime: 120_000, }); @@ -218,7 +220,7 @@ const ContextPanel: React.FC = observer( {lang} - {String(text)} + {text}
))} diff --git a/frontend/src/components/workspace/VotingPanel.tsx b/frontend/src/components/workspace/VotingPanel.tsx index 9cbb44f..2f0f6ee 100644 --- a/frontend/src/components/workspace/VotingPanel.tsx +++ b/frontend/src/components/workspace/VotingPanel.tsx @@ -43,18 +43,23 @@ const VotingPanel: React.FC = observer(({ taskId, projectType }) => { const { data: annotations = [], isLoading } = useQuery({ queryKey: ['task-annotations', taskId], - queryFn: () => api.get(`/tasks/${taskId}/annotations`).then((r) => r.data), + queryFn: () => + api + .get(`/tasks/${String(taskId)}/annotations`) + .then((r) => r.data as TaskAnnotation[]), enabled: !!taskId, }); const handleVote = async (annotationId: number, value: 1 | -1) => { - await api.post(`/annotations/${annotationId}/vote`, { value }); - queryClient.invalidateQueries({ queryKey: ['task-annotations', taskId] }); + await api.post(`/annotations/${String(annotationId)}/vote`, { value }); + await queryClient.invalidateQueries({ + queryKey: ['task-annotations', taskId], + }); }; const handleComment = async (annotationId: number) => { if (!commentText.trim()) return; - await api.post(`/annotations/${annotationId}/comments`, { + await api.post(`/annotations/${String(annotationId)}/comments`, { text: commentText, }); setCommentText(''); @@ -151,7 +156,7 @@ const VotingPanel: React.FC = observer(({ taskId, projectType }) => { > - {ann.user_full_name || ann.user_email} + {ann.user_full_name ?? ann.user_email} = observer(({ taskId, projectType }) => { ) } - onClick={() => handleVote(ann.id, 1)} + onClick={() => void handleVote(ann.id, 1)} style={{ padding: '0 4px' }} > {ann.votes_up} @@ -237,7 +242,7 @@ const VotingPanel: React.FC = observer(({ taskId, projectType }) => { ) } - onClick={() => handleVote(ann.id, -1)} + onClick={() => void handleVote(ann.id, -1)} style={{ padding: '0 4px' }} > {ann.votes_down} @@ -263,13 +268,13 @@ const VotingPanel: React.FC = observer(({ taskId, projectType }) => { placeholder="Add comment..." value={commentText} onChange={(e) => setCommentText(e.target.value)} - onPressEnter={() => handleComment(ann.id)} + onPressEnter={() => void handleComment(ann.id)} style={{ fontSize: 12 }} /> diff --git a/frontend/src/constants/languages.ts b/frontend/src/constants/languages.ts index c934ee7..c971bd3 100644 --- a/frontend/src/constants/languages.ts +++ b/frontend/src/constants/languages.ts @@ -1,6 +1,6 @@ import type { LanguageCode } from '../types/domain'; -export const LANGUAGE_OPTIONS: Array<{ value: LanguageCode; label: string }> = [ +export const LANGUAGE_OPTIONS: { value: LanguageCode; label: string }[] = [ { value: 'en', label: 'English' }, { value: 'ru', label: 'Russian' }, { value: 'fr', label: 'French' }, diff --git a/frontend/src/hooks/usePresence.ts b/frontend/src/hooks/usePresence.ts index 12a4b98..3e5e44e 100644 --- a/frontend/src/hooks/usePresence.ts +++ b/frontend/src/hooks/usePresence.ts @@ -12,6 +12,11 @@ interface PresenceState { connected: boolean; } +interface PresenceMessage { + type: string; + users?: PresenceUser[]; +} + export function usePresence(projectId: string | undefined): PresenceState & { sendEditing: (taskId: number) => void; sendSubmitted: (taskId: number) => void; @@ -23,6 +28,7 @@ export function usePresence(projectId: string | undefined): PresenceState & { useEffect(() => { if (!projectId || !authStore.user) return; + const currentUser = authStore.user; const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const host = window.location.host; const ws = new WebSocket(`${protocol}//${host}/ws/projects/${projectId}`); @@ -33,8 +39,8 @@ export function usePresence(projectId: string | undefined): PresenceState & { ws.send( JSON.stringify({ user: { - id: authStore.user!.id, - name: authStore.user!.full_name || authStore.user!.email, + id: currentUser.id, + name: currentUser.full_name ?? currentUser.email, }, }) ); @@ -42,10 +48,10 @@ export function usePresence(projectId: string | undefined): PresenceState & { ws.onmessage = (event) => { try { - const data = JSON.parse(event.data); + const data = JSON.parse(String(event.data)) as PresenceMessage; if (data.type === 'presence') { setUsers( - data.users.filter((u: PresenceUser) => u.id !== authStore.user?.id) + (data.users ?? []).filter((u) => u.id !== authStore.user?.id) ); } } catch { @@ -53,8 +59,12 @@ export function usePresence(projectId: string | undefined): PresenceState & { } }; - ws.onclose = () => setConnected(false); - ws.onerror = () => setConnected(false); + ws.onclose = () => { + setConnected(false); + }; + ws.onerror = () => { + setConnected(false); + }; return () => { ws.close(); diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 5fe1091..532b3cd 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -2,7 +2,8 @@ import React from 'react'; import { createRoot } from 'react-dom/client'; import App from './App'; -const container = document.getElementById('root')!; +const container = document.getElementById('root'); +if (!container) throw new Error('Root element not found'); const root = createRoot(container); root.render( diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index fdc07c2..4126e33 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -50,12 +50,13 @@ const Dashboard: React.FC = observer(() => { const { data: projects = [], isLoading } = useQuery({ queryKey: ['projects'], - queryFn: () => api.get('/projects').then((r) => r.data), + queryFn: () => api.get('/projects').then((r) => r.data), }); const { data: overview } = useQuery({ queryKey: ['overview-stats'], - queryFn: () => api.get('/overview-stats').then((r) => r.data), + queryFn: () => + api.get('/overview-stats').then((r) => r.data), }); // Fetch stats for each project for progress bars @@ -66,7 +67,9 @@ const Dashboard: React.FC = observer(() => { await Promise.all( projects.map(async (p) => { try { - const res = await api.get(`/projects/${p.id}/stats`); + const res = await api.get( + `/projects/${String(p.id)}/stats` + ); stats[p.id] = res.data; } catch { // ignore @@ -93,7 +96,7 @@ const Dashboard: React.FC = observer(() => { return api.post('/projects', { ...rest, config }); }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['projects'] }); + void queryClient.invalidateQueries({ queryKey: ['projects'] }); setModalOpen(false); form.resetFields(); }, @@ -119,8 +122,12 @@ const Dashboard: React.FC = observer(() => { const lastActiveProject = projects.length > 0 ? projects.reduce((best, p) => { - const s = projectStats[p.id]; - const bS = projectStats[best.id]; + const s = (projectStats as Record)[ + p.id + ]; + const bS = (projectStats as Record)[ + best.id + ]; if (!s) return best; if (!bS) return p; if ( @@ -142,7 +149,9 @@ const Dashboard: React.FC = observer(() => { size="large" icon={} onClick={() => - navigate(`/projects/${lastActiveProject.id}/workspace`) + void navigate( + `/projects/${String(lastActiveProject.id)}/workspace` + ) } > Continue: {lastActiveProject.name} @@ -279,8 +288,10 @@ const Dashboard: React.FC = observer(() => { ) : ( {filteredProjects.map((project, i) => { - const stats = projectStats[project.id]; - const progress = stats?.progress_percent || 0; + const stats = ( + projectStats as Record + )[project.id]; + const progress = stats?.progress_percent ?? 0; return (
{ type="link" icon={} onClick={() => - navigate(`/projects/${project.id}/workspace`) + void navigate( + `/projects/${String(project.id)}/workspace` + ) } > Workspace @@ -308,7 +321,7 @@ const Dashboard: React.FC = observer(() => { type="link" icon={} onClick={() => - navigate(`/projects/${project.id}/stats`) + void navigate(`/projects/${String(project.id)}/stats`) } > Stats @@ -320,7 +333,9 @@ const Dashboard: React.FC = observer(() => { type="link" icon={} onClick={() => - navigate(`/projects/${project.id}/settings`) + void navigate( + `/projects/${String(project.id)}/settings` + ) } > Settings @@ -417,7 +432,12 @@ const Dashboard: React.FC = observer(() => { createProject.mutate(values)} + onFinish={(values: { + name: string; + type: ProjectType; + source_language?: string; + target_languages?: string[]; + }) => createProject.mutate(values)} initialValues={{ type: 'TRANSLATION' }} > { prev.type !== cur.type} + shouldUpdate={(prev: { type: string }, cur: { type: string }) => + prev.type !== cur.type + } > {({ getFieldValue }) => getFieldValue('type') === 'TRANSLATION' && ( diff --git a/frontend/src/pages/Profile.tsx b/frontend/src/pages/Profile.tsx index d368f90..0504526 100644 --- a/frontend/src/pages/Profile.tsx +++ b/frontend/src/pages/Profile.tsx @@ -68,8 +68,10 @@ const Profile: React.FC = observer(() => { + void handleSaveName(values) + } > @@ -95,7 +97,9 @@ const Profile: React.FC = observer(() => { + void handleChangePassword(values) + } > { const { data: glossary = [] } = useQuery({ queryKey: ['glossary', projectId], queryFn: () => - api.get(`/projects/${projectId}/glossary`).then((r) => r.data), + api + .get(`/projects/${String(projectId)}/glossary`) + .then((r) => r.data), enabled: !!projectId, }); @@ -49,21 +51,24 @@ const ProjectGlossary: React.FC = observer(() => { try { if (editingTerm) { - await api.patch(`/projects/${projectId}/glossary/${editingTerm.id}`, { - translations: translationsMap, - notes: values.notes || null, - }); + await api.patch( + `/projects/${String(projectId)}/glossary/${String(editingTerm.id)}`, + { + translations: translationsMap, + notes: values.notes ?? null, + } + ); } else { - await api.post(`/projects/${projectId}/glossary`, { + await api.post(`/projects/${String(projectId)}/glossary`, { source_term: values.source_term, translations: translationsMap, - notes: values.notes || null, + notes: values.notes ?? null, }); } setGlossaryModalOpen(false); setEditingTerm(null); glossaryForm.resetFields(); - queryClient.invalidateQueries({ queryKey: ['glossary', projectId] }); + void queryClient.invalidateQueries({ queryKey: ['glossary', projectId] }); } catch { message.error('Failed to save glossary term'); } @@ -71,8 +76,10 @@ const ProjectGlossary: React.FC = observer(() => { const handleDeleteGlossaryTerm = async (termId: number) => { try { - await api.delete(`/projects/${projectId}/glossary/${termId}`); - queryClient.invalidateQueries({ queryKey: ['glossary', projectId] }); + await api.delete( + `/projects/${String(projectId)}/glossary/${String(termId)}` + ); + void queryClient.invalidateQueries({ queryKey: ['glossary', projectId] }); } catch { message.error('Failed to delete term'); } @@ -147,12 +154,12 @@ const ProjectGlossary: React.FC = observer(() => { { title: 'Notes', dataIndex: 'notes', - render: (v: string | null) => v || '\u2014', + render: (v: string | null) => v ?? '\u2014', }, { title: '', width: 80, - render: (_: unknown, record: GlossaryTerm) => ( + render: (_value: string | undefined, record: GlossaryTerm) => ( handleDeleteGlossaryTerm(record.id)} + onConfirm={() => void handleDeleteGlossaryTerm(record.id)} > @@ -275,18 +274,18 @@ const ProjectReview: React.FC = observer(() => { title: 'Task', dataIndex: 'task_id', width: 80, - render: (v: number) => `#${v}`, + render: (v: number) => `#${String(v)}`, }, { title: 'Annotator', - render: (_: unknown, r: AnnotationListItem) => - r.user_full_name || r.user_email, + render: (_value: string | undefined, r: AnnotationListItem) => + r.user_full_name ?? r.user_email, }, { title: 'Preview', - render: (_: unknown, r: AnnotationListItem) => { + render: (_value: string | undefined, r: AnnotationListItem) => { if (project?.type === 'NER') { - const entities = r.result as unknown as NEREntity[]; + const entities = r.result as NEREntity[]; return Array.isArray(entities) ? ( {entities.slice(0, 3).map((e, i) => ( @@ -348,7 +347,7 @@ const ProjectReview: React.FC = observer(() => { { title: 'Actions', width: 120, - render: (_: unknown, r: AnnotationListItem) => + render: (_value: string | undefined, r: AnnotationListItem) => r.status === 'SUBMITTED' ? ( @@ -356,7 +355,7 @@ const ProjectReview: React.FC = observer(() => { type="text" icon={} style={{ color: themeToken.colorSuccess }} - onClick={() => handleReview(r.id, 'APPROVED')} + onClick={() => void handleReview(r.id, 'APPROVED')} size="small" /> @@ -387,7 +386,7 @@ const ProjectReview: React.FC = observer(() => { setRejectNote(''); setRejectingId(null); }} - onOk={handleRejectWithNote} + onOk={() => void handleRejectWithNote()} > { total_tasks: number; with_entities: number; entities_count: number; - sample: Array<{ + sample: { text: string; id?: string; key?: string; entities_count?: number; - }>; + }[]; filename: string; } | null>(null); const [pendingFile, setPendingFile] = useState(null); @@ -82,7 +83,8 @@ const ProjectSettings: React.FC = observer(() => { // Fetch project const { data: project, refetch: refetchProject } = useQuery({ queryKey: ['project', projectId], - queryFn: () => api.get(`/projects/${projectId}`).then((r) => r.data), + queryFn: () => + api.get(`/projects/${String(projectId)}`).then((r) => r.data), enabled: !!projectId, }); @@ -90,19 +92,26 @@ const ProjectSettings: React.FC = observer(() => { const formData = new FormData(); formData.append('file', file); try { - const res = await api.post( - `/projects/${projectId}/import-preview`, - formData, - { - headers: { 'Content-Type': 'multipart/form-data' }, - } - ); + const res = await api.post<{ + total_tasks: number; + with_entities: number; + entities_count: number; + sample: { + text: string; + id?: string; + key?: string; + entities_count?: number; + }[]; + filename: string; + }>(`/projects/${String(projectId)}/import-preview`, formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); setImportPreview(res.data); setPendingFile(file); - } catch (err: unknown) { - const detail = (err as { response?: { data?: { detail?: string } } }) - ?.response?.data?.detail; - message.error(detail || 'Failed to preview file'); + } catch (err) { + const errObj = err as { response?: { data?: { detail?: string } } }; + const detail = errObj.response?.data?.detail; + message.error(detail ?? 'Failed to preview file'); } return false; }; @@ -114,19 +123,19 @@ const ProjectSettings: React.FC = observer(() => { setUploading(true); try { const url = replaceOnImport - ? `/projects/${projectId}/import?replace=true` - : `/projects/${projectId}/import`; - const response = await api.post(url, formData, { + ? `/projects/${String(projectId)}/import?replace=true` + : `/projects/${String(projectId)}/import`; + const response = await api.post<{ count: number }>(url, formData, { headers: { 'Content-Type': 'multipart/form-data' }, }); - const count = response.data?.count ?? 0; - message.success(`Import successful! ${count} tasks imported.`); + const count = response.data.count; + message.success(`Import successful! ${String(count)} tasks imported.`); setImportPreview(null); setPendingFile(null); - } catch (err: unknown) { - const detail = (err as { response?: { data?: { detail?: string } } }) - ?.response?.data?.detail; - message.error(detail || 'Import failed'); + } catch (err) { + const errObj = err as { response?: { data?: { detail?: string } } }; + const detail = errObj.response?.data?.detail; + message.error(detail ?? 'Import failed'); } finally { setUploading(false); } @@ -135,7 +144,7 @@ const ProjectSettings: React.FC = observer(() => { const handleExport = async (format: 'json' | 'ini') => { try { const response = await api.get( - `/projects/${projectId}/export?format=${format}`, + `/projects/${String(projectId)}/export?format=${format}`, { responseType: 'blob' } ); const url = window.URL.createObjectURL(new Blob([response.data])); @@ -156,7 +165,9 @@ const ProjectSettings: React.FC = observer(() => { const fetchMembers = async () => { setMembersLoading(true); try { - const res = await api.get(`/projects/${projectId}/members`); + const res = await api.get( + `/projects/${String(projectId)}/members` + ); setMembers(res.data); } catch { message.error('Failed to load members'); @@ -167,7 +178,7 @@ const ProjectSettings: React.FC = observer(() => { const fetchUsers = async () => { try { - const res = await api.get('/users'); + const res = await api.get('/users'); setAllUsers(res.data); } catch { /* only admins */ @@ -179,10 +190,10 @@ const ProjectSettings: React.FC = observer(() => { role: RoleProject; }) => { try { - await api.post(`/projects/${projectId}/members`, values); + await api.post(`/projects/${String(projectId)}/members`, values); setOpenAddMember(false); addForm.resetFields(); - fetchMembers(); + void fetchMembers(); } catch { message.error('Failed to add member'); } @@ -190,8 +201,10 @@ const ProjectSettings: React.FC = observer(() => { const handleRemoveMember = async (userId: number) => { try { - await api.delete(`/projects/${projectId}/members/${userId}`); - fetchMembers(); + await api.delete( + `/projects/${String(projectId)}/members/${String(userId)}` + ); + void fetchMembers(); } catch { message.error('Failed to remove member'); } @@ -199,8 +212,11 @@ const ProjectSettings: React.FC = observer(() => { const handleChangeRole = async (userId: number, role: RoleProject) => { try { - await api.patch(`/projects/${projectId}/members/${userId}`, { role }); - fetchMembers(); + await api.patch( + `/projects/${String(projectId)}/members/${String(userId)}`, + { role } + ); + void fetchMembers(); } catch { message.error('Failed to update role'); } @@ -215,9 +231,9 @@ const ProjectSettings: React.FC = observer(() => { okButtonProps: { danger: true }, onOk: async () => { try { - await api.delete(`/projects/${projectId}`); + await api.delete(`/projects/${String(projectId)}`); message.success('Project deleted'); - navigate('/'); + void navigate('/'); } catch { message.error('Failed to delete project'); } @@ -226,11 +242,11 @@ const ProjectSettings: React.FC = observer(() => { }; // --- General tab: label management --- - const updateProjectConfig = async (newConfig: Record) => { + const updateProjectConfig = async (newConfig: ProjectConfig) => { try { - await api.patch(`/projects/${projectId}`, { config: newConfig }); - refetchProject(); - queryClient.invalidateQueries({ queryKey: ['project', projectId] }); + await api.patch(`/projects/${String(projectId)}`, { config: newConfig }); + void refetchProject(); + void queryClient.invalidateQueries({ queryKey: ['project', projectId] }); } catch { message.error('Failed to update project'); } @@ -239,32 +255,35 @@ const ProjectSettings: React.FC = observer(() => { const handleAddLabel = () => { const label = newLabel.trim().toUpperCase(); if (!label) return; - const current = project?.config?.labels || []; + const current = project?.config.labels ?? []; if (current.includes(label)) { message.warning('Label already exists'); return; } - updateProjectConfig({ ...project?.config, labels: [...current, label] }); + void updateProjectConfig({ + ...project?.config, + labels: [...current, label], + }); setNewLabel(''); }; const handleRemoveLabel = (label: string) => { - const current = project?.config?.labels || []; - updateProjectConfig({ + const current = project?.config.labels ?? []; + void updateProjectConfig({ ...project?.config, labels: current.filter((l: string) => l !== label), }); }; const handleMoveLabel = (label: string, direction: 'up' | 'down') => { - const current = [...(project?.config?.labels || [])]; + const current = [...(project?.config.labels ?? [])]; const idx = current.indexOf(label); if (direction === 'up' && idx > 0) { [current[idx - 1], current[idx]] = [current[idx], current[idx - 1]]; } else if (direction === 'down' && idx < current.length - 1) { [current[idx], current[idx + 1]] = [current[idx + 1], current[idx]]; } - updateProjectConfig({ ...project?.config, labels: current }); + void updateProjectConfig({ ...project?.config, labels: current }); }; const availableUsers = allUsers.filter( @@ -312,11 +331,11 @@ const ProjectSettings: React.FC = observer(() => { size="small" showSearch disabled={!canManage} - value={project?.config?.source_language || 'en'} + value={project.config.source_language ?? 'en'} style={{ width: 200 }} onChange={(val) => - updateProjectConfig({ - ...project?.config, + void updateProjectConfig({ + ...project.config, source_language: val, }) } @@ -335,12 +354,12 @@ const ProjectSettings: React.FC = observer(() => { mode="multiple" showSearch disabled={!canManage} - value={project?.config?.target_languages || []} + value={project.config.target_languages ?? []} style={{ width: '100%' }} placeholder="Select target languages" onChange={(val) => - updateProjectConfig({ - ...project?.config, + void updateProjectConfig({ + ...project.config, target_languages: val, }) } @@ -369,7 +388,7 @@ const ProjectSettings: React.FC = observer(() => { min={0} max={50} disabled={!canManage} - value={project?.config?.auto_approve_threshold ?? 0} + value={project?.config.auto_approve_threshold ?? 0} onChange={(e) => setAutoApproveThreshold(parseInt(e.target.value) || 0) } @@ -384,7 +403,7 @@ const ProjectSettings: React.FC = observer(() => { type="primary" disabled={!canManage} onClick={() => - updateProjectConfig({ + void updateProjectConfig({ ...project?.config, auto_approve_threshold: autoApproveThreshold, }) @@ -419,7 +438,7 @@ const ProjectSettings: React.FC = observer(() => { )}
- {(project?.config?.labels || []).map( + {(project.config.labels ?? []).map( (label: string, idx: number) => (
{ } onClick={() => handleMoveLabel(label, 'down')} disabled={ - idx === (project?.config?.labels || []).length - 1 + idx === (project.config.labels ?? []).length - 1 } style={{ width: 24, height: 24, minWidth: 24 }} /> @@ -481,7 +500,7 @@ const ProjectSettings: React.FC = observer(() => { ) )}
- {(project?.config?.labels || []).length === 0 && ( + {(project.config.labels ?? []).length === 0 && ( No labels configured. Default labels will be used. @@ -555,8 +574,8 @@ const ProjectSettings: React.FC = observer(() => { setImportPreview(null); setPendingFile(null); }} - onOk={handleConfirmImport} - okText={`Import ${importPreview?.total_tasks || 0} tasks`} + onOk={() => void handleConfirmImport()} + okText={`Import ${String(importPreview?.total_tasks ?? 0)} tasks`} confirmLoading={uploading} > {importPreview && ( @@ -663,13 +682,13 @@ const ProjectSettings: React.FC = observer(() => { @@ -700,7 +719,7 @@ const ProjectSettings: React.FC = observer(() => { type="primary" icon={} onClick={() => { - fetchUsers(); + void fetchUsers(); setOpenAddMember(true); }} > @@ -716,13 +735,13 @@ const ProjectSettings: React.FC = observer(() => { { title: 'Name', dataIndex: 'full_name', - render: (v: string) => v || '\u2014', + render: (v: string | null) => v ?? '\u2014', }, { title: 'Email', dataIndex: 'email' }, { title: 'Role', dataIndex: 'role', - render: (_: unknown, record: ProjectMember) => { + render: (_value: RoleProject, record: ProjectMember) => { const isTargetManager = record.role === 'MANAGER'; const locked = isTargetManager && !authStore.isAdmin; return ( @@ -731,7 +750,7 @@ const ProjectSettings: React.FC = observer(() => { value={record.role} disabled={locked} onChange={(value: RoleProject) => - handleChangeRole(record.user_id, value) + void handleChangeRole(record.user_id, value) } style={{ width: 120 }} > @@ -748,13 +767,18 @@ const ProjectSettings: React.FC = observer(() => { { title: '', width: 60, - render: (_: unknown, record: ProjectMember) => { + render: ( + _value: string | undefined, + record: ProjectMember + ) => { const locked = record.role === 'MANAGER' && !authStore.isAdmin; return ( handleRemoveMember(record.user_id)} + onConfirm={() => + void handleRemoveMember(record.user_id) + } disabled={locked} >
diff --git a/frontend/src/pages/ProjectStats.tsx b/frontend/src/pages/ProjectStats.tsx index b920df0..ec0d4bb 100644 --- a/frontend/src/pages/ProjectStats.tsx +++ b/frontend/src/pages/ProjectStats.tsx @@ -47,25 +47,34 @@ const ProjectStats: React.FC = observer(() => { const { data: stats, isLoading } = useQuery({ queryKey: ['project-stats', projectId], - queryFn: () => api.get(`/projects/${projectId}/stats`).then((r) => r.data), + queryFn: () => + api + .get(`/projects/${String(projectId)}/stats`) + .then((r) => r.data), }); const { data: annotatorStats = [] } = useQuery({ queryKey: ['annotator-stats', projectId], queryFn: () => - api.get(`/projects/${projectId}/annotator-stats`).then((r) => r.data), + api + .get(`/projects/${String(projectId)}/annotator-stats`) + .then((r) => r.data), }); const { data: timeline = [] } = useQuery({ queryKey: ['stats-timeline', projectId], queryFn: () => - api.get(`/projects/${projectId}/stats/timeline`).then((r) => r.data), + api + .get(`/projects/${String(projectId)}/stats/timeline`) + .then((r) => r.data), }); const { data: labelStats = [] } = useQuery({ queryKey: ['stats-labels', projectId], queryFn: () => - api.get(`/projects/${projectId}/stats/labels`).then((r) => r.data), + api + .get(`/projects/${String(projectId)}/stats/labels`) + .then((r) => r.data), }); if (isLoading) { @@ -149,7 +158,7 @@ const ProjectStats: React.FC = observer(() => { `${pct}%`} + format={(pct) => `${String(pct)}%`} strokeColor={tokens.colors.primary} size={160} /> @@ -188,7 +197,8 @@ const ProjectStats: React.FC = observer(() => { columns={[ { title: 'Annotator', - render: (_, r: AnnotatorStats) => r.full_name || r.email, + render: (_value: string | undefined, r: AnnotatorStats) => + r.full_name ?? r.email, }, { title: 'Total', @@ -199,7 +209,7 @@ const ProjectStats: React.FC = observer(() => { { title: 'Status', width: 180, - render: (_, r: AnnotatorStats) => ( + render: (_value: string | undefined, r: AnnotatorStats) => ( <> {r.approved} {r.rejected} @@ -235,7 +245,7 @@ const ProjectStats: React.FC = observer(() => { gap: 0, minWidth: 0, }} - title={`${entry.date}: ${entry.total} total, ${entry.approved} approved`} + title={`${entry.date}: ${String(entry.total)} total, ${String(entry.approved)} approved`} >
{
0 ? 2 : 0, @@ -258,7 +268,7 @@ const ProjectStats: React.FC = observer(() => {
0 ? 2 : 0, }} @@ -309,7 +319,7 @@ const ProjectStats: React.FC = observer(() => {
{labelStats.map((entry) => { - const maxCount = labelStats[0]?.count || 1; + const maxCount = labelStats[0]?.count ?? 1; return (
{
{ const { projectId } = useParams(); @@ -15,8 +15,8 @@ const TaskBrowser: React.FC = observer(() => { queryKey: ['tasks', projectId, page], queryFn: () => api - .get( - `/tasks/projects/${projectId}/tasks?page=${page}&page_size=${pageSize}` + .get( + `/tasks/projects/${String(projectId)}/tasks?page=${String(page)}&page_size=${String(pageSize)}` ) .then((r) => r.data), }); @@ -28,13 +28,13 @@ const TaskBrowser: React.FC = observer(() => { - dataSource={data?.items || []} + dataSource={data?.items ?? []} rowKey="id" loading={isLoading} pagination={{ current: page, pageSize, - total: data?.total || 0, + total: data?.total ?? 0, onChange: setPage, showSizeChanger: false, }} @@ -63,7 +63,7 @@ const TaskBrowser: React.FC = observer(() => { title: 'Content', dataIndex: 'data', ellipsis: true, - render: (_: unknown, record: TaskListItem) => { + render: (_value: TaskData, record: TaskListItem) => { const text = 'text' in record.data ? (record.data as { text: string }).text @@ -91,7 +91,7 @@ const TaskBrowser: React.FC = observer(() => { dataIndex: 'has_final', width: 120, align: 'center', - render: (_: unknown, record: TaskListItem) => { + render: (_value: boolean, record: TaskListItem) => { if (record.has_final) return Final; if (record.annotation_count > 0) return In Progress; diff --git a/frontend/src/pages/Workspace.tsx b/frontend/src/pages/Workspace.tsx index 396c717..faf28b4 100644 --- a/frontend/src/pages/Workspace.tsx +++ b/frontend/src/pages/Workspace.tsx @@ -38,6 +38,8 @@ import { usePresence } from '../hooks/usePresence'; import api from '../api/client'; import { isProject, + type NERAnnotationResult, + type TranslationAnnotationResult, type Project, type TaskListItem, type TaskListResponse, @@ -75,7 +77,7 @@ const Workspace: React.FC = observer(() => { const { data: project } = useQuery({ queryKey: ['project', projectId], queryFn: async () => { - const res = await api.get(`/projects/${projectId}`); + const res = await api.get(`/projects/${String(projectId)}`); if (!isProject(res.data)) throw new Error('Invalid project'); return res.data; }, @@ -87,15 +89,15 @@ const Workspace: React.FC = observer(() => { queryKey: ['workspace-tasks', projectId, page], queryFn: () => api - .get( - `/tasks/projects/${projectId}/tasks?page=${page}&page_size=${pageSize}` + .get( + `/tasks/projects/${String(projectId)}/tasks?page=${String(page)}&page_size=${String(pageSize)}` ) .then((r) => r.data), enabled: !!projectId, }); - const tasks = useMemo(() => tasksData?.items || [], [tasksData?.items]); - const totalTasks = tasksData?.total || 0; + const tasks = useMemo(() => tasksData?.items ?? [], [tasksData?.items]); + const totalTasks = tasksData?.total ?? 0; // Filter tasks client-side for search and status const filteredTasks = useMemo(() => { @@ -134,7 +136,7 @@ const Workspace: React.FC = observer(() => { }, [selectedTaskId, sendEditing]); const selectedTask = useMemo( - () => tasks.find((t) => t.id === selectedTaskId) || null, + () => tasks.find((t) => t.id === selectedTaskId) ?? null, [tasks, selectedTaskId] ); @@ -143,12 +145,18 @@ const Workspace: React.FC = observer(() => { const progressPercent = totalTasks > 0 ? Math.round((completedCount / totalTasks) * 100) : 0; - const handleSubmit = async (result: unknown) => { + const handleSubmit = async ( + result: NERAnnotationResult | TranslationAnnotationResult + ) => { if (!selectedTask) return; - await api.post(`/tasks/${selectedTask.id}/annotations`, { result }); - await api.post(`/annotations/${selectedTask.id}/submit`).catch(() => {}); + await api.post(`/tasks/${String(selectedTask.id)}/annotations`, { result }); + await api + .post(`/annotations/${String(selectedTask.id)}/submit`) + .catch(() => undefined); sendSubmitted(selectedTask.id); - queryClient.invalidateQueries({ queryKey: ['workspace-tasks', projectId] }); + void queryClient.invalidateQueries({ + queryKey: ['workspace-tasks', projectId], + }); // Move to next task const idx = filteredTasks.findIndex((t) => t.id === selectedTaskId); if (idx >= 0 && idx < filteredTasks.length - 1) { @@ -252,7 +260,7 @@ const Workspace: React.FC = observer(() => {
{ { width: 6, height: 6, borderRadius: '50%', - backgroundColor: STATUS_STYLES[status.color]?.color, + backgroundColor: STATUS_STYLES[status.color].color, display: 'inline-block', }} /> @@ -449,7 +457,10 @@ const Workspace: React.FC = observer(() => { {presenceUsers.length > 0 && ( `${u.name}${u.task_id ? ` (#${u.task_id})` : ''}`) + .map( + (u) => + `${u.name}${u.task_id ? ` (#${String(u.task_id)})` : ''}` + ) .join(', ')} > { /> - {selectedTask ? `#${selectedTask.id}` : '--'} + {selectedTask ? `#${String(selectedTask.id)}` : '--'}
@@ -582,7 +597,7 @@ const Workspace: React.FC = observer(() => {
{taskListOpen && taskListPanel} {editorPanel} - {contextOpen && selectedTask && project && ( + {contextOpen && selectedTask && ( { params.set('page_size', String(pageSize)); if (actionFilter) params.set('action', actionFilter); if (resourceTypeFilter) params.set('resource_type', resourceTypeFilter); - return api.get(`/audit-log?${params.toString()}`).then((r) => r.data); + return api + .get(`/audit-log?${params.toString()}`) + .then((r) => r.data); }, }); @@ -84,13 +86,13 @@ const AuditLog: React.FC = observer(() => { - dataSource={data?.items || []} + dataSource={data?.items ?? []} rowKey="id" loading={isLoading} pagination={{ current: page, pageSize, - total: data?.total || 0, + total: data?.total ?? 0, onChange: setPage, showSizeChanger: false, }} @@ -105,7 +107,7 @@ const AuditLog: React.FC = observer(() => { title: 'User', dataIndex: 'user_email', width: 200, - render: (v: string | null) => v || '\u2014', + render: (v: string | null) => v ?? '\u2014', }, { title: 'Action', @@ -116,10 +118,8 @@ const AuditLog: React.FC = observer(() => { { title: 'Resource', width: 160, - render: (_: unknown, record: AuditLogItem) => - record.resource_type - ? `${record.resource_type}${record.resource_id ? ` #${record.resource_id}` : ''}` - : '\u2014', + render: (_value: string | undefined, record: AuditLogItem) => + `${record.resource_type}${record.resource_id !== null ? ` #${String(record.resource_id)}` : ''}`, }, { title: 'Details', diff --git a/frontend/src/pages/admin/InvitationsManagement.tsx b/frontend/src/pages/admin/InvitationsManagement.tsx index 8e838f7..5d99e39 100644 --- a/frontend/src/pages/admin/InvitationsManagement.tsx +++ b/frontend/src/pages/admin/InvitationsManagement.tsx @@ -37,13 +37,15 @@ const InvitationsManagement: React.FC = observer(() => { const { data: invitations = [], isLoading } = useQuery({ queryKey: ['invitations', statusFilter], queryFn: () => - api.get(`/invitations?status=${statusFilter}`).then((r) => r.data), + api + .get(`/invitations?status=${statusFilter}`) + .then((r) => r.data), }); const createInvitation = useMutation({ mutationFn: (values: { email: string }) => api.post('/invitations', values), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['invitations'] }); + void queryClient.invalidateQueries({ queryKey: ['invitations'] }); setModalOpen(false); form.resetFields(); message.success('Invitation created'); @@ -52,19 +54,20 @@ const InvitationsManagement: React.FC = observer(() => { }); const deleteInvitation = useMutation({ - mutationFn: (id: number) => api.delete(`/invitations/${id}`), + mutationFn: (id: number) => api.delete(`/invitations/${String(id)}`), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['invitations'] }); + void queryClient.invalidateQueries({ queryKey: ['invitations'] }); message.success('Invitation deleted'); }, onError: () => message.error('Failed to delete invitation'), }); const regenerateInvitation = useMutation({ - mutationFn: (id: number) => api.post(`/invitations/${id}/regenerate`), + mutationFn: (id: number) => + api.post(`/invitations/${String(id)}/regenerate`), onSuccess: () => { setStatusFilter('pending'); - queryClient.invalidateQueries({ queryKey: ['invitations'] }); + void queryClient.invalidateQueries({ queryKey: ['invitations'] }); message.success('Invitation regenerated'); }, onError: () => message.error('Failed to regenerate invitation'), @@ -72,7 +75,7 @@ const InvitationsManagement: React.FC = observer(() => { const copyLink = (inv: Invitation) => { const url = `${window.location.origin}/auth?token=${inv.token}`; - navigator.clipboard.writeText(url); + void navigator.clipboard.writeText(url); setCopiedId(inv.id); setTimeout(() => setCopiedId(null), 2000); message.success('Invite link copied!'); @@ -137,7 +140,7 @@ const InvitationsManagement: React.FC = observer(() => { { title: '', width: 120, - render: (_: unknown, inv: Invitation) => ( + render: (_value: string | undefined, inv: Invitation) => ( {statusFilter === 'pending' && ( { createInvitation.mutate(v)} + onFinish={(v: { email: string }) => createInvitation.mutate(v)} > { const { data: users = [], isLoading } = useQuery({ queryKey: ['users'], - queryFn: () => api.get('/users').then((r) => r.data), + queryFn: () => + api.get('/users').then((r) => r.data), }); const { data: projects = [] } = useQuery({ queryKey: ['projects'], - queryFn: () => api.get('/projects').then((r) => r.data), + queryFn: () => api.get('/projects').then((r) => r.data), enabled: modalOpen, }); const addToProject = useMutation({ mutationFn: (values: { project_id: number; role: RoleProject }) => - api.post(`/projects/${values.project_id}/members`, { - user_id: selectedUser!.id, + api.post(`/projects/${String(values.project_id)}/members`, { + user_id: selectedUser?.id, role: values.role, }), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['users'] }); - queryClient.invalidateQueries({ queryKey: ['projects'] }); + void queryClient.invalidateQueries({ queryKey: ['users'] }); + void queryClient.invalidateQueries({ queryKey: ['projects'] }); setModalOpen(false); setSelectedUser(null); form.resetFields(); @@ -74,9 +75,11 @@ const MembersManagement: React.FC = observer(() => { const removeFromProject = useMutation({ mutationFn: (vars: { projectId: number; userId: number }) => - api.delete(`/projects/${vars.projectId}/members/${vars.userId}`), + api.delete( + `/projects/${String(vars.projectId)}/members/${String(vars.userId)}` + ), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['users'] }); + void queryClient.invalidateQueries({ queryKey: ['users'] }); message.success('User removed from project'); }, onError: () => message.error('Failed to remove user from project'), @@ -88,20 +91,24 @@ const MembersManagement: React.FC = observer(() => { userId: number; role: RoleProject; }) => - api.patch(`/projects/${vars.projectId}/members/${vars.userId}`, { - role: vars.role, - }), + api.patch( + `/projects/${String(vars.projectId)}/members/${String(vars.userId)}`, + { + role: vars.role, + } + ), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['users'] }); + void queryClient.invalidateQueries({ queryKey: ['users'] }); message.success('Role updated'); }, onError: () => message.error('Failed to update role'), }); const toggleActive = useMutation({ - mutationFn: (userId: number) => api.patch(`/users/${userId}/toggle-active`), + mutationFn: (userId: number) => + api.patch(`/users/${String(userId)}/toggle-active`), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['users'] }); + void queryClient.invalidateQueries({ queryKey: ['users'] }); message.success('User status updated'); }, onError: () => message.error('Failed to update user status'), @@ -109,9 +116,9 @@ const MembersManagement: React.FC = observer(() => { const changeGlobalRole = useMutation({ mutationFn: (vars: { userId: number; role: GlobalRole }) => - api.patch(`/users/${vars.userId}/role`, { role: vars.role }), + api.patch(`/users/${String(vars.userId)}/role`, { role: vars.role }), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['users'] }); + void queryClient.invalidateQueries({ queryKey: ['users'] }); message.success('Global role updated'); }, onError: () => message.error('Failed to update global role'), @@ -127,7 +134,7 @@ const MembersManagement: React.FC = observer(() => { const q = searchText.toLowerCase(); return ( u.email.toLowerCase().includes(q) || - (u.full_name && u.full_name.toLowerCase().includes(q)) + u.full_name?.toLowerCase().includes(q) ); }); @@ -160,7 +167,7 @@ const MembersManagement: React.FC = observer(() => { title: 'Role', dataIndex: 'role', width: 140, - render: (_: unknown, pa: UserProjectAssignment) => ( + render: (_value: string | undefined, pa: UserProjectAssignment) => ( { record: ProjectMember ) => { const locked = - record.role === 'MANAGER' && !authStore.isAdmin; + record.role === ProjectRoles.MANAGER && + !authStore.isAdmin; return ( { const { projectId } = useParams(); @@ -55,7 +60,7 @@ const Workspace: React.FC = observer(() => { const [selectedTaskId, setSelectedTaskId] = useState(null); const [page, setPage] = useState(1); const [search, setSearch] = useState(''); - const [statusFilter, setStatusFilter] = useState(''); + const [statusFilter, setStatusFilter] = useState(''); const [taskListOpen, setTaskListOpen] = useState(true); const [contextOpen, setContextOpen] = useState(true); const pageSize = 50; @@ -105,12 +110,7 @@ const Workspace: React.FC = observer(() => { if (search) { const q = search.toLowerCase(); result = result.filter((t) => { - const text = - 'text' in t.data - ? (t.data as { text: string }).text - : 'source' in t.data - ? (t.data as { source: string }).source - : ''; + const text = getTaskText(t.data); return text.toLowerCase().includes(q) || String(t.id).includes(q); }); } @@ -196,17 +196,8 @@ const Workspace: React.FC = observer(() => { return () => window.removeEventListener('keydown', handler); }); - const getTaskStatus = (t: TaskListItem) => { - if (t.has_final) return { color: 'success' as const, text: 'Approved' }; - if (t.annotation_count > 0) - return { color: 'processing' as const, text: 'In Progress' }; - return { color: 'default' as const, text: 'Pending' }; - }; - const getTaskPreview = (t: TaskListItem) => { - if ('text' in t.data) return (t.data as { text: string }).text; - if ('source' in t.data) return (t.data as { source: string }).source; - return JSON.stringify(t.data).slice(0, 80); + return getTaskText(t.data) || JSON.stringify(t.data).slice(0, 80); }; if (!project) { @@ -312,7 +303,7 @@ const Workspace: React.FC = observer(() => { /> ) : ( filteredTasks.map((task) => { - const status = getTaskStatus(task); + const status = taskStatus(task.annotation_count, task.has_final); const isSelected = task.id === selectedTaskId; return (
{ display: 'inline-block', }} /> - {status.text} + {status.label}
{ /> )} {project.name} - - {project.type} - + {project.type} {presenceUsers.length > 0 && ( { - ) : project.type === 'TRANSLATION' ? ( + ) : project.type === ProjectTypes.TRANSLATION ? ( = { ADMIN: 'blue', @@ -141,7 +142,7 @@ const MembersManagement: React.FC = observer(() => { if (isLoading) return ; const expandedRowRender = (record: UserListItemWithProjects) => { - if (record.role === 'ADMIN') { + if (record.role === GlobalRoles.ADMIN) { return ( Admins have full access to all projects. @@ -244,7 +245,7 @@ const MembersManagement: React.FC = observer(() => { dataIndex: 'role', width: 160, render: (_value: GlobalRole, record: UserListItemWithProjects) => - record.role === 'ADMIN' ? ( + record.role === GlobalRoles.ADMIN ? ( {record.role} ) : ( reviewStore.setStatusFilter(v)} style={{ width: 160 }} > All @@ -186,13 +174,14 @@ const ProjectReview: React.FC = observer(() => { Rejected - {annotationsTotal} annotation{annotationsTotal !== 1 ? 's' : ''} + {reviewStore.annotationsTotal} annotation + {reviewStore.annotationsTotal !== 1 ? 's' : ''} - {selectedRowKeys.length > 0 && ( + {reviewStore.selectedRowKeys.length > 0 && ( - {selectedRowKeys.length} selected + {reviewStore.selectedRowKeys.length} selected
reviewStore.setSelectedRowKeys(keys), getCheckboxProps: (record: AnnotationListItem) => ({ disabled: record.status !== AnnotationStatuses.SUBMITTED, }), diff --git a/frontend/src/pages/ProjectSettings.tsx b/frontend/src/pages/ProjectSettings.tsx index 86843d3..5c417ca 100644 --- a/frontend/src/pages/ProjectSettings.tsx +++ b/frontend/src/pages/ProjectSettings.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { useParams, useNavigate } from 'react-router'; import { Typography, @@ -30,14 +30,14 @@ import { import { observer } from 'mobx-react-lite'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { authStore } from '../store/authStore'; -import { useProjectRole } from '../contexts/ProjectContext'; +import { projectRoleStore } from '../store/projectRoleStore'; +import { projectSettingsStore } from '../store/projectSettingsStore'; import api from '../api/client'; import { LANGUAGE_OPTIONS } from '../constants/languages'; import type { ProjectConfig, ProjectMember, RoleProject, - UserListItem, Project, } from '../types/api'; @@ -53,16 +53,11 @@ const ProjectSettings: React.FC = observer(() => { const navigate = useNavigate(); const { message, modal } = App.useApp(); const { token: themeToken } = theme.useToken(); - const { isManager: isProjectManager } = useProjectRole(); - const canManage = isProjectManager || authStore.isAdmin; + const canManage = projectRoleStore.canManageProject; const queryClient = useQueryClient(); - const [uploading, setUploading] = useState(false); const [replaceOnImport, setReplaceOnImport] = useState(false); - // Members state - const [members, setMembers] = useState([]); - const [allUsers, setAllUsers] = useState([]); - const [membersLoading, setMembersLoading] = useState(false); + // Members modal state const [openAddMember, setOpenAddMember] = useState(false); const [addForm] = Form.useForm(); @@ -70,20 +65,10 @@ const ProjectSettings: React.FC = observer(() => { const [newLabel, setNewLabel] = useState(''); const [autoApproveThreshold, setAutoApproveThreshold] = useState(0); - // Import preview state - const [importPreview, setImportPreview] = useState<{ - total_tasks: number; - with_entities: number; - entities_count: number; - sample: { - text: string; - id?: string; - key?: string; - entities_count?: number; - }[]; - filename: string; - } | null>(null); - const [pendingFile, setPendingFile] = useState(null); + // Reset store on unmount + useEffect(() => { + return () => projectSettingsStore.reset(); + }, []); // Fetch project const { data: project, refetch: refetchProject } = useQuery({ @@ -94,25 +79,8 @@ const ProjectSettings: React.FC = observer(() => { }); const handleFilePreview = async (file: File) => { - const formData = new FormData(); - formData.append('file', file); try { - const res = await api.post<{ - total_tasks: number; - with_entities: number; - entities_count: number; - sample: { - text: string; - id?: string; - key?: string; - entities_count?: number; - }[]; - filename: string; - }>(`/projects/${String(projectId)}/import-preview`, formData, { - headers: { 'Content-Type': 'multipart/form-data' }, - }); - setImportPreview(res.data); - setPendingFile(file); + await projectSettingsStore.importPreviewFile(String(projectId), file); } catch (err) { const errObj = err as { response?: { data?: { detail?: string } } }; const detail = errObj.response?.data?.detail; @@ -122,83 +90,39 @@ const ProjectSettings: React.FC = observer(() => { }; const handleConfirmImport = async () => { - if (!pendingFile) return; - const formData = new FormData(); - formData.append('file', pendingFile); - setUploading(true); try { - const url = replaceOnImport - ? `/projects/${String(projectId)}/import?replace=true` - : `/projects/${String(projectId)}/import`; - const response = await api.post<{ count: number }>(url, formData, { - headers: { 'Content-Type': 'multipart/form-data' }, - }); - const count = response.data.count; + const count = await projectSettingsStore.confirmImport( + String(projectId), + replaceOnImport + ); message.success(`Import successful! ${String(count)} tasks imported.`); - setImportPreview(null); - setPendingFile(null); } catch (err) { const errObj = err as { response?: { data?: { detail?: string } } }; const detail = errObj.response?.data?.detail; message.error(detail ?? 'Import failed'); - } finally { - setUploading(false); } }; const handleExport = async (format: 'json' | 'ini') => { try { - const response = await api.get( - `/projects/${String(projectId)}/export?format=${format}`, - { responseType: 'blob' } - ); - const url = window.URL.createObjectURL(new Blob([response.data])); - const link = document.createElement('a'); - link.href = url; - link.setAttribute( - 'download', - format === 'json' ? 'export.json' : 'export.ini' - ); - document.body.appendChild(link); - link.click(); - link.remove(); + await projectSettingsStore.exportData(String(projectId), format); } catch { message.error('Export failed'); } }; - const fetchMembers = async () => { - setMembersLoading(true); - try { - const res = await api.get( - `/projects/${String(projectId)}/members` - ); - setMembers(res.data); - } catch { - message.error('Failed to load members'); - } finally { - setMembersLoading(false); - } - }; - - const fetchUsers = async () => { - try { - const res = await api.get('/users'); - setAllUsers(res.data); - } catch { - /* only admins */ - } - }; - const handleAddMember = async (values: { user_id: number; role: RoleProject; }) => { try { - await api.post(`/projects/${String(projectId)}/members`, values); + await projectSettingsStore.addMember( + String(projectId), + values.user_id, + values.role + ); setOpenAddMember(false); addForm.resetFields(); - void fetchMembers(); } catch { message.error('Failed to add member'); } @@ -206,10 +130,7 @@ const ProjectSettings: React.FC = observer(() => { const handleRemoveMember = async (userId: number) => { try { - await api.delete( - `/projects/${String(projectId)}/members/${String(userId)}` - ); - void fetchMembers(); + await projectSettingsStore.removeMember(String(projectId), userId); } catch { message.error('Failed to remove member'); } @@ -217,11 +138,7 @@ const ProjectSettings: React.FC = observer(() => { const handleChangeRole = async (userId: number, role: RoleProject) => { try { - await api.patch( - `/projects/${String(projectId)}/members/${String(userId)}`, - { role } - ); - void fetchMembers(); + await projectSettingsStore.changeRole(String(projectId), userId, role); } catch { message.error('Failed to update role'); } @@ -291,9 +208,7 @@ const ProjectSettings: React.FC = observer(() => { void updateProjectConfig({ ...project?.config, labels: current }); }; - const availableUsers = allUsers.filter( - (u) => !members.some((m) => m.user_id === u.id) - ); + const { importPreview, uploading } = projectSettingsStore; const tabItems = [ // --- General --- @@ -531,7 +446,7 @@ const ProjectSettings: React.FC = observer(() => { > Upload INI files for Translation or JSON for NER. - {isProjectManager && !authStore.isAdmin && ( + {projectRoleStore.isManager && !authStore.isAdmin && ( { { - setImportPreview(null); - setPendingFile(null); - }} + onCancel={() => projectSettingsStore.clearImportPreview()} onOk={() => void handleConfirmImport()} okText={`Import ${String(importPreview?.total_tasks ?? 0)} tasks`} confirmLoading={uploading} @@ -724,7 +636,7 @@ const ProjectSettings: React.FC = observer(() => { type="primary" icon={} onClick={() => { - void fetchUsers(); + void projectSettingsStore.fetchUsers(); setOpenAddMember(true); }} > @@ -732,9 +644,9 @@ const ProjectSettings: React.FC = observer(() => {
{ rules={[{ required: true }]} > workspaceStore.setStatusFilter(v)} style={{ width: 110 }} options={[ { value: '', label: 'All' }, @@ -304,20 +260,20 @@ const Workspace: React.FC = observer(() => { ) : ( filteredTasks.map((task) => { const status = taskStatus(task.annotation_count, task.has_final); - const isSelected = task.id === selectedTaskId; + const isSelected = task.id === workspaceStore.selectedTaskId; return (
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); - setSelectedTaskId(task.id); + workspaceStore.selectTask(task.id); } }} - onClick={() => setSelectedTaskId(task.id)} + onClick={() => workspaceStore.selectTask(task.id)} style={{ padding: '10px 16px', cursor: 'pointer', @@ -395,7 +351,7 @@ const Workspace: React.FC = observer(() => {
{/* Pagination */} - {totalTasks > pageSize && ( + {workspaceStore.totalTasks > pageSize && (
{ > workspaceStore.setPage(p)} size="small" />
@@ -476,7 +432,7 @@ const Workspace: React.FC = observer(() => { type="text" icon={} size="small" - onClick={handlePrev} + onClick={() => workspaceStore.selectPrev()} /> @@ -487,7 +443,7 @@ const Workspace: React.FC = observer(() => { type="text" icon={} size="small" - onClick={handleNext} + onClick={() => workspaceStore.selectNext()} /> {!isNarrow && ( @@ -581,7 +537,6 @@ const Workspace: React.FC = observer(() => { } // --- Desktop layout: 3 columns --- - // DEBUG: only task list panel return (
{taskListOpen && taskListPanel} diff --git a/frontend/src/routes/ManagerRoute.tsx b/frontend/src/routes/ManagerRoute.tsx index 82bfe1f..e3b1daf 100644 --- a/frontend/src/routes/ManagerRoute.tsx +++ b/frontend/src/routes/ManagerRoute.tsx @@ -1,18 +1,17 @@ import { Navigate, Outlet } from 'react-router'; import { useParams } from 'react-router'; import { observer } from 'mobx-react-lite'; -import { useProjectRole } from '../contexts/ProjectContext'; +import { projectRoleStore } from '../store/projectRoleStore'; import { authStore } from '../store/authStore'; /** * Route guard that only allows MANAGER role or global ADMIN. - * Must be nested inside ProjectRoute (requires ProjectRoleProvider). + * Must be nested inside ProjectRoute. */ const ManagerRoute: React.FC = observer(() => { const { projectId } = useParams(); - const { isManager } = useProjectRole(); - if (!isManager && !authStore.isAdmin) { + if (!projectRoleStore.isManager && !authStore.isAdmin) { return ; } diff --git a/frontend/src/routes/ProjectRoute.tsx b/frontend/src/routes/ProjectRoute.tsx index 28e6ed7..180ca20 100644 --- a/frontend/src/routes/ProjectRoute.tsx +++ b/frontend/src/routes/ProjectRoute.tsx @@ -1,9 +1,10 @@ +import { useEffect } from 'react'; import { useParams, Outlet, Navigate } from 'react-router'; import { useQuery } from '@tanstack/react-query'; import { observer } from 'mobx-react-lite'; import { Skeleton } from 'antd'; import api from '../api/client'; -import { ProjectRoleProvider } from '../contexts/ProjectContext'; +import { projectRoleStore } from '../store/projectRoleStore'; import type { ProjectRoleInfo } from '../types/api'; const ProjectRoute: React.FC = observer(() => { @@ -19,6 +20,15 @@ const ProjectRoute: React.FC = observer(() => { retry: false, }); + useEffect(() => { + if (data) { + projectRoleStore.setRole(data.role); + } + return () => { + projectRoleStore.clear(); + }; + }, [data]); + if (isLoading) { return (
@@ -31,11 +41,7 @@ const ProjectRoute: React.FC = observer(() => { return ; } - return ( - - - - ); + return ; }); export default ProjectRoute; diff --git a/frontend/src/store/authStore.ts b/frontend/src/store/authStore.ts index 187cab7..f8a51d8 100644 --- a/frontend/src/store/authStore.ts +++ b/frontend/src/store/authStore.ts @@ -6,6 +6,8 @@ import { isSetupStatusResponse, isUserProfile, type UserProfile, + type SetupStatusResponse, + type TokenResponse, } from '../types/api'; import { GlobalRoles } from '../types/domain'; @@ -23,58 +25,67 @@ class AuthStore { } } - private setLoading(v: boolean) { - this.isLoading = v; + setLoading(value: boolean) { + this.isLoading = value; } - private setAuthenticated(token: string, refreshToken?: string) { - localStorage.setItem('authToken', token); - if (refreshToken) { - localStorage.setItem('refreshToken', refreshToken); - } - this.isAuthenticated = true; + setIsSetup(value: boolean) { + this.isSetup = value; } - private setUser(user: UserProfile | null) { - this.user = user; + setAuthenticated(value: boolean) { + this.isAuthenticated = value; } - private setSetup(v: boolean) { - this.isSetup = v; + setAuthData(user: UserProfile) { + this.user = user; + this.isAuthenticated = true; } - logout() { + logout = () => { localStorage.removeItem('authToken'); localStorage.removeItem('refreshToken'); this.isAuthenticated = false; this.user = null; - } + }; - async checkSetupStatus() { + checkSetupStatus = async () => { try { - const res = await api.get('/auth/setup-status'); - if (!isSetupStatusResponse(res.data)) return; - this.setSetup(res.data.is_setup); + const res = await api.get('/auth/setup-status'); + if (isSetupStatusResponse(res.data)) { + this.setIsSetup(res.data.is_setup); + } } catch (e) { console.error('Failed to check setup status', e); } + }; + + private handleTokenResponse(data: TokenResponse) { + const { access_token, refresh_token } = data; + localStorage.setItem('authToken', access_token); + if (refresh_token) { + localStorage.setItem('refreshToken', refresh_token); + } + this.setAuthenticated(true); } - async setup( + setup = async ( email: string, password: string, fullName: string - ): Promise { + ): Promise => { this.setLoading(true); try { - const res = await api.post('/auth/setup', { + const res = await api.post('/auth/setup', { email, password, full_name: fullName, }); if (!isTokenResponse(res.data)) return 'Invalid server response.'; - this.setAuthenticated(res.data.access_token, res.data.refresh_token); - this.setSetup(true); + + this.handleTokenResponse(res.data); + this.setIsSetup(true); + await this.fetchProfile(); return null; } catch (e) { @@ -82,21 +93,22 @@ class AuthStore { } finally { this.setLoading(false); } - } + }; - async login(email: string, password: string): Promise { + login = async (email: string, password: string): Promise => { this.setLoading(true); try { const params = new URLSearchParams(); params.append('username', email); params.append('password', password); - const res = await api.post('/auth/login', params, { + const res = await api.post('/auth/login', params, { headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, }); if (!isTokenResponse(res.data)) return 'Invalid server response.'; - this.setAuthenticated(res.data.access_token, res.data.refresh_token); + + this.handleTokenResponse(res.data); await this.fetchProfile(); return null; } catch (e) { @@ -104,17 +116,17 @@ class AuthStore { } finally { this.setLoading(false); } - } + }; - async register( + register = async ( email: string, password: string, name: string, invitationToken: string - ): Promise { + ): Promise => { this.setLoading(true); try { - const res = await api.post('/auth/register', { + const res = await api.post('/auth/register', { email, password, full_name: name, @@ -122,7 +134,8 @@ class AuthStore { }); if (!isTokenResponse(res.data)) return 'Invalid server response.'; - this.setAuthenticated(res.data.access_token, res.data.refresh_token); + + this.handleTokenResponse(res.data); await this.fetchProfile(); return null; } catch (e) { @@ -130,21 +143,21 @@ class AuthStore { } finally { this.setLoading(false); } - } + }; - async fetchProfile() { + fetchProfile = async () => { try { - const res = await api.get('/users/me'); - if (!isUserProfile(res.data)) { + const res = await api.get('/users/me'); + if (isUserProfile(res.data)) { + this.setAuthData(res.data); + } else { this.logout(); - return; } - this.setUser(res.data); } catch (e) { console.error('Failed to fetch profile', e); this.logout(); } - } + }; get isAdmin() { return this.user?.role === GlobalRoles.ADMIN; diff --git a/frontend/src/store/nerEditorStore.ts b/frontend/src/store/nerEditorStore.ts new file mode 100644 index 0000000..af0b372 --- /dev/null +++ b/frontend/src/store/nerEditorStore.ts @@ -0,0 +1,200 @@ +import { makeAutoObservable } from 'mobx'; +import type { NEREntity } from '../types/api'; + +interface HistoryState { + entities: NEREntity[]; +} + +class NEREditorStore { + entities: NEREntity[] = []; + selectedLabel = ''; + labelSearch = ''; + history: HistoryState[] = [{ entities: [] }]; + historyIndex = 0; + isDragging = false; + dragStart: number | null = null; + dragEnd: number | null = null; + + constructor() { + makeAutoObservable(this); + } + + initForTask = (preEntities: NEREntity[], labels: string[]) => { + const initial = preEntities.length > 0 ? [...preEntities] : []; + this.entities = initial; + this.history = [{ entities: initial }]; + this.historyIndex = 0; + this.selectedLabel = labels[0] ?? ''; + this.labelSearch = ''; + this.isDragging = false; + this.dragStart = null; + this.dragEnd = null; + }; + + pushHistory = (newEntities: NEREntity[]) => { + this.history = [ + ...this.history.slice(0, this.historyIndex + 1), + { entities: newEntities }, + ]; + this.historyIndex += 1; + this.entities = newEntities; + }; + + undo = () => { + if (this.historyIndex > 0) { + this.historyIndex -= 1; + this.entities = this.history[this.historyIndex].entities; + } + }; + + redo = () => { + if (this.historyIndex < this.history.length - 1) { + this.historyIndex += 1; + this.entities = this.history[this.historyIndex].entities; + } + }; + + startDrag = (idx: number) => { + this.isDragging = true; + this.dragStart = idx; + this.dragEnd = idx; + }; + + updateDrag = (idx: number) => { + if (this.isDragging) { + this.dragEnd = idx; + } + }; + + endDrag = (text: string, tokenOffsets: { start: number; end: number }[]) => { + if (!this.isDragging || this.dragStart === null || this.dragEnd === null) { + this.isDragging = false; + return; + } + this.isDragging = false; + + const start = Math.min(this.dragStart, this.dragEnd); + const end = Math.max(this.dragStart, this.dragEnd); + + // Single click on existing entity -> remove it + if (start === end) { + const charStart = tokenOffsets[start].start; + const charEnd = tokenOffsets[start].end; + const existing = this.entities.findIndex( + (e) => e.start <= charStart && e.end >= charEnd + ); + if (existing >= 0) { + this.pushHistory(this.entities.filter((_, i) => i !== existing)); + this.dragStart = null; + this.dragEnd = null; + return; + } + } + + const charStart = tokenOffsets[start].start; + const charEnd = tokenOffsets[end].end; + const selectedText = text.slice(charStart, charEnd).trim(); + + if (!selectedText) { + this.dragStart = null; + this.dragEnd = null; + return; + } + + const trimmedStart = + charStart + + (text.slice(charStart, charEnd).length - + text.slice(charStart, charEnd).trimStart().length); + const trimmedEnd = + charEnd - + (text.slice(charStart, charEnd).length - + text.slice(charStart, charEnd).trimEnd().length); + + const filtered = this.entities.filter( + (e) => e.end <= trimmedStart || e.start >= trimmedEnd + ); + + this.pushHistory([ + ...filtered, + { + start: trimmedStart, + end: trimmedEnd, + label: this.selectedLabel, + text: selectedText, + }, + ]); + + this.dragStart = null; + this.dragEnd = null; + }; + + removeEntity = (entity: NEREntity) => { + this.pushHistory( + this.entities.filter( + (e) => !(e.start === entity.start && e.end === entity.end) + ) + ); + }; + + changeEntityLabel = (entity: NEREntity, newLabel: string) => { + this.pushHistory( + this.entities.map((e) => + e.start === entity.start && e.end === entity.end + ? { ...e, label: newLabel } + : e + ) + ); + }; + + acceptAllPre = (preEntities: NEREntity[]) => { + if (preEntities.length > 0) { + this.pushHistory([...preEntities]); + } + }; + + setSelectedLabel = (label: string) => { + this.selectedLabel = label; + }; + + setLabelSearch = (text: string) => { + this.labelSearch = text; + }; + + handleKeyboardShortcut = (e: React.KeyboardEvent, labels: string[]) => { + if (e.target instanceof HTMLInputElement) return; + if (e.ctrlKey && e.key === 'z' && !e.shiftKey) { + e.preventDefault(); + this.undo(); + return; + } + if (e.ctrlKey && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) { + e.preventDefault(); + this.redo(); + return; + } + const num = parseInt(e.key); + if (num >= 1 && num <= labels.length) { + this.setSelectedLabel(labels[num - 1]); + } + }; + + get canUndo(): boolean { + return this.historyIndex > 0; + } + + get canRedo(): boolean { + return this.historyIndex < this.history.length - 1; + } + + get dragRange(): { start: number; end: number } | null { + if (this.isDragging && this.dragStart !== null && this.dragEnd !== null) { + return { + start: Math.min(this.dragStart, this.dragEnd), + end: Math.max(this.dragStart, this.dragEnd), + }; + } + return null; + } +} + +export const nerEditorStore = new NEREditorStore(); diff --git a/frontend/src/store/notificationStore.ts b/frontend/src/store/notificationStore.ts new file mode 100644 index 0000000..4b70e07 --- /dev/null +++ b/frontend/src/store/notificationStore.ts @@ -0,0 +1,68 @@ +import { makeAutoObservable } from 'mobx'; +import api from '../api/client'; +import type { Notification, NotificationListResponse } from '../types/api'; + +class NotificationStore { + notifications: Notification[] = []; + unreadCount = 0; + private intervalId: ReturnType | null = null; + + constructor() { + makeAutoObservable(this); + } + + setNotificationData(items: Notification[], unreadCount: number) { + this.notifications = items; + this.unreadCount = unreadCount; + } + + setUnreadCount(count: number) { + this.unreadCount = count; + } + + setNotifications(items: Notification[]) { + this.notifications = items; + } + + fetchNotifications = async () => { + try { + const res = await api.get( + '/notifications?page_size=10' + ); + this.setNotificationData(res.data.items, res.data.unread_count); + } catch { + // silent + } + }; + + markAllRead = async () => { + try { + await api.patch('/notifications/read-all'); + this.setUnreadCount(0); + this.setNotifications( + this.notifications.map((n) => ({ + ...n, + is_read: true, + })) + ); + } catch { + // silent + } + }; + + startPolling = () => { + void this.fetchNotifications(); + this.intervalId = setInterval(() => { + void this.fetchNotifications(); + }, 30000); + }; + + stopPolling = () => { + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = null; + } + }; +} + +export const notificationStore = new NotificationStore(); diff --git a/frontend/src/store/projectRoleStore.ts b/frontend/src/store/projectRoleStore.ts new file mode 100644 index 0000000..d9bcd7f --- /dev/null +++ b/frontend/src/store/projectRoleStore.ts @@ -0,0 +1,44 @@ +import { makeAutoObservable } from 'mobx'; +import type { RoleProject, GlobalRole } from '../types/domain'; +import { isAdmin, isManagerOrAdmin, ProjectRoles } from '../types/domain'; +import { authStore } from './authStore'; + +class ProjectRoleStore { + role: RoleProject | GlobalRole | null = null; + isLoading = false; + + constructor() { + makeAutoObservable(this); + } + + setRole = (role: RoleProject | GlobalRole) => { + this.role = role; + }; + + setLoading = (v: boolean) => { + this.isLoading = v; + }; + + clear = () => { + this.role = null; + this.isLoading = false; + }; + + get isManager(): boolean { + return isManagerOrAdmin(this.role); + } + + get isEditor(): boolean { + return this.role === ProjectRoles.EDITOR; + } + + get isAdmin(): boolean { + return isAdmin(this.role); + } + + get canManageProject(): boolean { + return this.isManager || authStore.isAdmin; + } +} + +export const projectRoleStore = new ProjectRoleStore(); diff --git a/frontend/src/store/projectSettingsStore.ts b/frontend/src/store/projectSettingsStore.ts new file mode 100644 index 0000000..67d1301 --- /dev/null +++ b/frontend/src/store/projectSettingsStore.ts @@ -0,0 +1,163 @@ +import { makeAutoObservable } from 'mobx'; +import api from '../api/client'; +import type { ProjectMember, UserListItem, RoleProject } from '../types/api'; + +interface ImportPreviewData { + total_tasks: number; + with_entities: number; + entities_count: number; + sample: { + text: string; + id?: string; + key?: string; + entities_count?: number; + }[]; + filename: string; +} + +class ProjectSettingsStore { + members: ProjectMember[] = []; + allUsers: UserListItem[] = []; + membersLoading = false; + importPreview: ImportPreviewData | null = null; + pendingFile: File | null = null; + uploading = false; + + constructor() { + makeAutoObservable(this); + } + + get availableUsers(): UserListItem[] { + return this.allUsers.filter( + (u) => !this.members.some((m) => m.user_id === u.id) + ); + } + + setMembers(members: ProjectMember[]) { + this.members = members; + } + + setMembersLoading(value: boolean) { + this.membersLoading = value; + } + + setAllUsers(users: UserListItem[]) { + this.allUsers = users; + } + + setImportPreview( + preview: ImportPreviewData | null, + file: File | null = null + ) { + this.importPreview = preview; + this.pendingFile = file; + } + + setUploading(value: boolean) { + this.uploading = value; + } + + fetchMembers = async (projectId: string) => { + this.setMembersLoading(true); + try { + const res = await api.get( + `/projects/${projectId}/members` + ); + this.setMembers(res.data); + } finally { + this.setMembersLoading(false); + } + }; + + fetchUsers = async () => { + try { + const res = await api.get('/users'); + this.setAllUsers(res.data); + } catch { + /* only admins */ + } + }; + + addMember = async (projectId: string, userId: number, role: RoleProject) => { + await api.post(`/projects/${projectId}/members`, { + user_id: userId, + role, + }); + await this.fetchMembers(projectId); + }; + + removeMember = async (projectId: string, userId: number) => { + await api.delete(`/projects/${projectId}/members/${String(userId)}`); + await this.fetchMembers(projectId); + }; + + changeRole = async (projectId: string, userId: number, role: RoleProject) => { + await api.patch(`/projects/${projectId}/members/${String(userId)}`, { + role, + }); + await this.fetchMembers(projectId); + }; + + importPreviewFile = async (projectId: string, file: File) => { + const formData = new FormData(); + formData.append('file', file); + const res = await api.post( + `/projects/${projectId}/import-preview`, + formData, + { headers: { 'Content-Type': 'multipart/form-data' } } + ); + this.setImportPreview(res.data, file); + }; + + confirmImport = async (projectId: string, replace: boolean) => { + if (!this.pendingFile) return 0; + const formData = new FormData(); + formData.append('file', this.pendingFile); + this.setUploading(true); + try { + const url = replace + ? `/projects/${projectId}/import?replace=true` + : `/projects/${projectId}/import`; + const response = await api.post<{ count: number }>(url, formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + this.setImportPreview(null, null); + return response.data.count; + } finally { + this.setUploading(false); + } + }; + + exportData = async (projectId: string, format: 'json' | 'ini') => { + const response = await api.get( + `/projects/${projectId}/export?format=${format}`, + { responseType: 'blob' } + ); + const url = window.URL.createObjectURL(new Blob([response.data])); + const link = document.createElement('a'); + link.href = url; + link.setAttribute( + 'download', + format === 'json' ? 'export.json' : 'export.ini' + ); + document.body.appendChild(link); + link.click(); + link.remove(); + }; + + clearImportPreview = () => { + this.importPreview = null; + this.pendingFile = null; + }; + + reset = () => { + this.members = []; + this.allUsers = []; + this.membersLoading = false; + this.importPreview = null; + this.pendingFile = null; + this.uploading = false; + }; +} + +export const projectSettingsStore = new ProjectSettingsStore(); diff --git a/frontend/src/store/reviewStore.ts b/frontend/src/store/reviewStore.ts new file mode 100644 index 0000000..45217f0 --- /dev/null +++ b/frontend/src/store/reviewStore.ts @@ -0,0 +1,96 @@ +import { makeAutoObservable } from 'mobx'; +import api from '../api/client'; +import type { + AnnotationListItem, + AnnotationListResponse, + AnnotationStatus, +} from '../types/api'; +import { AnnotationStatuses } from '../types/domain'; + +class ReviewStore { + annotations: AnnotationListItem[] = []; + annotationsTotal = 0; + isLoading = false; + statusFilter: AnnotationStatus | '' = AnnotationStatuses.SUBMITTED; + selectedRowKeys: React.Key[] = []; + + constructor() { + makeAutoObservable(this); + } + + setAnnotationData(items: AnnotationListItem[], total: number) { + this.annotations = items; + this.annotationsTotal = total; + } + + setLoading(value: boolean) { + this.isLoading = value; + } + + clearSelectedKeys() { + this.selectedRowKeys = []; + } + + removeSelectedKey(id: number) { + this.selectedRowKeys = this.selectedRowKeys.filter((k) => k !== id); + } + + fetchAnnotations = async (projectId: string) => { + this.setLoading(true); + try { + const params = this.statusFilter ? `?status=${this.statusFilter}` : ''; + const res = await api.get( + `/projects/${projectId}/annotations${params}` + ); + this.setAnnotationData(res.data.items, res.data.total); + } catch { + // error handling done in component via message + throw new Error('Failed to load annotations'); + } finally { + this.setLoading(false); + } + }; + + setStatusFilter = (f: AnnotationStatus | '') => { + this.statusFilter = f; + }; + + reviewAnnotation = async ( + id: number, + status: 'APPROVED' | 'REJECTED', + projectId: string, + reviewNote?: string + ) => { + await api.post(`/annotations/${String(id)}/review`, { + status, + review_note: reviewNote ?? null, + }); + this.removeSelectedKey(id); + await this.fetchAnnotations(projectId); + }; + + batchReview = async (status: 'APPROVED' | 'REJECTED', projectId: string) => { + for (const id of this.selectedRowKeys) { + await api.post(`/annotations/${String(id as number)}/review`, { + status, + review_note: null, + }); + } + this.clearSelectedKeys(); + await this.fetchAnnotations(projectId); + }; + + setSelectedRowKeys = (keys: React.Key[]) => { + this.selectedRowKeys = keys; + }; + + reset = () => { + this.annotations = []; + this.annotationsTotal = 0; + this.isLoading = false; + this.statusFilter = AnnotationStatuses.SUBMITTED; + this.selectedRowKeys = []; + }; +} + +export const reviewStore = new ReviewStore(); diff --git a/frontend/src/store/themeStore.ts b/frontend/src/store/themeStore.ts index c6e3a9f..77f84e8 100644 --- a/frontend/src/store/themeStore.ts +++ b/frontend/src/store/themeStore.ts @@ -10,13 +10,17 @@ class ThemeStore { makeAutoObservable(this); } - setMode(mode: ThemeMode) { + setMode = (mode: ThemeMode) => { this.mode = mode; localStorage.setItem('themeMode', mode); - } + }; - toggle() { + toggle = () => { this.setMode(this.mode === 'dark' ? 'light' : 'dark'); + }; + + get resolvedMode(): ThemeMode { + return this.mode; } } diff --git a/frontend/src/store/workspaceStore.ts b/frontend/src/store/workspaceStore.ts new file mode 100644 index 0000000..f4b41d3 --- /dev/null +++ b/frontend/src/store/workspaceStore.ts @@ -0,0 +1,122 @@ +import { makeAutoObservable } from 'mobx'; +import type { QueryClient } from '@tanstack/react-query'; +import api from '../api/client'; +import { getTaskText, type TaskListItem } from '../types/api'; +import type { TaskStatusFilter } from '../types/domain'; + +class WorkspaceStore { + selectedTaskId: number | null = null; + page = 1; + search = ''; + statusFilter: TaskStatusFilter = ''; + tasks: TaskListItem[] = []; + totalTasks = 0; + + constructor() { + makeAutoObservable(this); + } + + setTasks = (items: TaskListItem[], total: number) => { + this.tasks = items; + this.totalTasks = total; + }; + + selectTask = (id: number) => { + this.selectedTaskId = id; + }; + + selectPrev = () => { + const filtered = this.filteredTasks; + const idx = filtered.findIndex((t) => t.id === this.selectedTaskId); + if (idx > 0) this.selectedTaskId = filtered[idx - 1].id; + }; + + selectNext = () => { + const filtered = this.filteredTasks; + const idx = filtered.findIndex((t) => t.id === this.selectedTaskId); + if (idx >= 0 && idx < filtered.length - 1) { + this.selectedTaskId = filtered[idx + 1].id; + } + }; + + autoSelectFirst = () => { + if (this.filteredTasks.length > 0 && !this.selectedTaskId) { + this.selectedTaskId = this.filteredTasks[0].id; + } + }; + + setSearch = (text: string) => { + this.search = text; + }; + + setStatusFilter = (f: TaskStatusFilter) => { + this.statusFilter = f; + }; + + setPage = (p: number) => { + this.page = p; + }; + + submitAnnotation = async ( + result: unknown, + queryClient: QueryClient, + projectId: string, + sendSubmitted: (taskId: number) => void + ) => { + const task = this.selectedTask; + if (!task) return; + await api.post(`/tasks/${String(task.id)}/annotations`, { result }); + await api + .post(`/annotations/${String(task.id)}/submit`) + .catch(() => undefined); + sendSubmitted(task.id); + void queryClient.invalidateQueries({ + queryKey: ['workspace-tasks', projectId], + }); + // Move to next task + this.selectNext(); + }; + + reset = () => { + this.selectedTaskId = null; + this.page = 1; + this.search = ''; + this.statusFilter = ''; + this.tasks = []; + this.totalTasks = 0; + }; + + get filteredTasks(): TaskListItem[] { + let result = this.tasks; + if (this.search) { + const q = this.search.toLowerCase(); + result = result.filter((t) => { + const text = getTaskText(t.data); + return text.toLowerCase().includes(q) || String(t.id).includes(q); + }); + } + if (this.statusFilter === 'pending') + result = result.filter((t) => t.annotation_count === 0); + else if (this.statusFilter === 'in_progress') + result = result.filter((t) => t.annotation_count > 0 && !t.has_final); + else if (this.statusFilter === 'final') + result = result.filter((t) => t.has_final); + return result; + } + + get selectedTask(): TaskListItem | null { + return this.tasks.find((t) => t.id === this.selectedTaskId) ?? null; + } + + get completedCount(): number { + return this.tasks.filter((t) => t.has_final).length; + } + + get progressPercent(): number { + return this.totalTasks > 0 + ? Math.round((this.completedCount / this.totalTasks) * 100) + : 0; + } +} + +export const workspaceStore = new WorkspaceStore(); From 281a3b6bae2f472c40ac3c03b2f64e48827cd4a8 Mon Sep 17 00:00:00 2001 From: mvoof Date: Thu, 19 Mar 2026 12:17:56 +0500 Subject: [PATCH 30/48] chore: update antd to version 6.3.3 and successfully resolved the build error in CommandPalette.tsx by renaming styles.content to styles.container --- frontend/package-lock.json | 1515 +++++++++----------- frontend/package.json | 2 +- frontend/src/components/CommandPalette.tsx | 2 +- 3 files changed, 694 insertions(+), 825 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5dc578a..accaf15 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,9 +10,8 @@ "dependencies": { "@ant-design/charts": "^2.6.7", "@ant-design/icons": "^6.1.0", - "@playwright/test": "^1.58.2", "@tanstack/react-query": "^5.90.21", - "antd": "^5.29.3", + "antd": "^6.3.3", "axios": "^1.13.2", "framer-motion": "^12.36.0", "mobx": "^6.15.0", @@ -24,6 +23,7 @@ }, "devDependencies": { "@eslint/js": "^9.36.0", + "@playwright/test": "^1.58.2", "@types/eslint-plugin-mobx": "^0.0.0", "@types/node": "^24.6.0", "@types/react": "^19.1.16", @@ -83,17 +83,17 @@ } }, "node_modules/@ant-design/cssinjs": { - "version": "1.24.0", - "resolved": "https://registry.npmjs.org/@ant-design/cssinjs/-/cssinjs-1.24.0.tgz", - "integrity": "sha512-K4cYrJBsgvL+IoozUXYjbT6LHHNt+19a9zkvpBPxLjFHas1UpPM2A5MlhROb0BT8N8WoavM5VsP9MeSeNK/3mg==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@ant-design/cssinjs/-/cssinjs-2.1.2.tgz", + "integrity": "sha512-2Hy8BnCEH31xPeSLbhhB2ctCPXE2ZnASdi+KbSeS79BNbUhL9hAEe20SkUk+BR8aKTmqb6+FKFruk7w8z0VoRQ==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.11.1", "@emotion/hash": "^0.8.0", "@emotion/unitless": "^0.7.5", - "classnames": "^2.3.1", + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1", "csstype": "^3.1.3", - "rc-util": "^5.35.0", "stylis": "^4.3.4" }, "peerDependencies": { @@ -102,18 +102,18 @@ } }, "node_modules/@ant-design/cssinjs-utils": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@ant-design/cssinjs-utils/-/cssinjs-utils-1.1.3.tgz", - "integrity": "sha512-nOoQMLW1l+xR1Co8NFVYiP8pZp3VjIIzqV6D6ShYF2ljtdwWJn5WSsH+7kvCktXL/yhEtWURKOfH5Xz/gzlwsg==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@ant-design/cssinjs-utils/-/cssinjs-utils-2.1.2.tgz", + "integrity": "sha512-5fTHQ158jJJ5dC/ECeyIdZUzKxE/mpEMRZxthyG1sw/AKRHKgJBg00Yi6ACVXgycdje7KahRNvNET/uBccwCnA==", "license": "MIT", "dependencies": { - "@ant-design/cssinjs": "^1.21.0", + "@ant-design/cssinjs": "^2.1.2", "@babel/runtime": "^7.23.2", - "rc-util": "^5.38.0" + "@rc-component/util": "^1.4.0" }, "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" + "react": ">=18", + "react-dom": ">=18" } }, "node_modules/@ant-design/fast-color": { @@ -200,19 +200,19 @@ } }, "node_modules/@ant-design/react-slick": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@ant-design/react-slick/-/react-slick-1.1.2.tgz", - "integrity": "sha512-EzlvzE6xQUBrZuuhSAFTdsr4P2bBBHGZwKFemEfq8gIGyIQCxalYfZW/T2ORbtQx5rU69o+WycP3exY/7T1hGA==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@ant-design/react-slick/-/react-slick-2.0.0.tgz", + "integrity": "sha512-HMS9sRoEmZey8LsE/Yo6+klhlzU12PisjrVcydW3So7RdklyEd2qehyU6a7Yp+OYN72mgsYs3NFCyP2lCPFVqg==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.10.4", - "classnames": "^2.2.5", + "@babel/runtime": "^7.28.4", + "clsx": "^2.1.1", "json2mq": "^0.2.0", - "resize-observer-polyfill": "^1.5.1", "throttle-debounce": "^5.0.0" }, "peerDependencies": { - "react": ">=16.9.0" + "react": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "node_modules/@antv/algorithm": { @@ -1665,6 +1665,7 @@ "version": "1.58.2", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, "license": "Apache-2.0", "dependencies": { "playwright": "1.58.2" @@ -1688,52 +1689,229 @@ "node": ">=14.x" } }, + "node_modules/@rc-component/cascader": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/@rc-component/cascader/-/cascader-1.14.0.tgz", + "integrity": "sha512-Ip9356xwZUR2nbW5PRVGif4B/bDve4pLa/N+PGbvBaTnjbvmN4PFMBGQSmlDlzKP1ovxaYMvwF/dI9lXNLT4iQ==", + "license": "MIT", + "dependencies": { + "@rc-component/select": "~1.6.0", + "@rc-component/tree": "~1.2.0", + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/checkbox": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@rc-component/checkbox/-/checkbox-2.0.0.tgz", + "integrity": "sha512-3CXGPpAR9gsPKeO2N78HAPOzU30UdemD6HGJoWVJOpa6WleaGB5kzZj3v6bdTZab31YuWgY/RxV3VKPctn0DwQ==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/collapse": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rc-component/collapse/-/collapse-1.2.0.tgz", + "integrity": "sha512-ZRYSKSS39qsFx93p26bde7JUZJshsUBEQRlRXPuJYlAiNX0vyYlF5TsAm8JZN3LcF8XvKikdzPbgAtXSbkLUkw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/motion": "^1.1.4", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, "node_modules/@rc-component/color-picker": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@rc-component/color-picker/-/color-picker-3.1.1.tgz", + "integrity": "sha512-OHaCHLHszCegdXmIq2ZRIZBN/EtpT6Wm8SG/gpzLATHbVKc/avvuKi+zlOuk05FTWvgaMmpxAko44uRJ3M+2pg==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^3.0.1", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/context": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@rc-component/color-picker/-/color-picker-2.0.1.tgz", - "integrity": "sha512-WcZYwAThV/b2GISQ8F+7650r5ZZJ043E57aVBFkQ+kSY4C6wdofXgB0hBx+GPGpIU0Z81eETNoDUJMr7oy/P8Q==", + "resolved": "https://registry.npmjs.org/@rc-component/context/-/context-2.0.1.tgz", + "integrity": "sha512-HyZbYm47s/YqtP6pKXNMjPEMaukyg7P0qVfgMLzr7YiFNMHbK2fKTAGzms9ykfGHSfyf75nBbgWw+hHkp+VImw==", "license": "MIT", "dependencies": { - "@ant-design/fast-color": "^2.0.6", - "@babel/runtime": "^7.23.6", - "classnames": "^2.2.6", - "rc-util": "^5.38.1" + "@rc-component/util": "^1.3.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, - "node_modules/@rc-component/color-picker/node_modules/@ant-design/fast-color": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@ant-design/fast-color/-/fast-color-2.0.6.tgz", - "integrity": "sha512-y2217gk4NqL35giHl72o6Zzqji9O7vHh9YmhUVkPtAOpoTCH4uWxo/pr4VE8t0+ChEPs0qo4eJRC5Q1eXWo3vA==", + "node_modules/@rc-component/dialog": { + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/@rc-component/dialog/-/dialog-1.8.4.tgz", + "integrity": "sha512-Ay6PM7phkTkquplG8fWfUGFZ2GTLx9diTl4f0d8Eqxd7W1u1KjE9AQooFQHOHnhZf0Ya3z51+5EKCWHmt/dNEw==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.24.7" + "@rc-component/motion": "^1.1.3", + "@rc-component/portal": "^2.1.0", + "@rc-component/util": "^1.9.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/drawer": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@rc-component/drawer/-/drawer-1.4.2.tgz", + "integrity": "sha512-1ib+fZEp6FBu+YvcIktm+nCQ+Q+qIpwpoaJH6opGr4ofh2QMq+qdr5DLC4oCf5qf3pcWX9lUWPYX652k4ini8Q==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.1.4", + "@rc-component/portal": "^2.1.3", + "@rc-component/util": "^1.9.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/dropdown": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rc-component/dropdown/-/dropdown-1.0.2.tgz", + "integrity": "sha512-6PY2ecUSYhDPhkNHHb4wfeAya04WhpmUSKzdR60G+kMNVUCX2vjT/AgTS0Lz0I/K6xrPMJ3enQbwVpeN3sHCgg==", + "license": "MIT", + "dependencies": { + "@rc-component/trigger": "^3.0.0", + "@rc-component/util": "^1.2.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.11.0", + "react-dom": ">=16.11.0" + } + }, + "node_modules/@rc-component/form": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@rc-component/form/-/form-1.7.2.tgz", + "integrity": "sha512-5C90rXH7aZvvvxB4M5ew+QxROvimdL/lqhSshR8NsyiR7HKOoGQYSitxdfENnH6/0KNFxEy2ranVe2LrTnHZIw==", + "license": "MIT", + "dependencies": { + "@rc-component/async-validator": "^5.1.0", + "@rc-component/util": "^1.6.2", + "clsx": "^2.1.1" }, "engines": { "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" } }, - "node_modules/@rc-component/context": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@rc-component/context/-/context-1.4.0.tgz", - "integrity": "sha512-kFcNxg9oLRMoL3qki0OMxK+7g5mypjgaaJp/pkOis/6rVxma9nJBF/8kCIuTYHUQNr0ii7MxqE33wirPZLJQ2w==", + "node_modules/@rc-component/image": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@rc-component/image/-/image-1.6.0.tgz", + "integrity": "sha512-tSfn2ZE/oP082g4QIOxeehkmgnXB7R+5AFj/lIFr4k7pEuxHBdyGIq9axoCY9qea8NN0DY6p4IB/F07tLqaT5A==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.10.1", - "rc-util": "^5.27.0" + "@rc-component/motion": "^1.0.0", + "@rc-component/portal": "^2.1.2", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, - "node_modules/@rc-component/mini-decimal": { + "node_modules/@rc-component/input": { "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@rc-component/mini-decimal/-/mini-decimal-1.1.2.tgz", - "integrity": "sha512-ZrT0tYKwTVOsvRjC5jwaqL36h4sEe57gh1r4cnmIteEpr4YtvhnQGeTL7yjtA1Z/+5LNY3t4gyohaYKq1Q/Clg==", + "resolved": "https://registry.npmjs.org/@rc-component/input/-/input-1.1.2.tgz", + "integrity": "sha512-Q61IMR47piUBudgixJ30CciKIy9b1H95qe7GgEKOmSJVJXvFRWJllJfQry9tif+MX2cWFXWJf/RXz4kaCeq/Fg==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@rc-component/input-number": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/@rc-component/input-number/-/input-number-1.6.2.tgz", + "integrity": "sha512-Gjcq7meZlCOiWN1t1xCC+7/s85humHVokTBI7PJgTfoyw5OWF74y3e6P8PHX104g9+b54jsodFIzyaj6p8LI9w==", + "license": "MIT", + "dependencies": { + "@rc-component/mini-decimal": "^1.0.1", + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/mentions": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@rc-component/mentions/-/mentions-1.6.0.tgz", + "integrity": "sha512-KIkQNP6habNuTsLhUv0UGEOwG67tlmE7KNIJoQZZNggEZl5lQJTytFDb69sl5CK3TDdISCTjKP3nGEBKgT61CQ==", + "license": "MIT", + "dependencies": { + "@rc-component/input": "~1.1.0", + "@rc-component/menu": "~1.2.0", + "@rc-component/textarea": "~1.1.0", + "@rc-component/trigger": "^3.0.0", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/menu": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rc-component/menu/-/menu-1.2.0.tgz", + "integrity": "sha512-VWwDuhvYHSnTGj4n6bV3ISrLACcPAzdPOq3d0BzkeiM5cve8BEYfvkEhNoM0PLzv51jpcejeyrLXeMVIJ+QJlg==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.1.4", + "@rc-component/overflow": "^1.0.0", + "@rc-component/trigger": "^3.0.0", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/mini-decimal": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rc-component/mini-decimal/-/mini-decimal-1.1.3.tgz", + "integrity": "sha512-bk/FJ09fLf+NLODMAFll6CfYrHPBioTedhW6lxDBuuWucJEqFUd4l/D/5JgIi3dina6sYahB8iuPAZTNz2pMxw==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.18.0" @@ -1742,15 +1920,27 @@ "node": ">=8.x" } }, + "node_modules/@rc-component/motion": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@rc-component/motion/-/motion-1.3.1.tgz", + "integrity": "sha512-Wo1mkd0tCcHtvYvpPOmlYJz546z16qlsiwaygmW7NPJpOZOF9GBjhGzdzZSsC2lEJ1IUkWLF4gMHlRA1aSA+Yw==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.2.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, "node_modules/@rc-component/mutate-observer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@rc-component/mutate-observer/-/mutate-observer-1.1.0.tgz", - "integrity": "sha512-QjrOsDXQusNwGZPf4/qRQasg7UFEj06XiCJ8iuiq/Io7CrHrgVi6Uuetw60WAMG1799v+aM8kyc+1L/GBbHSlw==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@rc-component/mutate-observer/-/mutate-observer-2.0.1.tgz", + "integrity": "sha512-AyarjoLU5YlxuValRi+w8JRH2Z84TBbFO2RoGWz9d8bSu0FqT8DtugH3xC3BV7mUwlmROFauyWuXFuq4IFbH+w==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.18.0", - "classnames": "^2.3.2", - "rc-util": "^5.24.4" + "@rc-component/util": "^1.2.0" }, "engines": { "node": ">=8.x" @@ -1760,15 +1950,15 @@ "react-dom": ">=16.9.0" } }, - "node_modules/@rc-component/portal": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@rc-component/portal/-/portal-1.1.2.tgz", - "integrity": "sha512-6f813C0IsasTZms08kfA8kPAGxbbkYToa8ALaiDIGGECU4i9hj8Plgbx0sNJDrey3EtHO30hmdaxtT0138xZcg==", + "node_modules/@rc-component/notification": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rc-component/notification/-/notification-1.2.0.tgz", + "integrity": "sha512-OX3J+zVU7rvoJCikjrfW7qOUp7zlDeFBK2eA3SFbGSkDqo63Sl4Ss8A04kFP+fxHSxMDIS9jYVEZtU1FNCFuBA==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.18.0", - "classnames": "^2.3.2", - "rc-util": "^5.24.4" + "@rc-component/motion": "^1.1.4", + "@rc-component/util": "^1.2.1", + "clsx": "^2.1.1" }, "engines": { "node": ">=8.x" @@ -1778,6 +1968,105 @@ "react-dom": ">=16.9.0" } }, + "node_modules/@rc-component/overflow": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rc-component/overflow/-/overflow-1.0.0.tgz", + "integrity": "sha512-GSlBeoE0XTBi5cf3zl8Qh7Uqhn7v8RrlJ8ajeVpEkNe94HWy5l5BQ0Mwn2TVUq9gdgbfEMUmTX7tJFAg7mz0Rw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "@rc-component/resize-observer": "^1.0.1", + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/pagination": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rc-component/pagination/-/pagination-1.2.0.tgz", + "integrity": "sha512-YcpUFE8dMLfSo6OARJlK6DbHHvrxz7pMGPGmC/caZSJJz6HRKHC1RPP001PRHCvG9Z/veD039uOQmazVuLJzlw==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/picker": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@rc-component/picker/-/picker-1.9.1.tgz", + "integrity": "sha512-9FBYYsvH3HMLICaPDA/1Th5FLaDkFa7qAtangIdlhKb3ZALaR745e9PsOhheJb6asS4QXc12ffiAcjdkZ4C5/g==", + "license": "MIT", + "dependencies": { + "@rc-component/overflow": "^1.0.0", + "@rc-component/resize-observer": "^1.0.0", + "@rc-component/trigger": "^3.6.15", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=12.x" + }, + "peerDependencies": { + "date-fns": ">= 2.x", + "dayjs": ">= 1.x", + "luxon": ">= 3.x", + "moment": ">= 2.x", + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + }, + "peerDependenciesMeta": { + "date-fns": { + "optional": true + }, + "dayjs": { + "optional": true + }, + "luxon": { + "optional": true + }, + "moment": { + "optional": true + } + } + }, + "node_modules/@rc-component/portal": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@rc-component/portal/-/portal-2.2.0.tgz", + "integrity": "sha512-oc6FlA+uXCMiwArHsJyHcIkX4q6uKyndrPol2eWX8YPkAnztHOPsFIRtmWG4BMlGE5h7YIRE3NiaJ5VS8Lb1QQ==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.2.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=12.x" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/progress": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rc-component/progress/-/progress-1.0.2.tgz", + "integrity": "sha512-WZUnH9eGxH1+xodZKqdrHke59uyGZSWgj5HBM5Kwk5BrTMuAORO7VJ2IP5Qbm9aH3n9x3IcesqHHR0NWPBC7fQ==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.2.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, "node_modules/@rc-component/qrcode": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@rc-component/qrcode/-/qrcode-1.1.1.tgz", @@ -1794,17 +2083,14 @@ "react-dom": ">=16.9.0" } }, - "node_modules/@rc-component/tour": { - "version": "1.15.1", - "resolved": "https://registry.npmjs.org/@rc-component/tour/-/tour-1.15.1.tgz", - "integrity": "sha512-Tr2t7J1DKZUpfJuDZWHxyxWpfmj8EZrqSgyMZ+BCdvKZ6r1UDsfU46M/iWAAFBy961Ssfom2kv5f3UcjIL2CmQ==", + "node_modules/@rc-component/rate": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rc-component/rate/-/rate-1.0.1.tgz", + "integrity": "sha512-bkXxeBqDpl5IOC7yL7GcSYjQx9G8H+6kLYQnNZWeBYq2OYIv1MONd6mqKTjnnJYpV0cQIU2z3atdW0j1kttpTw==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.18.0", - "@rc-component/portal": "^1.0.0-9", - "@rc-component/trigger": "^2.0.0", - "classnames": "^2.3.2", - "rc-util": "^5.24.4" + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" }, "engines": { "node": ">=8.x" @@ -1814,39 +2100,294 @@ "react-dom": ">=16.9.0" } }, - "node_modules/@rc-component/trigger": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@rc-component/trigger/-/trigger-2.3.1.tgz", - "integrity": "sha512-ORENF39PeXTzM+gQEshuk460Z8N4+6DkjpxlpE7Q3gYy1iBpLrx0FOJz3h62ryrJZ/3zCAUIkT1Pb/8hHWpb3A==", + "node_modules/@rc-component/resize-observer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@rc-component/resize-observer/-/resize-observer-1.1.1.tgz", + "integrity": "sha512-NfXXMmiR+SmUuKE1NwJESzEUYUFWIDUn2uXpxCTOLwiRUUakd62DRNFjRJArgzyFW8S5rsL4aX5XlyIXyC/vRA==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.23.2", - "@rc-component/portal": "^1.1.0", - "classnames": "^2.3.2", - "rc-motion": "^2.0.0", - "rc-resize-observer": "^1.3.1", - "rc-util": "^5.44.0" - }, - "engines": { - "node": ">=8.x" + "@rc-component/util": "^1.2.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, - "node_modules/@rc-component/util": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@rc-component/util/-/util-1.9.0.tgz", - "integrity": "sha512-5uW6AfhIigCWeEQDthTozlxiT4Prn6xYQWeO0xokjcaa186OtwPRHBZJ2o0T0FhbjGhZ3vXdbkv0sx3gAYW7Vg==", + "node_modules/@rc-component/segmented": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@rc-component/segmented/-/segmented-1.3.0.tgz", + "integrity": "sha512-5J/bJ01mbDnoA6P/FW8SxUvKn+OgUSTZJPzCNnTBntG50tzoP7DydGhqxp7ggZXZls7me3mc2EQDXakU3iTVFg==", "license": "MIT", "dependencies": { - "is-mobile": "^5.0.0", - "react-is": "^18.2.0" + "@babel/runtime": "^7.11.1", + "@rc-component/motion": "^1.1.4", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" }, "peerDependencies": { - "react": ">=18.0.0", - "react-dom": ">=18.0.0" + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@rc-component/select": { + "version": "1.6.15", + "resolved": "https://registry.npmjs.org/@rc-component/select/-/select-1.6.15.tgz", + "integrity": "sha512-SyVCWnqxCQZZcQvQJ/CxSjx2bGma6ds/HtnpkIfZVnt6RoEgbqUmHgD6vrzNarNXwbLXerwVzWwq8F3d1sst7g==", + "license": "MIT", + "dependencies": { + "@rc-component/overflow": "^1.0.0", + "@rc-component/trigger": "^3.0.0", + "@rc-component/util": "^1.3.0", + "@rc-component/virtual-list": "^1.0.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/@rc-component/slider": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rc-component/slider/-/slider-1.0.1.tgz", + "integrity": "sha512-uDhEPU1z3WDfCJhaL9jfd2ha/Eqpdfxsn0Zb0Xcq1NGQAman0TWaR37OWp2vVXEOdV2y0njSILTMpTfPV1454g==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/steps": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rc-component/steps/-/steps-1.2.2.tgz", + "integrity": "sha512-/yVIZ00gDYYPHSY0JP+M+s3ZvuXLu2f9rEjQqiUDs7EcYsUYrpJ/1bLj9aI9R7MBR3fu/NGh6RM9u2qGfqp+Nw==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.2.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/switch": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rc-component/switch/-/switch-1.0.3.tgz", + "integrity": "sha512-Jgi+EbOBquje/XNdofr7xbJQZPYJP+BlPfR0h+WN4zFkdtB2EWqEfvkXJWeipflwjWip0/17rNbxEAqs8hVHfw==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/table": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@rc-component/table/-/table-1.9.1.tgz", + "integrity": "sha512-FVI5ZS/GdB3BcgexfCYKi3iHhZS3Fr59EtsxORszYGrfpH1eWr33eDNSYkVfLI6tfJ7vftJDd9D5apfFWqkdJg==", + "license": "MIT", + "dependencies": { + "@rc-component/context": "^2.0.1", + "@rc-component/resize-observer": "^1.0.0", + "@rc-component/util": "^1.1.0", + "@rc-component/virtual-list": "^1.0.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/tabs": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@rc-component/tabs/-/tabs-1.7.0.tgz", + "integrity": "sha512-J48cs2iBi7Ho3nptBxxIqizEliUC+ExE23faspUQKGQ550vaBlv3aGF8Epv/UB1vFWeoJDTW/dNzgIU0Qj5i/w==", + "license": "MIT", + "dependencies": { + "@rc-component/dropdown": "~1.0.0", + "@rc-component/menu": "~1.2.0", + "@rc-component/motion": "^1.1.3", + "@rc-component/resize-observer": "^1.0.0", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/textarea": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rc-component/textarea/-/textarea-1.1.2.tgz", + "integrity": "sha512-9rMUEODWZDMovfScIEHXWlVZuPljZ2pd1LKNjslJVitn4SldEzq5vO1CL3yy3Dnib6zZal2r2DPtjy84VVpF6A==", + "license": "MIT", + "dependencies": { + "@rc-component/input": "~1.1.0", + "@rc-component/resize-observer": "^1.0.0", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/tooltip": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@rc-component/tooltip/-/tooltip-1.4.0.tgz", + "integrity": "sha512-8Rx5DCctIlLI4raR0I0xHjVTf1aF48+gKCNeAAo5bmF5VoR5YED+A/XEqzXv9KKqrJDRcd3Wndpxh2hyzrTtSg==", + "license": "MIT", + "dependencies": { + "@rc-component/trigger": "^3.7.1", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/tour": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@rc-component/tour/-/tour-2.3.0.tgz", + "integrity": "sha512-K04K9r32kUC+auBSQfr+Fss4SpSIS9JGe56oq/ALAX0p+i2ylYOI1MgR83yBY7v96eO6ZFXcM/igCQmubps0Ow==", + "license": "MIT", + "dependencies": { + "@rc-component/portal": "^2.2.0", + "@rc-component/trigger": "^3.0.0", + "@rc-component/util": "^1.7.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/tree": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rc-component/tree/-/tree-1.2.4.tgz", + "integrity": "sha512-5Gli43+m4R7NhpYYz3Z61I6LOw9yI6CNChxgVtvrO6xB1qML7iE6QMLVMB3+FTjo2yF6uFdAHtqWPECz/zbX5w==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.0.0", + "@rc-component/util": "^1.8.1", + "@rc-component/virtual-list": "^1.0.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=10.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/@rc-component/tree-select": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@rc-component/tree-select/-/tree-select-1.8.0.tgz", + "integrity": "sha512-iYsPq3nuLYvGqdvFAW+l+I9ASRIOVbMXyA8FGZg2lGym/GwkaWeJGzI4eJ7c9IOEhRj0oyfIN4S92Fl3J05mjQ==", + "license": "MIT", + "dependencies": { + "@rc-component/select": "~1.6.0", + "@rc-component/tree": "~1.2.0", + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/@rc-component/trigger": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/@rc-component/trigger/-/trigger-3.9.0.tgz", + "integrity": "sha512-X8btpwfrT27AgrZVOz4swclhEHTZcqaHeQMXXBgveagOiakTa36uObXbdwerXffgV8G9dH1fAAE0DHtVQs8EHg==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.1.4", + "@rc-component/portal": "^2.2.0", + "@rc-component/resize-observer": "^1.1.1", + "@rc-component/util": "^1.2.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/upload": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rc-component/upload/-/upload-1.1.0.tgz", + "integrity": "sha512-LIBV90mAnUE6VK5N4QvForoxZc4XqEYZimcp7fk+lkE4XwHHyJWxpIXQQwMU8hJM+YwBbsoZkGksL1sISWHQxw==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/util": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@rc-component/util/-/util-1.9.0.tgz", + "integrity": "sha512-5uW6AfhIigCWeEQDthTozlxiT4Prn6xYQWeO0xokjcaa186OtwPRHBZJ2o0T0FhbjGhZ3vXdbkv0sx3gAYW7Vg==", + "license": "MIT", + "dependencies": { + "is-mobile": "^5.0.0", + "react-is": "^18.2.0" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/virtual-list": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rc-component/virtual-list/-/virtual-list-1.0.2.tgz", + "integrity": "sha512-uvTol/mH74FYsn5loDGJxo+7kjkO4i+y4j87Re1pxJBs0FaeuMuLRzQRGaXwnMcV1CxpZLi2Z56Rerj2M00fjQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.0", + "@rc-component/resize-observer": "^1.0.1", + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" } }, "node_modules/@rolldown/pluginutils": { @@ -3027,58 +3568,57 @@ } }, "node_modules/antd": { - "version": "5.29.3", - "resolved": "https://registry.npmjs.org/antd/-/antd-5.29.3.tgz", - "integrity": "sha512-3DdbGCa9tWAJGcCJ6rzR8EJFsv2CtyEbkVabZE14pfgUHfCicWCj0/QzQVLDYg8CPfQk9BH7fHCoTXHTy7MP/A==", - "license": "MIT", - "dependencies": { - "@ant-design/colors": "^7.2.1", - "@ant-design/cssinjs": "^1.23.0", - "@ant-design/cssinjs-utils": "^1.1.3", - "@ant-design/fast-color": "^2.0.6", - "@ant-design/icons": "^5.6.1", - "@ant-design/react-slick": "~1.1.2", - "@babel/runtime": "^7.26.0", - "@rc-component/color-picker": "~2.0.1", - "@rc-component/mutate-observer": "^1.1.0", - "@rc-component/qrcode": "~1.1.0", - "@rc-component/tour": "~1.15.1", - "@rc-component/trigger": "^2.3.0", - "classnames": "^2.5.1", - "copy-to-clipboard": "^3.3.3", + "version": "6.3.3", + "resolved": "https://registry.npmjs.org/antd/-/antd-6.3.3.tgz", + "integrity": "sha512-T8FAQelw36zS96cZw2U/qEjpYny5yFc7hg+1W7DvVr8xMoSXWvyB8WvmiDVH0nS0LPYV4y2sxetsJoGZt7rhhw==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^8.0.1", + "@ant-design/cssinjs": "^2.1.2", + "@ant-design/cssinjs-utils": "^2.1.2", + "@ant-design/fast-color": "^3.0.1", + "@ant-design/icons": "^6.1.0", + "@ant-design/react-slick": "~2.0.0", + "@babel/runtime": "^7.28.4", + "@rc-component/cascader": "~1.14.0", + "@rc-component/checkbox": "~2.0.0", + "@rc-component/collapse": "~1.2.0", + "@rc-component/color-picker": "~3.1.1", + "@rc-component/dialog": "~1.8.4", + "@rc-component/drawer": "~1.4.2", + "@rc-component/dropdown": "~1.0.2", + "@rc-component/form": "~1.7.2", + "@rc-component/image": "~1.6.0", + "@rc-component/input": "~1.1.2", + "@rc-component/input-number": "~1.6.2", + "@rc-component/mentions": "~1.6.0", + "@rc-component/menu": "~1.2.0", + "@rc-component/motion": "^1.3.1", + "@rc-component/mutate-observer": "^2.0.1", + "@rc-component/notification": "~1.2.0", + "@rc-component/pagination": "~1.2.0", + "@rc-component/picker": "~1.9.1", + "@rc-component/progress": "~1.0.2", + "@rc-component/qrcode": "~1.1.1", + "@rc-component/rate": "~1.0.1", + "@rc-component/resize-observer": "^1.1.1", + "@rc-component/segmented": "~1.3.0", + "@rc-component/select": "~1.6.14", + "@rc-component/slider": "~1.0.1", + "@rc-component/steps": "~1.2.2", + "@rc-component/switch": "~1.0.3", + "@rc-component/table": "~1.9.1", + "@rc-component/tabs": "~1.7.0", + "@rc-component/textarea": "~1.1.2", + "@rc-component/tooltip": "~1.4.0", + "@rc-component/tour": "~2.3.0", + "@rc-component/tree": "~1.2.4", + "@rc-component/tree-select": "~1.8.0", + "@rc-component/trigger": "^3.9.0", + "@rc-component/upload": "~1.1.0", + "@rc-component/util": "^1.9.0", + "clsx": "^2.1.1", "dayjs": "^1.11.11", - "rc-cascader": "~3.34.0", - "rc-checkbox": "~3.5.0", - "rc-collapse": "~3.9.0", - "rc-dialog": "~9.6.0", - "rc-drawer": "~7.3.0", - "rc-dropdown": "~4.2.1", - "rc-field-form": "~2.7.1", - "rc-image": "~7.12.0", - "rc-input": "~1.8.0", - "rc-input-number": "~9.5.0", - "rc-mentions": "~2.20.0", - "rc-menu": "~9.16.1", - "rc-motion": "^2.9.5", - "rc-notification": "~5.6.4", - "rc-pagination": "~5.1.0", - "rc-picker": "~4.11.3", - "rc-progress": "~4.0.0", - "rc-rate": "~2.13.1", - "rc-resize-observer": "^1.4.3", - "rc-segmented": "~2.7.0", - "rc-select": "~14.16.8", - "rc-slider": "~11.1.9", - "rc-steps": "~6.0.1", - "rc-switch": "~4.1.0", - "rc-table": "~7.54.0", - "rc-tabs": "~15.7.0", - "rc-textarea": "~1.10.2", - "rc-tooltip": "~6.4.0", - "rc-tree": "~5.13.1", - "rc-tree-select": "~5.27.0", - "rc-upload": "~4.11.0", - "rc-util": "^5.44.4", "scroll-into-view-if-needed": "^3.1.0", "throttle-debounce": "^5.0.2" }, @@ -3087,49 +3627,8 @@ "url": "https://opencollective.com/ant-design" }, "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/antd/node_modules/@ant-design/colors": { - "version": "7.2.1", - "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-7.2.1.tgz", - "integrity": "sha512-lCHDcEzieu4GA3n8ELeZ5VQ8pKQAWcGGLRTQ50aQM2iqPpq2evTxER84jfdPvsPAtEcZ7m44NI45edFMo8oOYQ==", - "license": "MIT", - "dependencies": { - "@ant-design/fast-color": "^2.0.6" - } - }, - "node_modules/antd/node_modules/@ant-design/fast-color": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@ant-design/fast-color/-/fast-color-2.0.6.tgz", - "integrity": "sha512-y2217gk4NqL35giHl72o6Zzqji9O7vHh9YmhUVkPtAOpoTCH4uWxo/pr4VE8t0+ChEPs0qo4eJRC5Q1eXWo3vA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.24.7" - }, - "engines": { - "node": ">=8.x" - } - }, - "node_modules/antd/node_modules/@ant-design/icons": { - "version": "5.6.1", - "resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-5.6.1.tgz", - "integrity": "sha512-0/xS39c91WjPAZOWsvi1//zjx6kAp4kxWwctR6kuU6p133w8RU0D2dSCvZC19uQyharg/sAvYxGYWl01BbZZfg==", - "license": "MIT", - "dependencies": { - "@ant-design/colors": "^7.0.0", - "@ant-design/icons-svg": "^4.4.0", - "@babel/runtime": "^7.24.8", - "classnames": "^2.2.6", - "rc-util": "^5.31.1" - }, - "engines": { - "node": ">=8" - }, - "peerDependencies": { - "react": ">=16.0.0", - "react-dom": ">=16.0.0" + "react": ">=18.0.0", + "react-dom": ">=18.0.0" } }, "node_modules/argparse": { @@ -3580,12 +4079,6 @@ "node": ">=6.0" } }, - "node_modules/classnames": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", - "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", - "license": "MIT" - }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -3680,15 +4173,6 @@ "node": ">=18" } }, - "node_modules/copy-to-clipboard": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz", - "integrity": "sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==", - "license": "MIT", - "dependencies": { - "toggle-selection": "^1.0.6" - } - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -6604,6 +7088,7 @@ "version": "1.58.2", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, "license": "Apache-2.0", "dependencies": { "playwright-core": "1.58.2" @@ -6622,6 +7107,7 @@ "version": "1.58.2", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" @@ -6634,6 +7120,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -6784,631 +7271,25 @@ ], "license": "MIT" }, - "node_modules/rc-cascader": { - "version": "3.34.0", - "resolved": "https://registry.npmjs.org/rc-cascader/-/rc-cascader-3.34.0.tgz", - "integrity": "sha512-KpXypcvju9ptjW9FaN2NFcA2QH9E9LHKq169Y0eWtH4e/wHQ5Wh5qZakAgvb8EKZ736WZ3B0zLLOBsrsja5Dag==", + "node_modules/react": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", + "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.25.7", - "classnames": "^2.3.1", - "rc-select": "~14.16.2", - "rc-tree": "~5.13.0", - "rc-util": "^5.43.0" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" + "engines": { + "node": ">=0.10.0" } }, - "node_modules/rc-checkbox": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/rc-checkbox/-/rc-checkbox-3.5.0.tgz", - "integrity": "sha512-aOAQc3E98HteIIsSqm6Xk2FPKIER6+5vyEFMZfo73TqM+VVAIqOkHoPjgKLqSNtVLWScoaM7vY2ZrGEheI79yg==", + "node_modules/react-dom": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", + "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.10.1", - "classnames": "^2.3.2", - "rc-util": "^5.25.2" + "scheduler": "^0.27.0" }, "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/rc-collapse": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/rc-collapse/-/rc-collapse-3.9.0.tgz", - "integrity": "sha512-swDdz4QZ4dFTo4RAUMLL50qP0EY62N2kvmk2We5xYdRwcRn8WcYtuetCJpwpaCbUfUt5+huLpVxhvmnK+PHrkA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.10.1", - "classnames": "2.x", - "rc-motion": "^2.3.4", - "rc-util": "^5.27.0" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/rc-dialog": { - "version": "9.6.0", - "resolved": "https://registry.npmjs.org/rc-dialog/-/rc-dialog-9.6.0.tgz", - "integrity": "sha512-ApoVi9Z8PaCQg6FsUzS8yvBEQy0ZL2PkuvAgrmohPkN3okps5WZ5WQWPc1RNuiOKaAYv8B97ACdsFU5LizzCqg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.10.1", - "@rc-component/portal": "^1.0.0-8", - "classnames": "^2.2.6", - "rc-motion": "^2.3.0", - "rc-util": "^5.21.0" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/rc-drawer": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/rc-drawer/-/rc-drawer-7.3.0.tgz", - "integrity": "sha512-DX6CIgiBWNpJIMGFO8BAISFkxiuKitoizooj4BDyee8/SnBn0zwO2FHrNDpqqepj0E/TFTDpmEBCyFuTgC7MOg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.23.9", - "@rc-component/portal": "^1.1.1", - "classnames": "^2.2.6", - "rc-motion": "^2.6.1", - "rc-util": "^5.38.1" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/rc-dropdown": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/rc-dropdown/-/rc-dropdown-4.2.1.tgz", - "integrity": "sha512-YDAlXsPv3I1n42dv1JpdM7wJ+gSUBfeyPK59ZpBD9jQhK9jVuxpjj3NmWQHOBceA1zEPVX84T2wbdb2SD0UjmA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.18.3", - "@rc-component/trigger": "^2.0.0", - "classnames": "^2.2.6", - "rc-util": "^5.44.1" - }, - "peerDependencies": { - "react": ">=16.11.0", - "react-dom": ">=16.11.0" - } - }, - "node_modules/rc-field-form": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/rc-field-form/-/rc-field-form-2.7.1.tgz", - "integrity": "sha512-vKeSifSJ6HoLaAB+B8aq/Qgm8a3dyxROzCtKNCsBQgiverpc4kWDQihoUwzUj+zNWJOykwSY4dNX3QrGwtVb9A==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.18.0", - "@rc-component/async-validator": "^5.0.3", - "rc-util": "^5.32.2" - }, - "engines": { - "node": ">=8.x" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/rc-image": { - "version": "7.12.0", - "resolved": "https://registry.npmjs.org/rc-image/-/rc-image-7.12.0.tgz", - "integrity": "sha512-cZ3HTyyckPnNnUb9/DRqduqzLfrQRyi+CdHjdqgsyDpI3Ln5UX1kXnAhPBSJj9pVRzwRFgqkN7p9b6HBDjmu/Q==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.11.2", - "@rc-component/portal": "^1.0.2", - "classnames": "^2.2.6", - "rc-dialog": "~9.6.0", - "rc-motion": "^2.6.2", - "rc-util": "^5.34.1" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/rc-input": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/rc-input/-/rc-input-1.8.0.tgz", - "integrity": "sha512-KXvaTbX+7ha8a/k+eg6SYRVERK0NddX8QX7a7AnRvUa/rEH0CNMlpcBzBkhI0wp2C8C4HlMoYl8TImSN+fuHKA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.11.1", - "classnames": "^2.2.1", - "rc-util": "^5.18.1" - }, - "peerDependencies": { - "react": ">=16.0.0", - "react-dom": ">=16.0.0" - } - }, - "node_modules/rc-input-number": { - "version": "9.5.0", - "resolved": "https://registry.npmjs.org/rc-input-number/-/rc-input-number-9.5.0.tgz", - "integrity": "sha512-bKaEvB5tHebUURAEXw35LDcnRZLq3x1k7GxfAqBMzmpHkDGzjAtnUL8y4y5N15rIFIg5IJgwr211jInl3cipag==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.10.1", - "@rc-component/mini-decimal": "^1.0.1", - "classnames": "^2.2.5", - "rc-input": "~1.8.0", - "rc-util": "^5.40.1" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/rc-mentions": { - "version": "2.20.0", - "resolved": "https://registry.npmjs.org/rc-mentions/-/rc-mentions-2.20.0.tgz", - "integrity": "sha512-w8HCMZEh3f0nR8ZEd466ATqmXFCMGMN5UFCzEUL0bM/nGw/wOS2GgRzKBcm19K++jDyuWCOJOdgcKGXU3fXfbQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.22.5", - "@rc-component/trigger": "^2.0.0", - "classnames": "^2.2.6", - "rc-input": "~1.8.0", - "rc-menu": "~9.16.0", - "rc-textarea": "~1.10.0", - "rc-util": "^5.34.1" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/rc-menu": { - "version": "9.16.1", - "resolved": "https://registry.npmjs.org/rc-menu/-/rc-menu-9.16.1.tgz", - "integrity": "sha512-ghHx6/6Dvp+fw8CJhDUHFHDJ84hJE3BXNCzSgLdmNiFErWSOaZNsihDAsKq9ByTALo/xkNIwtDFGIl6r+RPXBg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.10.1", - "@rc-component/trigger": "^2.0.0", - "classnames": "2.x", - "rc-motion": "^2.4.3", - "rc-overflow": "^1.3.1", - "rc-util": "^5.27.0" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/rc-motion": { - "version": "2.9.5", - "resolved": "https://registry.npmjs.org/rc-motion/-/rc-motion-2.9.5.tgz", - "integrity": "sha512-w+XTUrfh7ArbYEd2582uDrEhmBHwK1ZENJiSJVb7uRxdE7qJSYjbO2eksRXmndqyKqKoYPc9ClpPh5242mV1vA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.11.1", - "classnames": "^2.2.1", - "rc-util": "^5.44.0" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/rc-notification": { - "version": "5.6.4", - "resolved": "https://registry.npmjs.org/rc-notification/-/rc-notification-5.6.4.tgz", - "integrity": "sha512-KcS4O6B4qzM3KH7lkwOB7ooLPZ4b6J+VMmQgT51VZCeEcmghdeR4IrMcFq0LG+RPdnbe/ArT086tGM8Snimgiw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.10.1", - "classnames": "2.x", - "rc-motion": "^2.9.0", - "rc-util": "^5.20.1" - }, - "engines": { - "node": ">=8.x" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/rc-overflow": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/rc-overflow/-/rc-overflow-1.5.0.tgz", - "integrity": "sha512-Lm/v9h0LymeUYJf0x39OveU52InkdRXqnn2aYXfWmo8WdOonIKB2kfau+GF0fWq6jPgtdO9yMqveGcK6aIhJmg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.11.1", - "classnames": "^2.2.1", - "rc-resize-observer": "^1.0.0", - "rc-util": "^5.37.0" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/rc-pagination": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/rc-pagination/-/rc-pagination-5.1.0.tgz", - "integrity": "sha512-8416Yip/+eclTFdHXLKTxZvn70duYVGTvUUWbckCCZoIl3jagqke3GLsFrMs0bsQBikiYpZLD9206Ej4SOdOXQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.10.1", - "classnames": "^2.3.2", - "rc-util": "^5.38.0" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/rc-picker": { - "version": "4.11.3", - "resolved": "https://registry.npmjs.org/rc-picker/-/rc-picker-4.11.3.tgz", - "integrity": "sha512-MJ5teb7FlNE0NFHTncxXQ62Y5lytq6sh5nUw0iH8OkHL/TjARSEvSHpr940pWgjGANpjCwyMdvsEV55l5tYNSg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.24.7", - "@rc-component/trigger": "^2.0.0", - "classnames": "^2.2.1", - "rc-overflow": "^1.3.2", - "rc-resize-observer": "^1.4.0", - "rc-util": "^5.43.0" - }, - "engines": { - "node": ">=8.x" - }, - "peerDependencies": { - "date-fns": ">= 2.x", - "dayjs": ">= 1.x", - "luxon": ">= 3.x", - "moment": ">= 2.x", - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - }, - "peerDependenciesMeta": { - "date-fns": { - "optional": true - }, - "dayjs": { - "optional": true - }, - "luxon": { - "optional": true - }, - "moment": { - "optional": true - } - } - }, - "node_modules/rc-progress": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/rc-progress/-/rc-progress-4.0.0.tgz", - "integrity": "sha512-oofVMMafOCokIUIBnZLNcOZFsABaUw8PPrf1/y0ZBvKZNpOiu5h4AO9vv11Sw0p4Hb3D0yGWuEattcQGtNJ/aw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.10.1", - "classnames": "^2.2.6", - "rc-util": "^5.16.1" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/rc-rate": { - "version": "2.13.1", - "resolved": "https://registry.npmjs.org/rc-rate/-/rc-rate-2.13.1.tgz", - "integrity": "sha512-QUhQ9ivQ8Gy7mtMZPAjLbxBt5y9GRp65VcUyGUMF3N3fhiftivPHdpuDIaWIMOTEprAjZPC08bls1dQB+I1F2Q==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.10.1", - "classnames": "^2.2.5", - "rc-util": "^5.0.1" - }, - "engines": { - "node": ">=8.x" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/rc-resize-observer": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/rc-resize-observer/-/rc-resize-observer-1.4.3.tgz", - "integrity": "sha512-YZLjUbyIWox8E9i9C3Tm7ia+W7euPItNWSPX5sCcQTYbnwDb5uNpnLHQCG1f22oZWUhLw4Mv2tFmeWe68CDQRQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.20.7", - "classnames": "^2.2.1", - "rc-util": "^5.44.1", - "resize-observer-polyfill": "^1.5.1" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/rc-segmented": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/rc-segmented/-/rc-segmented-2.7.1.tgz", - "integrity": "sha512-izj1Nw/Dw2Vb7EVr+D/E9lUTkBe+kKC+SAFSU9zqr7WV2W5Ktaa9Gc7cB2jTqgk8GROJayltaec+DBlYKc6d+g==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.11.1", - "classnames": "^2.2.1", - "rc-motion": "^2.4.4", - "rc-util": "^5.17.0" - }, - "peerDependencies": { - "react": ">=16.0.0", - "react-dom": ">=16.0.0" - } - }, - "node_modules/rc-select": { - "version": "14.16.8", - "resolved": "https://registry.npmjs.org/rc-select/-/rc-select-14.16.8.tgz", - "integrity": "sha512-NOV5BZa1wZrsdkKaiK7LHRuo5ZjZYMDxPP6/1+09+FB4KoNi8jcG1ZqLE3AVCxEsYMBe65OBx71wFoHRTP3LRg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.10.1", - "@rc-component/trigger": "^2.1.1", - "classnames": "2.x", - "rc-motion": "^2.0.1", - "rc-overflow": "^1.3.1", - "rc-util": "^5.16.1", - "rc-virtual-list": "^3.5.2" - }, - "engines": { - "node": ">=8.x" - }, - "peerDependencies": { - "react": "*", - "react-dom": "*" - } - }, - "node_modules/rc-slider": { - "version": "11.1.9", - "resolved": "https://registry.npmjs.org/rc-slider/-/rc-slider-11.1.9.tgz", - "integrity": "sha512-h8IknhzSh3FEM9u8ivkskh+Ef4Yo4JRIY2nj7MrH6GQmrwV6mcpJf5/4KgH5JaVI1H3E52yCdpOlVyGZIeph5A==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.10.1", - "classnames": "^2.2.5", - "rc-util": "^5.36.0" - }, - "engines": { - "node": ">=8.x" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/rc-steps": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/rc-steps/-/rc-steps-6.0.1.tgz", - "integrity": "sha512-lKHL+Sny0SeHkQKKDJlAjV5oZ8DwCdS2hFhAkIjuQt1/pB81M0cA0ErVFdHq9+jmPmFw1vJB2F5NBzFXLJxV+g==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.16.7", - "classnames": "^2.2.3", - "rc-util": "^5.16.1" - }, - "engines": { - "node": ">=8.x" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/rc-switch": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/rc-switch/-/rc-switch-4.1.0.tgz", - "integrity": "sha512-TI8ufP2Az9oEbvyCeVE4+90PDSljGyuwix3fV58p7HV2o4wBnVToEyomJRVyTaZeqNPAp+vqeo4Wnj5u0ZZQBg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.21.0", - "classnames": "^2.2.1", - "rc-util": "^5.30.0" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/rc-table": { - "version": "7.54.0", - "resolved": "https://registry.npmjs.org/rc-table/-/rc-table-7.54.0.tgz", - "integrity": "sha512-/wDTkki6wBTjwylwAGjpLKYklKo9YgjZwAU77+7ME5mBoS32Q4nAwoqhA2lSge6fobLW3Tap6uc5xfwaL2p0Sw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.10.1", - "@rc-component/context": "^1.4.0", - "classnames": "^2.2.5", - "rc-resize-observer": "^1.1.0", - "rc-util": "^5.44.3", - "rc-virtual-list": "^3.14.2" - }, - "engines": { - "node": ">=8.x" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/rc-tabs": { - "version": "15.7.0", - "resolved": "https://registry.npmjs.org/rc-tabs/-/rc-tabs-15.7.0.tgz", - "integrity": "sha512-ZepiE+6fmozYdWf/9gVp7k56PKHB1YYoDsKeQA1CBlJ/POIhjkcYiv0AGP0w2Jhzftd3AVvZP/K+V+Lpi2ankA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.11.2", - "classnames": "2.x", - "rc-dropdown": "~4.2.0", - "rc-menu": "~9.16.0", - "rc-motion": "^2.6.2", - "rc-resize-observer": "^1.0.0", - "rc-util": "^5.34.1" - }, - "engines": { - "node": ">=8.x" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/rc-textarea": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/rc-textarea/-/rc-textarea-1.10.2.tgz", - "integrity": "sha512-HfaeXiaSlpiSp0I/pvWpecFEHpVysZ9tpDLNkxQbMvMz6gsr7aVZ7FpWP9kt4t7DB+jJXesYS0us1uPZnlRnwQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.10.1", - "classnames": "^2.2.1", - "rc-input": "~1.8.0", - "rc-resize-observer": "^1.0.0", - "rc-util": "^5.27.0" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/rc-tooltip": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/rc-tooltip/-/rc-tooltip-6.4.0.tgz", - "integrity": "sha512-kqyivim5cp8I5RkHmpsp1Nn/Wk+1oeloMv9c7LXNgDxUpGm+RbXJGL+OPvDlcRnx9DBeOe4wyOIl4OKUERyH1g==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.11.2", - "@rc-component/trigger": "^2.0.0", - "classnames": "^2.3.1", - "rc-util": "^5.44.3" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/rc-tree": { - "version": "5.13.1", - "resolved": "https://registry.npmjs.org/rc-tree/-/rc-tree-5.13.1.tgz", - "integrity": "sha512-FNhIefhftobCdUJshO7M8uZTA9F4OPGVXqGfZkkD/5soDeOhwO06T/aKTrg0WD8gRg/pyfq+ql3aMymLHCTC4A==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.10.1", - "classnames": "2.x", - "rc-motion": "^2.0.1", - "rc-util": "^5.16.1", - "rc-virtual-list": "^3.5.1" - }, - "engines": { - "node": ">=10.x" - }, - "peerDependencies": { - "react": "*", - "react-dom": "*" - } - }, - "node_modules/rc-tree-select": { - "version": "5.27.0", - "resolved": "https://registry.npmjs.org/rc-tree-select/-/rc-tree-select-5.27.0.tgz", - "integrity": "sha512-2qTBTzwIT7LRI1o7zLyrCzmo5tQanmyGbSaGTIf7sYimCklAToVVfpMC6OAldSKolcnjorBYPNSKQqJmN3TCww==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.25.7", - "classnames": "2.x", - "rc-select": "~14.16.2", - "rc-tree": "~5.13.0", - "rc-util": "^5.43.0" - }, - "peerDependencies": { - "react": "*", - "react-dom": "*" - } - }, - "node_modules/rc-upload": { - "version": "4.11.0", - "resolved": "https://registry.npmjs.org/rc-upload/-/rc-upload-4.11.0.tgz", - "integrity": "sha512-ZUyT//2JAehfHzjWowqROcwYJKnZkIUGWaTE/VogVrepSl7AFNbQf4+zGfX4zl9Vrj/Jm8scLO0R6UlPDKK4wA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.18.3", - "classnames": "^2.2.5", - "rc-util": "^5.2.0" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/rc-util": { - "version": "5.44.4", - "resolved": "https://registry.npmjs.org/rc-util/-/rc-util-5.44.4.tgz", - "integrity": "sha512-resueRJzmHG9Q6rI/DfK6Kdv9/Lfls05vzMs1Sk3M2P+3cJa+MakaZyWY8IPfehVuhPJFKrIY1IK4GqbiaiY5w==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.18.3", - "react-is": "^18.2.0" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/rc-virtual-list": { - "version": "3.19.2", - "resolved": "https://registry.npmjs.org/rc-virtual-list/-/rc-virtual-list-3.19.2.tgz", - "integrity": "sha512-Ys6NcjwGkuwkeaWBDqfI3xWuZ7rDiQXlH1o2zLfFzATfEgXcqpk8CkgMfbJD81McqjcJVez25a3kPxCR807evA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.20.0", - "classnames": "^2.2.6", - "rc-resize-observer": "^1.0.0", - "rc-util": "^5.36.0" - }, - "engines": { - "node": ">=8.x" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/react": { - "version": "19.2.0", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", - "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "19.2.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", - "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", - "license": "MIT", - "dependencies": { - "scheduler": "^0.27.0" - }, - "peerDependencies": { - "react": "^19.2.0" + "react": "^19.2.0" } }, "node_modules/react-is": { @@ -7519,12 +7400,6 @@ "node": ">=0.10.0" } }, - "node_modules/resize-observer-polyfill": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", - "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", - "license": "MIT" - }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -8380,12 +8255,6 @@ "node": ">=8.0" } }, - "node_modules/toggle-selection": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz", - "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==", - "license": "MIT" - }, "node_modules/ts-api-utils": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 16e2f73..06a2666 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,7 +15,7 @@ "@ant-design/charts": "^2.6.7", "@ant-design/icons": "^6.1.0", "@tanstack/react-query": "^5.90.21", - "antd": "^5.29.3", + "antd": "^6.3.3", "axios": "^1.13.2", "framer-motion": "^12.36.0", "mobx": "^6.15.0", diff --git a/frontend/src/components/CommandPalette.tsx b/frontend/src/components/CommandPalette.tsx index 32092ad..cd46510 100644 --- a/frontend/src/components/CommandPalette.tsx +++ b/frontend/src/components/CommandPalette.tsx @@ -213,7 +213,7 @@ const CommandPalette: React.FC = observer(() => { width={560} styles={{ body: { padding: 0 }, - content: { borderRadius: 12, overflow: 'hidden' }, + container: { borderRadius: 12, overflow: 'hidden' }, }} style={{ top: '15%' }} > From f4f5c15f91c19c6ef49a87467b4b82cec9abee58 Mon Sep 17 00:00:00 2001 From: mvoof Date: Thu, 19 Mar 2026 13:08:14 +0500 Subject: [PATCH 31/48] feat(auth): add invitation token verification and auto-fill --- backend/app/routers/auth.py | 23 ++++++++++++- backend/app/schemas.py | 5 +++ frontend/src/App.tsx | 1 + frontend/src/components/AuthForm.tsx | 48 ++++++++++++++++++++++++---- frontend/src/store/authStore.ts | 18 +++++++++++ frontend/src/types/api.ts | 18 +++++++++++ 6 files changed, 106 insertions(+), 7 deletions(-) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index a12699c..dd4b58a 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -1,6 +1,6 @@ from datetime import datetime, timezone -from fastapi import APIRouter, Depends, HTTPException, Request, status, BackgroundTasks +from fastapi import APIRouter, Depends, HTTPException, Request, status, BackgroundTasks, Query from fastapi.security import OAuth2PasswordRequestForm from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func @@ -21,6 +21,27 @@ async def setup_status(db: AsyncSession = Depends(database.get_db)): return {"is_setup": count > 0} +@router.get("/verify-invitation", response_model=schemas.InvitationVerify) +async def verify_invitation( + token: str = Query(...), + db: AsyncSession = Depends(database.get_db), +): + """Verify if an invitation token is valid and return associated email.""" + result = await db.execute( + select(models.Invitation).where(models.Invitation.token == token) + ) + invitation = result.scalar_one_or_none() + + if not invitation: + raise HTTPException(400, "Invalid invitation token") + if invitation.used_by is not None: + raise HTTPException(400, "Invitation already used") + if invitation.expires_at.replace(tzinfo=timezone.utc) < datetime.now(timezone.utc): + raise HTTPException(400, "Invitation expired") + + return {"email": invitation.email, "is_valid": True} + + @router.post("/setup", response_model=schemas.TokenPairResponse) async def setup( data: schemas.SetupCreate, diff --git a/backend/app/schemas.py b/backend/app/schemas.py index b8f12a1..6c96a77 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -126,6 +126,11 @@ class Config: # --- Invitation schemas --- +class InvitationVerify(BaseModel): + email: EmailStr + is_valid: bool + + class InvitationCreate(BaseModel): email: EmailStr diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index dab3510..a1f7f91 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -18,6 +18,7 @@ const App: React.FC = observer(function App() { const isDark = themeStore.mode === 'dark'; useEffect(() => { + // https://github.com/ant-design/ant-design/discussions/40290 document.body.style.backgroundColor = isDark ? tokens.colors.darkBg : tokens.colors.lightBg; diff --git a/frontend/src/components/AuthForm.tsx b/frontend/src/components/AuthForm.tsx index 9b89c7b..0cc7c27 100644 --- a/frontend/src/components/AuthForm.tsx +++ b/frontend/src/components/AuthForm.tsx @@ -14,11 +14,13 @@ const AuthForm: React.FC = observer(() => { const [mode, setMode] = useState<'login' | 'register'>( inviteToken ? 'register' : 'login' ); + const [invitedEmail, setInvitedEmail] = useState(null); + const [isValidatingToken, setIsValidatingToken] = useState(false); const formMode: AuthFormMode = authStore.isSetup === false ? 'setup' - : inviteToken + : inviteToken && mode === 'register' ? 'invite' : mode === 'login' ? 'login' @@ -27,11 +29,30 @@ const AuthForm: React.FC = observer(() => { const [error, setError] = useState(null); const navigate = useNavigate(); const loading = authStore.isLoading; + const [form] = Form.useForm(); useEffect(() => { void authStore.checkSetupStatus(); }, []); + useEffect(() => { + const verifyToken = async () => { + if (inviteToken && mode === 'register' && !invitedEmail) { + setIsValidatingToken(true); + const result = await authStore.verifyInvitation(inviteToken); + if (typeof result === 'string') { + setError(`Invalid or expired invitation: ${result}`); + setMode('login'); + } else { + setInvitedEmail(result.email); + form.setFieldsValue({ email: result.email }); + } + setIsValidatingToken(false); + } + }; + void verifyToken(); + }, [inviteToken, mode, invitedEmail, form]); + const handleSubmit = async (values: Record) => { setError(null); @@ -77,10 +98,13 @@ const AuthForm: React.FC = observer(() => { ? 'Sign in to VerseLab' : 'Join via invitation'; - if (authStore.isSetup === null) { + if (authStore.isSetup === null || isValidatingToken) { return (
- +
); } @@ -126,6 +150,7 @@ const AuthForm: React.FC = observer(() => { )} ) => { void handleSubmit(values); }} @@ -148,7 +173,11 @@ const AuthForm: React.FC = observer(() => { { type: 'email', message: 'Enter a valid email' }, ]} > - } placeholder="Email" /> + } + placeholder="Email" + disabled={!!invitedEmail} + /> { - {formMode !== 'setup' && formMode !== 'invite' && ( + {formMode !== 'setup' && (
{formMode === 'login' @@ -216,7 +245,14 @@ const AuthForm: React.FC = observer(() => { : 'Already have an account? '} { - setMode(formMode === 'login' ? 'register' : 'login'); + if (formMode === 'invite') { + setMode('login'); + // Clear token-related state when switching away from invite + setInvitedEmail(null); + form.setFieldsValue({ email: '' }); + } else { + setMode(formMode === 'login' ? 'register' : 'login'); + } setError(null); }} > diff --git a/frontend/src/store/authStore.ts b/frontend/src/store/authStore.ts index f8a51d8..3edb54b 100644 --- a/frontend/src/store/authStore.ts +++ b/frontend/src/store/authStore.ts @@ -4,10 +4,12 @@ import { toUserError, isTokenResponse, isSetupStatusResponse, + isInvitationVerifyResponse, isUserProfile, type UserProfile, type SetupStatusResponse, type TokenResponse, + type InvitationVerifyResponse, } from '../types/api'; import { GlobalRoles } from '../types/domain'; @@ -118,6 +120,22 @@ class AuthStore { } }; + verifyInvitation = async ( + token: string + ): Promise<{ email: string } | string> => { + try { + const res = await api.get( + `/auth/verify-invitation?token=${token}` + ); + if (isInvitationVerifyResponse(res.data)) { + return { email: res.data.email }; + } + return 'Invalid invitation response.'; + } catch (e) { + return toUserError(e); + } + }; + register = async ( email: string, password: string, diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 99c9182..9e627d9 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -108,6 +108,11 @@ export interface Invitation { used_by: number | null; } +export interface InvitationVerifyResponse { + email: string; + is_valid: boolean; +} + // --- Project Member --- export interface ProjectMember { @@ -358,6 +363,19 @@ export function isSetupStatusResponse( ); } +export function isInvitationVerifyResponse( + data: unknown +): data is InvitationVerifyResponse { + return ( + typeof data === 'object' && + data !== null && + 'email' in data && + 'is_valid' in data && + typeof (data as InvitationVerifyResponse).email === 'string' && + typeof (data as InvitationVerifyResponse).is_valid === 'boolean' + ); +} + export function isUserProfile(data: unknown): data is UserProfile { if (typeof data !== 'object' || data === null) return false; const d = data as Record; From 11ec92ffcbe34e151820d1973480ef1d563346db Mon Sep 17 00:00:00 2001 From: mvoof Date: Thu, 19 Mar 2026 15:42:37 +0500 Subject: [PATCH 32/48] feat: use antd notification --- frontend/src/App.tsx | 2 +- frontend/src/components/AuthForm.tsx | 91 +++++++++++-------- frontend/src/pages/Dashboard.tsx | 34 +++---- frontend/src/pages/Profile.tsx | 18 ++-- frontend/src/pages/ProjectGlossary.tsx | 6 +- frontend/src/pages/ProjectReview.tsx | 29 +++--- frontend/src/pages/ProjectSettings.tsx | 76 +++++++++------- frontend/src/pages/ProjectStats.tsx | 2 +- frontend/src/pages/Workspace.tsx | 3 +- frontend/src/pages/admin/AuditLog.tsx | 44 ++++----- .../src/pages/admin/InvitationsManagement.tsx | 20 ++-- .../src/pages/admin/MembersManagement.tsx | 86 ++++++++++-------- frontend/src/store/authStore.ts | 56 ++++++++---- 13 files changed, 263 insertions(+), 204 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a1f7f91..50899a1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -65,7 +65,7 @@ const App: React.FC = observer(function App() { }, }} > - + diff --git a/frontend/src/components/AuthForm.tsx b/frontend/src/components/AuthForm.tsx index 0cc7c27..1dd6197 100644 --- a/frontend/src/components/AuthForm.tsx +++ b/frontend/src/components/AuthForm.tsx @@ -1,15 +1,17 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useRef } from 'react'; import { observer } from 'mobx-react-lite'; import { useNavigate, useSearchParams } from 'react-router'; import { authStore } from '../store/authStore'; -import { Form, Input, Button, Typography, Alert, Spin } from 'antd'; +import { Form, Input, Button, Typography, Spin, App } from 'antd'; import { UserOutlined, LockOutlined, MailOutlined } from '@ant-design/icons'; import { tokens } from '../styles/design-tokens'; import type { AuthFormMode } from '../types/domain'; const AuthForm: React.FC = observer(() => { + const { notification } = App.useApp(); const [searchParams] = useSearchParams(); const inviteToken = searchParams.get('token'); + const hasVerified = useRef(null); const [mode, setMode] = useState<'login' | 'register'>( inviteToken ? 'register' : 'login' @@ -26,7 +28,6 @@ const AuthForm: React.FC = observer(() => { ? 'login' : 'register'; - const [error, setError] = useState(null); const navigate = useNavigate(); const loading = authStore.isLoading; const [form] = Form.useForm(); @@ -37,11 +38,20 @@ const AuthForm: React.FC = observer(() => { useEffect(() => { const verifyToken = async () => { - if (inviteToken && mode === 'register' && !invitedEmail) { + if ( + inviteToken && + mode === 'register' && + !invitedEmail && + hasVerified.current !== inviteToken + ) { + hasVerified.current = inviteToken; setIsValidatingToken(true); const result = await authStore.verifyInvitation(inviteToken); - if (typeof result === 'string') { - setError(`Invalid or expired invitation: ${result}`); + if (result.status === 'error') { + notification[result.status]({ + message: 'Invalid or expired invitation', + description: result.message, + }); setMode('login'); } else { setInvitedEmail(result.email); @@ -51,36 +61,56 @@ const AuthForm: React.FC = observer(() => { } }; void verifyToken(); - }, [inviteToken, mode, invitedEmail, form]); + }, [inviteToken, mode, invitedEmail, form, notification]); const handleSubmit = async (values: Record) => { - setError(null); - if (formMode === 'setup') { - const err = await authStore.setup( + const result = await authStore.setup( values.email, values.password, values.fullName ); - if (err) setError(err); - else void navigate('/dashboard'); + if (result.status === 'error') { + notification[result.status]({ + message: 'Setup failed', + description: result.message, + }); + } else { + void navigate('/dashboard'); + } } else if (formMode === 'login') { - const err = await authStore.login(values.email, values.password); - if (err) setError(err); - else void navigate('/dashboard'); + const result = await authStore.login(values.email, values.password); + if (result.status === 'error') { + notification[result.status]({ + message: 'Login failed', + description: result.message, + }); + } else { + void navigate('/dashboard'); + } } else { if (!inviteToken) { - setError('No invitation token. You need an invite link to register.'); + notification.error({ + message: 'Invitation Required', + description: + 'No invitation token. You need an invite link to register.', + }); return; } - const err = await authStore.register( + const result = await authStore.register( values.email, values.password, values.fullName, inviteToken ); - if (err) setError(err); - else void navigate('/dashboard'); + if (result.status === 'error') { + notification[result.status]({ + message: 'Registration failed', + description: result.message, + }); + } else { + void navigate('/dashboard'); + } } }; @@ -103,7 +133,7 @@ const AuthForm: React.FC = observer(() => {
); @@ -140,15 +170,6 @@ const AuthForm: React.FC = observer(() => {
- {error && ( - - )} - ) => { @@ -253,7 +274,6 @@ const AuthForm: React.FC = observer(() => { } else { setMode(formMode === 'login' ? 'register' : 'login'); } - setError(null); }} > {formMode === 'login' ? 'register' : 'Sign in'} @@ -263,12 +283,11 @@ const AuthForm: React.FC = observer(() => { )} {formMode === 'register' && !inviteToken && ( - +
+ + Registration requires an invitation link from your admin. + +
)}
diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 00e3234..ec856d3 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -208,7 +208,7 @@ const Dashboard: React.FC = observer(() => { } value={stat.value} - valueStyle={{ color: 'white', fontWeight: 700 }} + styles={{ content: { color: 'white', fontWeight: 700 } }} /> @@ -450,12 +450,15 @@ const Dashboard: React.FC = observer(() => { - + - {LANGUAGE_OPTIONS.map((l) => ( - - {l.label} - - ))} - + + options={LANGUAGE_OPTIONS} + /> ) diff --git a/frontend/src/pages/Profile.tsx b/frontend/src/pages/Profile.tsx index 0504526..3bcd1b1 100644 --- a/frontend/src/pages/Profile.tsx +++ b/frontend/src/pages/Profile.tsx @@ -6,14 +6,15 @@ import { Form, Input, Button, - message, Divider, Alert, + App, } from 'antd'; import { authStore } from '../store/authStore'; import api from '../api/client'; const Profile: React.FC = observer(() => { + const { notification } = App.useApp(); const user = authStore.user; const [savingName, setSavingName] = useState(false); const [savingPassword, setSavingPassword] = useState(false); @@ -25,9 +26,9 @@ const Profile: React.FC = observer(() => { try { await api.patch('/users/me', { full_name: values.full_name }); await authStore.fetchProfile(); - message.success('Name updated'); + notification.success({ message: 'Name updated' }); } catch { - message.error('Failed to update name'); + notification.error({ message: 'Failed to update name' }); } finally { setSavingName(false); } @@ -38,17 +39,17 @@ const Profile: React.FC = observer(() => { confirm: string; }) => { if (values.password !== values.confirm) { - message.error('Passwords do not match'); + notification.error({ message: 'Passwords do not match' }); return; } setSavingPassword(true); try { await api.patch('/users/me', { password: values.password }); passwordForm.resetFields(); + notification.success({ message: 'Password changed successfully' }); setPasswordChanged(true); - setTimeout(() => setPasswordChanged(false), 5000); } catch { - message.error('Failed to change password'); + notification.error({ message: 'Failed to change password' }); } finally { setSavingPassword(false); } @@ -87,10 +88,9 @@ const Profile: React.FC = observer(() => { {passwordChanged && ( setPasswordChanged(false)} + closable={{ afterClose: () => setPasswordChanged(false) }} style={{ marginBottom: 16 }} /> )} diff --git a/frontend/src/pages/ProjectGlossary.tsx b/frontend/src/pages/ProjectGlossary.tsx index fc64ff4..af038ac 100644 --- a/frontend/src/pages/ProjectGlossary.tsx +++ b/frontend/src/pages/ProjectGlossary.tsx @@ -21,7 +21,7 @@ import type { GlossaryTerm } from '../types/api'; const ProjectGlossary: React.FC = observer(() => { const { projectId } = useParams(); - const { message } = App.useApp(); + const { notification } = App.useApp(); const queryClient = useQueryClient(); const [glossaryForm] = Form.useForm(); @@ -70,7 +70,7 @@ const ProjectGlossary: React.FC = observer(() => { glossaryForm.resetFields(); void queryClient.invalidateQueries({ queryKey: ['glossary', projectId] }); } catch { - message.error('Failed to save glossary term'); + notification.error({ message: 'Failed to save glossary term' }); } }; @@ -81,7 +81,7 @@ const ProjectGlossary: React.FC = observer(() => { ); void queryClient.invalidateQueries({ queryKey: ['glossary', projectId] }); } catch { - message.error('Failed to delete term'); + notification.error({ message: 'Failed to delete term' }); } }; diff --git a/frontend/src/pages/ProjectReview.tsx b/frontend/src/pages/ProjectReview.tsx index 898b6e1..943c0bd 100644 --- a/frontend/src/pages/ProjectReview.tsx +++ b/frontend/src/pages/ProjectReview.tsx @@ -38,7 +38,7 @@ import { reviewStore } from '../store/reviewStore'; const ProjectReview: React.FC = observer(() => { const { projectId } = useParams(); - const { message } = App.useApp(); + const { notification } = App.useApp(); const { token: themeToken } = theme.useToken(); const [rejectModalOpen, setRejectModalOpen] = useState(false); @@ -61,11 +61,11 @@ const ProjectReview: React.FC = observer(() => { useEffect(() => { if (projectId) { void reviewStore.fetchAnnotations(projectId).catch(() => { - message.error('Failed to load annotations'); + notification.error({ message: 'Failed to load annotations' }); }); } // eslint-disable-next-line react-hooks/exhaustive-deps -- MobX observer handles reactivity for reviewStore.statusFilter - }, [projectId, reviewStore.statusFilter, message]); + }, [projectId, reviewStore.statusFilter, notification]); const handleReview = async ( annotationId: number, @@ -80,7 +80,7 @@ const ProjectReview: React.FC = observer(() => { reviewNote ); } catch { - message.error('Failed to review annotation'); + notification.error({ message: 'Failed to review annotation' }); } }; @@ -88,9 +88,11 @@ const ProjectReview: React.FC = observer(() => { const count = reviewStore.selectedRowKeys.length; try { await reviewStore.batchReview(status, String(projectId)); - message.success(`${String(count)} annotations ${status.toLowerCase()}`); + notification.success({ + message: `${String(count)} annotations ${status.toLowerCase()}`, + }); } catch { - message.error('Failed to review annotations'); + notification.error({ message: 'Failed to review annotations' }); } }; @@ -166,13 +168,14 @@ const ProjectReview: React.FC = observer(() => { value={reviewStore.statusFilter} onChange={(v) => reviewStore.setStatusFilter(v)} style={{ width: 160 }} - > - All - Draft - Submitted - Approved - Rejected - + options={[ + { value: '', label: 'All' }, + { value: 'DRAFT', label: 'Draft' }, + { value: 'SUBMITTED', label: 'Submitted' }, + { value: 'APPROVED', label: 'Approved' }, + { value: 'REJECTED', label: 'Rejected' }, + ]} + /> {reviewStore.annotationsTotal} annotation {reviewStore.annotationsTotal !== 1 ? 's' : ''} diff --git a/frontend/src/pages/ProjectSettings.tsx b/frontend/src/pages/ProjectSettings.tsx index 5c417ca..6de0f72 100644 --- a/frontend/src/pages/ProjectSettings.tsx +++ b/frontend/src/pages/ProjectSettings.tsx @@ -51,7 +51,7 @@ import { const ProjectSettings: React.FC = observer(() => { const { projectId } = useParams(); const navigate = useNavigate(); - const { message, modal } = App.useApp(); + const { notification, modal } = App.useApp(); const { token: themeToken } = theme.useToken(); const canManage = projectRoleStore.canManageProject; const queryClient = useQueryClient(); @@ -84,7 +84,7 @@ const ProjectSettings: React.FC = observer(() => { } catch (err) { const errObj = err as { response?: { data?: { detail?: string } } }; const detail = errObj.response?.data?.detail; - message.error(detail ?? 'Failed to preview file'); + notification.error({ message: detail ?? 'Failed to preview file' }); } return false; }; @@ -95,11 +95,13 @@ const ProjectSettings: React.FC = observer(() => { String(projectId), replaceOnImport ); - message.success(`Import successful! ${String(count)} tasks imported.`); + notification.success({ + message: `Import successful! ${String(count)} tasks imported.`, + }); } catch (err) { const errObj = err as { response?: { data?: { detail?: string } } }; const detail = errObj.response?.data?.detail; - message.error(detail ?? 'Import failed'); + notification.error({ message: detail ?? 'Import failed' }); } }; @@ -107,7 +109,7 @@ const ProjectSettings: React.FC = observer(() => { try { await projectSettingsStore.exportData(String(projectId), format); } catch { - message.error('Export failed'); + notification.error({ message: 'Export failed' }); } }; @@ -124,7 +126,7 @@ const ProjectSettings: React.FC = observer(() => { setOpenAddMember(false); addForm.resetFields(); } catch { - message.error('Failed to add member'); + notification.error({ message: 'Failed to add member' }); } }; @@ -132,7 +134,7 @@ const ProjectSettings: React.FC = observer(() => { try { await projectSettingsStore.removeMember(String(projectId), userId); } catch { - message.error('Failed to remove member'); + notification.error({ message: 'Failed to remove member' }); } }; @@ -140,7 +142,7 @@ const ProjectSettings: React.FC = observer(() => { try { await projectSettingsStore.changeRole(String(projectId), userId, role); } catch { - message.error('Failed to update role'); + notification.error({ message: 'Failed to update role' }); } }; @@ -154,10 +156,10 @@ const ProjectSettings: React.FC = observer(() => { onOk: async () => { try { await api.delete(`/projects/${String(projectId)}`); - message.success('Project deleted'); + notification.success({ message: 'Project deleted' }); void navigate('/'); } catch { - message.error('Failed to delete project'); + notification.error({ message: 'Failed to delete project' }); } }, }); @@ -170,7 +172,7 @@ const ProjectSettings: React.FC = observer(() => { void refetchProject(); void queryClient.invalidateQueries({ queryKey: ['project', projectId] }); } catch { - message.error('Failed to update project'); + notification.error({ message: 'Failed to update project' }); } }; @@ -179,7 +181,7 @@ const ProjectSettings: React.FC = observer(() => { if (!label) return; const current = project?.config.labels ?? []; if (current.includes(label)) { - message.warning('Label already exists'); + notification.warning({ message: 'Label already exists' }); return; } void updateProjectConfig({ @@ -504,7 +506,7 @@ const ProjectSettings: React.FC = observer(() => { File: {importPreview.filename} @@ -671,14 +673,13 @@ const ProjectSettings: React.FC = observer(() => { void handleChangeRole(record.user_id, value) } style={{ width: 120 }} - > - {authStore.isAdmin && ( - - Manager - - )} - Editor - + options={[ + ...(authStore.isAdmin + ? [{ value: 'MANAGER', label: 'Manager' }] + : []), + { value: 'EDITOR', label: 'Editor' }, + ]} + /> ); }, }, @@ -732,13 +733,16 @@ const ProjectSettings: React.FC = observer(() => { label="User" rules={[{ required: true }]} > - + - {authStore.isAdmin && ( - Manager - )} - Editor - + + options={ACTION_OPTIONS.map((a) => ({ value: a, label: a }))} + /> + options={RESOURCE_TYPE_OPTIONS.map((r) => ({ value: r, label: r }))} + /> diff --git a/frontend/src/pages/admin/InvitationsManagement.tsx b/frontend/src/pages/admin/InvitationsManagement.tsx index 5d99e39..ee51b07 100644 --- a/frontend/src/pages/admin/InvitationsManagement.tsx +++ b/frontend/src/pages/admin/InvitationsManagement.tsx @@ -7,7 +7,7 @@ import { Modal, Form, Input, - message, + App, Tooltip, Popconfirm, Table, @@ -28,6 +28,7 @@ import type { Invitation } from '../../types/api'; type StatusFilter = 'pending' | 'expired'; const InvitationsManagement: React.FC = observer(() => { + const { notification } = App.useApp(); const queryClient = useQueryClient(); const [modalOpen, setModalOpen] = useState(false); const [form] = Form.useForm(); @@ -48,18 +49,20 @@ const InvitationsManagement: React.FC = observer(() => { void queryClient.invalidateQueries({ queryKey: ['invitations'] }); setModalOpen(false); form.resetFields(); - message.success('Invitation created'); + notification.success({ message: 'Invitation created' }); }, - onError: () => message.error('Failed to create invitation'), + onError: () => + notification.error({ message: 'Failed to create invitation' }), }); const deleteInvitation = useMutation({ mutationFn: (id: number) => api.delete(`/invitations/${String(id)}`), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ['invitations'] }); - message.success('Invitation deleted'); + notification.success({ message: 'Invitation deleted' }); }, - onError: () => message.error('Failed to delete invitation'), + onError: () => + notification.error({ message: 'Failed to delete invitation' }), }); const regenerateInvitation = useMutation({ @@ -68,9 +71,10 @@ const InvitationsManagement: React.FC = observer(() => { onSuccess: () => { setStatusFilter('pending'); void queryClient.invalidateQueries({ queryKey: ['invitations'] }); - message.success('Invitation regenerated'); + notification.success({ message: 'Invitation regenerated' }); }, - onError: () => message.error('Failed to regenerate invitation'), + onError: () => + notification.error({ message: 'Failed to regenerate invitation' }), }); const copyLink = (inv: Invitation) => { @@ -78,7 +82,7 @@ const InvitationsManagement: React.FC = observer(() => { void navigator.clipboard.writeText(url); setCopiedId(inv.id); setTimeout(() => setCopiedId(null), 2000); - message.success('Invite link copied!'); + notification.success({ message: 'Invite link copied!' }); }; if (isLoading) return ; diff --git a/frontend/src/pages/admin/MembersManagement.tsx b/frontend/src/pages/admin/MembersManagement.tsx index fc5e181..5534ee8 100644 --- a/frontend/src/pages/admin/MembersManagement.tsx +++ b/frontend/src/pages/admin/MembersManagement.tsx @@ -9,7 +9,7 @@ import { Modal, Form, Select, - message, + App, Space, Input, Switch, @@ -38,6 +38,7 @@ const ROLE_COLOR: Record = { }; const MembersManagement: React.FC = observer(() => { + const { notification } = App.useApp(); const queryClient = useQueryClient(); const [modalOpen, setModalOpen] = useState(false); const [selectedUser, setSelectedUser] = @@ -69,9 +70,10 @@ const MembersManagement: React.FC = observer(() => { setModalOpen(false); setSelectedUser(null); form.resetFields(); - message.success('User added to project'); + notification.success({ message: 'User added to project' }); }, - onError: () => message.error('Failed to add user to project'), + onError: () => + notification.error({ message: 'Failed to add user to project' }), }); const removeFromProject = useMutation({ @@ -81,9 +83,10 @@ const MembersManagement: React.FC = observer(() => { ), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ['users'] }); - message.success('User removed from project'); + notification.success({ message: 'User removed from project' }); }, - onError: () => message.error('Failed to remove user from project'), + onError: () => + notification.error({ message: 'Failed to remove user from project' }), }); const updateRole = useMutation({ @@ -100,9 +103,9 @@ const MembersManagement: React.FC = observer(() => { ), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ['users'] }); - message.success('Role updated'); + notification.success({ message: 'Role updated' }); }, - onError: () => message.error('Failed to update role'), + onError: () => notification.error({ message: 'Failed to update role' }), }); const toggleActive = useMutation({ @@ -110,9 +113,10 @@ const MembersManagement: React.FC = observer(() => { api.patch(`/users/${String(userId)}/toggle-active`), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ['users'] }); - message.success('User status updated'); + notification.success({ message: 'User status updated' }); }, - onError: () => message.error('Failed to update user status'), + onError: () => + notification.error({ message: 'Failed to update user status' }), }); const changeGlobalRole = useMutation({ @@ -120,9 +124,10 @@ const MembersManagement: React.FC = observer(() => { api.patch(`/users/${String(vars.userId)}/role`, { role: vars.role }), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ['users'] }); - message.success('Global role updated'); + notification.success({ message: 'Global role updated' }); }, - onError: () => message.error('Failed to update global role'), + onError: () => + notification.error({ message: 'Failed to update global role' }), }); const openAddToProject = (user: UserListItemWithProjects) => { @@ -180,10 +185,11 @@ const MembersManagement: React.FC = observer(() => { }) } style={{ width: 120 }} - > - Manager - Editor - + options={[ + { value: 'MANAGER', label: 'Manager' }, + { value: 'EDITOR', label: 'Editor' }, + ]} + /> ), }, { @@ -258,16 +264,21 @@ const MembersManagement: React.FC = observer(() => { }) } style={{ width: 130 }} - > - - - ADMIN - - - - USER - - + options={[ + { + value: 'ADMIN', + label: ( + + ADMIN + + ), + }, + { + value: 'USER', + label: USER, + }, + ]} + /> ), }, { @@ -355,13 +366,14 @@ const MembersManagement: React.FC = observer(() => { label="Project" rules={[{ required: true, message: 'Select a project' }]} > - + - Manager - Editor - + } placeholder="Full name" /> + + )} + + - {title} - - - {subtitle} - -
+ } + placeholder="Email" + disabled={!!invitedEmail} + /> + -
-
) => { - void handleSubmit(values); - }} - layout="vertical" - size="large" + - {formMode !== 'login' && ( - - } placeholder="Full name" /> - - )} + } placeholder="Password" /> + + {formMode !== 'login' && ( - } - placeholder="Email" - disabled={!!invitedEmail} - /> - + { required: true, message: 'Confirm your password' }, + ({ getFieldValue }) => ({ + validator(_, value) { + if (!value || getFieldValue('password') === value) { + return Promise.resolve(); + } - - } placeholder="Password" /> + } + placeholder="Confirm password" + /> + )} - {formMode !== 'login' && ( - ({ - validator(_, value) { - if (!value || getFieldValue('password') === value) { - return Promise.resolve(); - } - return Promise.reject(new Error('Passwords do not match')); - }, - }), - ]} - > - } - placeholder="Confirm password" - /> - - )} - - - - + + + - {formMode !== 'setup' && ( -
- - {formMode === 'login' + {formMode !== 'setup' && ( +
+ + {formMode === 'login' + ? inviteToken && !authStore.tokenError ? 'Have an invite link? Use it to ' - : 'Already have an account? '} - { - if (formMode === 'invite') { - setMode('login'); - // Clear token-related state when switching away from invite - setInvitedEmail(null); + if (formMode === 'invite' || formMode === 'register') { + authStore.setMode('login'); form.setFieldsValue({ email: '' }); } else { - setMode(formMode === 'login' ? 'register' : 'login'); + authStore.setMode('register'); } }} > {formMode === 'login' ? 'register' : 'Sign in'} - - -
- )} + + )} + +
+ )} - {formMode === 'register' && !inviteToken && ( -
- - Registration requires an invitation link from your admin. - -
- )} - -
-
+ {(formMode === 'register' || formMode === 'invite') && !inviteToken && ( +
+ + Registration requires an invitation link from your admin. + +
+ )} + + ); }); diff --git a/frontend/src/components/editors/NEREditor.tsx b/frontend/src/components/editors/NEREditor.tsx index 9636997..ae0c958 100644 --- a/frontend/src/components/editors/NEREditor.tsx +++ b/frontend/src/components/editors/NEREditor.tsx @@ -19,9 +19,9 @@ import type { ProjectConfig, } from '../../types/api'; import { isNERTaskData } from '../../types/api'; -import { tokens } from '../../styles/design-tokens'; import { LABEL_COLORS } from '../../types/domain'; import { nerEditorStore } from '../../store/nerEditorStore'; +import { theme } from 'antd'; interface Props { task: Task; @@ -44,6 +44,7 @@ const DEFAULT_LABELS = [ ]; const NEREditor: React.FC = observer(({ task, config, onSubmit }) => { + const { token: themeToken } = theme.useToken(); const labels = useMemo( () => config.labels ?? DEFAULT_LABELS, [config.labels] @@ -149,7 +150,7 @@ const NEREditor: React.FC = observer(({ task, config, onSubmit }) => { key={label} color={ nerEditorStore.selectedLabel === label - ? LABEL_COLORS[label] || tokens.colors.primary + ? LABEL_COLORS[label] || themeToken.colorPrimary : undefined } onClick={() => nerEditorStore.setSelectedLabel(label)} @@ -318,7 +319,7 @@ const NEREditor: React.FC = observer(({ task, config, onSubmit }) => { if (activeEntity) { const color = - LABEL_COLORS[activeEntity.label] || tokens.colors.primary; + LABEL_COLORS[activeEntity.label] || themeToken.colorPrimary; return ( { - const isDark = themeStore.resolvedMode === 'dark'; - return ( -
-
+ ); }); diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index ec856d3..28efda3 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -37,11 +37,12 @@ import type { ProjectStats, } from '../types/api'; import { projectTypeColor, ProjectRoles, ProjectTypes } from '../types/domain'; -import { tokens } from '../styles/design-tokens'; import { LANGUAGE_OPTIONS } from '../constants/languages'; import { motion } from 'framer-motion'; +import { theme } from 'antd'; const Dashboard: React.FC = observer(() => { + const { token: themeToken } = theme.useToken(); const navigate = useNavigate(); const queryClient = useQueryClient(); const [modalOpen, setModalOpen] = useState(false); @@ -169,20 +170,24 @@ const Dashboard: React.FC = observer(() => { { title: 'Users', value: overview.total_users, - color: tokens.colors.primary, + color: themeToken.colorPrimary, }, ] : []), { title: 'Projects', value: projects.length, - color: tokens.colors.accent, + color: themeToken.colorInfo, + }, + { + title: 'Tasks', + value: totalTasks, + color: themeToken.colorSuccess, }, - { title: 'Tasks', value: totalTasks, color: tokens.colors.success }, { title: 'Pending Review', value: totalPending, - color: tokens.colors.warning, + color: themeToken.colorWarning, }, ].map((stat, i) => (
@@ -193,9 +198,9 @@ const Dashboard: React.FC = observer(() => { > @@ -302,7 +307,7 @@ const Dashboard: React.FC = observer(() => { > } > -
+ Technical details: @@ -140,28 +150,29 @@ const AuthForm: React.FC = observer(() => { {' '} {setupError || 'Unknown error'} -
+ - + ); } if (authStore.isSetup === null || isValidatingToken) { return ( -
+ -
+ ); } const cardHeaderTextEl = ( -
@@ -169,7 +180,7 @@ const AuthForm: React.FC = observer(() => { {subtitle} -
+ ); return ( @@ -261,7 +272,7 @@ const AuthForm: React.FC = observer(() => { {formMode !== 'setup' && ( -
+ {formMode === 'login' ? inviteToken && !authStore.tokenError @@ -288,15 +299,15 @@ const AuthForm: React.FC = observer(() => { )} -
+ )} {(formMode === 'register' || formMode === 'invite') && !inviteToken && ( -
+ Registration requires an invitation link from your admin. -
+ )}
diff --git a/frontend/src/components/editors/NEREditor.tsx b/frontend/src/components/editors/NEREditor.tsx index ae0c958..7e4df83 100644 --- a/frontend/src/components/editors/NEREditor.tsx +++ b/frontend/src/components/editors/NEREditor.tsx @@ -19,7 +19,7 @@ import type { ProjectConfig, } from '../../types/api'; import { isNERTaskData } from '../../types/api'; -import { LABEL_COLORS } from '../../types/domain'; +import { LABEL_COLORS } from '../../types'; import { nerEditorStore } from '../../store/nerEditorStore'; import { theme } from 'antd'; diff --git a/frontend/src/components/editors/TranslationEditor.tsx b/frontend/src/components/editors/TranslationEditor.tsx index 6d4d9e0..74b8ef2 100644 --- a/frontend/src/components/editors/TranslationEditor.tsx +++ b/frontend/src/components/editors/TranslationEditor.tsx @@ -17,7 +17,7 @@ import type { TaskAnnotation, } from '../../types/api'; import { isTranslationTaskData } from '../../types/api'; -import { annotationStatusColor } from '../../types/domain'; +import { annotationStatusColor } from '../../types'; import { observer } from 'mobx-react-lite'; interface Props { diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx index 5f7a76a..4b3cd79 100644 --- a/frontend/src/components/layout/AppLayout.tsx +++ b/frontend/src/components/layout/AppLayout.tsx @@ -41,7 +41,7 @@ import { authStore } from '../../store/authStore'; import { themeStore } from '../../store/themeStore'; import { projectRoleStore } from '../../store/projectRoleStore'; import { inboxStore } from '../../store/inboxStore'; -import { GlobalRoles } from '../../types/domain'; +import { GlobalRoles } from '../../types'; import CommandPalette from '../CommandPalette'; const { Header, Sider, Content } = Layout; diff --git a/frontend/src/components/workspace/ContextPanel.tsx b/frontend/src/components/workspace/ContextPanel.tsx index 012a143..6e33eda 100644 --- a/frontend/src/components/workspace/ContextPanel.tsx +++ b/frontend/src/components/workspace/ContextPanel.tsx @@ -9,7 +9,7 @@ import { isTranslationTaskData, getTaskText, } from '../../types/api'; -import { ProjectTypes } from '../../types/domain'; +import { ProjectTypes } from '../../types'; import type { GlobalToken } from 'antd'; interface TranslationMemoryMatch { diff --git a/frontend/src/components/workspace/VotingPanel.tsx b/frontend/src/components/workspace/VotingPanel.tsx index 1cb2765..50821c9 100644 --- a/frontend/src/components/workspace/VotingPanel.tsx +++ b/frontend/src/components/workspace/VotingPanel.tsx @@ -28,11 +28,7 @@ import type { ProjectType, VoteValue, } from '../../types/api'; -import { - LABEL_COLORS, - annotationStatusColor, - ProjectTypes, -} from '../../types/domain'; +import { LABEL_COLORS, annotationStatusColor, ProjectTypes } from '../../types'; interface Props { taskId: number; diff --git a/frontend/src/constants/languages.ts b/frontend/src/constants/languages.ts index c971bd3..fac11fb 100644 --- a/frontend/src/constants/languages.ts +++ b/frontend/src/constants/languages.ts @@ -1,4 +1,4 @@ -import type { LanguageCode } from '../types/domain'; +import type { LanguageCode } from '../types'; export const LANGUAGE_OPTIONS: { value: LanguageCode; label: string }[] = [ { value: 'en', label: 'English' }, diff --git a/frontend/src/pages/Auth.tsx b/frontend/src/pages/Auth.tsx index a3bc9da..c88e174 100644 --- a/frontend/src/pages/Auth.tsx +++ b/frontend/src/pages/Auth.tsx @@ -1,20 +1,18 @@ import React from 'react'; import AuthForm from '../components/AuthForm'; import { observer } from 'mobx-react-lite'; +import { Flex } from 'antd'; const Auth: React.FC = observer(() => { return ( -
-
+ ); }); diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 28efda3..a869eba 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -36,7 +36,7 @@ import type { OverviewStats, ProjectStats, } from '../types/api'; -import { projectTypeColor, ProjectRoles, ProjectTypes } from '../types/domain'; +import { projectTypeColor, ProjectRoles, ProjectTypes } from '../types'; import { LANGUAGE_OPTIONS } from '../constants/languages'; import { motion } from 'framer-motion'; import { theme } from 'antd'; diff --git a/frontend/src/pages/ProjectReview.tsx b/frontend/src/pages/ProjectReview.tsx index 943c0bd..e8a3da9 100644 --- a/frontend/src/pages/ProjectReview.tsx +++ b/frontend/src/pages/ProjectReview.tsx @@ -33,7 +33,7 @@ import { annotationStatusColor, AnnotationStatuses, ProjectTypes, -} from '../types/domain'; +} from '../types'; import { reviewStore } from '../store/reviewStore'; const ProjectReview: React.FC = observer(() => { diff --git a/frontend/src/pages/ProjectSettings.tsx b/frontend/src/pages/ProjectSettings.tsx index 6de0f72..8209cee 100644 --- a/frontend/src/pages/ProjectSettings.tsx +++ b/frontend/src/pages/ProjectSettings.tsx @@ -46,7 +46,7 @@ import { ProjectRoles, ProjectTypes, projectTypeColor, -} from '../types/domain'; +} from '../types'; const ProjectSettings: React.FC = observer(() => { const { projectId } = useParams(); diff --git a/frontend/src/pages/ProjectStats.tsx b/frontend/src/pages/ProjectStats.tsx index 01d0ac1..cb19afc 100644 --- a/frontend/src/pages/ProjectStats.tsx +++ b/frontend/src/pages/ProjectStats.tsx @@ -26,7 +26,7 @@ import type { ProjectStats as ProjectStatsType, AnnotatorStats, } from '../types/api'; -import { LABEL_COLORS } from '../types/domain'; +import { LABEL_COLORS } from '../types'; interface TimelineEntry { date: string; diff --git a/frontend/src/pages/Workspace.tsx b/frontend/src/pages/Workspace.tsx index ea49992..d1bdb69 100644 --- a/frontend/src/pages/Workspace.tsx +++ b/frontend/src/pages/Workspace.tsx @@ -45,7 +45,7 @@ import { type TaskListItem, type TaskListResponse, } from '../types/api'; -import { ProjectTypes, projectTypeColor, taskStatus } from '../types/domain'; +import { ProjectTypes, projectTypeColor, taskStatus } from '../types'; import { workspaceStore } from '../store/workspaceStore'; const Workspace: React.FC = observer(() => { diff --git a/frontend/src/pages/admin/MembersManagement.tsx b/frontend/src/pages/admin/MembersManagement.tsx index 5534ee8..5eb9061 100644 --- a/frontend/src/pages/admin/MembersManagement.tsx +++ b/frontend/src/pages/admin/MembersManagement.tsx @@ -30,7 +30,7 @@ import type { RoleProject, GlobalRole, } from '../../types/api'; -import { GlobalRoles } from '../../types/domain'; +import { GlobalRoles } from '../../types'; const ROLE_COLOR: Record = { ADMIN: 'blue', diff --git a/frontend/src/store/authStore.ts b/frontend/src/store/authStore.ts index 1c845af..4c37d8e 100644 --- a/frontend/src/store/authStore.ts +++ b/frontend/src/store/authStore.ts @@ -1,4 +1,4 @@ -import { makeAutoObservable } from 'mobx'; +import { makeAutoObservable, runInAction } from 'mobx'; import api from '../api/client'; import { isTokenResponse, @@ -15,7 +15,7 @@ import { GlobalRoles, type AuthFormMode, type AuthFormToggleMode, -} from '../types/domain'; +} from '../types'; export interface VerifyInvitationResult { email: string; @@ -95,11 +95,15 @@ class AuthStore { try { const res = await api.get('/auth/setup-status'); - if (isSetupStatusResponse(res.data)) { - this.setIsSetup(res.data.is_setup); - } + runInAction(() => { + if (isSetupStatusResponse(res.data)) { + this.setIsSetup(res.data.is_setup); + } + }); } catch (error) { - this.setupError = toUserError(error); + runInAction(() => { + this.setupError = toUserError(error); + }); console.error('Failed to check setup status', error); } }; @@ -107,14 +111,18 @@ class AuthStore { public fetchProfile = async () => { try { const res = await api.get('/users/me'); - if (isUserProfile(res.data)) { - this.setAuthData(res.data); - } else { - this.logout(); - } + runInAction(() => { + if (isUserProfile(res.data)) { + this.setAuthData(res.data); + } else { + this.logout(); + } + }); } catch (e) { console.error('Failed to fetch profile', e); - this.logout(); + runInAction(() => { + this.logout(); + }); } }; @@ -130,36 +138,47 @@ class AuthStore { try { const { email } = await this.verifyInvitation(token); - this.invitedEmail = email; + runInAction(() => { + this.invitedEmail = email; + }); } catch (e) { - this.tokenError = toUserError(e); - this.formModeToggle = 'login'; + runInAction(() => { + this.tokenError = toUserError(e); + this.formModeToggle = 'login'; + }); throw e; } finally { - this.isValidatingToken = false; + runInAction(() => { + this.isValidatingToken = false; + }); } } }; public login = async (email: string, password: string): Promise => { this.setLoading(true); + try { + const params = new URLSearchParams(); + params.append('username', email); + params.append('password', password); - const params = new URLSearchParams(); - params.append('username', email); - params.append('password', password); + const res = await api.post('/auth/login', params, { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + }); - const res = await api.post('/auth/login', params, { - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - }); + if (!isTokenResponse(res.data)) { + throw new Error('Invalid server response.'); + } - if (!isTokenResponse(res.data)) { - throw new Error('Invalid server response.'); + runInAction(() => { + this.handleTokenResponse(res.data); + }); + await this.fetchProfile(); + } finally { + runInAction(() => { + this.setLoading(false); + }); } - - this.handleTokenResponse(res.data); - await this.fetchProfile(); - - this.setLoading(false); }; public register = async ( @@ -169,22 +188,27 @@ class AuthStore { invitationToken: string ): Promise => { this.setLoading(true); + try { + const res = await api.post('/auth/register', { + email, + password, + full_name: name, + invitation_token: invitationToken, + }); + + if (!isTokenResponse(res.data)) { + throw new Error('Invalid server response.'); + } - const res = await api.post('/auth/register', { - email, - password, - full_name: name, - invitation_token: invitationToken, - }); - - if (!isTokenResponse(res.data)) { - throw new Error('Invalid server response.'); + runInAction(() => { + this.handleTokenResponse(res.data); + }); + await this.fetchProfile(); + } finally { + runInAction(() => { + this.setLoading(false); + }); } - - this.handleTokenResponse(res.data); - await this.fetchProfile(); - - this.setLoading(false); }; public setup = async ( @@ -193,22 +217,26 @@ class AuthStore { fullName: string ): Promise => { this.setLoading(true); + try { + const res = await api.post('/auth/setup', { + email, + password, + full_name: fullName, + }); + if (!isTokenResponse(res.data)) { + throw new Error('Invalid server response.'); + } - const res = await api.post('/auth/setup', { - email, - password, - full_name: fullName, - }); - if (!isTokenResponse(res.data)) { - throw new Error('Invalid server response.'); + runInAction(() => { + this.handleTokenResponse(res.data); + this.setIsSetup(true); + }); + await this.fetchProfile(); + } finally { + runInAction(() => { + this.setLoading(false); + }); } - - this.handleTokenResponse(res.data); - this.setIsSetup(true); - - await this.fetchProfile(); - - this.setLoading(false); }; public logout = () => { diff --git a/frontend/src/store/projectRoleStore.ts b/frontend/src/store/projectRoleStore.ts index d9bcd7f..98c8ec1 100644 --- a/frontend/src/store/projectRoleStore.ts +++ b/frontend/src/store/projectRoleStore.ts @@ -1,6 +1,6 @@ import { makeAutoObservable } from 'mobx'; -import type { RoleProject, GlobalRole } from '../types/domain'; -import { isAdmin, isManagerOrAdmin, ProjectRoles } from '../types/domain'; +import type { RoleProject, GlobalRole } from '../types'; +import { isAdmin, isManagerOrAdmin, ProjectRoles } from '../types'; import { authStore } from './authStore'; class ProjectRoleStore { diff --git a/frontend/src/store/reviewStore.ts b/frontend/src/store/reviewStore.ts index 45217f0..2f6e18a 100644 --- a/frontend/src/store/reviewStore.ts +++ b/frontend/src/store/reviewStore.ts @@ -5,7 +5,7 @@ import type { AnnotationListResponse, AnnotationStatus, } from '../types/api'; -import { AnnotationStatuses } from '../types/domain'; +import { AnnotationStatuses } from '../types'; class ReviewStore { annotations: AnnotationListItem[] = []; diff --git a/frontend/src/store/workspaceStore.ts b/frontend/src/store/workspaceStore.ts index f4b41d3..b8a47fa 100644 --- a/frontend/src/store/workspaceStore.ts +++ b/frontend/src/store/workspaceStore.ts @@ -2,7 +2,7 @@ import { makeAutoObservable } from 'mobx'; import type { QueryClient } from '@tanstack/react-query'; import api from '../api/client'; import { getTaskText, type TaskListItem } from '../types/api'; -import type { TaskStatusFilter } from '../types/domain'; +import type { TaskStatusFilter } from '../types'; class WorkspaceStore { selectedTaskId: number | null = null; diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 98e4f77..9cbb4cc 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -13,8 +13,8 @@ import type { NotificationResourceType, AuditAction, AuditResourceType, -} from './domain'; -import { ProjectTypes } from './domain'; +} from '.'; +import { ProjectTypes } from '.'; // Re-export enums for consumers that import from api.ts export type { @@ -396,18 +396,25 @@ export function isProject(data: unknown): data is Project { export function toUserError(e: unknown): string { if (!isAxiosError(e)) { + if (e instanceof Error) return e.message; return 'Connection error. Check if the server is running.'; } const status = e.response?.status; + const rawDetail = (e.response?.data as Record | undefined) + ?.detail; if (!status) return 'Connection error. Check if the server is running.'; if (status >= 500) return 'Server error. Please try again later.'; - if (status === 401 || status === 400) return 'Invalid credentials or data.'; + if (rawDetail && typeof rawDetail === 'string') return rawDetail; + + if (status === 401) return 'Invalid credentials.'; if (status === 403) return 'Access denied.'; + if (status === 400) return 'Invalid request.'; + return 'Something went wrong. Please try again.'; } diff --git a/frontend/src/types/domain.ts b/frontend/src/types/index.ts similarity index 100% rename from frontend/src/types/domain.ts rename to frontend/src/types/index.ts From 375fc6a36e3603d563e58c0b0fc80ffc580bc294 Mon Sep 17 00:00:00 2001 From: mvoof Date: Sat, 21 Mar 2026 23:45:50 +0500 Subject: [PATCH 36/48] fix(theme): replace hardcoded navy blue with algorithm-derived theme colors - Add theme="light" to Sider for theme-aware sidebar background - Override headerBg/bodyBg via ConfigProvider Layout component tokens - Replace hardcoded white text in Dashboard stats with Typography.Text - Extract static styles into SCSS modules for AppLayout and AppHeader --- frontend/package-lock.json | 402 ++++++++++++++++++ frontend/package.json | 3 +- frontend/src/App.tsx | 19 +- .../components/layout/AppHeader.module.scss | 22 + frontend/src/components/layout/AppHeader.tsx | 244 +++++++++++ .../components/layout/AppLayout.module.scss | 26 ++ frontend/src/components/layout/AppLayout.tsx | 242 +---------- frontend/src/pages/Dashboard.tsx | 90 ++-- 8 files changed, 760 insertions(+), 288 deletions(-) create mode 100644 frontend/src/components/layout/AppHeader.module.scss create mode 100644 frontend/src/components/layout/AppHeader.tsx create mode 100644 frontend/src/components/layout/AppLayout.module.scss diff --git a/frontend/package-lock.json b/frontend/package-lock.json index accaf15..b219a08 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -40,6 +40,7 @@ "eslint-plugin-react-refresh": "^0.4.22", "globals": "^16.4.0", "prettier": "3.6.2", + "sass": "^1.98.0", "typescript": "~5.9.3", "typescript-eslint": "^8.45.0", "vite": "^7.1.7" @@ -1648,6 +1649,330 @@ "node": ">= 8" } }, + "node_modules/@parcel/watcher": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", + "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.6", + "@parcel/watcher-darwin-arm64": "2.5.6", + "@parcel/watcher-darwin-x64": "2.5.6", + "@parcel/watcher-freebsd-x64": "2.5.6", + "@parcel/watcher-linux-arm-glibc": "2.5.6", + "@parcel/watcher-linux-arm-musl": "2.5.6", + "@parcel/watcher-linux-arm64-glibc": "2.5.6", + "@parcel/watcher-linux-arm64-musl": "2.5.6", + "@parcel/watcher-linux-x64-glibc": "2.5.6", + "@parcel/watcher-linux-x64-musl": "2.5.6", + "@parcel/watcher-win32-arm64": "2.5.6", + "@parcel/watcher-win32-ia32": "2.5.6", + "@parcel/watcher-win32-x64": "2.5.6" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz", + "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz", + "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz", + "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz", + "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz", + "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz", + "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz", + "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz", + "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz", + "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz", + "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz", + "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz", + "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz", + "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/@pkgr/core": { "version": "0.2.9", "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", @@ -4069,6 +4394,22 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/chrome-trace-event": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", @@ -4658,6 +4999,17 @@ "node": ">=0.4.0" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -5888,6 +6240,13 @@ "node": ">= 4" } }, + "node_modules/immutable": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz", + "integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==", + "dev": true, + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -6837,6 +7196,14 @@ "license": "MIT", "peer": true }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/node-releases": { "version": "2.0.36", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", @@ -7346,6 +7713,20 @@ "react-dom": ">=18" } }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -7554,6 +7935,27 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/sass": { + "version": "1.98.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.98.0.tgz", + "integrity": "sha512-+4N/u9dZ4PrgzGgPlKnaaRQx64RO0JBKs9sDhQ2pLgN6JQZ25uPQZKQYaBJU48Kd5BxgXoJ4e09Dq7nMcOUW3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.0", + "immutable": "^5.1.5", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 06a2666..8e1fb55 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -27,13 +27,13 @@ }, "devDependencies": { "@eslint/js": "^9.36.0", + "@playwright/test": "^1.58.2", "@types/eslint-plugin-mobx": "^0.0.0", "@types/node": "^24.6.0", "@types/react": "^19.1.16", "@types/react-dom": "^19.1.9", "@typescript-eslint/parser": "^8.46.0", "@vitejs/plugin-react": "^5.0.4", - "@playwright/test": "^1.58.2", "eslint": "^9.37.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-jsx-a11y": "^6.10.2", @@ -44,6 +44,7 @@ "eslint-plugin-react-refresh": "^0.4.22", "globals": "^16.4.0", "prettier": "3.6.2", + "sass": "^1.98.0", "typescript": "~5.9.3", "typescript-eslint": "^8.45.0", "vite": "^7.1.7" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4913555..005be58 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -15,19 +15,26 @@ const queryClient = new QueryClient({ const App: React.FC = observer(function App() { const isDarkTheme = themeStore.mode === 'dark'; + const algorithm = isDarkTheme + ? antTheme.darkAlgorithm + : antTheme.defaultAlgorithm; + const dt = antTheme.getDesignToken({ algorithm }); useEffect(() => { - // https://github.com/ant-design/ant-design/discussions/40290 - document.body.style.backgroundColor = isDarkTheme ? '#1a1a1a' : '#edeae5'; - }, [isDarkTheme]); + document.body.style.backgroundColor = dt.colorBgLayout; + }, [dt.colorBgLayout]); return ( diff --git a/frontend/src/components/layout/AppHeader.module.scss b/frontend/src/components/layout/AppHeader.module.scss new file mode 100644 index 0000000..af5fda4 --- /dev/null +++ b/frontend/src/components/layout/AppHeader.module.scss @@ -0,0 +1,22 @@ +.header { + padding: 0 24px; + display: flex; + align-items: center; + justify-content: space-between; + height: 56px; + line-height: 56px; + position: sticky; + top: 0; + z-index: 99; +} + +.headerMinimal { + padding: 0 24px; + height: 56px; + line-height: 56px; +} + +.inboxList { + max-height: 360px; + overflow: auto; +} diff --git a/frontend/src/components/layout/AppHeader.tsx b/frontend/src/components/layout/AppHeader.tsx new file mode 100644 index 0000000..bead377 --- /dev/null +++ b/frontend/src/components/layout/AppHeader.tsx @@ -0,0 +1,244 @@ +import React from 'react'; +import { useNavigate, useLocation } from 'react-router'; +import { observer } from 'mobx-react-lite'; +import { + Layout, + Typography, + Space, + Switch, + Tooltip, + Popover, + Badge, + Button, + Dropdown, + Avatar, + Tag, + Breadcrumb, + List, + Empty, + Flex, + theme, +} from 'antd'; +import { + SunOutlined, + MoonOutlined, + BellOutlined, + UserOutlined, + LogoutOutlined, +} from '@ant-design/icons'; +import { themeStore } from '../../store/themeStore'; +import { inboxStore } from '../../store/inboxStore'; +import { authStore } from '../../store/authStore'; +import { GlobalRoles } from '../../types'; +import styles from './AppHeader.module.scss'; + +const { Header } = Layout; + +interface AppHeaderProps { + mode: 'minimal' | 'full'; + projectId?: string | null; +} + +const AppHeader: React.FC = observer(({ mode, projectId }) => { + const { token: themeToken } = theme.useToken(); + const isDark = themeStore.mode === 'dark'; + + if (mode === 'minimal') { + return ( +
+ + + VerseLab + + + themeStore.toggle()} + checkedChildren={} + unCheckedChildren={} + /> + + +
+ ); + } + + // Full mode + const navigate = useNavigate(); + const location = useLocation(); + const user = authStore.user; + + const handleLogout = () => { + authStore.logout(); + void navigate('/auth'); + }; + + // Breadcrumbs + const breadcrumbItems: { title: React.ReactNode }[] = [ + { + title: ( + { + void navigate('/dashboard'); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') void navigate('/dashboard'); + }} + > + Projects + + ), + }, + ]; + if (projectId) { + breadcrumbItems.push({ title: Project #{projectId} }); + if (location.pathname.includes('/workspace')) + breadcrumbItems.push({ title: Workspace }); + else if (location.pathname.includes('/stats')) + breadcrumbItems.push({ title: Stats }); + else if (location.pathname.includes('/glossary')) + breadcrumbItems.push({ title: Glossary }); + else if (location.pathname.includes('/review')) + breadcrumbItems.push({ title: Review }); + else if (location.pathname.includes('/settings')) + breadcrumbItems.push({ title: Settings }); + } + + const inboxContent = ( + + + Inbox + {inboxStore.unreadCount > 0 && ( + + )} + + {inboxStore.notifications.length === 0 ? ( + + ) : ( + ( + + + {item.title} + + } + description={ + + {item.message} + + } + /> + + )} + className={styles.inboxList} + /> + )} + + ); + + const userMenuItems = [ + { + key: 'profile', + icon: , + label: 'Profile', + onClick: () => navigate('/profile'), + }, + { type: 'divider' as const }, + { + key: 'logout', + icon: , + label: 'Logout', + danger: true, + onClick: handleLogout, + }, + ]; + + return ( +
+ + + + + themeStore.toggle()} + checkedChildren={} + unCheckedChildren={} + /> + + + + +
+ ); +}); + +export default AppHeader; diff --git a/frontend/src/components/layout/AppLayout.module.scss b/frontend/src/components/layout/AppLayout.module.scss new file mode 100644 index 0000000..5862371 --- /dev/null +++ b/frontend/src/components/layout/AppLayout.module.scss @@ -0,0 +1,26 @@ +.sider { + position: fixed; + left: 0; + top: 0; + bottom: 0; + z-index: 100; +} + +.siderHeader { + height: 56px; +} + +.siderMenuWrapper { + flex: 1; + overflow: auto; +} + +.siderMenu { + border: none; + padding: 8px 4px; +} + +.content { + padding: 0; + min-height: calc(100vh - 56px); +} diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx index 4b3cd79..4d3e7ff 100644 --- a/frontend/src/components/layout/AppLayout.tsx +++ b/frontend/src/components/layout/AppLayout.tsx @@ -1,35 +1,14 @@ import { useState, useEffect } from 'react'; import { Outlet, useNavigate, useLocation } from 'react-router'; import { observer } from 'mobx-react-lite'; -import { - Layout, - Menu, - Avatar, - Dropdown, - Badge, - Button, - Typography, - Space, - Popover, - List, - Empty, - Tag, - Breadcrumb, - Tooltip, - Switch, - theme, -} from 'antd'; +import { Layout, Menu, Button, Typography, Flex, theme } from 'antd'; import { TeamOutlined, MailOutlined, UserOutlined, - LogoutOutlined, - BellOutlined, - AuditOutlined, MenuFoldOutlined, MenuUnfoldOutlined, - SunOutlined, - MoonOutlined, + AuditOutlined, AppstoreOutlined, BarChartOutlined, SettingOutlined, @@ -38,20 +17,19 @@ import { FileSearchOutlined, } from '@ant-design/icons'; import { authStore } from '../../store/authStore'; -import { themeStore } from '../../store/themeStore'; import { projectRoleStore } from '../../store/projectRoleStore'; import { inboxStore } from '../../store/inboxStore'; -import { GlobalRoles } from '../../types'; import CommandPalette from '../CommandPalette'; +import AppHeader from './AppHeader'; +import styles from './AppLayout.module.scss'; -const { Header, Sider, Content } = Layout; +const { Sider, Content } = Layout; const AppLayout: React.FC = observer(() => { const navigate = useNavigate(); const location = useLocation(); const { token: themeToken } = theme.useToken(); const [collapsed, setCollapsed] = useState(false); - const user = authStore.user; // Detect project context (deferred to avoid antd Menu layout thrashing) const projectMatch = /\/projects\/(\d+)/.exec(location.pathname); @@ -68,13 +46,6 @@ const AppLayout: React.FC = observer(() => { return () => inboxStore.stopPolling(); }, []); - const handleLogout = () => { - authStore.logout(); - void navigate('/auth'); - }; - - const isDark = themeStore.mode === 'dark'; - const canManageProject = projectRoleStore.canManageProject; const menuItems = [ @@ -148,145 +119,25 @@ const AppLayout: React.FC = observer(() => { }, ]; - // Breadcrumbs - const breadcrumbItems: { title: React.ReactNode }[] = [ - { - title: ( - { - void navigate('/dashboard'); - }} - onKeyDown={(e) => { - if (e.key === 'Enter') void navigate('/dashboard'); - }} - > - Projects - - ), - }, - ]; - if (projectId) { - breadcrumbItems.push({ title: Project #{projectId} }); - if (location.pathname.includes('/workspace')) - breadcrumbItems.push({ title: Workspace }); - else if (location.pathname.includes('/stats')) - breadcrumbItems.push({ title: Stats }); - else if (location.pathname.includes('/glossary')) - breadcrumbItems.push({ title: Glossary }); - else if (location.pathname.includes('/review')) - breadcrumbItems.push({ title: Review }); - else if (location.pathname.includes('/settings')) - breadcrumbItems.push({ title: Settings }); - } - - const inboxContent = ( -
-
- Inbox - {inboxStore.unreadCount > 0 && ( - - )} -
- {inboxStore.notifications.length === 0 ? ( - - ) : ( - ( - - - {item.title} - - } - description={ - - {item.message} - - } - /> - - )} - style={{ maxHeight: 360, overflow: 'auto' }} - /> - )} -
- ); - - const userMenuItems = [ - { - key: 'profile', - icon: , - label: 'Profile', - onClick: () => navigate('/profile'), - }, - { type: 'divider' as const }, - { - key: 'logout', - icon: , - label: 'Logout', - danger: true, - onClick: handleLogout, - }, - ]; - return ( -
{ onClick={() => setCollapsed(!collapsed)} size="small" /> -
-
+ + { onClick={({ key }) => { void navigate(key); }} - style={{ border: 'none', padding: '8px 4px' }} + className={styles.siderMenu} /> -
+
{ transition: 'margin-left 0.2s', }} > -
- - - - - themeStore.toggle()} - checkedChildren={} - unCheckedChildren={} - /> - - - - -
+ - +
diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index a869eba..57db186 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -18,6 +18,8 @@ import { Space, Progress, Tooltip, + Avatar, + Flex, } from 'antd'; import { PlusOutlined, @@ -142,28 +144,24 @@ const Dashboard: React.FC = observer(() => { : null; return ( -
+ {/* Continue button */} {lastActiveProject && ( -
- -
+ )} {/* Personal metrics */} {overview && ( - + {[ ...(authStore.isAdmin ? [ @@ -198,7 +196,7 @@ const Dashboard: React.FC = observer(() => { > { > {stat.title} - + } value={stat.value} - styles={{ content: { color: 'white', fontWeight: 700 } }} + styles={{ content: { fontWeight: 700 } }} /> @@ -223,16 +222,7 @@ const Dashboard: React.FC = observer(() => { )} {/* Header with search and filters */} -
+ Projects @@ -265,7 +255,7 @@ const Dashboard: React.FC = observer(() => { )} -
+
{isLoading ? ( @@ -353,21 +343,15 @@ const Dashboard: React.FC = observer(() => { > } style={{ - width: 40, - height: 40, borderRadius: 10, background: themeToken.colorPrimary, - display: 'flex', - alignItems: 'center', - justifyContent: 'center', }} - > - -
+ /> } title={ @@ -381,16 +365,10 @@ const Dashboard: React.FC = observer(() => { } description={ -
+ {stats && ( -
-
+ + { > {progress}% -
+ -
+
)} { > {new Date(project.created_at).toLocaleDateString()} -
+ } />
@@ -504,7 +482,7 @@ const Dashboard: React.FC = observer(() => { - + ); }); From f8e41f4a5755966107e9ab21926971f62ac4b665 Mon Sep 17 00:00:00 2001 From: mvoof Date: Sat, 21 Mar 2026 23:57:27 +0500 Subject: [PATCH 37/48] fix(theme): replace hardcoded colors with algorithm-derived theme tokens Add theme="light" to Sider, override headerBg/bodyBg in ConfigProvider, replace hardcoded white Dashboard stats text with Typography.Text. Add global SCSS reset/variables structure and SCSS modules for layout components. --- auth-final.png | Bin 0 -> 25824 bytes dashboard-dark-final.png | Bin 0 -> 67748 bytes dashboard-light-final.png | Bin 0 -> 62035 bytes frontend/src/App.tsx | 1 + .../components/layout/AppHeader.module.scss | 10 ++- .../components/layout/AppLayout.module.scss | 6 +- frontend/src/pages/Auth.tsx | 24 +++--- frontend/src/styles/_reset.scss | 73 ++++++++++++++++++ frontend/src/styles/_variables.scss | 4 + frontend/src/styles/index.scss | 2 + 10 files changed, 105 insertions(+), 15 deletions(-) create mode 100644 auth-final.png create mode 100644 dashboard-dark-final.png create mode 100644 dashboard-light-final.png create mode 100644 frontend/src/styles/_reset.scss create mode 100644 frontend/src/styles/_variables.scss create mode 100644 frontend/src/styles/index.scss diff --git a/auth-final.png b/auth-final.png new file mode 100644 index 0000000000000000000000000000000000000000..0cdbb629422097902b43f9f0327999405f456a3b GIT binary patch literal 25824 zcmeHwXIPWzx^C1_#s+poO2$D&q>1zvbOaeeKt(|a5Co-2limV2I*5XRNL8AOfJl?x zL8VFyy#(y=nIFIkVD*;O(FP%XqTX<_YH7C~gdLjLi4vgj7%_2~t}ZOH2{A$c1gJbUE!#$Vo(ZaRa! zkFtrpz46-l@4Uc0lb5-tDZq z-M}CwI@)qJ`8>^i<4!$&S`w5zKHlG2ryR#sWZmR=T#}N;C7|p%+5XB<>GEHXGlJoj zwTqRss9#x}PHtVA>ng`p`>FUW%ygz`DHl48))&gy#z^3*?u@@v7OAc);VW~SmNl<^ z6ciMcsV)51(l|zxm6ZwWrK+8q8_b&at69!?_s+YfrZ>}2YBg%0aN3smM#5um$gBJ9 z6?@(oB}@CUPdg>}&fID|9%J0;ZKbEzHQAAD!4@kXdRCbKT*``5_(sXyo1Kq~huyL9 zZi6;ye=X!}YD&tuqyH-Q*7!)J58A6XoY>TKG%;uk`90q9yZZzO<%%>hTRTp+ZralR zU2D_4a9aGnurTHM-@C`iZB~h1mW$tdjvqfRl!SNQ*t+jrGd%`4lVS&xzHAdNg|Vmn zI_E>-YVw}bnL{bRpf%FO=cohuR!;0gy*W5^PGVs~k!UllX=v!$Og`a|2WIVi4b5{V zw_$0rLvX_?7#cn8?Wg_M*CwJTzOL>jeeMy1>&ffb2wStdUj+&r;aAvZ+n1dn=V+?m z*qZBty{#a6<2`J!q^+R6ezb^)2#Gc4VU^Q2W{hb|lj`g4#?RW~lxVg4HNpf~OoG4P z%EA;=GT(|I0PA-%kbh~&g^=J>&qGP__nR@U!ZXKXa2O|Y?E_B5$t`Hc2QI(w5Bej} z)mhUs?W19;ejm4T^Z7BpS~P^RhA5cj`DO`6rw>{Zl?%@a_!H{_UkbW(e7**7ZqN3_7?Cob$Vob~YSSt)sbGik3wV&X>POtJ^HTC=&Fg`wRVB*$eP`%QV z`Tj1wC_CTRWuoP=mI~v`gN}5;izF{fLT;%|S1NfEt+OM2dC)coE~NeZ%y>l7+AUvS z-;G^wKLm!^H8eDoQ-+H^sq=V$acq9P)%aoMh4%AamqTw2df$FxKYYITf@WTkOXo!) zH?Uk-^p42YWvV}$wcOA)8mg3?mv?60-D?y5UbBUL(b3TYJ~O%8#&lWs;wX=ItvQA~MQw?wGJM;0KWA;_Ay9ILcll6kn#m60-;CGx>Bvu!vCHbv95)3`8 z;5>Fa#^g|v;}}&v_>lTJ0V&H%ejkQPT^vitqx+v2gee*3nyU}7S4r&kjga$242vtr zT(!DRc-OsuG*Uhnc9lP`I_Fh5+muw{MfYBs;n7wQxi;~89&??X@93F6I5?OYQZODr zezI|IjK%j4TexnqJ%pctZ=4VioVmam`tj|RWU{0`QJs?DVTzd?EVL~cU0GS-@@Lej zu6^aARxXc(;l~8`3+W7fp00T1LQq*Qu4dWa8qa+H<@Y3vulf5sJ@3eyIQZ_KHGS^0 zFe&N1FnM^U<(f4T3h&J}8(bVIu+h`%y=rQjcsxkYD35Jor63@8NNuczgDHvS8o7M_ zJS_d7bDOjNWTVH37ex>p_#;?xZiB$|P$~VhP!iv&L5?YZm_KV8N57SbEp@&B1;wy#yRdc)8p)uknFSzEnN}BpH$KSSeXwg}9Rzxgs%s4nvRbJlw z6ztl!^t5l&(}L0smP_6lD&O8FS25}&iZGZ?7pHiM;Ba15zct_G;brhp^}V6jSZj0w zd)c48TuMnSm+JF6A7komgRgDib7@J#ET%tu_Ke^==hBgkMZX8DCCgL$n6?{nyh1%Z zs=2A@$Pr%IZpXKoZ!amz>3lX#Vhp`HABeZ=9{V|wwYrQ}a<_W|VQiHgEHSbFd9h3S zng(@H4V(Qi{NM>ob~SsM4QB5$?ACWXMyLGYCE$H=RLmJ-$T|9aJ{u7rb ziN%hIQCRlDbH-NnPk0QEiZrM`m|0Ab-AI2UWq3{pOoq(o-n5080V%E~`b2AREVKPQ z*SMj=yp3{Uf|lFtpbUgRWh;eC@$$4Kq9Y;Zll<2|_s=It_B&RyED4Fe^*mN;-s{si zwxaLKLa=z*?BB_NsQc2&c39e0l#pOG>(4`yZzw1ga2Pfa*hFcrA z?r11YFs<|!!&cdKJga-KjZ+g@`JBt~?<*iNevS~LbVUdrr#x1WO_p%oy_;WgftSRn*HB&QkgND) z>NC4YPhziCuTNL|Etiw(ENe+&+_mcFE`fbsD^qF3C1x%zbR_O&GpF)iL%)55}nPYN>gVgG3?DX0}JPma@}=J{9TQ9h`aeEaszFyBfHTVz+) zbkKKoVfb@C`HOrCooyeLMR&S2E;m$N_;Z&Nmm%-eQ$qh%Ps6Dj5h#AzbY=$evc8eg)vKMk=Nl3fY_UrNR!LxzmiBK#`u?ho%xwAd zIBOp6o*u|=C|jIHC#}_TrmfX)a=O7cl&;sXC(X+7T(QIO;As)7iNC6clab597CU?^u(7r6m345K!I`L7e|d?iTy+;)yJcc;GTd}s z#1zXI?7ZFlS~$k_l(2}{Y6SF-~OC;Oj0(i z`IVmq>)CNFgB-(3@biZcA9~74G=@J*7NYo;GBSLX45LTi|3guC{%@CvJ22|3)w|EB zlu1qgbp9rrls`}M&!SSf!lUTIEdy~Trllr-ugWr_U-#Dhc5WS)6h6DU*^K&e0o0E*fGGCE4*suQgY3h5zx^ExKoZh_R7?HCCVuz@ zVoyKp;Kv&Lu!H{~obrb={cxrq&h#JYOdkZ&Q&Lh&OG{IJ+0rlNb`ohal`T;yp99e4 z_S2?)Qwhx6RUuf1wxtDP*U^A`%J5FlHo?*)Ka;&P;byQ;uYYn#81_6 zn@~Paeiru|+2P3%tV%|%tNZFdnLYV`Dk@FIZbE&S8%y+=SYg-ugAK8 z#*@S0m6iHArYd7@5*6QngTC4^2zaRvoOpU1spb4w`N(3nSmMfJj=xDuWQ?)%=R*Y3 zyC1hQ_0Y^l4FLPU@T&pBV8#LXT?E%5pd`E(zxC=CCN8W1(c$0(pkh@?-9dnGJ+^`E z-Q$-Bv@>WFaO2cPy{zW>Pxr|a05}7}7!ei)Y>j6i!j|>_0l+=N4=_vQcPohEs+8xQ zc9pr$zMy)|l~RHTRSTV3%$ESB0{Y#ET>B}4Qn=r0iItS7UUS|Bw1Cl&q47GoWHfX& ziOozptju1crzN->RpsA^=2Bhzu9KiRJ~}ZmL0VrbM!@1v0XzGEbpjOZJg34KKzgbG z>U&Jc3gEwgT%}gCfjuGuSm?7nS4qIE8oPF=V{_K|4Axmxf3)0b+uqE&U~Von24Bru z3NAxH$H?gDLtMc`KLA86?sjR*)!7^SF7_ahF7ROWA-v0gFU!tl6%{Q3w!fGhhC%Sj zj%1A_-=zTn<(dxVEH;Vc7`wE}NLjYrD6qZ~CZKX5l#eGZ33w3TGX``TRi6_b%4a3~ znau?@UFLktJQC7=YmDAN^^fe&x_gwuX4#ZS&y!RGYqLXti z0~DG8d-38DYr|oHeF?f<>Zuvoia+f>(~sX{s9e{;VklIqnv6VUrzuXcwK-$4WL*6Qr+&f>(G+-Iy68-}@xN5vV!07Ym zy88iNJzrP6|Lf}4Ky;{wYt3%CS{k!0a7T6tIf&k-l;b%LXetgU9w#BDr8H@>d3o4_ zWa2R_W7GNO*)x82(rhTd(mI*Dno;b(T1-wX`R%~S+a9Vf)hC!biLgY#UIsU`obm)n zeh#dqahm8wm64ITQkfh}@}AD}0hXr7VHBWjr^Kk_PYu}UC@{~X88=a|CUq&%f{AVM z3Q}lFuqeV)t}$p#XCA#Yoz^YZ-4t^CIB*bW(8h2PFZ~gqfE+7m_3~d}Ut0`1OJ?$# zdx3yW;4PUeo1V`!HZod?%E6CYsjeCx>sH>4bL+W|fX#k4)OrZ`2`MilerfB=2Z)nz zaEwpf^NqZ@tG2jRBif5AZuD~n!%-pz7X-!I4upEpfUS^ga$cBwTUNKd8YrJ-US3{0 z?S+gjxyeg^9U<>pqzFSk)Jma5Pjmg)Ru+(4$B5f$=UCqZRi7B~+p_>+zOXzuOlQ%) z?cTkc$pEsCpxPrCDo>WHX0~d}4NE%=m8@;Uu$csIiGye}y9uiAr$4vv4X&mqQ2E^Z zZyLVR$3c|fm2<$?_>{2MS4Yum`Zt!wD5`8PAe_Ohl+4>8dMHIp6vj(DlDyp%87`=C zA+<$_Dd5_EEdqv1ksy$DEI;<{RY@@HB&v6kdeIsowXTK?5NB1X)Omh~E&zO*EU) z0PEmb&v<&Cli3+lb>P9z?Omzb&7_XSK3r0DZ-)ljcPb@jBI)X}!M9}{q-1@6K=DVu z7K}3@e-*(w)g5G7c7NCPn~#vZQQ_${Hg$5C_E`-dW?SF(`?ur^AwlOVa)2(vh=?le z)0hM5OfvK&-{|38eDbGFD>E}i>#1G@AMz4#NTW$Qe^SXt$JK$@tzwXaSpE-b}OnpumX_c~d`bfgl#(RTBDQ=5EDK-(kF4k=1VgJSYuUOlVD%9KO4VC zB9gF`^WedQ;nwsny3FwxadE*Oi`1|CxOHw2aOWjgr&n2(Xr;@$5bIf_T@Y;1%>CCX>=){?X$#~FC= zh<+PH1)`K9-@$-Y2iZnzUsa#_ zHIS5_b|A(mG?Uq>B{41)6S#|~6XJH}9#cea&?p!tYE)ni>kP@O(MT*WK8${jz0MNS zO^i=Dd0}L>cyNt_<%|uSAt#a(Dh@hnUHNk~`4R1)fw2=L zM`Ivt9HFVwd$H`>yNpoG%A~yK>|kNtxP}or9(bBzchlU(JBJ~pE&H!i{7oS}?@dL6 zS^;@yEJC*pg)$SmVFmR^!gXWf(NnjoJk_fXT*#JBl(xBwktaVA3{t&mV7$o!Gz&`c z)fEAgeM~dZDKMWMC_Szfy|z64I^=@0ShlUCRa<1nQ&WGZGd(}t+eBk@>*u!>cwa1g|7!5Ep7C}? zkJZ^?3i?Xb2iNJI(^borRPET)U-gn!u2u0o##>F8Z$X{i$i4KS+srm4Y70kJv$IqB zI5{7?b`|H;@fhlo>Jm1RTdgqk=l-ld|H|1SVxGPH%4BjdJ)8>ZjJ$50N|IoVqS>*! zgJSF5BPz>7`Q~*&L_@MO=wU8K8w*=>+?$QvW&} zUBVHv{1%d%#mhxizaS+k%g5KTPJt31L82qy^JvsbOBchu zRdG3zj0hDZ+xoT-Q~{^X>rnC)bRfRK`i|0Y>*Kh27DQ&nv8U(FFR%GlYwQ3%Z@DMO zPv$o!s1_~{C9yL}LKxa(e8@!qlVyPhU~u=d*9h!*2{V3lbCG`8qmyjN@5j+Nf7T*a z_t7@gy)47OndBV>Qf6i*pMvu(+CabZhU`GTzGbvY>ks+f8kICsZe@aqLFAbFp$ARe7;_FK?lJD8L(Bv zl}3v&71`h1doMiP#WSDf=sS&+5BjlQ{6V_A(U>)4i>42T6km?F=0i^basQRg2xD8b z?8YX{%`s58I+jg=7DBeCuaBE#iGK#p*c4V;^h=P-pEHPdf)*|3FvM^@N4tL|Ua+Cq zG}vqBUY0*C7*{KFNMa$x5nrRr+=P069tm&TpS}~0Y9w0|}z=e_bS(>qa+1lC~ zDBwNG1SJzlz6vq(NLEG|f2aWwb<;%4^X3={)t6sPsNQ`b5^%0fPCsIgB5376O*+tH>@}Z zZ%p@pntt#fJK!$KEvS3N!}quDM8efDC_D#ShmH30?We>tYxO(DGf?E z)RnZ7!Kw%Gveb1szvROpH~)aZx|eQ-$#<%tz|! zeBKdpKTS8u6o*s2)%f&e9Rx2w(&~uEhfN$qo}(dJ(V_~gGxkfW zy)Q=Jmslc7vWG~c2^UiEcIESeezi6jv)HZY6b{>^7KQ=G0N3J)@viAArl)5c6`q=t z&DPV`FQQhh>cpNtI%2tgEneP!umEY~v$Gw(fBA@%q(QoyP-lr}knLaE7a}iU)%v2R zyZgWc(0n4IJ;eVG1*-S^!rt53+6op;*h{-lOkZ;a(VGAoo&)+{uEVFzK#mipy3TMn zmE=lC`uW-+q(3%Vf@<@?#j-_7&^*l6n?bYH)6)~454SYe*aey1KM^4J$HH}cy8sU#xKp-gygtaiWd z6JA8s3Pm?Bhu!po{Q$_$x|bMyzn)#5?n7MFmi334ZrEA0gn^OKk(<6vk-|`f7H}cx z#?rnNdFSsUITg-5*A3JSDp^a!Go+7PIHCZV_d`dEPt=zLQ9(0 z);`{!|7q9wY=yZW9#FYI`yTB4Rg%&taYW|MHBhKn-0i-ip>7y@fY(dTROOvVh4)Ym z00_lZ1P~>untKRDVvUT<+Y4>`K0e^6h900JwE^%2UG{>mlQ+`ExzLr;L01driq1KF z8*si>S}%Ev0#rSqUmo%-l{dqtIZ}2f`mIc%*S>HBa-sbYsZ$~pHCYThR|>eg@80xL z_54qFj%_?D+ldc_nmxXQYG{?P2=$-3vI!Viyn@RFxDR;!+oUY6X3Bx(Z#^QUCxhj; zK0oW_SeWV(5@>F2PM(#Om7V+Uwz@PM$93w|DYDaQ+L(yN0o|#-9Gw0QC-dN%GS{g* z*whOdAg~<k;$`jb%BbYD(bL-m0JT%qv^Twv#sT<~!GJc=Xh&~u@cz^0FsdIhFA zieWk@zv!i2dQ6bo>@oA1@__Q%*g#+3GRHp_bkI-l|oV^uLez5^k%Nj_ix;J9GuHy&j$UwCGIQPLSHJ??GV zQdIRvXJ;ol5zN~SRIDQUnUAE`K%%Cdgke?C+lcE^*f{WcuqGn35>jpQf|y*@bz0a! z(KuQZ?t%j`8~2W(Av|D2>VK+G;$7aoduOf-`%b?{Y;SL0XPQ?m^7Qz6JQXC~Gfp?tx6&y(wdl@8cBIpB}%?FD@& zufd}}7Kp|80#vf|Bht9*Hqkr*3f#?e=GJtbIOvpd1_lO(hCGf!B9|in=Q;%gfU-m- z`9o=pKu7S*zoH2sn@deS;YRdQOdU%OVjQw4;g zf*PU9@G{zyWs#sk(C=>9vDMOgmnFtAYNeJum z%ZTQ)Hw)W62G#pD0|N-xN^=k<{NfGr#o9bTYc{GZ4gzJA>e(54GxtgfW>?otHL$fY#%9)Qq6jXqc< z5qMQkPtQ1!7iH#Qi9j;t{Q#yo08lkIT#kk{M-otG#qx+xa}!`*N>UKq`z>Pg*EnTC zLJiveOykls>%s{3IaK0=6op^VVH+_1Z&2$m9d0`FJv0pxsm~4sGlQT1!t&)mdjR7f z3#fm}9n4(3bLS519Lik=p?4y{W(Bu+Qs$SffJ{Ml0e=4J!zTYp&kB$7Pn(zd@Ae3T z2EM*5U<=5(uU;M9GKhoPz{|@EGWsZ#;eTbZS!)>Y0rV0uRnXc#8SDWW?JjFw7)GKM z%L8F|160?5o5Clyq0TBz?qR=+*o2zdeuS47B=+I63Wvmuu7M6W2d8?DXT&c&78%%q zTmm@OEuT=((n4;7FjCxyd?CLmwn#M3+0Csmb62?63 z=~(&NxyZBU$wff~&nIn4q-}#A86)&R}`mOaI|_fJyKLW^oap{(_wdpukI{=l}G6L_9YsKEz7RsA1LqP+go{RCxC zvvW@QK(~O_*tZ6m4QOm?YMK$lLOqQXY=AP?GoaacWPqT(;4_zBLe!c$0y6dA75zpk zys$Gou%58`#r*@&gJz0B8E4=4^e8;x2VR7_pFe@g0Cm6o2J_vNz8Ui509i?7+ z-r3(T4F2Jd7rtV<3{ykz^={Y@*=cLvqTN^-T+wK4uagG5M!$< zw28*y&#*bqa-kpFcr!MC7wUc5AQYAG)QGA4LR5eOC8RJ6hQMsm@mihP1~3ZLN64(e z2D>(s4uy01VjCd3y9Jcxi3&(RcjwLvfOeEQ`Q*Rf+rrsj;xrm5tOpF>^Gzs}9rj-( zcspH$gG5TH)rL#4r*D4!6fmFzWvuD83SlLB)Q~p7(!fZ9ua>JIXt{d`V)GO(0r|UT zVC(OR$WWhq2pBMY1m$xAo*RDt?TJLhhTA!fjEv5h$$e@58PzTT9R0l_sIz<9+n;VK z|ATR+-rx-Q@W!N{eNIIM9-eK(!7cE|!I%ak!E@V}z|5*FTaU1JV`iLbGEXG_#V?zh zn_+h$BqeStzlPsE+H4K=$=NtPxYa>*H@8JF79bidC(g|5qxA$!m?0lQ?yDEMm!&hn zZF=H?eg$4P-=^!tW1f+1H1*&dEPjfd5mUJ?^AqZ2YZUB3K^#mtKuzT~-3!gCw6yfC zv#apEA`%iL+nh?#`*20SjVoR^eF|$|@!;P%mib?tX6`Lr$Kk3ShRZzdtgRdGEy14Y zo0`I0)E>=AjVax{-5K`T*iM0Wp94%^i6|#|uNdjU9 zinZ0>U>!F=E2!Hcb3m;c6&blO-KTr`VKMI5#CUUzBZN>u#0L_Sp$FIy?f1{RAz#P= zjZJW-v5AS8NR9YjTqR?3ec$P-Ct!f~+8c=jk^|%~*2-e@J(E*a4{5ib=KQs~l*S|A`!99e-p4mqJo9T#rhA*^*fCf47AB<64G+Z zZ#aS`>=?C7^ilHKTxUAP5WD5;`wvdm-KC zLQm@sNC(lSwtR9RpYr`{jnwZR&0@s}a|XlsxDBI7$T$67(S7p6G%%v3rpC4 z95G)L(I)4y(MOHgnXm+=9#Jv(_3PJGghXZufa<`pz!X`47fTZ`?J@b^!eUfcf5uqHB-%A58pg`e|4_hu|jiKs{)H(KDGqxNLaZJ-p&8Sg%WV8 z-3tkf3bOJZZH4Tv{{F+~6i72L z2+D^x2%SVo$~r9=_2z^m^1OvFo5 zK-CX#8baSH33GF=&GiMY?cq=}mUTgH5y5!P3|^eR`P+f7KYx_$C#MH&1uA_SpcL}G zEJ6b0uIX0m!aDUWu(*2)JphD<2Gr5PAyj3r%c$hM!!FC#7YEbj-Z{CJ;Zr8;XTvV= z*_32wi#xl#8G~G0yVar{jNG$DNVFjzP*J84Uuk12G@}v@6S+zvuCwxIsv|N_S1M*l zeAEZJM1U>GC+A?S|F$KU2_llxlAkZwP1GrHK5{%~H=(f^_1ztb7w*qNE~24SHQyvs z{5WA11`Yo@FTkY*hKFCIQL>VbK#_+DkOD|D{qWryfDX$5zLGbCA;v4t6o7)`a;srj z7MLI-fT>=q2SeuORqzB?fBd2%i?!F^g3m@m{3;g9TdE+@4EQ81wyzQy)mrS8I~3QE z93017X?q0##7S#78B+0qwN)eP@ln56L zM|$<3K4!L%v&2j*ey(7IfNLM7tU`eo~JaO^lz;GYMNbCB)g57QJZ2U5qjl7@ik-Y zDBFUnb1(B9RV|EN{zvq-tujZ{&hEV|x<^e=?aF4OTaUCZ8*lz*ZCY?i`o#^$?#Ks< z+ZI)l)h?cqB*-t;zoV%mo()t1?sKyrLn%(3zMxe}6E&jsij}()96VYN=eH<>SW{%gfB7D{PtJ6e_Vt}S1)6+*i)ol8HnmzIOV^% zctC!lAgV#BY1t(OPzCyd;P60({P}e2wWcb*NEh=qc5FDVE7iZh5&K%(ODXV{%Ttou z%|rl9E!(6Znk(IKXdsTfg3PkL%FJ0wTy3nt#_~x^EjJ3)if9<$*B}QL5dF*)J~!d7 zJfGT(dii|Aua13%J3FZki5vNm{MWDJu<0k>!=Ua@(p$C(qR#3e#c@y$ zc;fZ@2^1;_>1$6`K@J4y{lrV2#s`vIpMOG~y@HHU*7ZRr4w|eL$L}`-n`=&U)hi#{ zk3y-Zz*Zf=LHPmrH!>-HkN*@`jq+g?=o&7&BBfWvB*3U9Cc`l3sa(2CNln1p3pH^w zmcs(>z8}fDsc^nV%-RYF0swhKoNb@f8vocI(ScY2SFg#GYR>a;YuRq_@y`&%Y=8+y zj{kswke+_=Y*)tHw{J5uAtmHrJdHxV2t}&!f>UxAVI9OC{jHgP@_*rLdx53s?;fp} z{9nXPjWx}V_;$g#Kg33w^XMISqhnHd}e3o-@U!kssP6ET0|FxlK!K4 zEw>vjF@Loj>GxQFSyR{L%p1Y4M%>=|97uOFNX(Hf*+>7T1_+c zNnLca8JS;bx1GWztuf+;=9GnI^Yj$IvfsRB-~_t{9$YOJ0XxJyZEzrG*|gd2QLU-e zhEAUhUj4w?Byo12t6ADYr4p|`dQIyN@dR`{Y&Hu44)~xxoWU*C6OUIF|F71yb%tz)%Y(~2*;D60rF+bL@zcZNmxwV~!t zeeLU%5qiT~zJx-xf7)0k>5Sgg4tDWTagwXE_qv8nZ~ua$mF%UwH5;J?t@0wRKGxAz z(-(=pX-CDWJvW2-osT5SSAM+~R*^W8rO7JCulEoOG*x|+RTR=*UDk~cZ))!RXo5V$ zX~Z60ZuR!_JEC~hH(B?{Q1Vd@E^5MsC0oxGM*05Y{w#v&RL!DI{rxZlw6|E+x^*zW zsp%T7nW<4;piLCi_;#NQW9K=0<2mqJ0SF3Z{rt{!7Zqp2h?vcFSs@8_J!W;WeK0Dl zUjNw$Eho3uLmF8|<#l&%T0VKZvU@uUWh9#qz%-~!^mp=ac%^Eqz?Lb$e`D)pV zYq{ew^KczFUI`NeQQ@o>U7{hioS@Bt_X-YCS+e#t$@2~lq3gdf5z#9i#+a%UIo>e{ z2jqZ&1iJRlq4{g?NU`f|_U~4lc8}_q1h(=C)~af%VQ^QagkvrRZE7D&B?}3qNEGUF z%qEB*c@dorvFHQk3}M6MhPXQ45<9&#Qt5$jeBHI|iEjc#c&nbZG)X*p&{r{S9G!YRPT;z@gqBM~ z3Jw$H;4RwmNtAi<)ON_ikk;S^S`)2cEg9r*Lq~^KhoyaN%4p#aOYn7%9@GGfIN@qt;h;7_~mnGyXiGs8V={0bp-~KHp#y>zq zlqg(^5w+IYhyrvuQPe&1be`@15(e&(c}sBr^1o$%`FmK|2s66}s3>oeE9nuNbw_Up8HKCClW&#hrPU|BE zU#G7Rtvw2*dCuP69{N|ksT;H_Mz|zX)5tl01#N#JG1I7Tvd3OtEELx@akpnQRfV(k z*x5E|`Zmi&>3>24RS1B~CX}=f+}Pzan%SaO8Mas5>^HkVTC%?y7^Amf81uQTAPe~f9ovZec~gu>WavNb-jC93=4h;5go$MFMmHi>%aOn z6$y1x@1czWrVL;bB^s#^h9B0H@E~6T@0ON?X8sF$^ z68jUF?JgvE)=2_Zz{#&ThBv;^`pO*yP70-M!0f(1ZZH7=c^(uzRaNUCCs6@GAs2@6 z#n=LYG<4%naeqTjG6Kw^0qu_;?fM8E=*Tiif`FWL1t|!(iE9T38vj%dgqq3FQ?e16 zoby=YP-a6II;nUpc|IT;7+w70r(96`IU-VO=xF7r?hX7Nc(OC|Eu3gVL7K@Ae{3ms zf7P1c{Vkn2EM`>j)=A5+FGD}8iKt4?I(TAbL)hr}1rC16$;#pfL>qWtpZ=SHCQ!4& z0Yk=Lj{&Tq46-ihaXnfj8;n6y4_K1Tv~vkzbr(}6rF^fj<;`* zBj_zy4#~M$j7$0ZS<|+1IBMrS2w^-&C_qe<(CT?zzPIjPM)XGK+@W%gBQfFFr|9!b zpnOw>YCX%u+69_10!yy%TfG=)klL*J;R6_Y)_7dY>KJeHvI`suLju(n0<}wk+6rF^ zRqBLig3kh!oAYiid)opLx_X%ggC+L7)w6g8NWFlBc&3{N_%pXhtPqC8hM< z?(UX&g?!qve9Km(k48@Jg0D<)?YszlOFtYAMX#@38<#-z#+YM5Alt+!#dQ4^)<#=? zBGuNoGe9U5&TRqda|V7MSvC+Fdude-c~sF8fVvu+U!8UIS0bHvXvO;MB$l*KAZ>=N z^VjE-JsAej#?`+e{AJG9cJOypp;YCkv-r|G#8={u~M z2hbrQ0xF6)SH}0ROnw!>u!xN7VIWYT%qMt^D4^pUE$RA+)MIiiP{C026WJ8TLMxCM zB%~O>_p1W<0>sj|W;+O~a3B&uIT7U)*zfp=X_()E$k78BEKoZJ*RPMiE@S?I=$eiO z@?E`=sOs<6mN!c*mdH%J`)RvPmvSR?`K@(D@14LOKc7=TN5v?)PQLHbQA*J!NKpFX z!*;HYX;G$AwXdwgL1{dI;*5?vzZ)6E!XQzHmFY>3Z>=7@aB3JxrR0G603#TIHAf)1 zy&8Ps9;+?I`ZpTW#6*$KAC}i2Qv;V70J(*pj>2(AJamO{^4Qecs*Un#_P);EN>FDo zJ)UtHLThPiU-5MY2#b@VGcfVPl(Aa*1l7ZV@C_4jg^dD@3LMOq)R%>g5pfxL);tBB z%o->ubl?y$fC{H_Sj#|cQ9F_wCK|Fq)AIhkQ-p3j6BJ-83**ieI>e(M6ljarVMdFT zm{3grtrSO*Rt9v!I2z>T${;O-GjOsh3xS8E^v%!D*IS_Bh_(2KF5Fch$D)=^XCvJ! zTIo*r+wJ>LyqSHf3&Dar5kLQ#OJ(75x%;dD$kPxlF|h;-U7{jV9bLWz0!C*@~6p2U=i3$>q8HeK%F))mw!S8nMrj~ef#0l*tF}L=Y=e!D`KLo@8 z+|OSDn8yllh@?;8Y3V`%s}M#(2sPr`r43*n!I1ebBfBYN5%f;2b>Fn2xtsom}MdXWcW+JN=154r4R>%wj{Gq>XCqGOJsdz6m7Rvzz`RUdH zP7sY^_S%Mq{nQS}=4sK(61CgYllH|Y6R8VxB#32XHYkz0Io>*F=8`v$z=W44|`$?mh#E+iuin=O)xI z?^+Dhx1ds97Znu&rn&v)ICQ^f^kh+&_aP-I^79Ya2vRrvc>llex&U_dQVmiwXfcHD z9XjP-JI+=16YBB~q~N|a_T?%Z^@=ijyS5hr9#Rl(kmd#8-+?~gZ2f9p!+8Cr77CT} zfn1? zQtNB#Ov7K_cMhQ8!Sw#ZG{MmJ{7o_S;}tgS$9nv*hadj%!w-JMfgf?;M;!QP#ew*J zcsCU4CvD{aBk<4qjvv?k5yO7Ou>W6T7(EHBoy|7?$zu}!vF)$GqC66$Lx;rB-5nw#-QChHL$`o{%n;ID!q6ez z@SceG^S*0+-=BA_@5jflS;I9~?0uelAN$zHzWimS#qe;5aWF72@SyMB%41;MvBki^ z*dCMEN`Caf#j@X3PVW9|OgSMDxo@9B z3BF_9qgNrn93>#Q!5}UtmpS^c@rUOPhY>;#`dyn&FEfBqdHvO;E*- zC2#d~^I&0N(WgoObMIbgKmfS>pFjHejH~Nk{e2^+LS28tz&H(q-1_@@T^ND;_v6=l zoveR9eiQir!@;sF>^>R&>{-i{bpkFmP$S+&JWb# zi9Lt%C8x%Rk(KeT?S%wIdM;2WC0ru|7>F$=9nHhc7WPVppCMN0U z1~61SE)$JUBGfj<6hFV#xZSMisRA21ALk3^|I_}kwy_d#nL3^6BEMJ1gUUp$`ec$O zOis0N3L|@@4e}@{fp~}CIfL#Dwhuz`$01!UZEffe5{nB9Nu{zCG2fA*cVx1-^AcL4 zEML5f{j3VR-Tt57&~tscv7U$9oHWrD2O&bJ4Xk&-^A zq=aqgvsMoks;C-;(D;~EP@^c^e!uPrA>*=`d}Z8EWttTkIUpl=zOcD@(Zjn1QTZwa z%Rbnc@Z9SYlds;WoCqP~RXQLL3nf2a&InE6bG8}GmBWF(U}v}c5%!Eo!>lcUps%m* zYO7#iq;dW>8sz%4op?w=K|BF=I%XEbtX&oJ%e47^dn9Z0ToIy1!EiH{RM1zyg$N51}LPsWS zR93H!YwvWcNdf`cVuMER?(|5BPS-hF`bVQjAjgQC0smlQ%T;t+Hl5RKnKUGYg?)8D zJ#d_H=c1v>hOCL7o{pQA6seYpMvsk_>It6jDPaD&b(33$O`k5NI-7~T!%OC|&!=iS z`P#Hi_>fk*@Y^#l0RaIQ15JE{uBfPJQKIdb8>*4`Wt9y}K8tG1`cb+cdBpc3`0;?f=D!GJ-EnxH?5YU|7?? zp$ef!9r>-IC*~Hb_bm%>o|hur$OPP~FJwO$Kx4AxTmFT;{_x=glC{dFR9{530e`Z) zWV4i4Ra#tpywTltq*&ASVBJ_Mrp|8V3#2t3ZTkLw+HEWI@$&71gXs#>5yMpXO7jWM zJj;>dsmU7KG6W*UdGSeSHd?-w|8SDhdmPxD*KreKDk3UsF;#2d89{xrKu2x9^r5)= zSAe=@Lxu4`_VMxY9emO_LC=exWZt6Q1j%f0=#=wP^2S6}sK3ASd@$F=*{+N){UZ~( z^2VU1nf%z;*eab?EN5Zlsgi0oY-OgY$ZEY!n`=c`&LM3OzA<`Bu~%{srGWg zn&@*;Rvt;TY^*t3vv3?N(Tcf4(J1FMZ zs zv|QgdBqT)e?6=Pu!gX)?*>Q5c81;`~qOhQ?nx<c5ZHN)#(a0!=Cz;?8N^5{zvp< zAF&@&A!C2QE}caBFbkF2O|sY5&75cGC4Nrk!x*5E=m6i;)R}^kM*0z2kF#wt=bVP! z9$p(3;a~6F37l^=?e$9_yxc~}cpaitoWX7FEbZK7ei0a_=)#&!}Oh(f2ONfSuiTn&Dr!JZFaY}``C+& z&N)UjC`&H7xn}AQr(GXlfvBmBXiiE>0UnXT znMnoH(z!)(Xu&c&D^?Y^)18->A@)Ad`YuBEp3%+*PmMv!G+CKW2%TU@)zHxX9e!uK zIiIo8JNpDauA%qV9o8BhvEqX;&B;$zzWKG{NpCXJLV~n*rGFJ~?ZefAw4D}qt=lIh zVe!aF$R+S&sKAMl4;CKPtH7Y3`qlh`O!*z{V>vDN{UQ1Ih=}JkiB#6$-w_nsWR8>1 z{J(lsG&eWDZsEX=jfN{CAQ0{;uyOU+iP3bOZA2u|!XSv6kzAK^t$yarlbsxoon^!oD&Q!>2JCAn#1Y`?6f2*yG5GCx&Uf(} z3r|*BRGVZB3kvZS5m|^Zk;yhyxdZELX=$O???UL?wGCGp4~X83p_YhQz9*CP>Y4L9 zsO?5f`F;44&Xd&3vm97~5;-|}gumxbCoKqp;b>}~ep>lZ>r_S@9m#5mD z&iw-eBZaEA15#X5PV>QtZ(zeQ8)v#TJ=k))I9@v8!<%tGOg3A}2o<0|5TP!vUq_%s zL&=MfBDQuhDm70Tx^u?77H%~yN4M7(&IeMI@~u7S-p1`NJNiyOWb2l{ zPgYuvrTIi&Eo18 zf3ldYo)NxukGGVBi-Q$!70eZ}XCrCi%?EK7rh&6^78yDs%(U9{?G7w6O|ZGDetG^Q zCnvWtUNN92G*!}cQLER1U&r|cLV4+Ouo zQdCAGo%BLyK36Uy#p#znz)uMfN7*QQ%NjJH|3hkx80A<=(V0Sl`8TB+8XDv-%g_AT z_m>f*Zt5B-iB9x7rw?W-eF6kbk-h#G=lrxYCl<|&3pxga(pxkQxcG~73Vg(NJ<_kV z?4my;m4D|)`J7JWiYts1<|8NXJ@;;@+CP2JDQu_EHb`2tO=5sQXi5esm=1KVqiF3 zr?L{&wY7xmmqW_h$45temuI^Vz9#Y5i$`c+V@V|Je8~)cfqIk;AbK4@n;`gi%rIsm zKoX2NTir%=0MOGXMgjdqsgrR#G(un8sLX5(ai3E3N7)0x)mFncZO?ORxuDvAp{4q% z`!^8i`3VQBUp;U-v0<#knvPDkm&^@6-kC?T@E)X@HY{eJY3RMSSd4F&W@M6?z6{h( zd`;))VSBG>{mfS?!RGVj8fsBb(EhG*GC`R(%-@CvRiIpQbx#uj*5t@g(9-5Gs#h+T z1uRpt+#~0E<$2*Yfu`;qYSq=@#jc_n1b`!q;Z_8uwsjl9gRD+w%k5unS!Owd=kmP* zr)DZDqtgwpL=csyTMwXEck!aDy2Be98lt<86cp**>2XW@-wa`c$mV)9=jVs}1fko8 zb8Ndj-B4HlY1nY2L>px4LllYn^b$Tk{^j|WXDpK@{YYbGQT@fq+EQ0MWkLP%V&YB? z6=T{N86E1SVT~G_^UlI80H$~yAe5A?eWgjBnSVA~+Nwy-qmL&^r)SoZ;@%5CMk;OH zP0Ldiizg$g=XRVG^y7l^-MScJ4Cy}*iy-PidfTd+@rWG#3hICe9C03qx4*!a(U|#U zwe@hJYJ=+`xuA!ejSV7-%Y1xcaq%G2*S<6?>|g(!qEyOQD^=#(w{O4mqD;31nI;Mj zS;y3tL!<@9YmAK1c2R$YBGWl&^gNJr_8uF4DN}n;7L;)UYjR>LkMm6L4NVIuKcbnk zzI7#48@pkrv_%4mXg^~!oi6m;e<(&IDGU3OlpcMCPt~$DQB`u*h)(yQ6MC1)5bq9x z(RV{SIyz7mjcC{g-_~yn!L^*M-p1pae5zBb!5)Sy-N>Yhc&pTxF^9115O3ij|))N za)!5j#MbKwDbjAbIGpjcOx9ObT?~$Ag(EYekqfGH9UUFOeX5I&o~wB|?ZG6p%2gl_ z!)jhqZX~d~0d(JSl@_{Z?lRXujkf`)5dCG0NZT#_kIRIlA`)$oV>z!&WuOW5{H%xL z4iv&TI0|#B&N24ur5}ZdN|}LFALHX-QTHzA4AI~P zdhd@d5l?fnvo|ZMXC0_g80_$Letq{34-ZF#v*@+|2^yW#^WDC44|VeG07c6M%{ z{H9hcx6o#7_qlUVJX^V6R1urX)p)x8n5~zOQf@AsQf$vv3Hr;i6_4v(%nJrB5OFkX z?ZSVku*Sdk!Rk~10hNSR|8rw4RohPO1BTPnQ;A%omyfB^ncxeEkHMv-rNdwQEE1Ya z_J?R2$ej_c{785N)LPJwQ zIR~mkrFFsuB2G{+x^6Wnry{8+DTSFR-L~6^5ci#(nm2y%X8*)OyMx++v~!*6Q%tZM z2!%N^sU8PohP-ymPvw#YCcNsGYwgz-MBqBk4<%Ct8ln!V=>r5>cc@$1S`K*o`8MAXN)jyE83i}IIGPWQ z;FRw_h=r)`fO^CeWX%X|S0|_XqhEoD3g_L$n!36x#Aw)?68%U$)%tL)q$!5ab_@Zx zwzdYjqIxmGGzz|R>)1oR!i3to?2%ff*)fQ<=SzZ@;s40Zc$^+=zy&=|R%o#wL{nQe z#f^5r`7+UG#3s%U;FztnoNO?R@t-&XzviIkea;LN123=JhfL zOG(HCpB?l~mfeB1m3k~s5;G`2vb5ZC*cexB+1q{B4dU+4D$AMApZ`%-R)*SBDQVR^ zm2OCl+wHWDml?{mO+%!#PGZz8==Mr?0${VGUrea{CIg<_5=R8pX3+fQFlXie9o(Ap zB0tQvVe3U`yr=)cMYwQa@oR5(s5y8|(@8pL-mq-?l=>i4&$)Ag=(}Z7QNAwyR6t*$ zYT4}Udzcnj_+lZXazq;!r(0KLFd)B@V`C4SKtcuNL`lOzLO#2_fU1L)qocA=@>At+ z^asBPt|M^i&VX*xt;AQS9}8@FWABp4P%r4~FI5(8ZO;9YMgFqRtFiSA)wF%daN0G4 zow)R*%yfV+2@?|&FhL<9==eA;T3T9GR{2zc!{?n2E_=%$(mLXj@$Bub3~V)BdSppQ zn%ewUCt-kymonj@6; zL{Ul)>G&g1nZTO`mCO6M?3y~xm(;RR^l&N95aT!0J{4nHi^Natvc+3-+zpiM8ndH9 zTiLmYIyB}K_#~G3^kwQs*bZ+>oayRy#U?%s=^!clQb}tOpGr`ZHQa`L&d4a$cye?! zS}_cgC_wr}CeON)7~6x)os_%k+p=dORj^TtV6?Qfq@bXXk&)rz;sX06ud}D82Z+g$X=M7H&JO>5_6E}^26o+YHVNTbQ=)OseRSiC{{m`3qPD)j*>tjqldH$NW zePTN$9x2!H|JE>2ydSwzZ~a3Juey{(_J0}%La?8DaBxY0dxL_407;+sOhc4WxI(r${fNCCS*Q8ySZ8H#nU`7!3+4VYxaLMFTx^&HoWws zB}i&VM^WWLpo%FeDRGN-&kZt;tr31okL~#Tng;krjT{?OmDdDMKtvR4Li!5A3`p@p zdND06Eoo_K7Z(>B8&)pdx0uiW-qg3OAHa({8vfC+G>nC;hPCnoR;{J(1b=*j+FI`lmc0R%)cl;k40 zY!-fC-X(mI#`E6}!8nT5uXy(Cnf#C5UKvTrppcM@g9%G6v(Yq+(f3_{+e=m{fdA2* z{pQUzWjAoe;C1c{<|)+KuVw!q2g!of6SP4nnz8Lqn^FyMXIWX<9o)w+oqo@P!pZ;+ zHy`~v2MTtE7cYMA?w$g0cDZ$Vu@x>xu3BxCz9G6Y@|9&qea}_10SLM$cxHu)VFVt+2T^ud-4fc)xDSw=+pPDU z)u95%g&*MnQ>s^ufdZy(v$jAkLo_chPhQY;xG*V+6jPD-e>yZ-$EyV;H*emgsvTnA z0MtDXD7JskwNkk6$wA{S>oz!5g?g<{54Qw$T5kJ)Dq;}N=F|TX24dcLJMvA-FLHht zh$>{B*0roF1 zCxyr9ciQ(!KuBLzdHZg38O$-agt76yRk71P>o(5V1?`{{HQ> zS+fKxPf8u@(g~=H%*b75XJ?RUBQzvbaUMW3JKEb=IytFL@o{mZ zP6h`R8w6Nc5yF1>@?klEwkmVkH(5J?vtTM_-=#){Y$hn*LJtWkB;3r8&}g;V`I*Eg znQ~vvC6jI=3fZf8eP1vpDyr`&)Y?L4B!JxUudIK8&c*(ES+6(_AFv&O8Ed+t8GULN z6O|i)6$SP85g>_w1R$lEn3zNuY;A3$sVkXIqP`C#a#_YS$P2;f>@g7Eksgdu%EBUr zL92S?G+RSI&y?)!l>c6F6QF1TKs*8pc?|GYp!9^JVxWbE5K3POH8_BYSBvQr9!ZMZ zPN#0!Y`=(z$g$e;J+$uvK^Cikz;tD!^?bX_GfvJ*urG#(T3cEQQ5eyg<@#fMYGaHl zH#of2zaHaZ|GAp1?*|_r07x!KEUM?gVtpAe>F@6kK#WuZyS1&YE7(1xWMqK22-IW| z6x4169f&$_Z*S0XRB$)}ow@m6zc%Z4rQtQEGhjJ^J(-xC%(x#P9}j4RlDfLniV>{| z&`fea2Ba$RSulm*G@zVunCpMHe7;ZlNH$(vGN#*>!BzA{+Tw4Mry6;+9kORK4YW?us*fLZ{A z(z(8Sc|K{|TVXaPe=rxw-1s>-*!^^)Doaai4{%e_nkRF96c&xgivSL8Z-GbHjg6qUB1Ucc)H@ec@g-JP8Hw2{kR;RN%9{Tpus&^ zs^@pza;OdikxO1PACQq;m>T5~9gsTKSC2UGEhVmfn?_>=sZ_GHQVOxa$u)LT|J&o| z5Zcy@k;1GIXM2&BJ!gLH8jL}VkZWEJ{MPmV$m6Y(H-%K6Xt-Y^)!cJjELN65_eIy0 zCrOG~I$f+*H+t^7YZ7a%kWP=x!nAFzS+AH>98QiRrLwGROHe2q4wc_qikIQ}y za5WSL9p^t+)Ipd9M-NEAdq+Cd)1dRcOA4UBat518sh9cTb|@?SE!H?aO-# z8xnEGpXSEDSg~M?oZ?;J!CVoJFwbsz%G&s`x{4x^q$2&xqDFDP{_&a07-Zw^aSNPH zFNN;BJi-&1n=n=A(3mBOiOh*tDoNZi(Pv#W!mg8&Yg&~GBJ7vIw399Nrr!;svn~lZ zUz-%2Tr=(#qob9Hwr;okbtn3Xp4Mn){Hp!Po6{F!j?v{acDnK8bBSEfNgNN9#<1xR zWE3VjcM0_q5t%Xy-OX)LZf}#-vUd8TeF}!r#slFZYLSVGgO=HGg5|R*^yMWsR`Sq0 zQ3fyR25&OqmFPdXE@D=1OQfAy9u>y+z<>i%_G`}yM=+rAlA^ne7iO$$zbtT7^axxF z_)&#K3C-`HWEauVCAqd+UFa^A!1UR2C6ZcChx7Goj0y|u;;d914SW_VX|_b^(Q(60 zapI$Jp89v(&bbbU#?C9*aZ(BDVK@{}dg?8#90$VN=$VBb zgoc{r$u9ae^0c-yY7>!=>ux?OZAKqf{GSp7Ub)dLP(W7$bA33}`1rG(|9vtNTOi_DBF$VP|?yYIY zxQ>%1WD|2JUq4QX;Meg%F^0G_1A^9N&CMFd5j+wRCiGt+Ci&>*e0bwsDVZ?5MG9Z7 z;W7hL+#-USJBgx68XeptTUqq)(6lJ(He_ueZtQNqqDQt3w(M%|_+fb7(+R!+Rr-gA z482(5=B(VViI<8t54(fksAU>YFf3NEqa4W^qU4(5&zNfb=S(Sm3M|{C4e|d3k$iC$Ha#Qc`-mU8aXP4&=DL%lCkFU2 z-d$L>g@yZI_2x#_%&wb8WRBAFs@iUIaM6?Ns0q2E2Q;`{%QblEronl7g1|1Y?OMPrYo- z5yOl@1aIP-Qxn%|E;KyXcA$!AN;6}_$M%x#6`^Xrka37wnH;@Nj`86{{!``Sis1y@ zDyFn}GeOg43Yd8-?zJp!kaem1!?tD3&ySB!(xz;kOIK2@#@@G!YFr@TGB|1#kZW3^glScp8;l18I975Q~26O{A zh`2_T%J7=a`eBKgLpDBjwp?{(=S%O84Rg0I114mA=2D+^i2zCr3u4-KvF_Cm^FU~P zr1S7QQy}W>6dr2Q@ZDocR z&3xsmA6DK(9GH^pEV^RwRGaLVyfC<~k1-U!i>+Ok+tn6YR8>IAMORlXbd!`%D*C5X zSPVq(kJp5jqjcg8F-N>eOb&=1ML)4-C5>Rt2>2tbY^dq z^-SouKe1mGD{8Jlj-jHyDo|6r@F2X}yZ0MRjK z<>mUR9;?myv-WiRr<>6>{?vm-3dptB1P!UQ6r5#PeOGV}4^={D{35(`gybD;xu2Eq z4l_SuP%j9eBN~78nyshr=B-8G{P`QTOEO+2msj;74X2@?Hup+cE#h6@O@aH z-nw%)g;kXzq0qUsvo^>cn$bO;{XpvrR#~T8#qN%_sas*P`udDhDD-43yIq3f?!aBoQAKg2 zM$87EZN{ar&l#1JRz0_rE*c+CLv(+Sc(QMP*zrphDM|@#|6VCWrzDlzs@?50KxR1G zj9*KKn*#$Qi{5z@;lM;00~ylCj^0;&9@WQVHnt~o(-bi|SyG)iu3GsXNZfX7k|8Xs zj6Bk+3YBxV+gzIp-;pme=%FzK(s5J*SK&@Ja?cWd@T0;*&`hGq(W0=gU+ls`;-pTH zmf4xgY`I2dOl=|T{ucvKgDZ*izO=xTo;rxeYRE(ONs^7tp z>}3k!8sGDsAHYdbeYEw)yaZyGAx^%#%d-urS?Fkb6vN!%fDUc(^?t6*`>xqeW(F_VOF75syM3yUA$x-yFOF627DDv64$xnN4dElEV(H z(+X@y>7JLxQ>k=v_HSIg2k)(|9?u=kvTTW5B52EgR#nb6%dX5apYv`+_r1308K2Ib zjBS)rshi>R{2Um0(9-Q4kq>l?>#w4BS84~hE~H0BA~esf>~}ae_<1<(R(=oqk4D{` zFrOYB+Pz?KAOBeQBh;v)GwF%ML+S5msQcH@)}X{&=&=RK`BX7P=7p7H%1sPKk_jEu zVsi%(erzbFepv=hE{?-UuWe}CrcndD`z6v^Fn4E5_2cBZGx z5IPW~E{L08KoiCBuv>*MpPi`YBnmk2@%FN>RX#n><7yuy85l8bZXeVdpj6)Y;bdsm zpzfMnx2vPoxU#sZbt=6SlKPO6rE~)e?RPd;Ju4-KC~=|N@wI%Y_K9b0H@KR|So?yO z{YS@K@jYDwCaHf8tk2fHWtC}9<}hS^#Y62Xo`+tlYVi`ajO!PEYO#-rXkJWJqP!#+ zZ}oIIn~Y584t>`fClL^Rm!Ib{&lVNCeTu;iej(A#u{(#>*ZjxKy}ilY6;0eUVI(|Rpi zBxre&uApQpwZg$u5z0_oXLQfqMycPB?(!?+d0V)*2RVMkEA5%F`S}@znR9N{)upqT z{tEv!5Ox|7lgB^xv)=c)TiWo z;)5ZPi#3^{DcyC4;;APWA!2$@DseFgBp5aXJhGTIoZ5ptBVdtG=CSGhUK&1*=&$Gq z#e)TugJ{*#J^70z&++446Y7$i8jr8L43A2r;%psKprO(Br(bN;MjR^zChb>e)OVpY zYiNhLi?(darAoq7gB~~cN#?KOOYOzruzeL((tsZRjG5JLxS2Aod1pqt4DNpH z3vM3n%Z}tH@-x|uwgw?`q0!N3ngB=le}xNiF*s`RcLy)Fx7Wm#slOhr$-O3GzehIbu) zAo;g$LnucUHqR`%nvE`h7vAI@*|y<$7ejXHp)i5OVD`IPtg^ng{Q5xo5}Q$xVsYzn zq%M95HI&)GbjPQ`(PBL;ggjJZjo10}jJ8JNc zTp=}-B+>Ao(~6#s%j^E$Im@KPag|ieYP$)Ug`nk4@Q9D&!z|TjGU5xUMl3Jml2UB7 zLLht^xF;&};^_CbyDsMr&X&dE2+g#~(|`5i6;Em`d#2oaSC7W-hO%CGnAV<|k}lZ6 z)eq}VTzljHopeC@@0n%P1vdAkp-DPpZ=|Uar0l#Qh|xTsU!gto4M< zPTkUyh}OKL)9iS>EWTiW3`JZ`%CEkyz`w8%pG3y77QChAd(J!8?!7$uueP7IgJJP_ zeEMae6yvC7OA-N5`K6sj2>f%Yy(uD(Uygg&nN6HE*QX;`lP}APkI8a+i;fv0_1lJn z{hq_L2dkJvopPY9;Pd0-vmw#!2=}hMN50@zm9@P=XW-q$oU^XU_UfpA-q3(^y|mB%DEnjVreG#GCxK~ z%=)}U!%HQi6g<_#!)-I{sx9(aO7!A0>3bSzQ}@;6)QC*mv+=m6+K$40SWFQ z?^cN<4n)B%;HFuSuiHO;4IiAk39qKdP4b>P3$p*Hg7PnQY0NjX)m)xFv!1%KTg&a7 z?Yh)1O|sdv5dO8^E%J8WJ$ILb5&rlrOIu6D%|rD8iu(2{ZHX%TJJJQLDxObGhXXR9 z!e=QY_rLN2x6TIor@P2-a%RmUi6|*Z)(>|F=J0MMA0+pjZ;fmx;wL)x_O_-gP!dsa zom$-6MIyet%he!UQzy3)Qp_g_;&+z~t%)?&f}^1~$Uo&+{Jz|hx>0jXgB>N-^swlj zXX8eL5q9znxq7d@JPE$R)VU5W8}lt8IK6!(T6gigY@1AAdZB&gy+ZE1yg~k4Mr)H! zTLx1i4V4ngSE9z{-@bi&kZNZ)a;Em8>OH1nU7?CX|FdXqT`rpE&p}&kW3*Hl_R-~# z;)C4fMy zGa-{vG?xLx>RT&!FuqY29fhOvkG6ZL@f8|n->N73mz{?f8!XuZ;Cid$E}tcS>^-Bg zZJH6GZVp-QTDs>3ig=N7Xv28Ke7B__O*6P_@ORnI^zV8MAydJx5ys>Ah{N>u0pvb= z2#TtOcIM6vfPN+W%{us94J= z6vcB3tCaQPzh13ZZE<-sYANO`-AHf^H!A7oLxv2E9*G#r=g%L>y1o?^%@==fwa@Wn zm)$4wno#7>jJIrX^lnCpR2dg68g`=t+&8LKDN1d~bdXZY1Pk^0H4!#EX$~v~w26pF z_CuGyf>C@m)mClZ9rT-t5_R^vMoL|DZEIX)A^qpqH(q>i<`$gux;}q}KmSV3LjFmU zgp_lmwJw^6T5$Vw{!=QH(0$CB8Wwe_cDL(_+~naY+fNB9U8c(_Xrac)Lh@7|{Xd z?=vaXe#ne&g{5Xmwx50U`Ahxh`buIti~7eY$Rtp$s_kAwmrL2Kb%pJ4>S`cJAc^bC^DuC0K9aq;@Ug@*C} z$;{wmMFJc=yp744vTHYEUv&sy6V@l+eIG{p8-xj+QC#o#}_WCd| zh~z~&ZI*hHsiji5Z5Ki3$gnG}542E02NRxiC4L4CrihypO86w4l5ggKFj(U+;enC= zmVg>HqUktg6S%y*44OklY8576It?`WU=BzmGD2!>7z`A=1ZtH3`H48eR5LRe2?Mee zmt)X*zI*rX1dx@1L6!DU3PHD{-v~=Xa5E36LBn+Eq!o;z4Hv0Xyt4XMD^#M@unB%a zBbAVunYj)mL~TUcU7elIK%)jG7r`iFOKYp9rl#P@az?gPB4|h{&^G~9HE5H+(XIk_ z2QCQ)#`wWCf}XHlby1^%LN$_BCMIWRcNYl6c|iXeh|DA)9=-gB5cNt)tu^mE)4bm(}aG1u$vW;#P!x$m~4ndTiK}RBGCT27={x zUK3wL?w-%hHv1fI&7>wL7YRl^QsJy~*bVZqSwefO(gxZk-iqYC5w?*o}Sz z;k`uE*;1-!4``!n);Ua1Pb0okp;A3gj7>~TJUkkydZ2_rLJD-xr(l%ry0HcpvnGs% zMAXP=xj##SS~i8tG9{|0<|{(KSw3%ld$(8c%IlW(+oHfv)Q@6bRfFd_QHZz+bZ;Ku zt6^X2jiI~4HE}?bKy`PRg7DEJAY&Eluoe86Ar`vo3*5d|H&tz|2Lv~u$3MO)q@ogs zz*QG;KPi8NC@Z5DPXTITFn46T@Z-LJ**!9zYd!ZLTL$PmuMXy+VwpkjGW+6pS3H}` z&&ezaBIg&N`NEsf1|$!(GAWaw=@0BwtX{D!$vSO{PkgOM!qg5E;Ov@R-~}7@IFhE zeAs7I9Ea|*LddOPPoS%X#(+7p1{<{aM9!Ct3=$3#efP)Yr^c}Xbu`vM>0ynq%@8~O~JN1jE?RJv??StqPh{Ojz$4w2W@&3;G zSebI63JcgzcNnRxWAtlmmwFt`75XQD4y(4hdQ=*~@R0)LEwG8KtgQaQ$G@1lytq=k zAS@kz&o*0FSRfxb06&4DV>DPsJl4OygU&v6dQ5C0Xfp5jcqjoeM9kI~&&vz>m^Lb| z*eKP%VMJ&Owo<0^F59djV+x2~mef>IhjLotIL#CEyWum7jqaxi1fml3n{x%|=}nMP zbYL{J9!NZZ`o(d5RIVMGl$?|l1;<*-l8AM2cJ4gT1`{9WbYrtXlvAqT`2dV$UH}a= zQ~4(F*tFZO)bTJFnbyd>~14zb4e@85IjbBqDAicw89 zl>6{m4-zBl*2ZIuDD~(NRV;cLSO^eCT3G@2*xA{Aefc>5mvkJ)DpiI^qX>{xy?*VT zPd}V3`55vDBPy_550h|cG|E~DYgYGX$c@}v`B{#tD?qkn?@zj;si6UOD);oQ+<#p% z8QihyP~O`3mtb;m2L_{Qpo=U4WAqqSeS6uGO}74fhBmgg1%-6;g}6e#X=Jx#?2J40?Y7SrHc^o1@;Ip5h>eE`+;rN9H}@J6J;0;fo@chM8n2 zkP5s+VeH_k6zRuVFzkg0_L%IcP~UtzQ@@bzqYG|&iDw_OFHi}&uoAdBk9BrJDec^)lM|O61!wCG!STSHe4!A;Ovd#?>3=#0zn+suEK&R%JfPs7wRGS65fS zYUj_W8@VpSp-uzrk&W8_aRK1FETayL!5akAb>(e-I^O+Ovv*`QGtuD(VyRnO&pSnp zz`fmVpeqPVcTS4FkxN*vhq3B~v#v?_27w#(F7L+{2Z zxYKKYtW0q;+>#BxK6`&1Bmmgw{=T~r&F9Rw^i6mn%DE0zY-FRomSM{~2Y-)N&T21)y$ zN9zed5?C=faHO7ijsH1HwlTYq3V;|EpcH(JzLT~XMwmxRq56gHmTbnA6*vA|8f}Uc zZ!w2@>sobaVQ@%sk5gZr1@C{K#e3rc9;s{)&Y1=VhT`WP_dWR=LQ-#l`zka3ESdLp zZxOhUeDnmp5m4`y06UB~(N8cc4lg!X9#2r$tg z`(F>xM?h}*0!1Sx1_y2@tCz&ThHwlMUSL0tfB)xA3u)v3ha}_wN|5(|d$3}$|3lh~ z->vsRh8j))bHL^^jqbb->#`WKBp-c!@c>3*PL#}!ii!du2TW){kwh9JGc%rh%Y8t) z*4f*u44>cF&;;_C^V35HjNGR{)WtPvCERz?7Dxob-R-E#60fFXMNRUrI| zGLLbmWUmx52Mz#gRkO{>ntpGffH&^Xgo4`!^PeaiUkD3}H2^h8ay(-8Fs6sV#5hzp z{H6)Pn;1e!x#d}-%FEd?F)Dt72`V5dV(<+M3j^cf+F*|LE+OQ_DfL;z89*zmzo@9F zAZq3Bm{CvWqY1BO8po=>qTbxdg#k2gLb>9v5gqMQ?Bml4><1#VE^9n77!n8CHK5JT z<8xRa1&|F0{X~NvTZ7yH#EBmJL&_kwiHyi&$WnvAN41Lwu;RUYvHHhAToheh1B6w; z2|(Uv3*btow|q=`cRvr5(ty{X=4uv5t)joG44^&$M(I%Ro^?@z@>jpb;En)gUhvHi8LBV{8Hp#iU`v2V7vNMLdH_DiVR4z>tNm*{of|#cBnb zvWW^)v0IpF#GN$*C;Mw#)$<_$p#t#xg$$}{05GYI2eqNm7~z%L4@BC6f;E`3CVGCJ za>;(U*IcCoa*vq(1$fiOt5+)0OYBlWj&%wKN;0P+VQ++m;ZKY=7Z%LtT702UDD_67 znd(V?gUfzD1>scH#rY`;U&Yptdt0LPX9q-LU>v-9fqO10N8b>b@hM}G22m2gM2{BM z<#zVAfHDlA5HL9FIPJW2cD#!wAn;lRQd-4CkeZP?TCBv}->(K&POR1N7i?a|iaUdu zSuW(t2hy)-3=j7)s$S%}2QmK8r_M8rSf6&54?R7=ra$~Ib^Y1GzC?j4hplllbQYmqjkr!?E;>!JFS2)!Mc zmYmu=aPwM$#I3cpHHP4#@=_aq8EGQ4I!HEAL+IP`gpqC+g6D_?5}t$I0+3zlO?p2K3-jT^cv8rIsfWDn z7(BLfTmBNaTEij6Wo3zqh6Wiv!J}Z?SAmg64>lvGasL7k2P(yscJ{A@F?mN(O9?pd zWD4w{f2IqAH&qx7=7w`XU|?sH)K9tdVfNzou&0ai<_ZrDtk~uA~#el218l; z_Z|$CTIn(gkToQ?R$nsqKmWwNvi_nKLnt3J1Kx5F1)uwJS_xh>v9O@OGP*fdCZ_d9 zOWL6q454d5auQC;MdYi&>kKl7qS-{DRdD;xSaiMI3`?H>)%$t!WwK~dpsb570dEP2 zCd|x)?-l41NydO#Z%t!UP!tufKkE*&j|L)RQ?RXE3e+G#_g&+404m3K&Opan=~Ltt z@V}UQ6KE>;{tt9FpJ-H)Qj&SjSY~Mu3J06aC7TSTb|JF{Ny==?6v{jnB6E|9O-d*U zmA%bTrZV5p(>d?^zxS@Y*1dPFyY9W#I%mD_S!~<0pXdAgem~Q9w`q{YD%ussGwSA; zqRVh)bE$e?YK)VE1Q+*bThGG>>mtLiKC}`lr&Y_5GL1F~9DU1LR#t}oec!n+{tItC zja)BWh}?O_bsF;#JOe`qG&MEr?oX=vzk#ZKadX*%{_dR$^&QpsoKGUQ1%qK!uWtD! zssvBfUn@xLn)#Qte0dyhJU#K&3D=#mCJzq{TVX%%a))G+QB27ePg8>)!e@lqjjuA@Sa%vf&Vc9=H$J|omy2}5QxuQ4tvx#2ob_<6Wo?cOAq z%39#q7%z5sZ+rP3_tQ)vsV()*Jym^oy=W4aX4-|HF)j_|SLo`|m>LO7CE_Z(8A7Lh zgf#b;>E~K6(bdz7xpp#`_`K1{8`LtcC5}T(HV`WcF-kak>^sv6m%T^sebVw`r zJ}G&(tV>rVoTG3I(@r1$wt7IgfAWy|PDwn9mbuG12RBHl7#AepW&aM}Q zfVI(DQo$96H{;^pY&+Yz+wI<`AEPYBa;sz`oB-DIncVsM|jv52pWe?MrkQvG~Ne)ShK1=4kos3tb)ZEV zJP*z=?zZUMzG%WnR`8vBY%4V;U_=W$RHJp}d)rM*oh9mP&!LSue0$r#+Nf)C%~o2+ zPx%;cJ20^N^;5I@>gsCh4wJYSz*Fj%C@L#kh%jG^zPnXt8^u8msBqTYD^X-KMg*&2bE zwkQgh|M$K+ZJxE3xRpM4cBbVGHK);6#B>)sL>51sQQpq~-5gG6+qWa5UoCQQfJX?+JbL806+H zvpLPWDnFhqBb3}og)%TO04m0x1Iwi46+4GiTh?Z7n#`er38V}}mv*|vf2Vhzd%iY; ziOJtdZQ=KQ3xmijvKIjQlwR~%wSFhjUFjSUUOi^0r}7A4I}XWdbI6^ke`Dd+PJyrN zbCuU#?vRQo!dEaBvIDRUQhAm?9Xo4U<@xBKAnFt~Pb1&{2t{38-PSg>QZLmP$1;`Q zIHYZ?i}ve#dy)N_g4G-qw59&r)IXKt>SAT-;+TRc0n&|5x+lx(IYxL_z3nN|Idl8Sy#or)9ObiVLze7_3NMxD6iEuEJ@O_7AQ8Et$SUHH3U4fCBa~iUTfV8@D!UxR^ zyC7dS)s5DW_3Yx4%gZ8u5YZCo8`#btF||DH#~|C<+twOPfxs`wF_3P_;@vGDQY&5QXF+W>&$clnH3Py|4)G7lHpm4XgO4)pT zaI#J5x7IS-V>G5>{55*>lD5*yOFwM}8hw%NfurJq03F{@XGYy|RNBVpWLA5P-y-Zf zGw9rPB1KkE%FeulM?|24M|_y@pRdiI^mWSI24s?rZroD2A$s0@35l89%~de$sA1SU zIx3mfn4oaM*`;yS7X#z%@zSIRu0f@z&?!fhTAaHpOOi{dI%cYseY&AAno-0ySsx%! z_NhQ3WKw=Vx~!QDKEBhm4FpnC_!G@1O**t9KE4B32YLxxt!tBd`KEnqL`2Y^)}&=g z2nY!9@uj|1?SGz0;U%J9h8(zkf2SNw5Y$Z|vWhls+5~JUhEL&3uQ`EswCLX6zIWt9 zLHA8|Q8qR<#MGRqN)#p;xfbS%$(+ip508rCi={3u%=0&&K~fdKOylN=Q!j6o;loby znKGR>WJmoX3&*z#>KBo<*W*S~MTiv6;vpCU0MQ=CNCK-*JNF9kM(OY|Q1w+W_J;vC zQO|RQdj@9&k+4%QJvxWSFQ4?}-nIy)_9HuAkFihx!2%lN;5UNJSbJNwL3v@bzzF!S z&}ab1I!XNsii(OL#b);K@!1lPxcKnE0O!`NmtK4;LXZS1FY!RazzP&jLTVM@781!( z=Ki}Qach!~uOye|T2(2Ei8A|64ecErO2WcfT9E>PyT*RhEY7<)JLkeS zS9r^lW}ldth#bCLQNk6E$T1Hf?P;F@S=u$mp-IS1-2vp8(cx0lnfW)bFkR<$n|ACz z?tcM5$NSk3xBJdrB9l>KN?f|TkUCQr%&Nl9$vF)`v?+e?DC}95m6y&F7u-izN5^X* zEe50BIL23?%RWfZ!Eh8k15=l|iAC}0zt5ff0mKy$5who|V5_PB5aD(Lwhx$0#>l3s zdkPVY6)kEa@*fEDKyiJwPS)~AZL^)xHt_*V>qY;EP+UHuu`YRK`3skW>ypnV15)UC zM_^IIXX>>L22RmLM}2+%eHnkpzdww8`G=MxkushCzYR^)XGgV`vw3j?nBHFG^kGQm z;W>#9X1uQn0GBZ#tm=Q)Llk=1WA(5YJx2Glnbg9DZjp%VKrYjc9gUVLn7r(9=?-zQ zvam$d!id=h%UN!2Zf0gCihK`Y*z+6{(9w;RPP4A@op}y`eW&uJ2NOQOQHpQ^9tCOy)-yEJfesEYHh0%+1c%_?Bz>g2t;9hDSYqY?T3I!<8VnNXCFgn*P+Sn)6&slPF4*nonsevE`f5lu8^PME29T_{+nS)4!-Skb54w;wFG7F z-qKhZ`>BEVn(d>=jGJjc!83M`?W7J5-fVwSlk)hj$VkJ7+nt+J>vSZm5Sz%mB3ZZM z&VQ~=8Hz|22aw#!*Sw)%K454h!f}vRNTz)@^^|OOz}4Aae7182-Y?pfVmk(%M47+;!_a}099n{xzynG>(|F&mbxoeQC_ayt**<8 z`*EdV`qA@LYDCK^oc4r8jHI8FYhF5nu^L%^58oJDTs)hEJfHpmVoDB;uEotU+}jHv zMyi89P0FIswvISvRryHTv{_0Yww?j938Wl`kPVE zDia}ZKAdHP1$!@QNTiq!P5xX`f&@Fh?Iza)e=o^=FH*u>;vcLDv_sC`-hRG_q48!lgQ31e{kBPsua8gI z7zQCCwQuN-Dk)Q3VykHIg9m%6!s6patqGb2u~cYr;bG*)@rB2{*tbROod$V1jLbv? ziX^(aS>WX*P3yS!J;0-DaJ>BD0Pqk*xdg@f>l|EBf=`JDpHlV|+B8h<2PF|Ck-01fnV?$=HYB7dC;&;Fc0>udnnN z$Ngs+@zspp{H-xj$wU$u-8jD6fF^ndjgOx0i~e)?p)*_Q{_8e4M;NQnCOX>tV?Pi?Ze^Veoo_N!|ulXlZ)!p%R8tx%H=Lm$f!~+?C>5yKs1|@_Y<=#$GV-$uVw2& zHMYS}s9#3R9>tEW+AMH&!>5c0k5J+%0?Cg%AwUFRX!fezaBGA64wk#I#NX_g?nj(B z>*o(_E$#9IOtv5*XRuS=`A)U}!6w}CMpMKQ=I!H?_P~AA%3;r$Bw|pv3d>;0i@tN` z@wuTW=q(`d_{rZhm-9d`hbEb64 zQhj=K6>3H{6d6YMAbao`QKrO36aZ5=6!T6HSDvJvwoQSW%BT$xsW9zl0`kx<^u~>g z#@!9nZ^WnK;s+9yxHd#Bl?)6BsFN$XS0H_{U%x6WHrCX$nzyd-5xl#35*kPV4j#Fc z;B6*a>f8=)sbvXA_ZWnH|0E&!`_1D4cooV`WJKYUBz)?)NC56GVlIKxwx}vEscIrf zF4y_hb~{^J!V2-}@Tp3^!Rx?fBkPJ0#Yf)wyMm;@48&r`BW2Ju+xnZG>(Anfq5QzQ z?)N4^fkj>Xl`yBj;*%~gKx8R9g1>HasJSWU{rlV>ZqxN|bid3YpsDEZ)pr=M*TE)@ z(fDJ@8S{rtZ|oVdqW> z$&*#lf11N6J!DSNkobet1O|ga{OO9RgCk!)fW-aT^QxoG8Ns%QbE?;cP$Ka~wa?7N z&tJcgpI|gaZ)}9pAkV5A959E3#icJ#mi&2x?B4}hiAvLF^zIJjq4)II*FI6rT%;!M ziXFr0ckkXkKAVy@KbVz?bIs~(cXidlrovQUmSAfL%n{HSz?`<*ua_2ngruP>o1B9| zcmx$jtY_VkebpBS4BC?!0A@nlM3JNPqfRi17#|7~L7~uJ5*o07o8Xqeq1Az`;9^!S z2yLNM>g%g|HZsmi! zl5IBu0s(JngTMs%oJm%aH07o@kvtv2uLJoEvc2m5t6vDoE^pscVs<3uZQ~4q_f2*D z(u&LVi#p2YSQmhJBy#=1Ey!+|fcGB*gQ0k_yK74D|P(&|ZA{on*Mr^7f9r=N>FNd_>(FpO*AlBHT}p1tq}b_PK2C)^$R! zXK#=8rblgYx(ULU&>czwlAg#)+x%%5;wBm2x!+b*o?0Z!{wz#LuclI#w*_wzXIZ!I zu9ACxaB#5mk9PZ{=z_5poKvcRW>yz6<0Di&5b%Zb|9>8qIMrM zn04T|f<0Ejw>_#iUdDm5|R!xV1mqfGp z2H!vV&eB)7^|ox-kW5Mz2p5px z;dz=*P8E(^2~z)<%?A=EL4B)(`gW|KPDtn&sEmt!kfK_6pySy*$TGc5X4~zR2PQvv zvypD{gPn}b#SxNxINVUIg4PF>#<2qguP&rHTF6&?R$b~&({lA=h9Pc2pxlog_`gEQ zXFhQYYTx5;!|3Y+JFQj!PU0Pu01i+`w7C64$UDcN3*3zMwW({mh?G2bB$)4eMBygl zpb7XdvoB^u_XPoR4r38 zKC(pN?o+m;mgQ74SE*y1VEnUpeBbJ?_{GLEhBzs20^*E;nvRrQUU#|XGq-UByXaKq z5>maUZjMCyAHB+>KjH(6qoak#37sbNK)+kq{0Cis&W8mLtXQHR5VlD~A z{v#{lG$}*d&c=EB@N{I4ZZ#=8xL6J885|0$Xt@&GQ-EPurp||N$_`G5CB!JLH~#z- zPZj+heysU5nZm|NYSmEtVe?kLrLapM4JE$$%Aaq(Tt(O5V)r)~=-ZH= zpO3Z?6#un_AUcck5Bkbu0+I3Y+wpTEyyALpZf+}lXX5drYeUSy8341I`{LhAFykL|78&+3^jHB%!?!CI!e!M!WO38>jq7 zm$cyV*Wb7Qe3%j|i4QZ`%rl0M?%z3aVI3N4PW;{fBMmWDk|a7gef94fIXScbeX@+o z#v>?H#ee_blND0i|8Z|)EysU@v;P0SmP1dDkB&MuCa0)KbkKjk9C@y0YoM#Pay+mz zJKGi00+AnHWY@S26DXRa-EwXJmS|G)t?aW6I79l;_M$_pKvJ#8J|rF?y`M;h#_2%$ z{0zOy_AOh|V=hpsA>3-jY^0-+S3g|Ran=hgzil)W9H>qI5fb{(XG@qOMs^NZ1HWk- zw>Gg1!oYOO*0kZDX3^j@GNM3aq#uoCL;MWV6$^8RoX__*a0n*5crbY3S4DwZVso|8z94gfc`m4qk_{NSRMq{ z*%~lwXY)LxXWi}3t(UfYDQvW@@f|t#R{9kS@;m&os!-yfRRNZCHV<=BJ4$-_Ul3n; z@<>X$yiMMZOFjm*%i5J!57wav1BSm3GSKBO^i(FJUNoYgwFV!zyq*v!qoxO+{f>Dq z{K-uDQ2r25s%7hye_#OWM$qOzZ|mcIoU@ohFt`x=3#P)11fkH8mp9%WC!POnHt+g_ zU+#jJ3!Uik7TeZSCtUL3aWG2%EaTUE*DU_RaK@FE!Uan7p;mr6dg5)!`^5)8IUota z2!9t8xc5W~a7ynz{r1cE((X3yo%_zI?>;3AjPk%BCZdx*)xm_I7=-u4&!2Vm^&%7} zKMYqmy+4MmEGqYwWFj**~Q%4XW9Y+qcQ;E40v3?j>u5uNy6m%bn;>)Y){{ z%~-H+WsHeYp|gIpq^ztF8v~8%X6-QMSF}U?=P{cUW$y!898)ZYZaJn9Y9tIMl>mQ+ zhlg47T|sXG`q$wT|3Jo2FoB!)pPHaAk6V=xxo7Sgb0dCf)0l5go%VDm=jW?j^X5WE z+?w5ov#%H(Li+6J7?U2HXkaSRl%Qbvu#Sq$cI_EtyIrUQgpNmed~v>eF`80~1u8fr zkj8c};EGs!$D=1$@w_^$lVE9m^<_(@$Pq$`Ap9Lu zmA0oFW+AXP29$Hi_>Z1w!P_%i1m?>iY%D0<9HR$ z%yRV%ThKd!`uw&EKPC(I7JG2b^@j&u7*CZRx*LUi6h($e+?lV zC~V1$L+MD-OHu{Fo=x4Sq(q;ps;XkoZbj)q?k$HffM|OB13a)Sk6yvMHKy0fvps$9 zCFci8*=MqC02t*|LBb6W23?+Ul?iYUz3a)S5mjlzm|m67eSavfJD@eY#HBQ}^y zGeHlkf`*tmFkr9sLK(7b1JyRMV1&qD^6lNMS=s^2J|RD7FZk0N5u-wwRDmSk6)wIrW0xvtzDb(~G4g)Jkf8 z<&>_hn!PY~D-9uoF5Ijgp2~f-qiijQ-j}-gToMrWqrV%DiQ(TFN*C?fa@T6Jpp&pt zXG`PwY3HsthOP2XVM{u<1m&sy!O5#uL|9^ELc^Ow#`%k7IxJ<$BASnY_Jb&UgEbF#J$qyA2^?2azS#z; z^q5d0O=3E??>V6kOLIYt`zN3*y9oe5f)fn9j+R!qTB*k5*!wUiWyp@mkB({6epX*r4VKT? zaq|s1dY4jD%a4;68orI#Jg$7y(eq)t%GJJVyOk1=>%#roV5(wKq+21!5G}CTDLjpy zPciUR_x9d(3gu&^O&GJV_0*eKTU%>+ol!gXzV#b&hCLYJn({7P7Q-*ozAk`MWv8EjUhpRz|CN3_q0wH?fwQHy2XwlL9k|G5Lr+W;-ZtJWOTw!|H(*t}P zI(>js9fbJQ(8`4E0#s;0Vi?{r9;b&=B+5;Ir$k+(U z?%3OPsR#E6wkXY`XCgNAczAH^JY0Uh=Wx|I+?(2a{jiP0pc<*xXyV)f)95fn)-yRK z07%!swSxgUs<3j1p~4$LNHu;uV;$4?{XjetgmH>kyV5z0x!%$^Vb95D>?Rg}h(690iohh{*I>^sR(thnlx%lOnOM>vs@RO1e!~9<^?7azh ztbX)`4{NHadHo_KN%>UGD!Xgnt*U%9doS>SpyZ8N@2h3PVPjwKRAvSD1pA~kaP`#h z;CvJEDX{0NkII^>pS)hvIGm6AjCn311Eu}qKBwjlHRfI(AI9Cn=BK?6dKX!{+QD9<8E?XL5-2y+T+CD)|`g{8g zSe{2aEnN<}H<@?m-JSg?)g*m_dr#tZkN;o+DpTxKjb!Ki;~|iB;4uZ^Ka8`2@fDm? z)~kAI*j99_P)aO7DJe{!ehO?r`xC@DY7!kWXSyH@iI@XYzYpQwx~Ca;QYCl2;!`ok z!mK9dz6mhyNvdb6=j1@_mU+*^)pdKPikUSP1%Xfl%E%}Ll>ygGgWgu{duPQjwA%G^2evh1M7b6g9ma3T^8W*)oZ>$ZfI-+6$ zL~ULc(O@#jPj~wGAjP(@*)USS0D|e6mY2<6BQ&T~f>}g^n;TD~2|T(dAz0)LQQ9 zRL9M1<-gN*>NI6~&gos%r-!FB_wRoSK#Jff)%dky)5N+9t4&Q0r7R!28XfY0?-?4> z___3nb<}C3gC8#R)uK%XLVOn$W+pEhBTI;bU9J->9YCe#MDTE#g%dwPVDl=B0S`|d zhb!x%ho5$1qI=gg>bSO@3Q!=Wk;nHjxn%c-*GNv=<#FE7_1lWs(UHeqbMo`v)rs57 zLIzDQUFj8=Oi)eOQ0eUCN!NO1ZA#mh{3zb`snZliyDBO0s@U72vZ2c|WtQ=e2kF*r zPncH@-0Dn}s|t&B8FQ1lHmg*U;O*^V^ya%yC3jWZXd`!|*-=H+`JrolhnG*B8L+GP z7RB?lYGxup_TE(&wD3bYF`fH+^KHp{~ry0LJlssgbXuB@d_DZ|9!- z?d)1xe^ZM7WQCTxn7v3n)vCG1Z;_y!3l<^dbLNP=zW`wUzV&pJ%o>HWTKhwOQuW^z zC^W^%=}1V^(;O7OhUrYqcw84(_*C~*lZE;z#rkE`vQUe1Z=61a{7jIG+O=!dPKrY} z|3TXLdWE_$pY1Ysw~Xo^9UN+PH81R#(Ynq>V(>F84o(e%$cDH%_mAUNoxNFxQWwJ? z_9!pLDndl!e4ADXs1K>S<7w8B(BH?!ZR6m`aDN1>FD5!VEwn7WI^tmdOye)xhv-SP z^p4dd!!#mX>vhh0l{O{Adg+g*_qqs|rHQVU8TI7QD+6YVYJw0CPs+zi^Y^kY=(p7pbnL#3D~R91yK zZHEyc`pVo(rnLXXMI8H@9s@x6;Kn*a2rnwB%zE(nLs+Y{zble@M!ji?||agqhZqmpk;Xfvcs`)n3J^G5xt|+R=*K^{mP~`@c77HLYVk z{z!&bj()o0=T&=Z_iU}Q+ogBC#VATKrEY#-JFfMo&Z&x9#JDu|x4j_C`#QN?;Ok7e z92{SC^7}$_y7v01^N;(qtyf=(lxZl~T{mW~hR76i&r7}){PV2zkA1CPGVY1>Whq5{ z*RS?b9XSNg9NgRF)mM9%Kd#T>Zso4CO^e&KniFZ1iSzDZe2P3(Cy-6yu_FBrV<~wV z%rhK6u&a+fJA9l#gz1XYg}PAps_FB_#4w!?vrt7&c$r@(7Ge~NttdNaDr-u6#J9qj z=G3D@6MH8*`CW*f=B%)#p7VO4#m*MB4*u#=eg4SEHD&4I1wtHl%pbyiqEuQDtcBLT z(}$`z{!`D6+%p(ht5t3yWD4cW4-67I)=~-z?TwAc$`3U=ut2cSWf~y~qA=@WRY6>r z+y&^9e*JQ3P7RvK<|`)C#Ex4(QFP697H`t<%M|o4yyyL>YrDRpbsPH`Ex-Em9qm_K z_Y^)dFgP{vVqm(Ux>8J_;Pf#XrHOJG5On)l<2Dhu0%t8rJ?SCBUfCsR`Nrne)~DKC z&Jl})H5_$r@ZvTCE4ioEDjgwCi~-Q0MGmnLLDtwKGvV3cZ9UVuWXY1TEz#Y*C}hqi zF{LAraS8h^B~)_D-~OwTa~if4@O=Ck6A^kvRl<|UUd8S6Gz1bQ*l% zFKk_FnsmmXDa&cohmXJF&7An2L>KsRH;r_)OukBtjgh-pHgxrlX8z>b9~b-IKRYuU zG(24SEb?rHqC<)KN8jCjDSAzfhZEyn6gmAyB+ILldL%0rkKN?kpFg_jqTC_$wyv}G zN9~L8e7k^IrCvttxA!a)RcuLldSp3BhE8xh{6ZBqz36FErs^O*l=RR5agHVQ<9fRg ziY8t*V{cgnwOGL2Xv5t@cHgUlexMh%y7YC$>PNg{wmsCZvQTFb8lkGayNE{2Xvu&< zBMeK>B4d!btGfCna2l+yw5iIl#;O^<>o|}w@S@{GjCGXnfy#TOWAOJxZXN|RGjoof zoperJFO&7*I<{sy8}}j@;sDx1w|VV|`1lIw1qL)`W@faHm=##5__VDH*qzEMR^zGl zBQHs93+z;e4&iu@VP)p3nPxf2T_6T zN?0?A?&r$Ewb_RwhLEH!5h1LjP^&?X42W-b4&)oUAI&}8FtUAhFEc(}BFOs3iT-De z4sB|2l}q^@M(?x-sL4DWI5+hm|88e|nYKg%?aXIaky00(Qib%(ZuaJ zG5qdUe!E1wFO96YCGVH`MmM0qh{-WGdr`N#Ou0WgbBAD)&X3Wm=8VUp7OkN~W`IJi zu&i>~@e+5^1A*5wTWv+1+$@~Uoo}ZQW>V0tn=PMs_Ec-cS0%&sbemI#P(@-#jt=M! zCP{!pm&v8`tryeYAEYi*BSHZE-g%hxBP55`!>{wuu~lSVcTa6EeP*o#2+v$O&jN(^ zD<~;-C~gWUyfxjbxvHX~LPt=?`lyDL)~lOmjj4`;tW^gDh0nmOt?E&`qE4RKIyv#y zOsM#L5C?4d5J^oknE|QI<^H~#H0I@f#@@cusIMJ~{(eHhLt#>{K~Sv<6;%6$#mmm> zmNnbu#lV8Nefa^alxw5hhbSe}EkNLO(1fPi=QW+mD)QN+J&n5uw`aIJT4eFuIx4tv z=x=t7HXz)DcMG(m?RfAM03d>(vTikElhBFdacRM$nyL7;v?`Wfno%RIsGoFKaICg?PiHn@y3|$}O)2%bAX}yr%TkB^-F?vE=T$F+J`QZ%u0# zt@4^#B}?+&^h>Km?sCab7(#Kbczkqm7k1=jaWFio?;hblq$UOT?>!^rwq*X=f&49I zO*a|Ik6+k8vR1;{(e-5in-hd}Ucn#O?tyy@G~xMZmrO$(+v0_n;Tcnp4p#>1b9W1* z%rRpqpwOlXo*#$g|E7c_rxSKZ!Nm@ygpmycMzc#jwq*<-w~@?pKu?(NU;vfHBUSN2 z)yZpjg>|^KJsNg<-w7Urg*0T}J9C>4<`W7(~?NEG6yX2Lbr^IiUE2tjv#$KbW{H0cByg9gb8iLzisYR~rhBGI7XleP zZN*Hc(H)yy6jvB+hk;=;W7n@I%hp4Jw39$yaMF%^dZeS9Vte_IdR=!1!=^gHDA|2AJ8;DtmO1Rd(?h%0>`a2qFKjzxBiPv(c zwRN{~o*9_rW1-QLDap;N6(Ou6K;0H%@(e^HXW$1!f3@voJrISDACIs0;7@zhnVb5& zFn{8K?ZcI8x4jlAyd$!`&V%WMAP(DEwkme7?Nun7N0M?-KMQ?=Hm3<5KmCSfdJ7*1 zfjx@t>VzC6B)ZUK>_FIW1K*`HHZyjV%jnSu4x})*7wcA?7S~DV+Pv9l>P2Np_}=T2 zs)x(b84~M&0K{ZhGTf^!^yvm&Wqz>Di}EeC;0@QECnWMJzo4)@CW0&D>XC=p_!KlG zMA1jfCf4}_t27|M-d_zT95{+thzl&ssA<{lR57C+R7I)9?@19=7ER21Y@b5rciEj# z`qkqiXEpBpqV9L)qWSre-EZ4^+Se{kY%?EuAb6Qtw4qP@6`fl&IMi3i^s(?6X4K5z zqw*euD;HN^IJN$8(yySvEFCsRK=qv7>EYR9E~7mv)4`7!M^7Q5Fn|3ipU^s8OP!U z0!*xZ$Zsy^PEuR{e)jp^{ZKuO>$-l@y{ zF%G4P5ZyAAL(z}ncg_WHou z>IG4R9+GAA8vi7zK2fHtr-d!|0A>iW4^&B!j9Tj8t`GN^Eni__LO#qnEvLE_O`r}u z5bFTVE&z=@-?SNg_E4~gSNmIRj&AIt(g_UJ0ridXn%ZfVe`T=9T!?~SLJN&Q&SXN^|N zncqCBYGyisI|+9W`abOEFwRtU6*k-wvQqplJuJe(=+Rn;uPgCRYs7+mPX0Y?2>uAN z#IwRfjl3LI$Jjl~Zj zd~RY{0*EGj`0V-{x2;f^pVqh<`O~B3d*eU9>`bnV_jz>!>-INe10}flw(l@O6*7skr?YhuVvsu`E=o?i>xhV~Qd} za7W$k&&eV;uA+Zg>ZpdC6G~ZQ{vTSvC=!xoDCQuX!P<3be^z|d>rw;K&UPGzsEi5s zji6c58)}s9_x3xUo05IJO2jCo@^O`dpmE=!yuB2`?3>9eR*s1twA}TIjgZ}~O3EPK zpTA?=<5tQ%HCyK`qN`5mP7 z@T<_!6VPaO(y^OKUz&Np`#}Sq5E~E>kcO4xOlH>2CwL9wAAK#6feojEZ8=>vDSUKl zqP6bph=>R%?bz{i=}kUj85CluO*RgW`|iF#idaH30MDwmk?OZH-eh0{QUeB3^J+V>=akxmG3e7 zcXfsZE4+9cyo9uQXt$TtA7v31Aghd|tRV@}xOOnM@@TS7Oysbv zf%;2LO>G3;JfR>iUd9ogSF994;$@r9-$2f41eM!H8`7-J&ta8W`1rVG{r}nb(dnYt zH@AeOFRfq@RnS3vLi&md21jBJwg*|QA$_@0{~=g-HSw#lm+nm+^1vf`10bM>1nk+r z-y7AlP6u}oiNmPc>Zlg3nGIUGKSC~&wtyy!f|ZaG@#ZWx;OeUL3wQIR^M$aeZ(Mox z^1#2Z$0Zq+xbFqLZ8!dWOi`m7458xs=V>MR`}}8J9EmiA(*N&&{uub5FhtUcB$oIG z#EB^v@Knq4R3HC^71-)b;N)ymI{MvZDe51=r)Q7Kun2?$4*WHAi_Ku`%;Ju}aLN#smt=1Q&jG2`BW90TA5r-Nq0J+(b-K;B2dXjbv*Cd%p z;A?#s*Oe-_Gv7Nt$tzRh=Ct>l(lP;ar#(lUygy#_&Kl@1Wx*37z0dJ>zRB(DjtghV zhxTMNk2O_0*#rmMrrS&oU7PM*OS=mYE7IxGTOe$lNA~q9?gy4+OuQsD3dQo z=Z+SqEX62B-uJI3X#rM8`S1Ttp4;GW7J}oJrFT=|meOo(%DEQnXT?L}WOs#lF3af1 zNNU(QQ*t_2bW9o^vZV;UiIrW*8)=RUHI8mbuy9)We#AZMr-Adu*N~1XIVP%j!+=i?CzmIQxtGKC7JR)8iQ_k}A#9dlfImt;xT*(8ySN^=a$a zvh#nF24y{^q62y9_!SUs=J1;l;=2hv(cd|#A zq$g+dm-Kps zzbGx6J~YI2RV%0dxM^P|ZoL)@v*U|5%PH2T*6(WaDl@YMkTUZeB3P{KzLy?>M(Sk5Ph zFV~M;;rchZz|5+6=FX__3(e@@+Rzo59sRXN+d{xEaN=9ozW67*NI>kGJ+Xi%VuB1-41e%gak`;_U%Y{5$uY z@W&D^jI%(^0?=}J$nV+n1B9~6`}EslMNS{`(Jd4DUW0CcBcR|xv#X3Z$P}Ih)d=wa zRD~%N$Y42oK$*gutas!{*ds;&2qwMxb|Q!fu{m%Jx3{+w{9z*!HL$o*;^_Oz?O7s% zl8y$%!S}n#Z-Mt!3_wjldGjEp(7C-&5}EHeyeSIq8k_o{#BzW%2dEj`+%*`#vT8s> zj9^P@`wcEbtQUI&?G*s>O32S~I(;xfJ2nRWlW+hWj&mU1;S00E`Y$Z@%0nn?rfbz5 zXwBAhN~eOnLc1Q#d*z~eui5LR&CduEl!5{g<|FQ72o?07ma5w=Qgj(NaHAl$ zg7zxqz$&dJyLow|Ro29n+*_3KNWW4cUlV^PUP7*WGJdSJZmnkXDf<%cGZhT$AkFkq ztBJ!I6=ohEJ~$bS6_)Vryz6z*Rof~ZzMdC%G8{t}7k<^iK&6Pc4wQ!}v@388L8<$a zvow>$}@P z{fG&$Vopv0S~x~Ep!LKQS_l7RO;lcX#wlWI-Y6`Z?IR_1;cZk>q=%i}@bxm&^MRAw zutfZt)z5QhZ5UshM%$d?Zh5{EPjzL~b7lp8sDFO=xm9?9V6|6$yQK1i@y4BN%N5E! z>kdc!T8yBczHNX1>{=IZg~3-b2J04#PuuU#pDl17U(oV0PB5LnUZi(?yh2s9_{l&L za^u_Au6uKLMrV~mfo^WIb=K)cl(IJtgm$E?j0-nZ94^ZdVxS4l)^^FZ?`j;g(%(yD8Mu zeJ^K!u0nOCUlvwXEcHXYL4LV5OT-E~Q)_ip&2%rASE0u0iE7+blZYlZfT z7*5elA`Jq93`TYz0#+v1%0tKq?;%ar^jnXf>h!^>{m)Kot>P07vjrvgaFij@vje0(jypy(QKQR|0)-qeC;W0x_4*usOql=Q})I+`J7zK6uS+UWEPJ9 z2Mh3f`<^qrL;KUF_O(riJ1KOlW3!GBr>VLlM{Q`>yt}luwQiL!VK6>&Cr9jqaN2z^sM+;H zg>(rWYr=j$l^1Z&_udj5i5x47HRVHljZN7HR z$OXN#+DAXVTg(6bXo`5uxt8szlEYg*3D~@zs4V_i@j<`#be=P%+B_!2(+)2p9$si^ytYx}##SuYk7HXEY^%#yB6ONOHb5C+ zDb++?`;ftmZnIVBX_5bRBgFUnO2O||Cp)i1nmmva0c{`FWGh?JTu^;iO&@ zM*1Q8^Wsx}Ekmv`uCiRD%{l3lHoiC>gM*@LleV-40^k{IMZ}<6ENXt8$-pM9rXvxQ z`F51X!JMI_XQoJiD2t>hM7>r!6*B$mPWkbABv&^<^ z+P^`@YmLNCRq>S{1?Y#iU$9ToAAcjr74tncX{KYksp&$bZdSx-xYcQT8rS4CzuA;R zC#r4TF)42CX1#?@@8Jw;<@-pcP3T3^;7Yhe;>3M&s}pGLZ6Id?XO-TVg{Nj;y;w7O zMYS0P@!s8>Cd=CecglVZ6&A4f_HuvwRwOf>y3}7W<2skk#FG#2-o2aJwVRLe zNPu3u(?|OqtX_dTJu`86_{Ed`sO@GnThkKZK=G?jy%z^>-C8rB)zF}wz@l~GqFX_C z$}SmUwT^_1ze~6>IoAX!#gzXJKoZY}il4kbQm3r!n%|5LPCn-K7}aZYjr^6*!ujI_ zPB{9__N0xEL{Hi~aOeJX@Hk}UIoGb%JN;<;Zi*I7r$hAW)oZLl6@Cxaap|Z`Y5JsD zEZsg>M}0QsvTdy&`I~>kY2@AO--~Mt4hjh|<^T*}i!4X91@A&P*A`A$Ox(ZaTO4sV zyig(eb*(NrR(ZP{S-?>DUfJUA>B3;YtJijP@7w3K@>lE^#*T!TZ8?(0#Zr2o7K>ae zhPJI;OuAMRp_H+sGwhUX{r;4XCc+eYE%j&c>tl*}uj=c)8>Nk>-nQ3VUU=~FKTo%> zA6Na1TD;7I($U;sFZ3_im|JF04h>{zq6bY-^Q%5nm7q3|QZ!?KrRW~II(gW?pf1`A z@tIZ`E%*#*VFCxn?ZOJ}k^*1ng0KX)p}y3F|~Yr&q5nT$_sytLQ8rx$QrEa=>t zF7&wO^(A{r(_plh;qGqb7qu5iu7=0FKPezD)mF6=kH$+axxwV6ff8> z*oco5tnfWkz}im@c4b|yM6*j&`NsD4bw(}wWy`}eB~??uBQ$UNh+i!VOkINxCid20 zEtbl-sdsBa1Wx%5c%4?dn*H>tR_v;$iSeDXYN86h9Uu00j@~FddVdLtKkM(Rtv(`r z9finyUeHgP{C#ETr7xF3hQqS!2iG6q5@g~yKul?B+#(rbrnz_3^5vP@8_;}=>BYtm zRlZlN5m~i)Jh)_=&~3l^r`>Bq@}K&QhU?rpZ(iLJBl+o$J+efmT9-*Ito4ao|6}xA z)wMSnezDP>{l9Fi^P|LLUjMB0){@y5yHF)~P)bvg#&b%<`#k?`y(({uX8qS`^x^A>^NBQQKp^shZ6}UUBOhBz~7_o>6KVtGl(Y zD!Hj>n8}#3%{l31cu#t)h@I4ONbc(f3`Y_J3?(Hc9Zif7-&bBe32p~yIdf+#vhH>T zuBS}D_z4+*Tl%r>D#j9&s8|VJ-X8d9sYVg>1hskFo${^(xuT8Nw^$q$9DCnW_$}nf z?MU|r=I2DqSUydKTTfEF&DIrnaTe?nR5>~O0UtA^c1N37kw^VlS)ILf)3ENR`K#_l zz8epudtGy{*9jJ$G3#oa)jB%t*M56Uui&R+v&`g?pmDpnTPwyy?ruy>eP5v2h4Lvn z-j};L|66yi?GT&dZ`a3GOVsNeoh1)*t9~Z3;ZuBz+E^p*U-h=chH}W(cfHCLOp?_* z?)Q{8cpvBvOlVL|><$zx`zV~agS>jegSJeuldlw6t>)~EjGb1ZiwVlh;yb5e3HrHmP&e z{Mbz2F?aFMe@gMoAnaxNT~rhqxY5g9*o(cBS#vr> zG(3Id8R#9TM_B&)>urW*{<#@tIy$|sA1`B;R)pMI^a|Ibi}D+*&c7G+i0xHbGvXSf zH(o8})LoR%R_gk&oAO-wnFRgTXS;J#rxgb+2d*WBuQ)1DkluTgPiZ0Z`C^f~;5oO} z$hbWn2-q`ZZ;H z`(~Arx)o~PD^v{UU+CW0sh4PM=G5J5;(0sKw8;I{pjUNNm2QM#!CZw^kA%X}LasA$ zvppwG7Uplb`?$U@Dy2lKy~6)~o97!F zu%;K@DD)-38(_UK7K3b+u5*#hueHx)aSI&J%g)XQlUM||{3Vu6!F1Sc&9^;X)E`0U z>~Z67)9-HY?b@o-g{SX;jL_|(N|*OHA99zU?`Kc)VfH1BD4y0ZE!ZEQG(Y`@y}iK1 zv&71D^ydFU+aTVe)Vw}(4-0UX4^!Imwj27fY0AOM@ zQ|CG6tT^T}whUme=@{oZ3}Fd_==n2-V5J6SOBOum_gE&c&yP&e7&9yuYYrJ3Jz80I zT*Vv(@CJOJCQuN)o4y1b^?N=Hlk9$6x%MzOX5=oEb|s)i8%%QtjM+>+fpR|&abEu$tnM{0&}z~W9B z26K>nSd!`;mBdVp%P~_l>&#$^ba>QEL3DqEE_jHqwICjEgjM;nJIFPTm6^2Vp^wK2 z7$q;WY?6_P*PsD%V&tjUAG+S(k2X@FX9VD4_s?*jFFnD%2Bui25f{xW8FEB8fkxZW&`CzM;M{xbeHQ zQMCV5PZZXv=34(xC`i)vU7fBe>N5%?;S|8hTbvjd&=W6{IR#KZwF#y)}G(*m9yKdH)$)qne;r_1`RJzgBM$RiRQbC| zjgSP-TOQ|bkh;R@@BKLaVAqAwgr^DR_DYqSsFB+3FN(dq{7UK{UUvTdoMkF#!tL7afu3Kd zqo!9+qE_Uk$u$zkF6;ko>|X6r;)%fB4|-%M#eM-)C%@5V-@Xvt*2G=w$^f^LOgYhy ziym%vh%O#N$O?t_M4&L_yEQjhtXv0-7|`57(p|f2lSIEYpGktP4B)T;YY4Ip&A{|v z2f@o37NQpf7tRD=dhr25F&I1d#l}mIlM>jI2zEpLY zoqmXsv8-QyKQ(;g{YrLA8kv;#p6MOuY6`!!^1`?%!oZg=_3pO4|pbUS-NvdYSqjg`M}`-n~MIADFWw6xZ|2kWMXf0H(fz`X&rZ*Jz!YuBz#x5E|?7uylcYLHs$ zXmEH`)m)@y(xRm_sg(8BLSCYHU#oUHD#ca7UZFK{1ag2f&Ew^EVF!gFbWH{tdDM`7Hp!WZiO-ejG7V(!7gdfhr_)#WkRKiyZu?4l3nLV}dC#VMN-3n&si1cx&wQSHA-S|78l2cQJ!yR)f#vtBV4xk<(9?%r+^r|T-G|Dbl2r?NrRVT^WO zxSnCuTwvP94Glo4(3MJf&BpFuv;+*rq2y$Cl~QW<8aFrS!j(YU1zK0hIb7qTHhAnz z16?J2n`@yU65+h-7=Ryz|1mzKd)W}$hGvuV#b zLN@MFk3P&&yEgE;-GQ7T^8!gukbkUv=GiQj$=3zajQQmc6^H8B?2ITyC9hz_XKnDoe zY=gbEQUog9IaQI}lP2etxeRTOyGvKNN5qgZYp@z9@Bl+I^?V8gy@oO<#89Ggml zG?3{xwh_i#G;TNPXEzWHrNMmUv#J9MBGhPmd)`6e#d7)9Gow^QQ{lJdUYRZwj{o%U zC5{EcOym|&;R?$=UMXoQA1F&YT|(W`7a3fZ6w{l1y<|JmBzrR0vfil<3Ucb^# zkL%_IcX?jy)=TkAeN(j5xU!_7+Gkdr0^2sUAKKt0l*3Ed_(|>;3UXv(EF-b;GX%Mfeb zs_L~b%uV+gL1w8{E2B`)@6#NYM_bPG&zz_S7;Gfpqjuh^7F~&cBPr_U>E0$F1czrD zN%Kah$8Dt5_h&?%y=7;fDBJTS@hnR13bWQUoxgl&s3HSfj(eEiItaR&y44Xo0I>(Y zrd(KC;fQlD+H6{p4y-#Oa|A>HKpX1AUC()o0z|ffm4$u-PM%2TiHl)Jh)4PinudJHBv=Zpkd12i!T^vfU zQM42-a}QpHW3|7YqA|RC@7^T?|BcyN_u+9Tf%Yi1W+18Z@3vzjf$7fC6|vIf_08Uz zU$mC2M;3cpulc|vf@cP3OBgVms)iMN=U+W`U`1`Wu&~&$rp7k|Jy`m;_kmR0+CS+l zAN_4NI%bdM+ziSp%^9JQs0@b3{~8E0&TVDH9a2Fc0>vYv#3lN{j4q5Rp~O6HWcm!h zYf_X(;47N$t(g)|<_hH}^;`ml1J5q8PFp>YjJLknJiV_MckR{@r#K2aNMcA<5;^H&PJ(>>ZH+K7cJ97229+^|xh$`4MN%D{@vlLEwh6lI04T=jb zQKdtP%my|kPH|YJ-!G zVkL>RC3)keM6SBDL#zBEWH&A^US33mPSV$Wt$gVMx}PLUe7*Gh4g95Oa=;JpsqI0* z-Q;NRuGq690|@Jfw5Z9IvrnCKKgr8k8KEID8`tZ`!&fq14=C>^G>BrObXCCD$7cX> zKrL6j2h!8o2iZlRl9({FypG9&7Rn3i^ZUt-GfZpHBJ$RP!W>D0O+|OtX?4kxoR&}b zKQYc^y%nyEzT7v`vb`JVeErt=!5#Sw1`T6iFbwxp(8<-&7<*mmAGh%z(6hplJ0tCh z`1Mj`JB;R$`85dZSLqq^X(fN@KjMqH!WD~#e5glnOdiALz=-4YCjrGMitPFv*f3?2 z9bQ+LS}CX12&v7e5&{9=z!xbhRrw*cC&Z>d3yFS&OffUb@4X#E>tT`gcg2h!mgw?y zM$}C}zdo`He6a78=Ouye+0P;;K1upTtqY(ftvgd2C6xiPMyzFN6m!}GK zOMaz}>Rs`fPjl@8Sqb_gN>cavsgig`@);m#hi5)86j#?s73J+8ze-y+*UM@?BM|_T zGUn=rbO0n|OE_C7cJ{S-SkCZMpi=IT5pvYi=@;qWmuw7QcX7CDsvqn1tLys(r2-y^ zt9Z|+GcF#A&m5?~t!{~p(nXsJgz<)WHMxL3p-9W8tfG6DSZ9(JS{}Nz(pfQH#7l;s zNVz-8DWJzHdn5I(v9bUA^LKHen0!+}=lqJ2-LM-(3ZkA6Y*LQiCzsj2EA1~*&oCLb zg|J_G+mpVwlpO{?<}piMW@S0JLq=-4#U1?78POaNpnqnxw_)wIS~H)(TvtzQXoHAp z14TdT0nqI4fNPjOZAQBdCGXY?#G!M(Q$aMQto)*%`D(xafw*-b6a!oYf{P{C{V~c- zSF1v65M$pBEJ?!I8$MLsRM6iIcq*k{4?Pk)r6%`dw`@_y00Mi7!=mZHDe*JPz ze`5%>!qpZ5fZnR?4CQQ`#tPJjqvqEk?Hrc--8pq*O-}pylSG@mZkk_ChnJY~BqYHU zXl;EcKc9WMFR$hFd%#TW$pwU7QY|3G%U!nwH*1ydJ^-ExzrC;pxRBN$vs0lgwhKqu z-?9^p!?r+jfl?#fw910anHfT$-pEBZBqDQjiBFPOnqH%MsrGC>J9}P;5i_K$+u-ev z3T&1P?tRq)`qN%M7@J|E!xUWTiI~u?w_BJ%jU$gSj&MTEwwcl z2(fI^TqtLu|H6x`wMFG!3Kg~qe{MT*BJDRgsl!)CNJgSzmvuwsZBfzgzaRX#1JV?I z!aK{PFYRr;c5lBt1v890fmUOkWTY~fWB-)?^D^3(GS;0|0x|H=S{$ewn)yXV6WZ{f zSlD(rnJWEt!T;3j!duChLVq%mx0Q3wY$327Od=5yy(C zpJv^KZDA`cx=4(_20HpgD*pQQCWz7|p}AIeB7_yj_Vz2##ls!!u=T*16jJ-)3%we&U4_#r#sG4u(wR+0!9@g$r_NG z-vjOsO5BD^03}SpcKYj2*M17T7!1(>OadIU507Y#X}&eIIm$i&;{qm(THiK=g3xts z@B?@2=>fQ5*b?f5o_{;^h91NXAe7r}W+A+T#-WI^t?v@(6d0A5VnMHKy@f)f{-Dz_ zsz$c;B*RQRA|q~6><)B$_m=;PfI6B;pE zF#}Ca3N+}wbBJQRp{FTCCDlj|k(nrn#kF~;XG}{(v{UG4es(`5I>!S+5K(3U71m@i zN8^9Kk9KR?8|f{l7edF$aO0av@LlYpJVfu zO~>UES8hvkNdwiWZb>LyCD)AG9(2x2RoK^*djc{2L8ZoDshx+Tn1|cFRHxq`pFKo6X+1?Mr14rLS_s_ zSu#2t zyZn^{5(oJ6`z6!Z@4*ZXUXWoe0>zY}-z0bXHQ~WU7Llk1gq=?uuS3kO4<4+!9m;ah zyl{CCLvXi?#46}6H#>UeLy&r2h6tQ2=s7s&w>Mq|Gh<(nuO1Qr)wALoa2iDM0_7B= zX^OIDS0;NOYAQ_j3v>;Bzqz>y)$~@6UU2nX#Z90HXwgG$mZxMn+8N-PLh?5RLIO3c&42LmI*X zwBcaD;)S~0Zdd0QzbCY!K9!mNl{+jLns}>I%f3lw+~s` z2M~oAq&!>6-GlK%^i_ZsWXkVnGY7aN*sE-|qY0(`zl2YphN_$-WJ2KiRgnyKhHR1=0k=i8yLulOgPB<4nadIphS;C z;d^9cJFgEHk$qk`EuxP@hEE16Fv%~#0pj$?5WfB^H~b$iO8+AdimYe0vgY*GHdaPzRPGiu+y zeLGOHk?cmF%Y&@P{3Jc)j9S)T+iz?+cM2k-hWbB<7ykLy%GKIhqOYxW3YNqW0 zDMIZpZ^S{E*sIE`cAXRfat!yk6il?UYZR z@nTh<3bNSN0wg)q3WjviRM{T7`kp<{&NFc9I1AALOxkqEVl5< z8T9q(!Ygw!R&v))J>he^mUfW1VI|t&XfPx!*K>_aK$xE`C-S~2V%nW5p*FALl428W z6S8>o##2S+POCY7kB?j_AF5(GonbJ^BGnc1V%9GbH6JB%Kyu{$XK1HD@4F}hs( zEn=w;E5>0qk;u-}+!eo2k0HlbTKdo(XMF3gdO9)Fv?{W&=AYK+qET7#THfRn+A_Tj zn=*8Rhi_kDsEjLBX3@xG8y~|g-)EHHyeZY2Ek`OGI&{IOFf=owS}wdKT|9sxx)Pc?s0;P z*G910acprwPqAzYSq*2=1Fu1ng(JrtAJMo~R+Zo`W%dARX5(qO-PZ+*hHQR^`B}R0 z$ATN-!po)Avu+z2w~9Y2Wod4_$lqzS9Yn(ox&UJPOgBf8pbBo}jN z%B8}L-YG0pR%mS4|2CQHDm(eE?e*x(Z@&jbh2V$&2yB#>ya)x z^#xyp$g;nP-|A#{*68zXF$}tXix-UA|3&F-mi^|t4N~dHn6lG) z2Oqy}N4FNuOfx0Y(D+NK?$7l2_`ZA_-}Q1)QK$gl63&nj8bBX)H5-4wuelt%XGf+b zMVjkZ-H4M-E#ors@R1I0*X9?A_s5^X;$@#Nd$UOp^RvvV2IamFXYx%Dr>b=~5*$%0dW9pmNUaPrAFS~Z z=AJAxU}!MTdIpg>0}0Big422C`=h-Q0qOBcks2H{gvn|rNK+(T?z_M#z?hxDCD0a! zi4ZD#LgeRH8tmdTT;EALd4?@V?Y`;Ax9~?e0_~7kT%Q` z`E;RR{LCO`$WQ0^u7zHrpH0^Y>z-es&#$TkFHROFkNPw+N&Y3{(QP}PG@EhDn8))u z{JvtNb04d6OQih1IF@RO^8aj*5qz{eAx1QDLQyRSKUMABk5$jHm7z-yl05!WJ2e7#kGbt5~zX zE#j9#IKqvNStqr8k2JFxE`@db7G5N$A?Z42%OW?u7EdWjO>?Z1c4yC?eAGX2n>p4` z{m-D{gR zLyM7Z?r*S$39<#h>bcLxt`v;cOWPyQz8}L!uo2vdj-{2yz19u2sRI|V{Q8=}@Rv`; zV@#s5sk|j$(M_{*h3MLb1V|I4k(rVfWQBUTm8y@(FvNbC&#lvb>Ti`pO5hdkGUf39 z?&&Q(XF2Y$WEryDc3QqygdEd2P^7bGOWLR}CuCdqMMj3TkhgKW`7jxSS8`dLZA%Tf zEGkV`$*ZI@riam9zC2cy?Jvo!Rkz*AZdx_flN@9dz;vj}8R;4~;C_`|=31}T9;UKR zWH|H9{M)vd3wXI(lEZau=NUtWhQ^C8WxDS&@8s#7^841^N3dc~_X)b>$e%`xf9Ghh zF(udDsE$`UOryq-PsMGOR_Z)xz`tK9$fOy7d48Cwx8#f4EZD0qI-wZbE<5QkjF~Qo z4fxt`YLv>8I&|0bp-#>2$4o-yBo%m-UMth@qMUS|_Ow3sL$#cdEZ%w;(qEVVo(m}M zDX}$~3#d2o96!zUW$>+~qepV;BpuI;_WN zPLRp*nA=R>V@vna#H1pUn0sp%a#gz(CU^>qTFS4o<;dJK{aBc+tg0GfW?X+ZzY9>jR zaqsXP)mokCvhJl6g~P)W9QZ1EbQSe}jUf)^b7O;m9I4aGKjeWeh zCGPYd`AT&QtreaA+d*8OBWE^4CRgX9=4Q?dX5#=v8QhT*TCy&TRol)?+m>QA63g{Z zHp(|!^KPaeF5{X`d-CN$&KBW{Xna#Yrl{@-Pm4dM0Pne^GW_Hc^P%;?uz{GjE>aDDT9LsHQ&<=)-u_N-Y>tFWMX`7AD@&$pOrBl^_g#MdmY`nm|@gEVB4`&w|Z~m z@QzaXyP>Cf48Je#NN7nl?Pe>@Z;J5@R0MD?=^1X84gJz?yvz9cV~(U-)OgeI7DM8M zw(mB7te3J=o%|SV13Fe=aqcP$goFPy6?p&a~WQXrvc8z8^ zoaihWj@zamU01&^Tk4j(ATUzL=JRgUwwIZ8F}L)LaIVQX#eyp_MRq6Kyu;_DTA^Be z(&p4cSe;6=`nh4xO%Y{P?%b9%w>a7;b(OdzucPAeMDt9`^dxeRh2oZVNw&Gyu8@cI zGl(t9?RUJ!tRcDgHgnQg1#0CZ1mLTM0 z&ra<>okn%2`iABCYM>fb{!GB|EB7iM)vkj&aSjRm)|K7w9?SHLt5?n`g_N(Jt5|+S zKU37sZffwbWwh*u#7LDy%Z!(uJ~nu;yxXGr!51c92#ndAC#k)o!!b8Z7IS06#4N`B z+8v`Y_dOKv_uJY{^|Dxw&Wlr`-`I|=8K+Xs?Qdo+mF*sf)rDoBSz}hPyT3=PokYMc zZ?L9&KbohFyGU4IlA!KP6(%_=wCuKLSINh;)g|@h&gTBf~ zP4FlTf)_SF4vWqWe=BpMrId%7j@$z|#;F#$u*opHwke%py-6+^-rT7tMVQF)rNG_~ zQ+F^ceH*V!icV3-CAEA;o*T5HSYuQBV-wPeC%XT{oolI!@ z=xMHTp#aavCBQ2mYNHrOtN-bRifReF(eVwz-Q%p)xukF9T9aTaEbLnNpkVUOYUk!QzP5@I<4nyUQ|sss|6g3kdss!mar~@V;;w zmveoAH3a?DiMMR%VZirSCS&-rXx81O)`Y!Qt8iCB&UV3*=}oC~eZ(t|a1B@~J`MEI zO}hfCm7OmbUfF{^*lZ;k9^~5*VlvrdiPChEW&8WYPp9*ImhANg*P~{Kn|0|==e|1o zMF#(?lyDx;QA|JR1sf&{P)^#IT%dwt~_m5Us*mM1^GsIm#{X2GX`wK3N{Mcqe ziE?411^@bSXH?S(N6ccj$r;hE#_9KBSY)O;8TKSG_&_>98^R==Ik=EFov zzB&oYa~s@oMnQ4ZWuLFJ;2$LI9yBemw5NDWX@j5V1<=F&P$in6oUZUhZpzpfeqU&IKVNV;8}2%81PvhYL*hD}Pxz(h=+C@K5tjht@0aCcVOgmdK$|LHs;;jdV?&6-4~jHe>-h_Z0d#kE zB?$^cBRPlj^X2+5onhxMzQx~X{3`ZV?l$bz=1!dmjD3t`_-t0#=4K7xo0#y%_-)1) z=Qkzx9OZXbd!m#Q9_N(JeQ@i1a^mZE0N_Lil!l@d|7bxy;uQGy68@OWH{|md|EHCx zf6grZm7L1Dgf$gFy??RwMNoyvFrsjF&5S> zfG0ayZwa#FCl>bwodTgw6`U4jLN#(-k%}Jqz)YOYnYx+qBNz7mM9O;H>x+R;md9#@ z;7Y)4Wc}twmXGSWz|_nYT1JbCQlQ9)xs#A}NgaPeoF3|FDWN~cp~>4sn9TZb_V)Iu zKvs!h@2r0m<_1UydE{q$h0nud@UA9lC_sg)CkDW4)sRO;p+Y=5;*pzP|53*4(>~WG zD6F1J7TD}MLjEI#YRwLharVoXFAi)ZU+pUG%fvqj{&eCnAkYmjHF(5Ns0+BI1%~!Aij*ylnMXf88NHWeps)GlB zq#7!vZ6h)1i96Z*EB!ypdjLxZ-t1MS%cx(T%D9`l|BSnmkOX)+TqHmsM4+)6(su-+yDd_KmF^o1)***+~=W&qFNQ8p_$wt+S($uSw~0|Gn&Rp zLsp-;hM-ZqO2!Pd7n{T83@v4ItgWIJiA_Svjo(MpbPnG zs})Mw^!79NE7~FO^PWbfgsJCHUDP))SbAuI+ND{#Rd4|ci*Um)nPS1NY5~myrBbL` z{Dks)rOR}_glO#!NDVJ|C5rvzIN5k{}P<(wz7P;h7Zn*x)Bbj=yoLqjMNW&Y$d@$oKNrC zxR2fCDvw2jm=`L&vsJnL#9x0Ly9FZ_{$27q9i9MTvV7Jf!e{KWgAR?L)#hF{x${1v zPbpqe#^+`=Z0#Y(m-``pNv}Y9^*Bq9N;1LYh3sBo&vfr=o|PV6UbnL?(RI~gZwpm> z^N^!^6sS)2bd=t_-dZnK{;c{{bzM)X=}W_9T|RAxulz8)DC;c*m|c7RQ>!*#dH?Z= zps3;1+VkglIm7Q#h~22a*L&{=jG^2FE^7?WPtQC&c*{Gw(<229u0R&5dXs>p1Bjh} zyTOD@{}S6<-5LP`(q1u8RJD|>vtTkgYnG``y|`vBr3!PrB7kmOsYL9+vwta@PuC$0 zgY<`axpLX}#+LFY#UN@L&y?UXDE=}9@EKOGGfeI)!1wUtVVJg*eJ*I+QVEwjFX0Cq z0|eQWmISR+K?4}n5sopSjYt59niHVAFVJ593hF7uv3c1&&=L93Ruo=}tBPdYk4x;O z%py=^n7{~@5#|vdG*bAD>-U$-x}&y=tEa<~c=+{7%>e~oBRVu6!3{){*fSl5M+%Aj z{(sK}Jeg$KQhj&Ijl+@FwL6ruX=y75vdbhH572{(-a$g;J~osLmbE| zz{R8zzP?1ypEoG-TAKHc4Co^`;wH_<<%zOK>1ZzAo|?6{PxuaX6HmN4XXLGAZ&cyB z@QPuMAkMy<+p!-@=$ojrm}0VJ*&nPP2y~Ub{&j3JV1sD%1A#I^2k?*@=#+ZqJUwOX zEJ#A^pb4|+=``Eo22sNDVDY4)?B)wVo^yRGcO4$18kM8q2*w< z!sv7aoo^eiinrFWuTO4C)T=kpwOB>cgD1NoQu4QUfuxDU>4tP4@<@izx`~a=<#KPw zEZ3^url3*>)`_j1Tezas48mHTz3ri@_fp`NEaSXml8@$5GPNyU)q8Yqe_;0&OBg*e ztqNc8G@lhMK;uYp?lQm4@oIdlyHR~04IpXUMQ%lrIx(!A^RI=4Z{LnJ`pouat<@u0h2TCQB&$H%3;Hon{I*T7+XX=dD1%fg1xWl@G#t+wmata@N? zq?MvMVC!_G*WkCZ8sELFFW`uaQQWgP@Jm9^1@f+q*8fW2T5Tu9P;O zs5LpOFLNnjjZDODBLjKvcR(lZvGa~fV-9G2E@qzPnl=AD-1J%fN)UQahLvIASM@mR z(T2@o_1P!tHS4VT{yU!85_Q_LMipkqFm_{}iy~49vc@&ugb6i%mWTWy!!EBr9a_G6 zux2QmOK{g8tJz%e*PPJeTw+F3NqXq-^2QKo_EGgb_Q1(;D2oku|95aaRom)%6P2%qQxS|FR9zVD4LoCer9cHPe zog=Zc49s;V`*oNJ_nxPRlX-bA2LS|~o0g^EU-SO7UqOCs@TyK~EP+8w zOksyWVooFJF}4sq(SP!&eDn={?^B$ zTv^AZElghO3%C5F`yI<7Q$JzH1oD{9VNH{#e)wyYf0k5Y7*#wsgb%kuD~x z`OsbRZR~8Xczd0WY@b<+1XUt&4J&s<>`n2LQAEG<**S{b@~5ZalrbnC9MUsDSLd4) z?nL_qY*!HZ3+sYwC-v53XMdL)S#Jr@Dl+IVF<4V?2wonoE-%;ZeXFvknH!(emy##5 zL@iP~%S*Z@-e0U!d-YMz?ST8=hnyz|MEtsat>!1FmRN`rg!TuGhet(JTi#I_m%_dv z@!ZQ}fV29!>I z%Ef2;*&pny>}n+J6%oh5a(tp{e^GY?GGdgG4|x*d!&NTmvGmS$!Hi+<$jYWfUFJ+g z^wW)bmXGCO4?)CPM)o~?#^ zPs$h0kRrD!e)cM0b;x_zA3S&f%?;&*5yglCKmZ5_HHo-hz`WNa3LkA?eJ;eavJ}Me zWFCtZGy0cI+s_9j+&2*A>`A(ywREy4N5o_S&L&<{ZTatrJsjIv8?~>${khat0{(+B zSNKwvXe*i&uHO?3JF%H~%Dr0Iy&E=ayMqvM!D38jMJH}7aEtdfwp_ldyoc@X9!*TY zHoJ`$e2cPv1wq?%`bhE2shL{lu&7-jf=1lwPq!j9u?3k6@U{@anZeBBpl+&F*CrZ3GwcBHUWm1){0^6zHE%X8NKYqg{AF88 z2+$6@j2Uj6q_}HPFZF1Q0S=xh|3~IwPhT_c=sz110Sr94kCd8czb8Z%Xx6!li;KN= zU~~q6!!3e<3Xva%hH=oG8AQZw0drW^Eejd~z54@zprW0}g#tAEPi_n^pNar++Z9tLkzl0nM| z(Idkx&moMFwoO1@0o9xkjy9i>LIb*3KA6nrqXV?+quWY{*j+v8`c$BW=@=s_{)XL2yKdVmKsSpk&eGh9{h27onD;+6`{C_w?=&=QsAcGv-c z#=J=FH+V^x&1S_*pvMa>bt3gDgf%v~=>srJ0D^y2p*8y>drq4Gt_F1O7>w9a!Y{mj zf=yf)#l|eiRH-Ch!#R`Ef@mc8R?I4RS70A0Jn|+xgS5B0l5GE-4mN(xa1*Qt(2&c;(Zs$ z5x~aJZ*+8mnnMBR4+OE$Z3i}?Ld=i;i7SE3-X{PyR3$^hE6_^egV*)B1Hx)@V;(*s zxLK==M{`n>2VH<98@o=dna`+${_+06eaAXkfu44DGSUwZIL8Q0GRXoJH3>XGczs4G zFg|G2=QAy^?FBvSRfwq@x4$;_zN5`@INC$>tDSt)^iCuLVPhK})h%Qn3>4}20^=R* z2{{O}ixjTOV6A{wZVj5*2JXtVfdG4y-WDAdsH`T&l0OKhgb;-Q zKZ?EX4G=tmiez!;X+Xz!!J9EH*@vwFj`I=NIM6Q*$1INwO{ZPg1@YudWKPnR4&(aT z+8#i`!ZH`@7(cS;N|VSw0IVVsE(7G3DmOnbZ~Dp-*nNZwFtdu^Ubv~I>kZ?k&Zi{Z zey)eT<-02+N3ytDP;N+m40R#@tO^*|`!+1a5gdj>{ST1i|CX<#Jo(=C+$kL5a!^qn zfX~h;C@26tFj#gJ#fg`Zd4+|A1qEXbKA!IG?r_XkaZry{MZ`3=zh6R)lq37|ppTJt@Iy1RJ{4$#>W5K6aIJL^=~|ZsDfZY>+5|X851o>*a!8{ znGSFW4Z!V0csu8Un*NpSi!TNa2@TRlxs}0@r$KtHehacf2)o|3TSlxa+CUemXk))Y z76RBI(4a&DSWA#T0xpctmpm=!1Mny6siMf#1^bN@0*=*XEAV7j2(mz!*ZwY9m z!I|dY`x$kG3_KaAG=o&VE?JaWr4%5&dyio4fT7Q^@djG#G7vGoQkkhrgBw1cZ_zSk zWPCLRa;M`SqxOhE060;9AWz_`5M~S*BnU~I*O1<5Nu4~Jqm{%hi(7V*Lz4*v_?l#$ z_#dOU;eEUmfqZcCpEPM{_9k%tdX2u1;K5Y303cfmwk$5-xjF`oJPyB7@q@eoKxBb; zX5#$l(E%(wX3|XmjNA_X2ze>M9Ge*00WQ^95%Oc8wc%Wn#Fc2-dg|7=R>!{~w}0G& z0R8Fb!}__xGN2sQD^8WQdh#1A_}A6#pbNp*3TR1P3hNAPQ7cTkfZJuNnwDe370m}+ zD9!1PBzoLy@zb5z$X^i~Jtp4Q}q*gv0t>(&O2 z6iMyDSV$5g5^t;f{XG{D>e_rnD}eha=yupRoDVi<;~@1hiJ#aD!%jo+@q+?V7-~-E zWklxT2%JNN*qxVO6jrkKHaA0`KKGA%(7O!jI!j)i;suyszz;ikNx<9yO@`ycK}=M# zOGFH5&|tI#q#++x`y)M1AvCE6XYB!LK6Go)V1XV*6(a}2+xhV1%#YNEKE*2&R5ED2 zCL|{E8`TQuG{A}~1u*`ZcG`tixnArEQ*-J60IgkV(Vozowy*scTHCaQ_U~wIfxFPw zK8hb;;>S&-{4Jr-#4p2rKo8*)f<-8ECnWHwtpy|tA@*MQ)ST|)_R=7KP8;+&7=eT% zH1`9if$-2_*rAUH-9vGl1`z;Z3WWl8!^=Lc8Pd~%3jgVLZpwK-B>cS5_hWCo_44{}51ZTFutr%f_M&AU zTCI9%Ff975Jt{FanQCiZDqqbhFhab0GO)obagp|Z4Y-lCt)QMwMoBoyh%lhGl?Ak; zhBsQRZY|_B|1WrTMAA&!{;Ge?)`yrYG;B1!g|sB;c}zR5UB@Bj@Aa^dDLWKQ9A_Np zRHq^Myj=MdGh6Er>Qqp=Hu2Fj=&=>Y?5o~=_mLw*ubt0sU;Ks*8WaF=80@aW=&%FG zLZkeqE}fISmhbzUo^j8V{1L~V>wYHk&3ff%_!hulnHN?orsC@^)CHBrWI2Y7DtSM6 z`pJDEIJfDOPj6>8i)EykZ_leqh6JJ@WqxyxRo`$Xl8-OH7lbNT)+;dF<9NhN-Ld(y zR${niYS2iIpLgJDZrb2=4mvLnPHu9UkU`Aqh}?q6;(GEg_J&m-Le?_h{KsJGoQ`3G zWxHjw$DN#Q>TtxV)_CoG(Zk$Yj`RM0Sk7k~WppY?iA7@UQ0a;U z-my&e5kV{r5-sAy)U0lswnyl*LCqew;l6{^gM^5JgpQm&d(zZ7#7ajvp?YZWpiV6O z^%*tC>Pq39PLuKF>+55O;pfh%lZ(VJ%<*h1wHl{J_-jD!epx@B?|9AafXUf%s+1*{ z+dn?ykz=jdqh^ule}hgN5r=9tJeZbZGP4=x*qZrNx~gT3d|d_S{A_v!mJO4;@OVIk zR3?62Q&RU+`7Xn^Y#d8i#Vv5}nH#7`Qsx|4cqrMGu(yt3Pfh;pBWh8m*uWj(kM1?I zuC;a7^;fLjwtCEdsqfpMhwtX3U7d@cDm;Q6KMfckMkjN#-NQ;aDrMASD1${&#az+o9c8%_cMb z&X^LaSF+u99Q(4XPNnGEbfnqcneBb^X?)T3+jLkROm;LqJzMg@O7r>HUcWoLSg-Aw zjlky(hH@O^CBkHloRptR19F2dw0+YEz9JM;YP_(U*c>kQdMVTQ_=!J&5I){^&IZ)R zo%sNv@vw_0+{isMUSUd0f^rt2X^hZ89yXtpt@B4ex4i#OP zCJt-#d<_8_mUK2Jm6|;_%^>APP}kCYkg5F}FV}=q7$^CS06t#XtsV14tFot@md`4* z+CEm%WI{8)u6YL0WH$z2J9G<_D_7NDY@YlJ;QV%a1lRNk9ri+#ekP0WSpMM6bn#^3 z1irCnCC7Fdkc1J?eA1$`x4@%bcTV(%pKuqReR|WT{xHYtBwNahw_d zN9h-p$7_znmtK=+VvLHf&IgFNEd$0x2H%Ip%Q4b$6E@ZGo^XGgJ5>L!e9{ibBpE_CNfz#SAwM9M zcPtxCEx2m<;@EJv{UWiyjZeaN%4hf-a7Q**ofbz+YwEPK_xyetFX0*O4bHtq#*NQR zb}(QJ?zr<0L^3;C=}JT`JJ0c2xx=79wQ6uzgr4hS#$iru$B{=A?oj5SYGIq7rsz=1 zf`S6$nFP&Ie2rgFb&kCY@g$Gt(* zxmNhKOUbq%v(jnR&I|k@N(r|A8zgyI;avV&F^hRk&lL|LN*3aS1%;>6LTK)mW^7@D(4~(dTZR&BetP zOJ9yJujnjUwPIsxtx!1?*hGI_xh+pQCG`gBM_U?A!5qz%YztvhOM`iD$?%o^8aTT* zDwB49^l*`->m}_DW_3U9wJx+7pM9Zcw)8RGQmk>2l& zT5GaSQegiTWu%D&tL1Ocj7Wz?-nk11tMTD|>nw<2Wh(J!)OiQk+J{?$YcJ$|s!$|s zR~;Aar!K|FQpiJ{xZ1XP$hLXlw#`vbyLV8O5vdSy=6v0f==V2`>LNa6_q>(!Df9bY zum1j>@3_y1dx?f~Ajg0vp1p6Lz{^m|n$w+19lYb(7pZ|PM*2W4FIO0*t&ZQJVaLes z-tIV~qC z5#+Lv0O3|FNU0(a0~n%2xdar0Pz3_b60+|R>~wdX&UB_b+nou2LK425^PTtm&h!4B z=j7B+# zFvNI27LqiU3i>=Y2#H{;%az`m%t zcfr1H{GY?V@fPoZeeKNN1^eFJ?OkIzOI00i9aLVZpCXmJ*JA6OKK_-!FC0tuy>(!> zgf+Z%^Iy)~FnXq(K4g&s3m%*}>F11Idtsou#L!UE{{^?Nf*8zw!Cn|jGzMoz zibKyNc3aML0kQj$qW*>Y<2?H+Z{j`Oc@Z1Oa5^ef2vI3&NqU3qe$Z~%TWdFv$;++0 zuJnOYk?k^FEDr{@xVFaSermR(Ug{r%qWP3sZ0&ptu3I?S&gl%;>SK~ICycC&zVOm1 zyMHR2P(_xyJ!G&1C$LBR-y*v%(5o+g%35rkD5K@yNlQJfocv(R3tbMOWT$7%E^7*P z#$#%N?|J2#K%BIq6I>=sq@orRR`lz!(t70=y_>jo@u`1XIWV+ z8lOy8TRIHeFe}R9-xix`&Lv`xJ=0ci$}YI>!;XT_i&GH~CX>+HbWUMZWL82p;})9e zba^nh!^MS4_Rx|e-S&cOQjB#~9l_U`uYnaA6Y7;$xDXTJa$5APi$~n71O2JYt+t#$ zRkv<7*C1y{aD&vbYPzpPJKfSmVJg^Ex?Ds`dO3CXN1;uTC83Po`9p~vyB2lPw7+%^ zxf*T@r#BxVB*d@g)<4PI@UMEL#>o$4lPCfYE%{(U1@ zHi_UbMl+H=jeem9)WU9?TGrQuxI?4gRP(YW?+1C!YH~(MnSI*PUJs)ll05FE{aePo$1lUomCyT;Ld!!XFizVS_6lX-)FEiVm=Gc=R$yz|jjzCq8a8rOsyK zSh%$cOk&fhmP3aLb((yaIq>0=CBMciI=viusRvra<$dGUdhFhs1k-8f2+YhxNg#Pw zyD{G9SnP5c?`r$P+FpL!$N6I3tod_$X@0bxN9^D-gK}CjIx$Jhl}Ky8`jqmLnD@GZ zO$rfJbv~8cTr{#tbN1)ppaD1jdMdzK%*J)GmQU#6e*C^l>Nm;gHlrPm3-FHkQRm`} zG^1_u3HYwc0i%pI3v7o~+561vd8I3o&zfI*@>aLofcNjKJ(S>AucX@szY|n91)*7U zgkmd?djI6G0yn<;YCWl&j=6kIP^AxdV&es7%Bss2w&`%6L+_Au0hJtVcdN&`JRnN^ zg4CU~#$2>)58WiVtB1M2N-GuK1`7ve?rE-rYXWRb7u8`UQo8cA&|1o;a4Wl{^!%9BszD|C!2h*~nSw&c$jvouZ0W0(!@DifZu~+}GbBr0R(L5!!>epGZ}D`l>y6M`vOj_Ka0cT(a2=LoGCbP!cvo@yauYA2pg5 zNmD!XY55|pA`SEFn9;4^#=XFk-bBOyZ3VF}#HZi}q5+^nz(b(r30V`UF*Y8a zg1U~a3FJ}Q#RuU9%oEV5^ofdUki()(a|rq%RUyP&{C2RBcenXLXHO2LrA`J_3qEQ9 zIa{{^BG27sWxDu#)@px}axHU|xf9`ip3L$sLlAVIBCsJRKw zArkr-f(JB{2U-@b=LsM<;8REL8wPL0d)ke`2bu|-VS7!@C5Qto4b?!$?Fn3 zLc$?m_4lIyA)`SFe$51Vqm}-~G^lJ8vU%}e(HY-{ImsrttmORW47;pq$*R`>Y1KL_ zEzz++7;luOAlxcZa#784gQn|QkKXwK@Dt=mcfB!lo3tMG7JGj^A18g?J~FoP^lI6g zQL+Pu`h8ClllEZ-3Z@DhWVeE*zIHjRu2Wt74qm)9QTtT*z*JFgEWnp@HBqlg{W@ufQ zX~vC_k@4Kv#RuZz>r+@Jcqslu)2#gG2LvLQpponE&o#XC*#CY#7|eR=@6Q(EXp+A_ z=92#Z;o?4NJ-x({veME<;g26bu5UQ>FR~+%NP2pDR#w&~`QAw+Hipmo=wKKB#_aSW zPlOhK2b3_K_1i1FykB*WZ(`wm34$#|D|6_?zwg!mz`$TKfSM0^C*b}2yLL)HDde%`-$U7B;IDLg~w z(=Do^`Bw(UFO4wn;k>l82}(vGmZq6B*Xi=khB&&E$m&=t*IJO%V{NYeU7(t%SFc~U z#R;YrQ_+w!Ya~B^`O0;DdUJEL+xOP3TR(pMup%6-aN3NnOl`QWmis)4%aDdI#2IG9 zZ7@TX!`46FsA%!#kx_^+fi!lBztIe-!X7NX_+#3~ts(ekhHB38NacQs?F1{!#KZ(O zuZ7F{bW5A)afS2B$S;}tz-#WaUwj>2;8GQMH#Jz{{#{?66q@(y6qtOf@hf0zx*ge> zTBlO9{0?v92*mc4fFDRE(z9oowMuMOf7N}w%&d3#H76>&T4i!z3LBB5TWytD9pRy# zz{$@3C{!gNvTg*qKz!y*JB2jOCH93+mI2{)G*a$vZbtPDw?#UTXQi%QHm_ElV&|P7 zD$O6U>dO_w8DN{P2|CU4X^I-rtrLWtktb@@oA1t;IW8um_4vJ1JlezIdT-kjm)bJj z5>t0I->Bu{zk>@_kdTs!7xmWBHxw1E{&0nb(Jwq}PQ2`;m65;JBmEF z{Kbihq+%(h%wmjoxTyEhpiyH`YMcA+nxXLLzQ);(q&gvCA6wN>Ez$_bsaOH~+DEMG zkBxnnzm}KRbRTF1wdkWfJv}vxt=PQN8VU9LDOzc$sP?8~tc$Eit8^;e=%oDl9cF{` zTKq0C74)W94VO`M3UBr4D++IhWmmXu&P&sJcBLz)QSq20xOlse`659u@3e*ZS< z`r47-CQ@!Se2-I6AQ|hqx6#3oHqySYddFjZ6t$92G~7 zCQcSYZp%ZZBbDw839pyA+_x;)Xg7U&v$frS`7yC;mnD1h?!E1;bcjjtK8lKJ|9AH{ zn8C}WG@?sn!8CEMvckjid_CG1TQX_CDW?Tqh4}ezFB1_F(NX5D%=i-H zb@M8_R*6vy)uGwE@z~N5+q9_A(rSAE?8&dr30`}q!zG4T6d3*xOv%pb1Q`wgUBjj@ zWMJr)xuD~GSMW`7 z1#=redimPaIO3{&bj>{Zjk*L;H8%{J@i1g|TZMchCbNljOy#_+aC4Sb?(x~M@s~vfeJ}pWji6&we&Sn^25CM z+~^ZNcBPVrZgbV)S_Lva5}lsQ<|`wWWuFDa^YrQ_S3J~}hmnN4nA+OYL_{;Kahl6f zoM)v%!lTFOb)#PFaXhr0jFnp2lbC5s5OrFNOl_v>31`#fy?Iltm%d-gx%V}{EBZ~4 z$exTfjMh?JIrq8PLVDt;=l1ZAx5RQ%0Z|tU9Nc$`a_&t2=JLFh{SH&;u_QmdCbFj@ zDZzxy4q51~Kez8v&UWsM`_v4}#}Q`b{KdHVcRAkGN5h>l^^7WO8#WnSi?!LSS8gR; ziVnPY*l|g6zqpDUT|Tvtrs^@4FjznRq>i^y20ipeoQTwn?ApELD74pvYL*7Wv{-ai zRaJDS8BHb&lYHdZ)YRtKYcew_UfW-_D^>fCs%guj-Wl#y9;|pDvrHeI8&*42K8H(N z7!C2VcKE(?X`sT@-mG4BztD2Ds#5GVVRUWfW{>9HOxo&p-A8gBv7$GgX^IIwBZQS1 zZP_`K1BK>$d$gV#PhQe(E)A*@ODwH)8VVkT{4hm|^_(XQ2y#>}Z!Nfi`=!%-DbPGh zFS8e|c#KrZRC}?Do=egYG0V)vc7J<@%T>dzcc#cKWO+qx@mewqN0M=35ZpME=0<#gS~y;z#-?NNbTS zcYjSI-@JY6_qyNw+fm8fgok_@fnjTV>Fi{`GN62;c&ePe$?a^Lmu-}ZFs=aR`^UCJe`CZp0yE9Z52tyea zG^#vy!K;Cf+W$e!LBWkMk4>rPoYt1HS{f+SEVAghA;U#~kN1iG)T>fqmF06QJ@h$w zoxSy*tqUoR<4*FwW{9&%_m6%Z>+ouqHSg!D@ZY%Tqj_ax=C{T@Xs6xQ)#+S*PE$&hoW;(PIgpeS6CJN|cmnp*tgu znCLvsKm2M=6|d6V)*6`t5_o4-Q(pXhhl(w}|MDc}6-okS$myM)X&lxov0H7b2@ zw7(L4gPK~+i&^>01A-)6-Gk$+1$}#yp^C_gBe1}N#J_>mke-ooL+`ORW~%qG%_L&LNMy9i}35z2IeA!2v2n-W8*h zpc@?pFBl|xv;*sGF4I?Fsw{Vuv@37cMnO)V_N4`0C}5WB6l#SeM~kaI*!LW3U+e?D z<%uQURO^m)ReiEJ?x{tEZ_|7BD_MQ~xL%`$j)pr7_9YC14A$)1M_KEhyvJqyPoyxc z9|fd!${gn2ky2mxKCo>$vY%{NZ5Jo=b1k}1shw{Wu(^E%@veuvTJ3=pY1amKPn%GZ*#ehO<|A9(1dIx_m-49R6sZLtDLjTT_d zNl8hY372SSG8^qIEHV{{F050#jQ@BGu+nj^GbKGL`(V9Il;<_qFLF|92rbFd?ajYo z^Ef*@_e!{r)w~R4QtXk&8`(J8_N!4#^sr;o=@io-j0gtN@4xG)KkL)8d#}4~eJ-c> z^{a`4bM&O9!^4v=(IURBqg$&^ZR2x2UzJ*~lv_Dk#?BP8y>O$pS=3FaB5YYbyOzLC zAm}epzS6ujFy&Fp%oWL6Y&qD|+v^OmYp}=?rzrW_sFKCoxDXtS*X~qAR&lYsJ?r%S zespF+kFNJ2L^}4CUgWnP#nl+=s=3}AUAMr{TZb4LVYdP>pP%nY*Ecq3L_A8SJ9?jkyQ!vdR&}KX>ZQ|{ z{w!3T!HzU0_1bYOhPAg4_fM%=_ctmtQ;D&a73)mJ)o;$(68W3@^?`GuxWU14oADoe zWW7R@wSJc(*tOAf{G`;pG=ffx;J`%p7xQmPe((T?u({CNOJY+JWtb0vY`Dx3+e!PJ za-RI^)hz(Rgr`qaT%t~%akGfl!w2z@Co?&9*Q@>+PI+;igRIz>dMYZPb0a1DZHP6> zB!2%#dy?r$qc>r3#zgs)$)xe?4}PZ^`jw7*lRP)xj4mkWu)2@x#@>3}h_0#wAOaA7 z90oVFl76?=gH5ioJLvc&(e?IBf!nuD;MK3NsL0zIlvr&IT35q=&3})551``d)Lc^I z_t?qZderAYLwtzss=lx7G96{CQD9;_-^EO)Qgo!;yOJRyi3b`gyo{N7@xIiLp4Xop z#y7tbfPUnR0Lc4~>EBaoFX7eF)Xbb>y(RZVrUVZzz1sI4yKAOFACC-8DcJs9p&QtCp{MJkniqv~frntCx z9nJoH%;O{}QUy6Bc@*xvhhU^>>H(Icx?-(~WuYevx2^nodTYtI?sLt5T)?2FH42hW zEK8ohRJY4|`xkMDMR#;6N~+eK1N1gd4S=5m56O@;ZAC&F^ziGw#W%La1~+pi8|@?< zeoWJSI-b+|Jvn7U8mQUz+tYGBynjJ_uxD*k#?x&A#bX!FJTI)TkQ-CtH(_{JT_x_G zW2ZrX=SQS_H;O#le-p^eJRy95+ zhRdCMt<@YH9MY9i{INOTZBL#$RUbg@+}k1*NE4Nks$X<){+6F~GpqApWQx;2hg9I9 zxEmiT1Q(iP-2$AVSaqw0?3`iw{v?-ZdW8aHHz_e0?oCIJLI2*K{5wd{defDqPqpM5 zw*lO!;}U(B-IdIjEqvMTeBVdTZ{d$$2$*fAT2&|;zW{EfLZ(J zm(x*YT>SiMv^-AL9=mqnQF&CV92Zcj&9=6-tE;QB>>mx6AX3Av`KGNKeBMZ?=L%SC znWs=cYc(CRTpgIplBQ$1e9ScC@Bhf#5R_-gYrH1x9ExFWn^3?0{E)-mjw@h;;aV&41KGl*^6uc z2cj?dB{n>go7x_6S6or~`dEzb(M83d7tnK84!ERB*ZOS4zvqSw z<5n49=!Je-MOR-Oh_ntqMGaAJGDdjaqO6@&TX z^!^Rl_?t`J{+xI2%&*qd_MTSCkG?=BHH(U7H*@JrRfxIB@K9)@U3`6}tvCENAt@EF zMP$`DIfu@{d`6Db%80N*!#|{KJOao-e0oV2b@2Nq+2-9Lf)(d8&IdY-*R@R3&J)hxb01m{)DyeRQ1IBj{uuVqBN~oAAmCm%6mygLuV) z#^aV>)W2M;{7`fEd@R@lyx7vt5)%NkdM2^6EO0}!vtTWyrll-t?yEQ`HYH$ z#9`Uy!^<3>T0J?ms|w89tT+@E70DFIoRv+UH%5?RYF;RyR@`n|TdzPa*6*wqGbKHP zQtQ`2=5-A5x}Hft_E5+Mj^)?FLI(3g1A}Bt6XZg2BEE%I>8>9=^%KAco4AEe8c%IG z3Oqok{-UI@j(du!XFVnJNU;4;g2?!M~R4*Fa^$efONgcR{ z`+ok^9-ip#K6jP%Ya4t2hlGTLpr9ZKu3yVP3JV?WOhh+;HDo9zh)90CdJR(DvFmrK z3ALnEB1!)cm?gaW5B%TMzJX8}2iP9dLM{2>@+yaN(bLATH2U)u6fRo_0>gb+92R-y zU0PasjA^Fv8YgxR4`aQ(y-6b&%FmLJ2)V8$_hswVedu33r?cv3C*0)lvMLkvQ{ltF z_-22}TcI6|SiO-UjQhKa6 zb$^DO+_l8r#m&Vmb8&q91&La#T4v@;JD}^9r`BwvWN_mpB_$heJ1^QPgat88dgpB_ zg(sb24S006%GoLOQRDY0^8Zk?u|JU(0yl4p+!PTK68inC4y(#8Eg>7Duy6(yCue(SdwcBt%viwUfEIRkcjx|P9*coUSwI0eOlhVc{16!No5D{aI8%Im zxnks(e%g1;|4Kdz_{+4MiFme?^&6-{0^if-RWw(wFibybe3)d=5*pyq|2~{y_!P>K z8%(sqb=_>}lc={>xh|R`|IM2>r~h{r6-OZ)6WCgmI&NnWTU%@FXUd6u+&aZ9053)_ z{U3vhxya7+F!3GsY%0TgfNLeQ3a@rnn}fdmt1rYM9jKyF3c}{ z+)W1cXDNOB7Ow)pgVbt1n%ir)yvAis%1K3OB?%Bq4uYC#c*A{vYpGBza!0@U~jb3dL@C0 zI+CM*^3I+iy4nlF*WN2<=lUVdl5X+uPU3&4(TINMHlCN-(R6?M!~5_ZiN*=ZlE0-0 zgR`atj-PC6n;Py|#niX{tyMIfQ&K=BO(>j_e3>a8YIvye*S|D4q%&guRisoxBO7}E z3)j)tt5AP_d0!>i4!yX{=y%dUz=Q3t zZDiK`zZX!JPu}o+kT~>rQ5w$c{i0{oq4DL!)@WM@E#>V+)~Di@Uw&Ecu>CtS17e+a^dc&PRl1cWO_CRDy<&x_F^QgQiw;pGVqRJtQ4IG~<~`_% z*cr8tdoP&ssL;$86099v6f63iw?=MWk#_nYmOpcNgPOQ}iPth_rkJ{d{q2B#wrX1I zyDxcb?jNVpm=v&OpO4l*la`C_#c7@4Wpint z@yWhn4DLF&(_LbLKey4UFR+Y{cJqtlXBNy43@4Q;iGBGA+f*-ShC&|BEVEl5Fv|IU zBwUJl)O0Y6UWlDsLp|b|OmX^Ub$#ftIt#rP99z!*?1OAQRcFq4+-n-LkSEnmEoahI zv@i~w!rvT%1OyESuAcoE#`|olQo45V;JgA^u&(pN$0cEGw>_yJwz;K-8(TMYv0b#$ zxRsQmPC$7We7TWryf|fwe9e`Ea=lz=R3qN%4tH^s&d((Z*W3@uT z@_*i9ZMyiZVzY3#vqP08-Ap6&O-T&Bqo zJ>OVtKJ}Itz02d8pYKtjbv}B=tPHWGNXic z8z1}+mE$d$<)mfuJkVPuH#GZP2KtqQ|L&*2qQlr>B@W+u~-3;aRQ&g{WWqHqB*exlaY`hu2 z+?nxmk?dgRZDEdC^x1)Wti!V!Du!fd&&CMlcg4KI*U(kLw~K2Y|L0>%qOmWW%HJpa zH2&GHRw|}mdijr9m)+x3dPt?pex?&6=)rt_4cGV~B z&10dk*y7Fo)ont~M#k%_4iU3zl)}1pZjFiL@@+d)6UPHJ!}DYES+Ps>zoY(oP*Xb> z^iWojsm9rle$>e>A~^4|=U=G7p-|qnk1=(ydCg_F_U&>DT~JrqrMdVjFXPvPd8xNc z9Zulh?t9HzyN#p}b>17xqBvXbX)JL2>4#|^w%KK4>x29jN#W;WrA}Cfys8rl^brxJ zQ|TGZw2y;q#-~M{gNV+eMV!kwhyG4OHuELM&?Sw*jZxt;$`@tGQI5gMmPe@_^>6L5 zEi$G5uyWe29p1ye-LaC09B*Ur#Ha87aRE(PX@;%r_&1>W&c+!YyJ`2P51Z(jTFOz@!^IYp z-PJprqRewFXj4uP1pw8 znkuwYY3IA|Fni9+&QNt)e=xmOLL#`A?T~O=xT>gtVhc04Z7^5+gQYzf@_7nP|$ z%=oaTM6GUj|6Oay>2*a)??IN)tO#A-hQ9jA?>P+mcgVv}s)!=?C&^%5d=?sw*8deN zd;lOg)H=oZR+w-s+6F zXB#@lbq5wED~RFtfo}bxirf)S=^HtBrKCGYlQhfZA}P+TT4=2!DOFlL(O{4gw|A)* z7Q1Gulo+PeXED{)6}AY*yQOJVUA?SElqDvjdS7fF6(amnc3rt}(?3(t*4VZ6IqtAO)HyI5TWLA(L_wZ414C?^L)JV9hSLn-tWbaezwleQpc!f8{dQ;Wg|_B=bC2W<{Z-dQe_cXK;tT_^j0qc<}3 z2Rsk`1b+rAhha6Pnz>Wj%oaQiJ)25iahN$5o{>!!iC*!o;wJT0Nz2BzPb+aHn1YGd6mlBj zGm867u=)X8JbH#d$FWLTeWK3Wx(CMU-2Fl#&cey2eL3TMY&grRSm*vK9ZFm%RUW$Y z_JQ7eBs-I{;}#}PO`Y;oF!BCFI`7r3FrNCOe%3Mnr?)MlDSz7hR%(*QwRlua_8L!i zZ>8SewTsLYvd{oDDS;XC3$OFIP?u(mhloD=51RB*moC<|4C#{@Fk9haEamVBvcK-_ zn4q&9c?q|M>%l~-0S=9xGj*NoEeNzRPHu~7J?Y%Fs z>sFX4VCCjotWSVIjERK(3(l`Er;fUwQ2#*m8lOHi^J=F2>yO$u^6QfYvC&+k-SPKv z1s9{mG#{9W>wBB(h*hbx6ek{s)3J=wIAf>;!#33u=ImTwa#<}^^EUi8zx!)qJEE~a z=2*jf8Z*4DT`($??r_soQ!?J_Rw$uNK+tl9WPCw9tj!@)_vhA_Gi2&yx^gfXg3(ni z4*TlP%6H8^iAHz~v^cO;(Zr*-6X%Njy1OnF%H1>g<~z%eqrZY89(`6AZ8KpKZ2Ij( zbzo#RsmTHPWkw=)Sqx37o*TQNK=k$bu@vv=7MT|DZiW0u9|c~=SJXLNs1_1PR(hg* zFNdRiZWO1pu%Ew1VER-A99 zh%;|sSu$8>QTp<}qOumbTGx5%B8A1dXw9d0NiB~`GP-WFU*a2?%4KR+CGf}$oU`+u zD_F0Xa}IqS9f@MO@@YonXuZ97L7vRBUwLf?seSXYy^Uv~V~c8=du~VzXEtMlsKs4v zfn4n?tD7h*wc16GEO?M3_t8`NTP12sOuN^l#cVt#H4;yN<|L#RA z@%4G^E!Rio4Lc}QFi~UQ6Qy{(@beGX7hCI3K#wQSbj8ANC{bfHf1MPcq!dly|1I2gL+Up84$Q; zMegSpdc`+oB~0*V!oDKU+tyJbc0WJ#To_68_hwMqja7}IA|vM|`F>rb7vk-fQ$w^) zPB6Px9Bm4=&_57Y=q27>Tf69fd;0}G{x^Kv>p3>wBY4e-C4Z%$ijqUy$=&;bry_<_ zTPxg){c1XyvJBLlfk~mEi}=*&Q7KP1D~yXZi29F>C}DADz&b}2spMBwjHEK3`J1Q9 zKn_1yAuLI{2%82-${^NRWt~P z?%dz{#Gxx<>l1vN&zj87kNnRaYQiVmqHa5_^K4TuiivRfwglcDVf&ryXG7$^C zkP6eI|9-pbQdxqUTH2EHgufveS@x@(#}ua5#!~#bz=mMyTP>N%KM`0gr|R!h&l|>X zK0C)G(tD@KWTckBU3fw!5+)oz!NOCkvWriJtK748)(9?nE=CZQGcXRYr4VvG`7a%h zt7VXph}C==YazH$wHsC+Xn#H8!LZ)@|8Al1bXD%Fy3iNX-8a)y6ZUQot#TyD`0~g@ z3`@?GGhy}4M#7B4g}!F0A+dP$Gs?mbIal2m@a5ROH$;U4dRGU)1`u_Jtx`I*&0o9D%muaxqT$Fx&|d^*N2 z04nB5PtcoGm72*i7yf$y|Hse}L((v|*I`f-7PK1TuM~9Km<67_&yJ#F3CMnnQtFEr z(NOLH0hxLaXznt+kB`WNe&2@z@~G=fTuBMP_AV&NDvtIRKR%^Y??(dk{S5tUmktId^(0jm3_hrw1FUy%b5EO%DEA@1-2G%CWYwa1)u{# zfwS7o-8P#=*c8n*(;TII$;^{I#yOz*42kad26LJ@z^-Q zZJ_48cE){w|F#j%%--1AI)DCrmCI@(rU{7r-uDMze6M$U2a3cUEkI@eFIk2dD~oj| zsAg1%rogc*yvoBwQvOUm8v(Vm?FPCaylwUnrDAwZenh(AFSl) za6d=0xYgw=Od}NMdk7Q~p@tRju_mi8z{ofa6+8?t2suiulq+0Tj}K-<&3iICTAyZn&Ot8vzP#CDBWyrZsMg9QZrER`Ma+W zHrnyOT%xZPn{+%7H&8Hg?00iB)b0t_`?x$@9z@AKof4tbDX{}(B{JcSb}04x`$93K z^2JX=r~+Uy400&s)pM6`KWBAub)AB%j9X*N7Ok#QQ=1DtEwwY$|DsiDrx#B&G1(Xj z0wgH16nLEw6BBdG5J0CkVor>@uT+9!W%oM~ZKh6T8Q~}h53c-KeHdA*3`#oa`Y`b6 zU>H=+a^ZIjPvdKgdhxgE4t*cW%UkrL4dvyV1Fs3fQh~;aPqEGW=k z#F2E5&{Ev9Q0#(h85;M9$=OZlElUM$#%i*)%dLh=p6DvU<^%0mJCtSr+c)G>{{456 zVnrnK=aJl3k$OD@G1KZ-*YE3-CiP}%ZujbXn+94O24DY;mjji9l(HOZn#qHB@yS~# zAFz}Ew7L3Ik11-h#QVfUWM)QO}Z5h|p~KY#Kwcv~CG^NJ`Qu^zSl zGoKtwq2>@xY&}q58W<&#+=%9M5jec=zL*EPV6fLgovZHHUmzsJnXQ&eQh3Vi3d1_K-6x z8Gvw;>YnKDnp03HU)$cMFEZE^LKZ}*j?^TH5rJRUh^IzC! z&?W1m2YO#Anw1(clGX_Z{HI6Y-@ox07T{3lHBz(cOqN~qJ$dq^k``Gq?SH!bG329b z`6T>-+@Xeie0(5#bLQsbKH)`Iuky{iBb;6o&=%ahIlb<)Q|(9hB2!A~Sf>9ZJUN8&?lWAeo4c{7)d^FTmmVb#bv1b-y& z-GWg0{iwpdV(V=#NlHZ8m)oa;ho9gF5XjQRDua4i-hO9vNANPj<|=mO*3z>><**Yr zBLCyz&$knssT#S0uio)OL`lkB*MFDbo^u3`HMsgeuc=RDo%=*^|Biou&exq223Og< zmpxD7?-BJek>tLUv;biZL0`&kx$)eSH?A~$5!}F0a<4WDe}Esrr1}SVrOSPX9HfZlweGwoW#faiLg=5e5wZV>mv5f>`Ih z5b6pTFE?gHaCmS7qG9=VJ)#65$a_T=M)6NQqQT4rgRnW_5OU)0SCB6UN)D6V$oN8e zs)pck*#GkTzxK)X|3$|~;3kOswpK=~T~>dgc({$4zrtpO078I3zXlg+_T7-Ryu2Lt zW-{Oc(0n~=t9tR`1z!39vYJf6hkKyu2Z3HXlxt5SoGxCzj073hBqGYJF@&K$h?Wlp->ZLnnhwG2GMmOzuKC6OdhFCyoLQ z_>&|lKepBc(VPGpfX+^nGc(3vk~eSO1l^q1(e4b-rwfRNcLmPX5Hibbpj`m8RIJ)% zPwmGyoX68rK>%iFjhtS_r3K|>8vBELA8wsMuA% zJ)HxkGu4q9`kT)0g=(!r&HLiAJ&2;ZHJA5WFTq zyG$c>NLfo5voeI1&r9_XcVQWj01=*le^vG}{s#dOkvVOQQw61TzP~yz;Xkt-8$+D(a}-aXW6{?6QDI!m6IFxgnkzY5wc5#rr*C>c0=T$b`GuP6&@{jT~B3j)+w@3 z_u|%XM#rB6SHB5%uoqml#%nn+djcl#p2v=j=QFph5_m351c1$_gLQa;)TY~cW(3zM za8#yn?b93`;T98cxcEkd&G~0pKfL#sid*9Z&3QaPCZwKkbcI8w0tC1kCl<0edyID1 zrewFuB6LMh+$WrEtmH&ue*UDpd)EfEauDw$ICOQ2t>SnI3!d5RZI}kUD{OqxkJrnB z9C@xgBcjL{S>KT;U*1$3+X1Z@`H#Om7?|!r`7bMFY4Efp;ia1a=PZkbp!;HAv13;K zl!-#ZCM5x4QA{zVAlMZ2g8D(isSJ_v&U3rrMwc4pu4CDw9ZWuS0OruwJ$D-sC8yX} zE2OS2AtAB$&H#j1g<73!S-RC;8YOdt11+Q9p0Nls;3|<(-A2=p6NJzxI}5;GxavSf z1MyxPE3AMj-0e79q~#3ihLk zX9bo2sM5XUFHh5zG?d<-nspt~w5}}FuRovn36;DTQc!TEhO^)Z(uA)nebCQk4!Zcr z`UVrG+*N8DeMaS_wC>A{&>OJO+O>5OVSMiFSpla-H85$%{+1Snwz)#t&sYtlX}V#v z8py<5m+s&$%FEr+=dir{6f{!ANbT%r87lYh*QCfrAOky=0+#dcInJBRMNAgQ4!Cs~ zHJ&09`fQlggFT7ZN}6jV=BRkiYX0<#q`HZT3FvM^BZM%DoCRSxJq7AB8cK7elmwT3 zgp&)%k^l|!4t%kfDciVxeO1(?qcW`*fC_S z$eM0_s!&Egt0A=n_T7&ilQZBYk*LBqU5kB@g%e;Sjk+-+$x}<9bO+e$^sDx)sHiAu zAY1>Zc)oc;Di&%*P`Ms*Ky#MhZ+4xEuXTk+LkiKzjv{+VSscjestJSDVZNc=L&JKr zO(%b%tEgi`-FhXOm5iEKp86{^V7=^1^g7&wy>S=i4FX;0&`IlvyK?19inbG^WZhHH zb@BeoXPY;*wGx)Gv#*@+rr-=-P&HetBR0ixp=ZF@{cz7w&NGu07pY-%O1>(>7KyO= z3SNntn2OT87-e|f0Q*+GAS=<58oO0?1i1pLJTEVgi0pdTFD!Z*Jd(z&`W?dnmjcNb zhyI|H!wbbf(MfylFYyV7@xDZUzktY1h!b><7qFKSEJNo1vo5Rgfgvcn8l;b9jtj_+ zyHaPA?w@F29c=cWy7TPY#zL>+;A>MPJ==^1X7Q??h%xs&g7@8iu{=qi^G75VCy}hY zDyh6@-x9y0=JOPH** zPpr@i<33X1DuuRa&Ld}&Gm7Vq%*bpVse+1`pOiP~&hXVZlySQ5HRM%%gGbaR4wx=94(rSF%sKVik z6ck?#KW`FBwm5KD@|yUiFA75aQ%!nwiO@?Z%kH;D1#lJzS_PCEi(ht$_|O7wqpl;CEQ3vPosl z2dE&|{2D2JD~&%#(oW+kRWEDzYjEj@*@Jj=)OMEax=9Jiy!{R==D3Ydz<_BmKW z0CJ=JH@mI{Ke)aglZKh>`D$^p{UvNPFlx&b($vP^=P9j+;> z+1ZM163spSV)f8Ht6O68rMP$--=zc19r*Z}rCHqb;Ni!+o=@dC1J*}8x3wq6+-`jx z1ZnUT2&Ub1T&A0&_(ojB>7EZu^5O260#pPDweg zJV8tKQIXFpLtm{bS>hC$u>s&x=h`E|7G!G=eHrMPH6}rBFE6-IpmupEXM;LhSM)Gj zr`k(f&=&yNx5~?UM{l11|AS)FObr73DSL@hKhgu-BGU6bbhC1N>nQ|kqKITxPM-tO z;4r}ZRk!-+6#;R9PiKW1_FmR)wo@;l12xq#+o*o;>s3QQUcZsJM|xSVj_#J%XDMI# z`ug&U7Q}%*RTLk;qov#8g*EwbG-<#k^yFJ2YJ@x2zXB~aYg*ytY)brK!Vf~wT$I{c z2=p1VVAu)HT{ao1D7|V3-KO-gu9@PBWg&d@%7Y~UjUckca2bABPHAZb-#faw6a5n3 zSpc&QsMQ-R!M`yA=OqlZ3Uts-K!1(SMNnPrh4AS3SkGH zq!P4m%;9*5dp0!~%}#8?qs?5>(z83_Qf6H7{U!P0*eeGG9IYX&=KT%;ey}v3K(pSR zTn<#8a@wOpdMt`%M|i!Nd!Wg4CeGPEfae_$7F*C=*;DRp&Gw0>_v-rP9Ws%f&+oe2 z)P}tvrSS6dI?2@e^g-ryaG5lw@Fe1fT;8(r!=TK^VQ=5Qg^WTGc8pz!-1>I&mw8>IRrg2hy1Og>yHT>{Ud%|MaKyzUQ>) z0)eX~&{6%=6y8r4SG>`lFt z${bAf3=!|-ZxbWcuh#$G+yt<{1X~d})^b>DkaBYrvZck~-w%`0*4glxA%%GfcUydT zXb2>_F~A)`-?A;ZY6#hCXam`h1z=ijL_t402^Kovl}-mwY$$q&VpWIs2QWWB0hhV< zFRu|kQ5l$)V+GcMao<=PECwDJ_^+k@{272{6x7r#h+tCqUmE0< zM@E1HFCiL4AcOJ-jv62;)!RN;E2SY;diu%Zqm^S=wZ|}(;K1+=O~4n(tWGKv)9{#u z9gnsGMc&(c7c~CINKeS;nV0!r{5Xx}1q#t|jWm!}^r%lih?2U%qF)J7@|7e^2r+iX zU!9P~3XGhJMKk)=g$oxT{)~WqL1GIrWny6gM(1gAdmTe<4QEcAK^X8+RX~Gc25^yb z?%cx9sG(^Dkfnb)kP=XN7|MO|6WRg2k9Lh0*r1hn6FM`92nhTpw4qlD`q5;Up0LF- zJ&OHGJp_q6q6BG-5<&#?h8Wi50hZqa!k7XJc$LSj3$|Y}ppI>5BZ21cIq12khmpuf z-W`XQkCTZ0fOkI1A$;k=! zIRLc*_M!~HQP7gJwh2jB#~ICLrhrCTPw1RYN=#G|dj9wf{FGn$J7I<c*-1-Tm4ie)_wY+9 z?7r2NtJYQv+^+JV(cZ=!=EwUBbWJEWb5^_+!T}?|e|&!T1I1Q&*JG)$7d)X70DNo{ zGBCCX`q)P)D244N8vrh9iaJ1Sg^(*jzTrp3XBGQv8Hx$9cVGNSmJJ0Tvb-9OXlPp0 zT!Z+n$4gR^2TPka$&!Ve1ik@CJ|dVH9;xO^XcvS1PY$yQH4ati#V;rZTBrMTzZx%w znc9E#L=RNP@-6zgpk9&Z3kZyA;vbf$U+=={3-m!=udwY8yw84qAxFiCNXY$7KupJx=k9p7Ahv9ed zlLt{GIQ*^=rA_?otVw_o>nRkN;3nAbS%iTrgbOV=za(0Bo)@Wa#JNl8u|zqptlGZ$)Ib3J)pV+r;5G^)aoNU zpmLyjZVTFdF~iOyuExMN0dm)Y0~9#*9_3#-$BxKu+2f;hD>8~&+o{|h*&jVVN+ZVi zjNa;~ua{n&1ozNkqfQQePZ0)aVqxo8Ay+gz2{mu?o{}~^M*TE?+V~ykp90GCSL+AP z4T54aHv;_;-!cs(OY`d!V_suGHmXC)Q)yTVeKME_78Z+8P^^8Fn3y=-8fVT54K&!j zFFQLsQ0@_CXAg9}3tA&oU8G^nxYbC-F4Pk#a9g{3dyk@#>5vV>%GAOM8qCwBB9J4* z9RM{{ytls()y8#L3HVma?;-;$wnw#-MXMy&P~hG@jYR{Piejtbi|fpZj=@>5+ihms zZ))?~{(AEc+qiwrx+hy(P+yB10{uE@ooq5;FyuNeh&DiNS3ozw zXyL(9)ElQ8WB0#&`67CJke|1>u&@u=A>*hKn1tm(0r<#ogk;xaJ*FTZ@HZ%x@Vkhw z7r`oQ4?B-C(9=_3A(&u(MDv*YGh?5^<33zw<}&O03ZA4EgE51HX{>b>tc#;42Zdml zVtxSYG~Je9%}O0H#tlg#TRbClvzZ#vkB8gGZ3?kyXh?a-(2;IgNI}2>=ly0!_9Hy*Zl3P2Yh0M9J-% z{@8~WUIy>0*vxwH3d zQ(XgGOR67~VsF6>L3ib2D?9^H7V7yv#I{oC%se|Vf@q{^$gd=rL{btG)@R?s`0=PE zRXz$0P2jn?x!?rx+yxxa@NaXEO(dXDcD3A^wzeD?K6tCK2eHUDG^VLBf5Jbd5oU13 zatpFwxpzBzd#zF2X%2IpWfj0n11^DNyVdPABnvziefTq$sP6wOEimYU_DLP+jRw&J zLzFSdX+Q%#(x?EmBpmOQ*=sT+1e|7G2RI4{3>|Qv%KhbX@C{c7p~S96_!u_9YQJ%U zLdd^R(MP+06W_HSF~Q#2356JiW7JDE0SKYoQ-F6=vb9`+_(5$#oiv)yIzG<>1RxNL zhLs|m7yG_&IzYOW;IY<-U;6Wj)U!tTQUx$;{||3(8CK=G{f$mkEX1b6phQZ#Ls3FX zMY@#k?p6sY5s)saiF9`=UDA^l1?ldFcRViF-v9T*xvq2G>zw1KwKub#8uu8#x}iFX z`V$6yCs_T9qNKy&L#<$&EauaKJKYe}7frw)d?e?)cTeok`3BkD6JS9xq{W^?Kv7U7 z;WMeLLLvj$?({z$LXUv1vVbB7_bos+6&90?AUKeTVa3g+j*>eS9Gte3nSQmYO{*w#X6&xKT25Py;~y!jB%QL&K?|p&^xsSnYJ$ z@XOBO;M32-RRtrzbs3|t42uZMCq0{jl#4(yu6sxnL_EG>90W8OE#eadiSZ)q{&A;-6@Gx$Is9wopg&g%p6CS-fMTiH9jvj%IJL)hCmMWwp&7M?% zhYo-OA8vOT28#0}KokN>j#a2RPeese;E{rEPTEzm!!t*%0-uN|GH##?+I{e`s|jnX z0h@LJgFB#_X?ZqKRN(&kOvj{A&1cX<`9FR}izSSGFzIEqti$QEdARb!hF~lodL!U$ zFmxb&DS$Ho&kQnN!)&?K0qY0l^tBFkb#)|R2reA(kL#0xf|rTe?!gOMUITP}IF}k< z4J~U+{BtX9H#(SP`$!!iYy;r(%i6V?|6&39;LnCzkQWw`U@D3EFa&T%x0iRw%J9R~ za8GS=IgZuj2Ev^xN-UkHz- z2Nl91XQ$2I5@~(>t??~z==hI;o~}qDJ^k~PF+=N%Nq>hh_NSQLK}C+cx>fybL-`MR za(;&EAk8*7-yxg96RJgAzXOj?i*WqzWYNAr;a_~Oh0DyZ$m!9pM{!5$xmuqKkbJqf$2P@`^5GH}@MdLrr|7C`t&6^U&_5V9%k9+}(Z6gYG zIT2nNtON1@CDF!wJmgo9A-_VXQ8_#f%%d+vnpoWFC)AZGkS{v_9~r@`ooYr92k7jF z;}a0jpgs;2(wB$pUILj#ru%>N45Jdy!|uYX)A>JVtakZHBs=I6 zmp40QD(v_=Mv+TL+PIsNOyP=#o2k~|Kf(+5NnaL_GTv6(41B=BA>WDOYv($RdS$2l zz5Yas^j+d5_aqRc{M9ee+|)VC+2o0FXN4Qd*P->k1z*-~(Y_t06)PYHA0HQ-p_`yz zmWK}?#aT#{RAKsVtYI#Hww4hQMz~8FsIll0R-ql==7B8H4}RlIQRLs#W{{-E;e|pBaVvKZb0is43e3W_MIce@Xjq{n8d* zo_Gul6GP^#nngCKnAr8Zeu4($7?x6E$eJ^o^7l2H{thi765-_9NwevcJGoH3E|ggreEacKw2?KTrYP zEIEg_qnc01`h z7D)@g*kg3Cf4Oa&)K9SzEh=hh(ytB$uRq63kA+idz1c2_u4=%jo4e)vQ?j1DB>0dvj>M6J2 zjhAFnAH^g7-;ff=Y{98J=DsE~e490~1f$+;K1MG%)EDd1AY!BydTIt}s3Ssjq|8jn z3J%?LA*iqc)Rj5_MEF-Aj?}HjXocB4+?d+D^*b#!ZXO~@^D@Qrw(R=qCch7?!JXS;x4FI^%m#C- zaKRi1djB!P=c)l+S5m^sd~5MhjTqb_`zcRN46&=tlrU<771#$rBXks7k+%if3k>kd zFMbtoS%;pTO_ba~x9Wut!!JwdwO4rB%b-z$n1XT}I)IoRccAx(B1Yz+Y|&~MGav~H zAn{B)3JQv?D==C7Q^Qg8Pb8Dh685dRG{96cQeJLTKOfaDekc2{Q~2X2l03nEY%3@E15- z6%I(AoR~lWBbcE6IbN~&hzP>za!<0k0r8rbY5c7_40IBc1r@e!OZDt}XM)8#%1CUT zPGZ*{8fK0{qja;0c%c<;{UJ`J?bX!?$PWR2`{?Kh9t9O>^-F#@((&`ot3xq>uY^Hz z8n`ti@9CeKHuo_iVs?6(+FdPWJ7wY^Y%66O3$G=XP$xc;@Z-aWC5zi7lV~^JiC1td zE$M_!Bt`KW<@3QYVl6)eWq-2PBNODVb*Ip;G=~!n5YmNLg3z3ZS-h2nOQc~Kul6YM zYyqsGlC!qCxe0aPatU;sw#%bR`RTQ_LM~I#hMN8umEOohkb3viXQEQb zK-r&9Pu+gDhmMhP8Q?{jAl#-x6=009D$^sDxy8INR#7t82|_M_F;otuH7+9uVw?QN z5*|?)8WKu?6LHKiqBH`Pm12o0avO4aaWHJ{!n6EVatkmZXd1-`HVZIJzn~4o9ughj zN1v#+pr5Z>sV}kOLl)A<2i`GdK|d%e%fuKx-)E6$P^4zJRQm-h zA^OuVFqVTVD`3iYX&4XcHBinyQ=xPUhWV8s^Mbp1b__1*5>TX2S<2Vok*s&-e*=@V z2}~()hKI=@0Z=L{5)l7z&>2kX16^bf*T?$TyDI{@msH^TZbLvCq6Pc)4O^!BYzy|EUV#)-q_psM{LVr1q`3Z{HSDd6A~(O$!N0uwX+BUE1>` zFLcY-uV3Ne?ZLT5|5b!%G{3IR?r+l%_asmR9ZK}4`*IAZLhw>ZFe}a?tr3B)g z8npPS8h8qzvQHhX)C`0q+`K}LkI!NLQ)rTt3y>3-zuOapy#ah@f&YT>|NWlzJa`(Q zNJQ%pER_uCmBI863%9IO2Qu9Qtdos1oU#J$lrCdbO{yau)2C+(DJ zmng2(VhIpp!IS~w1G7J|i)Yn=F$qH`cM@{)>;$Vfr?3m3>r%Qy4^a(0LiT_Qdq{tk zb&5z&6k%gSg8|gsCg0^>dw5e}>o{{H@Kx~D{2!*NhEln-0BB?ABXSJM>+1ESZz3A8m z4aD4yIlXiZFQ7BD0nb4uQkf+Xy&4V|rY&=s46~U8gIYfkUI;3iWcoK4F3&6b<(1(yazI!XLJ0d4k?3!KVSDgYjTw}W#WVo{SE2UvRZqW^dt}Fi%<-I!-;ak{EG#6;w-#HXCSh-O1ThKkeW4C0hXFQ zfd1%JKzt9(FNtta!vZ~@oN#q;Xpv_Hw{t9}j z!fxm)$|!S073LSZQ}y-HZH*k5a|0E`bPHIpMrY?FKv9J0y(e>MpQ~JCQcQ}3JqOfQ z8o3c}CsBuT{aGpsAqxxZZ<5-DTma~;EV-T68aM^|l9TuDK<5Dml}@Bj+gY#q4|0JU7Y-le;{KGn7* z@x{b;DX&W%OYH;#%MC|V-@S!revamzQsB1UY>o7Rai#*X9dr&(ifJ>`9#yv{pLH_9@6jdsI2?@J(fj@6l)>8g#GzN4JO$|pZo?p_)AR6IE)Us(BovsY8+H8>;`hRIUL`ZSmGF~kUI z(6|rAF-0?U(XoPti_WIAVM%h(>i*uI3&P>GYaoLI<{n_Z{Z`ogtX<7qEjJ8I^!j8R zv!B&er^WQjdO*B0(N+hLgarAVhB8!yIDLSg>3Y-MtO+{Ek!-w3zk zB$)&np5ZbcV8zE`hk*G(p|~Q0XpO34l8=IwCycvDmEOHH5jVIW-q}uAOh5gL7sd-( z^~zF4SgCMJfH(sswn&|Rhi5<;MPU{C&1ZC8<8KdGmWLH@Y`oILMMDf>suZ_cQWj-f zeFyQ}e2A}uv$d~72}h2<1C9;Tn1Y5EfPm)ASE4ZCCZws*1;7k*;lc&xW#2u~3}Ra( z%`xyZ9eWm(uXqj`8y&-()>oG>9LEL?9cR*1AaIw%x9CF7Ehz*vo`H=20PXj#cVUIr zDAa!*FW^$|97@6eIq38(!8Af$Y}l0ouLB@!7rhfEQweMXAskCKN4zVQNeuhY!5Un7 zvwdb(xBN5NSp=P>7eA>^2YHuU#9oJWfU6U`k~Fh}`P=HPmG&%S{YA5KvXfmwqvAMe zqhXz-l_>~ic-dxV7mVjkxwu_(*_B?bn$wO4?8?OFN6UkqOH~=YpKh$@Z+>Q@r?aw+ zI!m0POhuI%o+)_8V6}~+lOsHKhKjPsnlmTRU&BdnZ_ROiL?F`Ocu?V1$Pqz`5yQ}? zopfxStRp5Up@6Duw1s`2lbcI~Jd^TQ36+Ml>~$B4N7QCPgZ5?DsgP6Ux_*U3c?>ijynlmfV7Pb zOp;kno0r7f$KHkI^<;HW+9-P)5R_vKmG=yos%+zourZ)g&AP;<^T)Mme%B;WUrQUN zU-5NXwO%L@Y_@$K3a6*Oe7T3KUDS=Jw)j64mw{BLh^hzz{@cPtCNf)AU`{DAK(FO7 zzZkr}OUx?$-r@EKQ)7cxqFxC3HSTwgRb7ux$#np2|QTD=U}>R156kJ=(ljR zlBE0ShmVg!SSIptZ3NzWH=)E!vC47IZK=ow{%m7qUDM(~KkTjt0 zniE+(C zMd|_$b*5gYsXdL0V`M4{dQ^?hbddRMAU{Rv44W5t@Dpg)$0qn(zd$>DJWPz#BY~?MXHx?Bh8Jh?4@gO?aab8JN3}0YN`D5z`t<}|2kZ*SfP%O8!XQ+ zssAcIO`abkG~4oQodRZY5}bjKV7~Oo{3+Vt_Ej4NU~cz7XYnPWnE72n1Wjbmk1d$w zn4^!qkYz?_o85MCuv!;XBW%=P3ICu1wQ5XRVRe!tO3*kW`~-|Qg=$GYSt5-sOjBUS zhl&m*p}PWxz+ygaqO{5P;*|8~WS9estIxy)v6I|bQSO~p?Mc34fUGFL(k`SOldyly z1ZAF!jYZjJug3skfiXYHZ|HHifs+*Tn&p%lUqX8B-^w77fpLOpA*AwT{}=IO`{oeC z#yb^f(56I5nSrO#=`porL@tyJdfrvl1BSv3VOAYxQPN1Ry7d^f6XIZFe|gtAg*-Xq zN`EE#noBGJ52Xv0(+pN_oJf?fVOV>r!e~_m?1aB1Nlp5xy!^PYL-|wkq9|t9es&RZ zBY4gDXf52ru`b9{1=!`&b1)vqPwDrXeVqh&7i)b6M;N1WG)!ll1ajgxg> z5Vz6CkCOLJ?r4$mtBzm-Snca*)vpe*RCTL$vIciW{e;usOAcIrm-%%tgb#Edf~HkjB!}T32tsFG_)!;p^<|?C;au?Cc!sBGA}2 zkg_cRZ1T1Ad;-heW_wmO=`}f}z`Xuc3@K}g5-|9%a|bCoxhYZ08KZr6I<@jmx03^n zwkUv7fTg@$*ODLM&0v(D`8vZQlIq=5l%ytkglfuDypGtS$ed`U;3<&&&E>n|!cH|? za$B4W-APR_t$O2(m!&o}3V`3Vc=q|UY%~^#q9d?er*wZ-}a$An8F?jmL zC~TKTDO*K(8@qMwcCWd?Q72(BUv3cX+_6i%HjgXLmBIM!c#C=BpdhJ?*L-w@luqlE+6(@k+2UHjS z8QTWG9YF!nu~p9}>}i^rbw|zNaMvQh+6IUpFu90efX1_C#WlAn*yIcv9UBXv>DQ}) z4PelN;v8M8NE=7YMV!Strl|4G#yCqUx7r=%f%y=_ZP+ZuU{VjAR&`(NJFc1l2rP~_ z0z!v>_qhbna)FZ9%;1f382i<}rGSi;QE%6ytpR{lIP=y4WL>C8?RedC5BC!oy^4U4 z1Zr7H-tet4+g?#a6->B**e(#f0wfm*t5^%0CIDOv6?|PuLEY55)i`ypsO@o0yOJ~7 zSWEg+xKAkV3RB2MPbq~B*7jjqs`VC?D?X_e2Cs*Oh=xZCj~8*vh#9NqTQ6_O@!7?3 zN;$;vibAqXuR*tKZ^O?0Xtz&45~2*GXCirz9bQh(?Kx@hzC)WU@%KtjU*?eNt=ub& zsWK=Z)rccSEACZ!OEC#!i$+abe~*`7!D(T6<)-Wg?kCxHvVky-Q7~NZgJ!P$Gkc^( ztT$yLK{?1_HRE0=@vb&F`y#uM03@3QM?9R8MncG+2vZF_klAtkfPGDZuwcwkKH=@0 zW?~p^6`;79F?#wFAnFrdlI@S`v==L3&KCc&R-!Y{i1bd)ss=82hSvS9tdc(Zl)mOkrMnPhPXoU)6{2IA+3O?x~PRt#%X9Wu6F7cHUBAu+)n zg?AuDAxDOCZgOfWrD7q(bfj1+spMu3zhc@8Di??Sx9~eNmnxvUzTI+me{nb;*)wTU zfBMTv>RS3HEt?CbSRIT_4Kc6ggV>`6s{F$~AD?EImZb12=;PZpfa{tcvDE0-Q z2rw&pKtn?b%N$Wid!Yl0T|hY(`_e+RO$2l&9x-|~b1q2IW<9*|I~6Taao=)USm_|x zK>9b6{FsCOfCH^;7h92Mk5HNBwIK`jkeRB9HQF6nEkAKX1tv2LVgx7PKLGu}6_uf<5FP$RyJ+$#TMJ4Z_;nDyo zFCxP>??!+%2r=pHqH(oAf&#L~D8|F_lcV?`YLdc%eL!tl619z#PaEiHQMUb0yygM* z)_}BemX^PG7zw+f_3rBHq>w$>t7)z~$s43%T~D7MWM>7YsZGH(bb@g0hS4WETOk%y zLj5Tc1pM|OUf8sL+H)8$w6gU!~MR;r7{6Z^?T^^DCfWou!0~u)3)Y=>y=~J5% zw8GSU-0lo*ryE@S(B#H3#&b?9?2U0JOI^4{5wPgc_7p!Glr&c8ll;gJIyX=WaO~|P z$o0qPdS>s(NrI(7D;;^8kFOFwJ*>i7{vYA=Z71MuS!4mq^}2G)RJH30@=^IroSf4m z9!LMhoz*I)!g?hVHL)X8#7rxWmVm^IybVMI;Klzaf9?S42?PLuSvPCRHy4sM()-A# z4#GlH)`ko+=QQs9YR0pBNKGqOYudsRe;4@@RPO1HU07MMb8j5+q+BE2aA|Ap*6fgL%9d^4xx>)*^q|1X zF2wXoJ?9#Y>UUBb2^zka{ibFDp!5Cn?*O)u`w0;uK(&K%qLvN2negbCushi`w&zFi zii&*5_s;CcKRl{&C#>)M%=E7HQI_{#Q4!2mO@Q2U#u<2uR+xatc-gfp*E=J8#uDIm(_=eFX!(e&9D%l+W0+$B`)!x-6y z^DQi@K9J?Sw9%a%2fU;u$63_Q9exd8UtgB_Q+b^5<4WV|V{Pr1$0gKfn}^U27pcR(Zo+d| zX;2T83G|cds^upECBqe`8ZAG~k8b~E{8!Xl z_~S-`HQY&PEF!N>wSz%27Y3Q8IXA?U6eOa%9!chxIpUk0^1w z%6-5VU*u;StKv)cjep5_Kj0`u4G%(y;eIHP9njgJ9dec5R{xao>pC$NQ5aJyMBv&y zg;YdH1-yR~A3o0?uHx+{rVyKOs??*a>lxy*@rLc`VD|N?lEV2&G&1#*Dz74nO8XUs zL%9!Nv$Ut>0x8lXO3y?V3F|I>;wPwIo(~H<#!Yq;Ui+PDiu(CjDcSd$GBfI@9~;sBrrRmleY{q>LU-=RnV;2<=o-84z4YY7YMzG7FTJiC`M1b0 z)10$Iacehk@zH0~Gd|RukSzIdT;&<=ZSV9x)f***k8b$2TZC3_>}D&&NLSfajHXhA zCm|)2?a2C@%ZZlY(qR8Qb9n0S&t7MiPZw&EdPg<9jlyom>?%?GH19nnd35+aK_*#d zCZFwL8JQ6eyo*g>pZTr zm%_bJ%A?HuLAXOR0QpS#*-hnFjiW-_4fc}ZJ+T65M!r5FyJswj`J14aee_hr4KJ%` zh}@jp6kp*Cvberb-%6v>#rb#~AI0b|?=SqkfKs&Ml(sA-2(+>Ura$_rB|nUpj_<=Z z_)apjxshZ4eHW#zMt6nxcd&~=S86X!2R&%!)^>I@iVQ=!o;xSbrM8NFD8V*2*%7DH zg@#zU0K&ecV%|qlu~#zu3Hji{6c`vu7k?1^bH@OcpDUGGzXxI5tU=x+ zat~YWRZs&Wbd45YlwQ=0oYEFm{d&bbb5fxJc?lNk`%Mxi@0|A=I;|n6P%(dQHh$Re zfD4m%+oIZ^-vCsYfK~(Jf@r^iQ%wU7POH)1C$MHCB)O}r3wkDG-xg?pNSHP2f&TK} z0%p3a3bv_3_E`kg=pwYVPb~*QeF^>Ef-smMkVyt$hf?U?+LB;k1CrPdc=Nxqmv`r` zTtwX_yIyqWyFb2tV;mdg9?*ruAixi?N6NC9ih1AGpA8qu5dU{KPfmzqGx`WbBk=+_ z`U&`dT6OZ5xShCG^&3hod@MSdoASQw3uUEHj&*Bseiua0XKJo&%KKLNenh#Ge6E zVpLoVLhJ$g3(_4Wus`JFtbky0+reFd`d;uO|5fzU{%VKC+K?KLr3-8rd%Sj~)vr%=Lcz&9XRAR+*WAz628V+H$fuTF$7zQG2*Tp za}g$xIJ0K%*5PhYIxsUyl~-Cj*`Hwc1RTGu92O07n8k_be^uzb8C}M~M$huYeqNl; zjB$wEb^od44^{Ghi5dGWgjoUu0ca)V8vRghz9;tfyI@1p0HG_8-z)I2 z#M=GnJ_8v)t-~K3V6p=>%jOV2A0Jo^KWCA*Jey*fpP{Qc=b@Cd#x%dBev&A%^oj@V zty0v(?PYuabLiVVkp;hPv-mCZ@D7bnOu?O-{%LsA<+?qsKl=|%=NOfz?b74*%)L*+ zA|vM&`yf4#l8L${NH%lp;?!D1?&0)z3q05nQn7&P5ncFXxaP#<^`Z%q(Y#vW>2!vo zC}~vb88ATDl~zQ%)_-PL1v{WP!DgmDlq^Us>?y7~TTM(yKqqhf&EoytXEf ztW~Ev$?-t6qQ8cK zb&yVg3+0P=Uh#auX>uNG0K1+b$O*m5#uPlEq=K%hCGqS=VFEmmKm@)$1p>CmU5|xW zv=6%`O0=#kEMAN+V%#i9J$ia`eVy*--UzhChX%h$YI}0>S4YPjTQ>|21`uWDjCmZo!9S`yQ zt&a4}^I(ff_KUYxpZtuHISdf(Jk!8<@%#d87>W|IeN`yRb~sjQp_Q#lbc;R_&$}2= zmn5-&^7^K1WDNUTAJ7*qKQP2M`BrE&Ann9fwA2d~6~GyNI`I<jWzgSr)pTaal|v zD&n*pem#?dY8SVS<91Y^W!ef0^fS60@RV6OR(Oj>Uf;PEh;R6kTkrRh$4S;@qP5AX z`zPT^kCMO459?y1Jcg&!X1H|BM9LJmqND;R_q&-0a z>Np0zXfS0Y%)AB_7A$emIXQEiPh^^ljfWC(U?k-%?n@1EdiKR7l&)rpY3*fF%rSP> zz|4J(dm2gk;vCz}b2^!QBSvkPjjHLP{mK`^0}2PR0N%QF3vz@^-<-m}|MJUcT>Aj) zjF)Me#vz(ZAzrS4jFW>2=^JRG!0nZ53~q`iNyQNVH=uXv@@0&c;Q*RBIz(Q)Q8&s| z#dXm*!x2w1YpRYQajV#NFDCSJ6m0!9Y)rgQ%49Q zs;RZ-wpZ`-FH^QL82ui$q_jLncR|Hf`f$~rpCdlU2?|N;%{4<+ZUt-OM{h>cF3W0E zt;lOzf3s4%D6Qyfwa_gV;2$l=szWEM;9&1mPSl3c&BCG8Xm^@#OYz5dP5Y)ErJGP1 z_AeHo$~10~@%WBxuaL^I22*)A)ljZJDmScOnHf5Pns7y>{xn%VATh!!8awZW^oa%v z7!vy@nk0hV+Au#e)b9s7TB}E0l4260``ioexu=^o&SjG95@nh{M6jxVz4BQ5XfZH0 zX42N(to5cFEKn|C0r#ga1xP9uQ;hN`NvlU_l#=UlOK;4VsoN)i#9;fRX;ARD1oeK| z>WjfmP)<2q34NiQIy;8n?=n*DG96xYM&#UVI0W=9mcGyhT_)$-1$o;)8@SS+`&2_S+NI^?L!}z5R;Ag3%*Ja3imSbTcd87%V@lz!mH5<68@+o*8EJF4Cj>-%c72G zy+W$-WW(=oOx9D^9!nH^8{Ip!-1%0MiKCX!AQ>{n#__>VhR=>&{)#>k7X}?RSH zJ1iD<&aPQ-HKUe$LD-1-!ddq|og#V3mf#(pinkc~lO4B{68kmHB(zviiY2ol{$x5z zq}te_If318*#6$P4ypS$Ik7967)9bsZ?cvOzsddLA>&qMJ0}wJJXqqXr$ZpxK{w|L z(XP&`p+t4E`QOWmOeNkG@)v0ZC5T0(yVKg3i9JamZa#qBQNV(`u{8QW7tXBBVDH=f zrN!u`DF~MLZ0BK+U^G+jB@%)z7ZL0(Y>6CD#tE49HR-C#e!UVGW~mSvT(%HJX3)J8dEQ)^59EWX|iz ziz@<@sGq+1kMKg6;tkw`Im$pDXk1kdQqth((63?)pH$oj8ftoOjvE5pmR2ek)WdXZ zf=<2P+b^6z;c5uz(BGu{$!(%7PEn_p&!4G0M>;&e+;h8gwnYK@43l<^n0xEa;cp8t zo{~p&gO+EcT#Y`pSWIa8Pj}x48;_~DW4%n0BI}&JI&y*JvdE@MsK)8H-t$xHWpsxp zagv+srEMz#Nt4G!$!Mj5ALSKeLsV|2fqFdnq$RVBSC~FsMTx0}88#?#ij1~nD@(S= zDze6Q1c0Ok+0t?N!al?f{O*iDfo^^fNC-z;Hb*+p99D9z<%4(lhq|f8Wm;Xyv_gB8 zQvBmslIb$C*wt}|fx<*0%-;7-&JcoW_O^X`$JE6%I%YasuTE7078jFdf_J_)>GA9L z;=#Ut*QHHM^Q51Slq7t1${|%(JP&>PCow4X+U*1et(A4VwR&QE^MBVcu^J03E%p{6 zPS~XsiEwiJpI9bgpzF*6t1V`($I&CK(_ZgJxbNMfth0Er4c7gBTce%ELM45(~S~{8|3*rJrrh>6RPSPk7 z81FD0iO-;{&!AP`YDYGgNtOuX$uJ1(y|9ryA$Gw2ubz7_t>o%59ICljmHvrEjyc@4 zeu>|Om-+d9KC96|-IikWw$Qcp-& z&108Bo#;DP@FMc4e}}V!Q#`FLKZ}rJo*gat0+bZeo$AWbvnF5{gmZWE)-AVb|G5`m z?6Q>V`2u^v9{{siCW(#QbmFdr&P~}5x9kRr^SSXGQQgHY-zECz8WGAog z13ZT1iKSs7ZVvwmbb5%P0(}AMvLAk=*A-4vV`BNk9vDxc+XO#I*W;fcCU$%z4-#)+ zA6oeD9rV0AaFnr<(88NXrM8FcH8EWviE`yfhvTUn*P3c) zh20SsUqxG@p1j%<`EzFc#6y+kX1hxsO##0>jS_~1x{*9K>GF(VXrz-xOY(^FjI>7FQZgHzxY4WPYT)$%@sgvZl{jdLHCl?LlNq%ybz0^^MjaxTbb z8u4KLybon1Q%t5!eV={GyHPN1{vN6cwD5@W5h$FDtQ{n#9MVaU?YDt=a~L24+h5_( z37C@S%EmySvqdlU&S32M4J?;J$jKNkK7nK%V6_!od=bwO?DhAcPf2xr3(!l0vm3Nh#QosTmO?G{FcquYPo}gSZU} z>0Vw5w#`?CZLLjKoI#%sg6m!Z2@^I0ne;<`f&v$V#RK`3yJk{XqDq)IcN+*{XYpTw zH(Nv6Ub-vXo91_NnN4(GEuT7ZP+$1we3&~yL9I$I@GCz&Rb@}}N6yZDmM=%K;)er^ zeqAU2Q_E88hTb~9qONI1>0>2SI1Weqx^n|)_3p`qG36;|>dQ>eOTN|oz*>m9?A~TB zG5Pef(Drp=V#k3Nq8?ff4ot+&^&fjx7U8(I)u#JuiiUcxT8L(}aRn;cbRWk1;#>^4 zKGPO1i@J?R^u_UuCd&ZKR=}ka z;S_2I3qE4#@uS4jGM^Bd)nEAyFcPV|Z1r!Nk;dz%U%Vc{&q!n_hRU_Cr;jC(ElBnP zVD`hV%~^NjM>1@DvFh3m&%{pEx^&b@RhCs6!I*=(t@SU;=|PRkzhWknDpD1t<78ob z8nsiQ8aL5EyHPVo66_Z*a7swzUx4=T*QsU!KG+vj0(Qt`{Q9d`Qy-lfI~_;a2BBtc z336{r9yf|h@(HSQY?T{seF-=r?fZ!O_`HuE^F_(s82FfGXPt{;1?wEf&KkYuL%k{} z)SNJ$7Qrwk1vbADf7URHV~CH+20Ex&8+J>f(Z=x}&TR{W_?lX^KV*>y&=a?TV?jHu zDHy~pXS>$ves6&f5dx0J{3^n3AXA65)4|Ll#IG;@!`dK>$p0YKTGdco!3w&nVPE}> z9-`bc{E&@KNIekpfFHrK(In67 zzLGE5k=deJ(%}WlJ)|2UP|mfm{8S7$`uAO!#J5};Xg*q0ZHqq8Sv~I{(kRnbE?M`2 z1+@#03%spu;icMq7`|Q);MzdP@d4Z5z6tycX@)fx(O|O!K~fjZ-@jERIwZ^R|L2WK z2r+d9l!U)vcO&5_AFJ6LbG=xoEZW;EFv#<-fK+=#9XGBY( z9397~g#>W-dxZT4C1dxu(J!?0FmJpBRiZWdJhY79^>BmiC2SNa)15o70bd7Ly%`o# zP04=^G;E{ z6Pc%WS^atiY^%1pk>@lw1M=1N7DrIZx6zF0e(~f{9YYN7G>l^h>SA7 z!I?uat8IbIixcw-fqwiH?n zg70DB@&kp$$d!1=`Ik-rL8N*>b z*ad-23+%(TT;Q5U_!~Sa$l*TQRGtqJ9s5ISki!A!8SKaYAe|;l03^kCrpGM|jlRGw zUvBJ^id(3O8Vomj$qZ#sb}lj`E>h>Kczt0<4vje6%=92OftCZhJH5<-0xC7FL&bLB zU67d((P}LlX2xir=3nbc0tb?WgfM?poUxMYL8ikSj)Sdc$46)yIk>5pAS-|SyMafQ z)r>Sh*(f|M_@Ch(>3=u-bh2K$T@X(RaxGyM-m-qS^E5wTYo!l1(gOWQ(L;TDBa7sQ z4-s0TRsFirKfsOs6>L2NMlB&gr?fG(srpaX?2m^incdfRnPKSns6J69^YHXk!W0_z z&x79!sHL7Mm~jnQReEPYNIh#%=$ffa<>~D$xIL(~|D~}BJYyW?SL+XDADT3Bf`7g9 zTZnN|zX!^lpD?wlF&|%*SahCt)x^t0`)SO@|IlsbiaWEcMFt> zt%^DEX>ojDz(51M(MWux4Wtw;?8Ls`Vt6r=EDYJbN8UeZ{XA6e?P+4UOUQ0liMU(v zG=kE73zFdaSaTZ#q*^@U9)N?p>WSY?G(V^aA(*yWr(VfYdEQ{f1%myyoFyt|i_+pJ zbfcJHIDaJ5Y+VNFLylfF(;>z19BY(WJ|TdKEwRK@*O44eD-4xRc2op4nlmvmLEjJ* zraSzy*DEE6=^RA!UnqbQ4^;s=&dxWp#ro8u4IGjBnVC4l z!<+&SDK2wC>LEv407n_Kw(x*zjcXM^3MZXETxt#E$m}NtS?iuX@x6$D=4Gxz?q@{` zTd72J1Rv|ItLSrpnBQ`$mJa^r@RxMq$!_D!dU*HV&t$ga}%9?!Is zojWDW&BA(#b$mrnV56)v_Fz;m!ijrkbewxUy;V*!)8Zv_*0aMl@KWeb z9VjOb`fJC)uznV@f`_t3umO%dLArMLY5G^?QftL|;KCg~N zJ*DZLVsd+iSwYwb8TTy(WtuRthOz5;zCJkV5%T&#jl%OgY}w`(RY@y<4-VeyIYs>n zu9WBML!$)659cgErrZ~c<5@JU!EHh; zK#DIFk7P&aaoeU6u)ddy;bK<*f^5;#zyUvWD!h6#Ww70MDOK@HR@FOxk^X3tVx?|s zI&$OTSzWWrwMGata%iPZg``*t>fwf@Q(6fQLa`vXhft`c2B~ETraEpG6|e+xbQ4wQ) zB9Bz~7pz&0!o~Z#&<}jzkD`gSegP`k$u|dME3OKekicjxH*Pba203qUz3JQKh!!7TIkKDs}OSxcl+L{Ue_KU&LQg}x~Y;rehXkjyC( z*-6c4!mCX@DZef-uU$pvd`0!PdUUp?JMI7|bwI?|g0EtH4JT9cemvpDrarFeZv>Fa zGq6xER0at}771d$TIUY#Q=F0~P*KR+5bSGcMRriCyZOa)F!6qCy`pMk;0N4%LbiJP z&%4;i+a)y(BI;j^1{>5XN_C9;8%!qJ-d*OI{o%A?VjYbtQ~)`F>s(4W@Z;D05HH>m z4DYyt#6UCy21wra(Vjs;^Y8|SRcG-8_;`AFh%+tJY=%GktvoyQ~v8^uP8`Rp$+vmXg>{EXqZC|)mu~JQP zy57sc;oW^SyAJ_NhH6~>Q%ZS$+yoQg1)y_*2c^@ioG4ODM%&*~vudMD_)4tfd>Wmg zy>n`;t#6P;rI=4!C;!gq{kqd?4JfHECNG(F6jUvqxO2P)VXKtQj#1`MzvzVFz`=Z< zjvUCcq@n%>P6vKy#QfIUJk!(D2VW6G!@Fh))azP$bwVUwZ;Zy#yaFgVX!G4mbHTn> zPkP4`U!o3RGUE}G667%@jw`!4ALqQU9Sb{V@q{H?v<;g2P3by2T`B^v?h3f-De*Iz zSq34>+7{jdXNI@C1}TL!(!3CC(do%SqCo_?;s~q7T=GT|vH0H*kLK z3L(?+TMSR_Rz%J0B!v(hu=W}8!%xqG?hc`A2LJH2sL}}qi`>eL&;$uwwb%0 z!w&YPY5;0t>?eExw+n1ylfTulCGi>zL4FHbpR6FvTL7H5%n=`)6!)a16qto>3>T(H zu+u8_GR)_;_=G1(ZJ|6`TU$lU`mo(YUEZgQ;+?MDf7XENl8kyF#M+6B-YAp+7Xg^=aXm-2Tk2(EzYla_RIH zl!>w=M>vBDehuW-Q+2(V7+1wRUFgtn^GL0p^uMnr%srj_=qZh0m+MP;`LoJTrKAD` zx!`}u$zdz|W)OZ>Gl&f^{Hs=N2B6_1dU3Anq2P@!^-tm9=aios7^ESukGPmbRpyH| z9EutGB7gC;`13~&+t^*j?0g4zyWS$8^2vJK&vS()xH79ag}31>J9S0nAO-h@JPk@n zmX3j8NfrJh#1ijxu7&tN;@D=8F(T*icHlpogSzwm6U>NOhY=Y0!~f9>KK}qjn9cQY z7c|kZO~uK3iVvXi5mi-EQ#(o(JetGN*nMPTvIOAnai6S=%bK6YBJ3B`YKA1%`!QA9 z!)Qjp1iMly$jftpFa}~^1xqoxf;7Y+LQ-`Mh-yGaAd3f~wJ;$1GCga=L~ zGmw7+s)?E^7wqn_1CVO}IHAgOC^p)4ZLof36_&;Wn$?5^I^KtCKVIXu*dExC1EHNu zc}?UN1%rpkI6vxO{F}_Goms^=?};iAASSRP5kMe%0JL%SuE)D7WRN)xQX>u2tB@35 ztk01kK;>YBMM->zr(qrPHHf2i8Y~yf15MDSUBGknkK-Nu(i+JqCChvOZYIzu2{7K& zI&(2^36JD;UMzv#^=SQu*4_Wr-h0PInQq;JWpmol3TP`?C_@X9LCL99S`kSKl9LpY zBoU_`Dt-9uA6_))vO_ zK`?6tlq7VceNX5QID zg{ztbttbS>Gzj8$fQis4wakJ#I~)=j_YaXC9@b7=?kj}B76&;s#H?&=0QwGa1KQ?H zj3vcWg&>ZjPmSLP9vLVDVd>YfZ9+Un>VtLjS0uNK0KxGD-$zd8Q8*z4@siwPc|GUk zY$wb3AZav^!Rf^~&cvjR1W4(m3@GG)Ek9tMCepg@Rv;vt|7izliOmk_v^cjX(mq^6 zW@)iwP!u|F^~Lw#t##)-s$(Ch+@`D<^iPAEql_*vz(84CBID7d(=qq*DbF#Y$SI|^mKbuohl2J3<}GH%RjQLRx%$m;nYTSouCbcIA)c;_Ri zXyB%Q9~TK%WFr!7VYnYh<*A{Uf6#-7E%v?36b2~S|0{yZAFc7`D5@{x{rgKdnAs>g zir#Pt*jz&?$fe(+V54Yl{7-3g(-^PqM`eV!MSLEOtIbMBQKlRKC`Ui%Fz&vj>ECxX zA~{q^Yi+$-8oyoL)pRu9x7y)XQwFjNyN;WCl#S-2u(R!NX~mUH{jffZFA@n~nepn@ zRNNrC^CTT>`g2BXbAydtS>#jc0-A{>9 z!Ms8K9+OVRbm_Rb)H`C@>d;i8N#wpDogTVDk*b~S%~>M4m|?TYLkJA}!E!(eon_tO zZQ4!eiwRMf88%<>)zifvp{QM}4Cvlgz_@M2@@`Kzl?YXagz(QKXx?t_1uJLXzF!-;$aVqd87grliQ4lK| z@iPH_Ax4Ck%y@MBPer#f=%al(Qkj+O^A!K_(W2rWMF&Uy(ZSVhkRr1zD;d) z8u#6VjzlgA34{9R#<5c;BYcIYdvG4^1}>i4Voy|tN(KhBTz~x%efV|EW;~N{NJzg) z4rou=m=6+8-qv(zwkN!LzTk4eE8{P0d`wHZ^PKonX*ywRvsAhK49*IJseXY8Z1kM6 zmCnper_?WJmyd;QKpJ><6x=kVW33{2Ea#}09*wxq)PW9n1kbPDfuJhZCjy?ROVFE0kJWHUVMXBJ-H z%F|3MQrL`(@rDS3WHF+yNPaoHjVpw*VW7C>sW@PbJp^Qs#$6J!)MKt}G zHf_j|D05TIC_W!Y#oKb+ge*#Vx@=p&wl}GpVSW0Y!@7sVip}!H-&oa)O0l_K{Af+- zlUz`EB@MT3P0X**yJ(Tu~atkvakq`^vj4|wf3go!7*|~ z`0DQaTcepm=&L4fpexPG$ieEf)$#$0`9_CNcd2h(tAT zQ&-WZavwj~J{cK%+P{xd@j-nNpQ?OJT43UjS>7~-Mb{38F|`!Asjn}_-HnWmnh#0{ zG=te`IhLwg&!xr4Q^6k2;d79zTYqUPQzqqE(x@7L1D7~2hcAF7vhW)H!qO@7rq~9w3ptdxG(%MV-aMq9zQyMUnnno$+ zbi5zK>_wbgt-P+jXy5Srs^5aLCg$-$k42z1iDaMtQPQ~IW?5g^fZ18Z+`z?Bjo#jj z{vh~a0eP0R+3?(6+01?ImBIW@?T(Tx*_X~Fbt|9X*ce3E=k*Q1F;3&qZPh&4Rn8u} zpF9Z}-FP`NtyH0R*0j_L7PV?Q+Jbd9rJMfI9ETe8rnlh-K} za>{<^7&53ogK=rxXJ`t1{R`=;P!E-!y|%|ft8^$2fi5(;IpbE9vY5L@MPLwCiPYU) zOQC<_GA@Frg0xl9^OLnmhqsjY2syk+lS@gh=1m}-uC==Fk1GLv5uLDEO*JqZZ2Mm`{jDmU6v1zoVzEwq`ynkB8f=*!JmzZ4>UFCdU<<^KG3v zKK9TDw&o5E6|oAnVL_3mxoq`qBB94q8KVb)U2ygQTARKL<2dQeu4IJeVl&iUdO=o@ z+(VCx6CK*z%{;U6CU@GYPv+{!F+P4^koBJa-)pnOm zRE(@Sq{1mmWR=kAlf^=y(oTlQn+RC@{LGAVu#nDmBw=@jxW47pbdJQK`0Bs6w0 zC`VOab>LRY#t#gWGnM=o+NcDwh1BZbx!x$7{C)q#dK;Q$nl}fR`GBVI!3XRRAM|4q z^kbPvcOhXsL}|+PT=T|lxo)8!p&wHM&G1M+K9?ngNzYFo&c)to7c#&Bfd+3eck#-%%+2LD%V41;a0T!=W*>V$OH7-y6=SvIQCKJa^C9yji^< zw=p4oGM_4>c$LojZtv0+bh`fD6drHc9&p(!Zu!!n=fY|^#X^7Mex=gR{?zWtx>W5C zF-5aE>b2x@Zc0_2X;BWbpAE@2owY&OLN2e{)-AHZpEFss*0i^lSO!k#doAI*EJeEQ z+fp=q#+>w?XyYn>>1~`LUfEoxwUQK$RpboP-6@rdyz}bZU((EkON1p zXKW^mk|C5L(fAiXZq?teddYM>oy1RPn>b5Y!LbNGuAg52oa;Yh>}$5s?ZZWySu2_@ z*-$CZcX5qAurTT#r(C#Pl2hT7r601i{{C;zEORHUim%f09&Bdn*GwckO!1LoH$Mi4 zF243>?aUI|;$Wo}CyNV>qKW&*HZJn0)E8Y)m9Kmscv1bjDnGwlNI#vs$9=Ur`$w6r z%K77~i9bRJRix?8vCvX&NubV%a#0g_C$v-&_=-!~++p&1`YadgR5`(?vLR50o9<*M z!@~S(Xm$?UnEZyUHPs2Q)kaLCOsmg82;VpUd2-WQUoB=D&bHWbi#yl~_-^McT zUZ;#LGSNC)IT24%bMd-UQMnr3*u`b&?9HC7Kc|p>MSbxb4y|AxLEJiN=2kHKLBGfP zzDoDqep{|w@7>NGf|APf(W*5aF&EviSJ|?%vWy%**ZW>AX;G{Y8%4SF&T$H9XuY-i zMZ$A@wJv3DF_2AmZP!oLX}z(qOT`WS?q%Cs{m`GN^{bZC%3gBPQeiy#Jd&)o^EGr1 z7e6_0O>`=?8{}#;L>j&-Xfv^FymgtD?m^Xv+tgA|R1LXzzx!(^IgFLZ;^(!j*}Sf7 z6=uv;53GtslXo%$jSTKg+kyFRo!1=IuhI9JGO;zb?k?W0Wpg7A?US`B2CFn%OdqgX z{v4^q`}KKJTB(80$>Zv2foARx`Ij15)$8Bno=bH)d{ho`%NYMuEyEyZ-(Os6;#R(K ztne!u?97wJ+#p?I0A+^$S-QA)KgY$ggp8DXDJ(wnHe3!Ym5siSB87HAll{vZL6a)6&M=hs}k;&E5o)NCAmt>=%qBtGCtu5|wR|zl^NrChE7_Y}muLLt-pBanJE!Z_9FRVy)@HT2gU~-R?(o z!m#sUceG30q-{Lei_!UPj6!Zz^@PlthOnDH!EMN{&>^Ki!9bv+PSi1UVNh_i(5csq z5NZffi(8fdtV!B%M34+yb&2v`QlQz?L;jfaNSydFBT9qhJpZzZe2Z&rrm7VCX1@s< zHjKkKtniB}|8)%g`uXNC*Pw;SVN06Jn*KrytDn+^s@Dc>zJ1*nL=TEeT<6|8sGrul z=#(_N?LiE_ZA(0P9X~^5`dFR0Nf}2pJ4n?l;~jn3=dskc8XnW7aHaF)^{$OqrP=0Q zj#nvhZohEXmGtPxQw8*nqPo)DIT|BXQ~dk;tz;eXBNYeDyU`a(e_{b`5*Jikc-!Q0 zA4|!!%O32b7VCRHUAdbbzD1v!t^EwW!nfP~uSTyE-BSWZ1GgObzv!-g9W8#QFb`X( zAH5Z>%W~^PYmMPt1xEJlhL5hR2H=w&$Sh} z#Oa6&c`TNuYtqgyqhHY2bn2y<(L~zh2Y8dwX*ncpApSiwt$ssz{f!rblFFh(20=Cp z8*@LfvovZvl@Juvv6FkE9A9Pfge}(b(NKGGgUgrY*LVV2+2K5RYY*LKW}m*MSW^>*d3yq?A* ze!srxl&bszY~$;*3Io}BPqM9q-#O2lEiUUNdTq)xc+4dya4;G61EC6;fub$}oA2g# z+4E7)f0SE$`EgsPK8lh;E1f@VKDQx_`@>3V5O0usQ=fgo-oTdI$EOctgo?gkW!y4k z#zfclrT*VYZQmts`+@8}V`xUin9nPfH-oz+`(GtH+&V4Bt2aY=PP>OZ*1Jg&tT*P7!0S*WRS46<)bJwbCD|*t;Q%JJFok z#jDw3yt!xZ32%i=G1Fx8Rz$U&Cua{lkm9lxBHHU^`aI5 zHmxSL{FN#3ajuUtWSq%*Zui%($fOCm9q!NSnLWBIk+e-Rh_Z2YD_Y)JQBdN2eoUC9 z&j2H5JyNbDHi8}&@Awv&LmJo)4iIG7XYzU%{ngfJ+`6B9HG;+5I*59RW5oS|Tf*wc9;M6O`P?4<~E`^Bfem`kDx?; zopS=S(`a+>BVqF312#IABNf@1`5g6+Y1>N!Cnq>ve}kPu zIP~yyT>Gwbtzu-F{ABd?E=tOjrOH6|^wF0Wzjj1F1fEA^WI*bDP0hii#)id5fs%Hd zn8o)l^7BolUgvh+6bt?1bZ=@nkzQ=u9zbmwOIdJQYw~$dwB;=}X)XbtnsQP}H)K%v zADB)ll65Pt9ZvHbbgv`0P)h);DUgX$fH3Iske~YwO?SoztSOu{So6oRA8~1I+0s&r zzF(Q-&K@;|(}j7Dsq+ZKf-paWK*!jVcI=MV+HO#XmRu;EiT~MLD&hE;J4b_KqNkM( z{jZnYhARQ8uq{u6kN*3|tSxnZoV&!~_L@!Oo*CFEdT~*DkC_j5!~yD`V9CLnW?sBK z#^XVLV>1{_=ykqj%Z*DLh+?xqfS>Em871 z(@{$q?Py9LHKXq8&=;&Tj~~&*Ok_6|aonR}6k*WtkQi_m}35=C!+~NNQUUIOaJ~CQ-R+UeJ$1mH(WJ7uhb;B@Y!I#=ddhFzl7&=~Qq2xeY z0L-EV_QFBpq3=UMfpX!gr@fZDjtBn9M^Qxv`M*Dlyl6mC1`qI{we#SrroUiDc6Kip z#Uq-0Jw!74sG<_>IEynNw2zE#XI1@nofT3;j~o^3XbLN+j%XAPa0QXqu(^twnwn;@ zs2c|=+H58G=+*6f)r_duVn?2Cr===xsPgAvQA26dWr3Q0@EDd`eH>vz3Ym51^H5Bdt?pX~r&r(XH5gZussSttVjLwb68c6RW| zV#gm)mUm@iv57@R0?@g6j9;#OOV7y($+v@M0IlqaohXrWa}+4cJB!y|-1g*J1tyQ4 zrY1wszQ$L?WmOFTzAaUiG^l>ySBYSyfPQ$%n|ZeR5HK+960SQfDR2iA%JG-xJiUy} zOvAd4Y;EDU*KSn)9E1qUHA>Y3SMQwTP58})ze!s^Vt(@E38a)keR!bQTs{BVje~Q| zWQ~(`6&HYWp@#_TFU3K-Ut~RAZ(0nUM7!`i;LhLQ8H`!_aw;msrjG+Ue+qT_maD64 z79Dm8aoiXZ{&Fqr0i+=`HQ#nNp|ZI8PUbuD*?~Wp=?pyD9e%-CsKY@$^)^;S5~N?Q z3BZ*q;H6jwkA0knYb+eM2Ne<264>!Ds;j6NvYbNo{nB^FpBv`v_b(r`YhBE(o z;dtRcztq+qhKvEs4_fm;4gJ5t>~>0*Mlq_WsnMbywf3BYRIEFzrvIIuzP^YE#!l*w zLv_)$2qv2_`WmJtR{0Sm7yEen&LKHQgJz zunb!v*3y|0LKX#n5dQ;;JN-l^3%rU#5Px&W&tn!>D)i=!uM^n>x#f@uU;|(+tiNGus>WZ)BQ;UU<`+?# zC03ZsC-XVw}EIOsrfpDO|9?LJ?L z)B$2O3cCG;rvJ2{ftsl+dP2Hgbmm?B;Wsv00wgx+MQpw@JCCgO=6VV89Flf$UA^yGdpDR3;ZZE#d~w3m1;p7ku|tv$vMFP(GqJ!X`KIeUx&?X4paq2T1DrET zhHH;OIJA9;pb)BdIBu@4P=-GELKWa;Z-;OG&9ez3WU;{;Uv*nkmrdQ$Qe-nGY)te1 zR_4hP(YdF~whF3_OFVS)ix2g#&C7^e^j;2EclX>24pLn9{B)u^7p-aAemy8IrpO^i zk2z$da@db;b9uCS@hsVT=>3lQ^1;|gRVn9iNpGU}Sh?Y1ZMg6=+!q>?ZOMa~>t2oo zA-1J)Vyklmd3IVfS6+L2*}YfXYIJQkF~)N*nfuvNFZf>v6o5K7A)=Y6GD`Eam>Vy^J&`98tGg zYTx);ypfyZ_0%m#g|hh&SMth+CsUt%RQ{rPf9rnTYHPc=*RaXB&Uz-UU0Y98JGnYo z%cz|$Zs1^(5Tg{U%HX`dm*gHY;9SVVEH|#LrDAcOPHLx+VTCun{A=A9#X-dQ=4@^J zIDhUhZ#?$Cjjg3IH{G+M*R>t9tu7gUFM*7fBK6ls1D{J)X#7{o=9`$}PGr|52C(d3 z#7=!s3Z?R#zrzq2;X5|kOf{Ho{(;HoJkEMFQ+jRvV{9eK@o~K`Lvv8+$lka2IkEQ) zr`@}gX!yrQv+Riul8wuygWRDhe9n#8;AD%d6BLYgS*{*jQPXu@?n9#^{LRpqqJtBW3guSc zDsLB5jnXntWy6Z<1MB&{eu^`H}JfeJth6fkh}WXb-?SPi*Ef8N!PgIbC7bs ztb%o9qvbvQC2u>IP^0kT9mxYdyfNN+pR$UwR4KWC>~Ood zP%pM73%W_rvq@`vmv`|0|lcObt@)Gd&ZQSE;ow9)}aI zK^dXwia{0%G0W~se5?|;81d!)+gNjRys*;vx;-XlZQ@%>eRQi>D7OS4~2fMo0>0B=aQcsi_X{Qz{ZIJ0xfuK!*@^=@_M|zXU~_)!3VAQoRc3iK`>x#zW9d~d z+$wI2qvWmj1Ks?xis55tvFEGI`=y;%_9wNc9XOPUt<~(-i#uUcOYSMT3Syc()0!a$YtXIw`v=a;WYR|%{LAj! z)r3mN>eV`dNZU1wYWFy=m9qgk^g4&Z-KB>EJE`HlM&;gaE}ufJ-@4D4X)hN_&+G+d zq_yUZRC~{03$HYr$Ga4JHGFGhmfL0=DDRYvxBWF#kmwe$GH8j56(g@Uh=vOmQye%P zcH>tRC1W?g&11= zapA#&v8)t%cWi@JjXYt!kzxL%biCkjH*plVozETEYTTA7vCza&zh0v4P$D`@#ltx#6>)k6v{$v^Tm|@^T%9X?_t~@bsMA|%oGK`CY zDMRk3PxxU2$K%y#VEo2rW&S7}Gt|Gq^ zKqJ3fa`!8wvM86C|6><{{2mnzRIljKL3}ObDI?Bcu6mvS{`*X-Bc@(-=c8-Lr04fF znWJT0H3P$gsP}DvZN3c^Z|yO{x!z<_<9T~|AV;W?=75S*fRqZ)}otE&s_L}43=5{g3h zUL{LsYTT7~N$jnHGj=1EBd&+fc5kBF5Jey0Vi-al2@tY}+!nGSp-WH|a#_r8{NYCC zIq?R>BG-E_(z_grkip;cbR^i>kzq%}4Aez_tQXJ^Cxq6fz_w=xtUlm&H&nn!T&rym z-&MH95`7wAPuSnEwhSd)Y?(4Lh^#S~ks>7gW>(51Qo0UKMx}$sG2A5smv~mdO1)pl zUIEU1nwrGJ4n*J!M6s>Qry_vLCY34#M=v1W8g0=ifORUs4w zF*^+y+(Wjhsc=N0rP&yd;w`OBe`&Z5M!udRqfpog2L~>o4jZ%rNmt6C)>p}6;;ikS zwfDg=kewL1XTXd?kX4YM6-1=gfmoy{zErf^O^*hS=J4&Bvy#Bigk$vp!eC!6Y!Vu_ z8kU$W{cSr9s1^~o>+QJ8u)opHyOm7r49L>rfK5{kN30x>4vREA)6a)NIE3ivLB6Mt zr_MAzfTId*TavU9ZV|s)$Vr4gIuTR02L2x)v=t0zY|!X#{9Hy)L*L&5B~(CJ4YoRq zioOecDd`im?j_i@UZ{b3|5PjQ(M=GS1rlq%G?nN#Fsc!ojxxkfNB|ob?)cIFJ0JgQ zAyn-6N7{-Se+stR1974nu!{$Eofi)j&CKRwIsPw%_oKpKBt*I#D$xb}y-P-Vo-yG* z7EdY9bbr&hMAHmUT(1svW9%MiqU~(X_nLPl%L30{j;qfNX!de(^a70`IlJA+1002c zbQk;e8c1|uMg40*I&7&k=~dbhsOCo0H9_0?t2%DK_askQf&mdalhhgNwOM*)`JQkg9L(Ut2P?OidkE(Ig711O|x{TpEXz{XKj|JnzWn}ZO&EnYefd6Tnh%I*H zg8ye*MB55#6zuFM^Dcsv^%?OB5}Yg=S+eLk(QxLe-v@ulb{JB?m`k)O*@0=c1D1`U z;q_2f(ed#1{j+M4@X!z!C%(*e9IdOfS5Tpt$;isGaNs$P($M_-pJ|m=zaLHi1FTpT zMwHZjZEYsF8|slR!l9d=p9hLFzyW2aQ9~bIzkVI!cu&qT2X16>8ak6J_bh6t2o?w^ zzpG!-t#J6>%_s_|r{{|4Gfowu;dBHc;VI=GmB>JchKqL9B{7T5n*1;ngfLo>8S(1M z5!qLTF2AD@V(=$k_%6@j?-XFaYGk@o7dbp zj(kSuJkWka%L*zg_LaQ%yXk6H{ooCu{_+qi!S*NUa7blf__zoFBuGgEklT6nLYRbe zphKVskt&9f8PFd@Nb|5{fbpjR*e)P)XmBDb9eR?vduG_8P+`Oicz&=XyoKU3Say+- zikLm+yc4mV0M!9esE&ctaI8)3iK(e6B&k|~yk@Xd+`LPWE*J!utwi70CdL#R?+XBk`1WhU8uvl22%x;FzSAB2D&05 zR1mZUb7RD+a%rOW(=l`vVj)Coi24zs=2P+-YMd{e{y$TF$>3FweI@~POqkr4t!Smc zfxBZvY~zRhD8rCJY!LTE6T~OLtb}>sbj<`Z;q(Hg_Ybo}p#Bgd0y1t;9nk>d-mU0o zk3dR@=y~Xxp#_4!SnJI8X7h2N9z?;zLNybbF7Q>GU(|)htD>pBwru>S zJZcTXY7ZaQb-1p6{X`iH7{c#>+$o_K)=>=uSTT|MAE<}( zr(~kl=g}^)05co@AFLcE0augQ>QM<`aRZX;q`RR;)p|6T>7^DF*g+Eq8`uKChewQ; z>b9UovJOY=+k@%(FY$mC79!;U^xj5>pAe4Uq zGE}L-hNr5@q=Vdb0UZ5lI>U&QFp!bh`J63|KGHBtM^&|VHHn^6SR&FI;scO?$v`oO zBI=plYpGwLJ+E$80oKtP#eW&O+aGO-xFmucUeruPL6p;}GUizKbDvveNSCXmI6{T5cdW7ZQV7hsRL63?9CB`6cVY`5s1D7t~1stdcE( za;5pBp2%ZPPCt;T(GM8n^%fPoW**irUclC5z~8j&UrD?i)evv#wIPHaP)lxhz_SgV z=9Gc`L^nJvL()Pf>bV%m2dWQ#fh`vk3vmZZ>lEHzo65+{;n3TLP?A;HCI-y+WdK58QE7}! zduIog5pzJdHvBM}w{dm42l)Nx#T;gVQC$hIhu}vqrKyl*6jj9f&!dOGtGn?1KQO@` zP+6*fF8P1fU;LL8L?F-b&jZVU%PssHR3AS4KGGBT;gqNY5GcHExYb>lZvQUm6?yc3 zM-;e6fihQxlB0x>Fl4Fy38Y++EBwa-&W+*C!ee;K_GCYTxWB#^QV(?FEMUs!=Qa`= z*|J^s<`NAK^vsNWqR#4yT`GFvU3HG9T2j7@3#yA|R+H+0w4R5T#;gV{)gb@jS{Bj{+Nz9C)z}-F@C!Bj z-ltDCc;snxTdS1$h5U&H=-wm{d~O94tgiafn6tQF=hFCjEJ}sEzT`*P8eI7NH?v{v z#G#)(<&l-9lBAeCfW_QYpKX z*S>c8$TR)Y_;|erbp2$_`Y83kn{o@!pO6hM?n4PV%=kR!+RM4Q^x9g=Ki^q=-FGU3 z>7mH~nqS+LNczsNt&eJ)1@!AWIYv1{vXqj%V{LXgIZ{vfc!U6Kl6Rg5CkY2T{wJv^ zao(4>auwqpx6<$tF)6|gx}*C#E_+LSdCt4*X9z0NT}|`Rs{r|9Fya2fUXLT+7+4m% zYN5U3$*z}-v?)rDo3Nbb5fSM41)DLz=e5^h($!25&za;lWDAg!Que;IRO-2#tK>)A z_v2y4PdWRUvnX&H)<_Tvdd$`q9vJl0t&SDg^t8?0-(F$~Av(Gk+z~lwH;dXRCjXqn z65IAtcqTb)Cdoa(p8AA9`6WTp6x)J{s@3OL7(s#7AZ7VryLsz=A%%N~-ohKBHqGVf zH=c!zU0JMAV0?Z4^5lBKebuw1r4<56UAq2tp4|BAk|F+#kNWvpLubkzT*O#$_Y?u6 zNHx6uA)azFClXKN9N;Y}qO&JEHaII^7&>d-7VLEhgNm3Ii~y9gyRK- zJ$9D)tey<9#=RexF!pH}g&Ex;516Cce5y$o3cuUSmnXn;&fLnwc+@&{ZtMefLs-bW zsB51RiTGJzwJcGGw2nl9k+oa91W$r?qrWhY$!jxzwRIS8-`#zbS=<+BA2^c3?&w?2 zZBfIUyS0w&I}8P$KY33^;K(|G!6Ac!?+zl*iPkf>Y0>M&&@|{UHh5j0Qo&SUs%m(b@miB@ z>c~-s26-xrG8a@kk6ZxheZRfa@~ z=G(GAd!L(}_URu<%G;Vwl!;sol2M+ye{p-~og~-R?c3@jG;wjXEW7vU_lkQQH*@<_ zYJB}Eh%<$FqP}cQ{rcl6s>A`uO{d!$t6naL3kI84(r3%IzZ6Na_T+m{EL`qlnP6D| zYMxm1OLi#p!!_q;^|fd9mC|&$kduz7A9PdS)dp$)2JUmzU$q9etNSD`Hayd<^&Vkv zz{NH&H9XtPyJ^C9NAp z-yL@)oB+Jl$)7Ma&RU`~)kRVbA!`RS0V%n_B^a{TVJJ*iqC6oi8KbsbWJJGJoCnfc zq9oQeT_wd6?$(j1n%`-w4!vgVdl~5pNiprBzqHOUd9LNzv~#kXJ1Kdd4K$nF$Mmg~ z#w*Mbq%iUn4|hg{f~BO4+8sR9*9yC9C#DQG+RQ}bPK{R>_EKVnn5nJEV{>)SW_r!z z=KCbDgwxc^Q5B~}w-xKB{Mo}NVV*HWUUpA*hlAz{Lk79CR8$>zhaQ+Es`uyhJEfZjVsD;n{%u0)IogYhsKnc% zcW&Pz;@a}8di-}V7Er^1ziqD2q;h|Dmuop<$g|P0U@%i-cFU((@$MOWS510hZinU5 z)4YlliqfO4>uk&UUB|W*%^9VNPUB~XUK;l=-H&FybJb&S)n<5u@GL&fo!~q!=CS;7 ze=ElEC(nw0-bVckM>`U&=6fskPLO9x1Rj=0Wf#*^i8^|DDl$`!T=GPCr4M#hsk3*p z_noLMp3$>z>!)*K-P+2}*?$~vlfyTR>%La5-yr?$=6td=E=DmeyqfQNtJ5_*EOB;v zIaY17o#4||^;08JjJqyuW6@xXUqFG!=^jn}?ZsD&*VCmmLr&g4rb^z5Qo{nf=O4V% z$=!eOO3xfnD;~_eT0w}VN%pSMDQTzO=>mVg!1}rV9n%9Y*vqNoJnCiB$%cpG?46{~ zJab2p#H7|mgkLQz74|~{_DLs`RMf$xX`lML_=%kD>;5^0Z2aP06RiTARy%E5qK{5( zVNHj#4F`&?2Xaib3bUT{I@fQO->>K@>v0}ea^2LXF{|kb+m<(XbC9Jz<_?t5--tj7 z)d4{@poF^enJ*m^-&>LCZfW)=-Wn+75fz)K+X84MdBBZ^-*KNH)17A&-K5Z-Y(Mwa zQ6asG^~x2qkM8HhFJfyvx_ZqR!!i~1^FMVp2{1R!9r&~HD43{A$OZ;{&MpYU#L=X- zoTOH~JJXUY;}^iIx6|QnS6>UM8OX{6%ZAq1x3kDMUSc7Z)zE)n0Z!=pT;f7hxd<&| zK>w)xY4Kn^S8>M^BjhJUv(;I$Ye%9V*#kHy0&dTHm0fub-O2TL6jo-W2-E(nr6S14w z7lc4&-J-h|VYabwj4Bsfs7RQ<-t}vXK90LZ>rEdhq#7#(%i11#E+1lD=CX^1O*;$D zNzoc}eQ3{RYf)rZJ;TYHt0*0Q(x>DnYp>P0Lb?=&c*8q`IA_Mpfk*3AG#k}jwb;`h z+wtAMR#3V*op84ZiyJE`aJg3}ID?MHPMOwb-Ek^Tz7|WpwkWpq(gu(7_maf7w*MHd zv-YyV|MVW{Qn{bQV(y>0LWj|6)>(oojv8Wn*xS0xETIR7JKduc%;L!jUP(jI%=Mkt$%sH|8UViQ3(QQCPg~|v1@oGS ztRa@#%*f3}?6srcc%=leCR6Hpah~7EEb!%2JEl(wgOy4@OEwkiuwq+4Q*5o&(7diceQw3Di3lt=4&Ft2YKu!YEQiOp=SQ`}d`NSu@hnJjJHp z{Oho|#ThG4DaVbvN&1s7(^CEAtYn!f*!o*@J^Ow$!-UO?`Q~_wd2HgkLu>CHbIYt? zkNO8CGvqvRmc-|5tMwt{1iG5_aDpJf@2nGIGFk!$4HPQ-_4FiQvo7QASfFhO|8VS@mB_w2fjDLMcw$Tc?r>JUw-?9x z&2E9Mc_k?po@(wz7tLsK$H*Epy`CjEUlZwpD!IV;eQ&m@&&O6y^ZYA`@^5?pAW=>x z;Jh4uVo=&w(9jF_`m)fb$P`~>;^Jm{zDKvsMqqMbLem~#b$2Fpw=ic_<34igCtt2$g!d6Vo}4{birX? zMSl%b9S?irH_`mNZ?}>VTMcVdQ~rfV!zd{BVfDpUj2;8y-QeNECHG!T z9dA)z*JS-$LR}`lbtd(IsX;-Z@S``L0T%zfIe;Z^3_q05AAPb^tqUn2LV-9wbH%r5 z{v0Ru@I@F-dFg!rLOzV8^!ZLc{B8|4#QpC$yZhgH^XARpP#Sbwux@r(0U74Z>mPt& zxDR92v=kI37CHdvK{>udLi&Bk$6bDLlj4!*2SCK0I>{BB3cmUWn{XZ;`+stdF15Ke z38XJLX}^s|uGkLwI}z6-KqX9t>kVE1gNr2w3_&1CWw66Vx8>oYL9RC;_knua@US1j zE0)69c0Vzys;M=sFpKS4|L_shc>rv&3)g%Q79b4|MP6VOd*jf>zB91CB`}9Yej2L# z`Upy^dwY8U*+5uoOG=ip(b0@ykKl6WfD~qxIdhy}=jxqI8ck`)hO(!taLuX!JYf3f zF@w~po8Qmq2=xnoj{46Lfk3YG-;8xX@YhU- zVf-IR@cp-dxPL8Z&`JM~B#8c6qyCEHf6eayR|_*>Z~Fg-$xHTjTGT*Z_0db*7X3Ee z9pp1g;t#*;r;UH4zgGWFT4eddp7`V6Pb@CqRpn8r&_6orD}VD}|M;&4`)iP({+bx5 zzZL`3U#lky^`E;K#Qg2zTNLWZL)l+%KKw&f^{>$LpA)OuAaaw*$!o5?@Zl)Zz7(jX XWR*|+8wdVKP?x=}_-oFsN5B6cUL>#h literal 0 HcmV?d00001 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 005be58..d1162a6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,6 +6,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { themeStore } from './store/themeStore'; import router from './routes'; import ErrorBoundary from './components/ErrorBoundary'; +import './styles/index.scss'; const queryClient = new QueryClient({ defaultOptions: { diff --git a/frontend/src/components/layout/AppHeader.module.scss b/frontend/src/components/layout/AppHeader.module.scss index af5fda4..6986a13 100644 --- a/frontend/src/components/layout/AppHeader.module.scss +++ b/frontend/src/components/layout/AppHeader.module.scss @@ -1,10 +1,12 @@ +@use '../../styles/variables' as *; + .header { padding: 0 24px; display: flex; align-items: center; justify-content: space-between; - height: 56px; - line-height: 56px; + height: $header-height; + line-height: $header-height; position: sticky; top: 0; z-index: 99; @@ -12,8 +14,8 @@ .headerMinimal { padding: 0 24px; - height: 56px; - line-height: 56px; + height: $header-height; + line-height: $header-height; } .inboxList { diff --git a/frontend/src/components/layout/AppLayout.module.scss b/frontend/src/components/layout/AppLayout.module.scss index 5862371..8e08888 100644 --- a/frontend/src/components/layout/AppLayout.module.scss +++ b/frontend/src/components/layout/AppLayout.module.scss @@ -1,3 +1,5 @@ +@use '../../styles/variables' as *; + .sider { position: fixed; left: 0; @@ -7,7 +9,7 @@ } .siderHeader { - height: 56px; + height: $header-height; } .siderMenuWrapper { @@ -22,5 +24,5 @@ .content { padding: 0; - min-height: calc(100vh - 56px); + min-height: calc(100vh - $header-height); } diff --git a/frontend/src/pages/Auth.tsx b/frontend/src/pages/Auth.tsx index c88e174..97a91a6 100644 --- a/frontend/src/pages/Auth.tsx +++ b/frontend/src/pages/Auth.tsx @@ -1,18 +1,24 @@ import React from 'react'; import AuthForm from '../components/AuthForm'; import { observer } from 'mobx-react-lite'; -import { Flex } from 'antd'; +import { Flex, Layout } from 'antd'; +import AppHeader from '../components/layout/AppHeader'; const Auth: React.FC = observer(() => { return ( - - - + + + + + + + + ); }); diff --git a/frontend/src/styles/_reset.scss b/frontend/src/styles/_reset.scss new file mode 100644 index 0000000..d57ce6c --- /dev/null +++ b/frontend/src/styles/_reset.scss @@ -0,0 +1,73 @@ +// Modern CSS Reset (based on Andy Bell's modern reset) +// https://andy-bell.co.uk/a-more-modern-css-reset/ + +*, +*::before, +*::after { + box-sizing: border-box; +} + +* { + margin: 0; +} + +html { + -moz-text-size-adjust: none; + -webkit-text-size-adjust: none; + text-size-adjust: none; +} + +body { + min-height: 100vh; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + transition: background-color 0.3s; +} + +img, +picture, +video, +canvas, +svg { + display: block; + max-width: 100%; +} + +input, +button, +textarea, +select { + font: inherit; +} + +p, +h1, +h2, +h3, +h4, +h5, +h6 { + overflow-wrap: break-word; +} + +a { + color: inherit; + text-decoration: inherit; +} + +ul, +ol { + list-style: none; + padding: 0; +} + +table { + border-collapse: collapse; + border-spacing: 0; +} + +#root { + min-height: 100vh; + isolation: isolate; +} diff --git a/frontend/src/styles/_variables.scss b/frontend/src/styles/_variables.scss new file mode 100644 index 0000000..dfad798 --- /dev/null +++ b/frontend/src/styles/_variables.scss @@ -0,0 +1,4 @@ +// Layout dimensions +$header-height: 56px; +$sider-width: 240px; +$sider-collapsed-width: 80px; diff --git a/frontend/src/styles/index.scss b/frontend/src/styles/index.scss new file mode 100644 index 0000000..5cfeb30 --- /dev/null +++ b/frontend/src/styles/index.scss @@ -0,0 +1,2 @@ +@use 'reset'; +@use 'variables'; From c953db9159d731bdfdb0dcac59eb66f2dede0010 Mon Sep 17 00:00:00 2001 From: mvoof Date: Sun, 22 Mar 2026 22:24:12 +0500 Subject: [PATCH 38/48] refactor(frontend): decompose monolithic components into composable sub-components --- .../src/components/shared/NEREntityTags.tsx | 35 + .../shared/TranslationResultDisplay.tsx | 25 + .../src/components/workspace/VotingPanel.tsx | 58 +- frontend/src/pages/Dashboard.tsx | 489 ----------- frontend/src/pages/Dashboard/Dashboard.tsx | 232 +++++ .../src/pages/Dashboard/NewProjectModal.tsx | 111 +++ frontend/src/pages/Dashboard/ProjectCard.tsx | 147 ++++ frontend/src/pages/Dashboard/StatsCards.tsx | 88 ++ frontend/src/pages/Dashboard/index.ts | 1 + frontend/src/pages/ProjectReview.tsx | 57 +- frontend/src/pages/ProjectSettings.tsx | 816 ------------------ .../pages/ProjectSettings/DangerZoneTab.tsx | 55 ++ .../src/pages/ProjectSettings/GeneralTab.tsx | 300 +++++++ .../pages/ProjectSettings/ImportExportTab.tsx | 231 +++++ .../src/pages/ProjectSettings/MembersTab.tsx | 178 ++++ .../pages/ProjectSettings/ProjectSettings.tsx | 90 ++ frontend/src/pages/ProjectSettings/index.ts | 1 + frontend/src/pages/ProjectStats.tsx | 368 -------- frontend/src/pages/ProjectStats/KPICards.tsx | 71 ++ .../pages/ProjectStats/LabelDistribution.tsx | 65 ++ .../src/pages/ProjectStats/ProjectStats.tsx | 177 ++++ .../src/pages/ProjectStats/TimelineChart.tsx | 111 +++ frontend/src/pages/ProjectStats/index.ts | 1 + frontend/src/pages/Workspace.tsx | 554 ------------ .../src/pages/Workspace/EditorToolbar.tsx | 117 +++ .../src/pages/Workspace/TaskListPanel.tsx | 245 ++++++ frontend/src/pages/Workspace/Workspace.tsx | 251 ++++++ frontend/src/pages/Workspace/index.ts | 1 + 28 files changed, 2554 insertions(+), 2321 deletions(-) create mode 100644 frontend/src/components/shared/NEREntityTags.tsx create mode 100644 frontend/src/components/shared/TranslationResultDisplay.tsx delete mode 100644 frontend/src/pages/Dashboard.tsx create mode 100644 frontend/src/pages/Dashboard/Dashboard.tsx create mode 100644 frontend/src/pages/Dashboard/NewProjectModal.tsx create mode 100644 frontend/src/pages/Dashboard/ProjectCard.tsx create mode 100644 frontend/src/pages/Dashboard/StatsCards.tsx create mode 100644 frontend/src/pages/Dashboard/index.ts delete mode 100644 frontend/src/pages/ProjectSettings.tsx create mode 100644 frontend/src/pages/ProjectSettings/DangerZoneTab.tsx create mode 100644 frontend/src/pages/ProjectSettings/GeneralTab.tsx create mode 100644 frontend/src/pages/ProjectSettings/ImportExportTab.tsx create mode 100644 frontend/src/pages/ProjectSettings/MembersTab.tsx create mode 100644 frontend/src/pages/ProjectSettings/ProjectSettings.tsx create mode 100644 frontend/src/pages/ProjectSettings/index.ts delete mode 100644 frontend/src/pages/ProjectStats.tsx create mode 100644 frontend/src/pages/ProjectStats/KPICards.tsx create mode 100644 frontend/src/pages/ProjectStats/LabelDistribution.tsx create mode 100644 frontend/src/pages/ProjectStats/ProjectStats.tsx create mode 100644 frontend/src/pages/ProjectStats/TimelineChart.tsx create mode 100644 frontend/src/pages/ProjectStats/index.ts delete mode 100644 frontend/src/pages/Workspace.tsx create mode 100644 frontend/src/pages/Workspace/EditorToolbar.tsx create mode 100644 frontend/src/pages/Workspace/TaskListPanel.tsx create mode 100644 frontend/src/pages/Workspace/Workspace.tsx create mode 100644 frontend/src/pages/Workspace/index.ts diff --git a/frontend/src/components/shared/NEREntityTags.tsx b/frontend/src/components/shared/NEREntityTags.tsx new file mode 100644 index 0000000..ca7a7ed --- /dev/null +++ b/frontend/src/components/shared/NEREntityTags.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { observer } from 'mobx-react-lite'; +import { Typography, Tag } from 'antd'; +import type { NERAnnotationResult } from '../../types/api'; +import { LABEL_COLORS } from '../../types'; + +interface Props { + result: NERAnnotationResult; +} + +const NEREntityTags: React.FC = observer(({ result }) => { + const entities = result; + if (!Array.isArray(entities) || entities.length === 0) { + return ( + + No entities + + ); + } + return ( +
+ {entities.map((e, i) => ( + + {e.text} {e.label} + + ))} +
+ ); +}); + +export default NEREntityTags; diff --git a/frontend/src/components/shared/TranslationResultDisplay.tsx b/frontend/src/components/shared/TranslationResultDisplay.tsx new file mode 100644 index 0000000..61d80df --- /dev/null +++ b/frontend/src/components/shared/TranslationResultDisplay.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { observer } from 'mobx-react-lite'; +import { Typography, Tag } from 'antd'; +import type { TranslationAnnotationResult } from '../../types/api'; + +interface Props { + result: TranslationAnnotationResult; +} + +const TranslationResultDisplay: React.FC = observer(({ result }) => { + return ( +
+ {Object.entries(result).map(([lang, text]) => ( +
+ + {lang} + + {text} +
+ ))} +
+ ); +}); + +export default TranslationResultDisplay; diff --git a/frontend/src/components/workspace/VotingPanel.tsx b/frontend/src/components/workspace/VotingPanel.tsx index 50821c9..3db8a2f 100644 --- a/frontend/src/components/workspace/VotingPanel.tsx +++ b/frontend/src/components/workspace/VotingPanel.tsx @@ -28,7 +28,9 @@ import type { ProjectType, VoteValue, } from '../../types/api'; -import { LABEL_COLORS, annotationStatusColor, ProjectTypes } from '../../types'; +import { annotationStatusColor, ProjectTypes } from '../../types'; +import NEREntityTags from '../shared/NEREntityTags'; +import TranslationResultDisplay from '../shared/TranslationResultDisplay'; interface Props { taskId: number; @@ -70,48 +72,6 @@ const VotingPanel: React.FC = observer(({ taskId, projectType }) => { if (isLoading) return ; if (annotations.length <= 1) return null; - const renderNERResult = (result: NERAnnotationResult) => { - const entities = result; - if (!Array.isArray(entities) || entities.length === 0) { - return ( - - No entities - - ); - } - return ( -
- {entities.map((e, i) => ( - - {e.text} {e.label} - - ))} -
- ); - }; - - const renderTranslationResult = (result: TranslationAnnotationResult) => { - const translations = result; - return ( -
- {Object.entries(translations).map(([lang, text]) => ( -
- - {lang} - - {text} -
- ))} -
- ); - }; - return (
= observer(({ taskId, projectType }) => { {/* Content */}
- {projectType === ProjectTypes.NER - ? renderNERResult(ann.result as NERAnnotationResult) - : renderTranslationResult( - ann.result as TranslationAnnotationResult - )} + {projectType === ProjectTypes.NER ? ( + + ) : ( + + )}
{/* Review note */} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx deleted file mode 100644 index 57db186..0000000 --- a/frontend/src/pages/Dashboard.tsx +++ /dev/null @@ -1,489 +0,0 @@ -import { useState } from 'react'; -import { observer } from 'mobx-react-lite'; -import { useNavigate } from 'react-router'; -import { - Typography, - Button, - Card, - Row, - Col, - Tag, - Modal, - Form, - Input, - Select, - Skeleton, - Empty, - Statistic, - Space, - Progress, - Tooltip, - Avatar, - Flex, -} from 'antd'; -import { - PlusOutlined, - FolderOutlined, - SettingOutlined, - BarChartOutlined, - AppstoreOutlined, - SearchOutlined, -} from '@ant-design/icons'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { authStore } from '../store/authStore'; -import api from '../api/client'; -import type { - Project, - ProjectType, - OverviewStats, - ProjectStats, -} from '../types/api'; -import { projectTypeColor, ProjectRoles, ProjectTypes } from '../types'; -import { LANGUAGE_OPTIONS } from '../constants/languages'; -import { motion } from 'framer-motion'; -import { theme } from 'antd'; - -const Dashboard: React.FC = observer(() => { - const { token: themeToken } = theme.useToken(); - const navigate = useNavigate(); - const queryClient = useQueryClient(); - const [modalOpen, setModalOpen] = useState(false); - const [form] = Form.useForm(); - const [searchText, setSearchText] = useState(''); - const [typeFilter, setTypeFilter] = useState(''); - - const { data: projects = [], isLoading } = useQuery({ - queryKey: ['projects'], - queryFn: () => api.get('/projects').then((r) => r.data), - }); - - const { data: overview } = useQuery({ - queryKey: ['overview-stats'], - queryFn: () => - api.get('/overview-stats').then((r) => r.data), - }); - - // Fetch stats for each project for progress bars - const { data: projectStats = {} } = useQuery>({ - queryKey: ['all-project-stats', projects.map((p) => p.id).join(',')], - queryFn: async () => { - const stats: Record = {}; - await Promise.all( - projects.map(async (p) => { - try { - const res = await api.get( - `/projects/${String(p.id)}/stats` - ); - stats[p.id] = res.data; - } catch { - // ignore - } - }) - ); - return stats; - }, - enabled: projects.length > 0, - staleTime: 30_000, - }); - - const createProject = useMutation({ - mutationFn: (values: { - name: string; - type: ProjectType; - source_language?: string; - target_languages?: string[]; - }) => { - const { source_language, target_languages, ...rest } = values; - const config: Record = {}; - if (source_language) config.source_language = source_language; - if (target_languages?.length) config.target_languages = target_languages; - return api.post('/projects', { ...rest, config }); - }, - onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: ['projects'] }); - setModalOpen(false); - form.resetFields(); - }, - }); - - const filteredProjects = projects.filter((p) => { - if (searchText && !p.name.toLowerCase().includes(searchText.toLowerCase())) - return false; - if (typeFilter && p.type !== typeFilter) return false; - return true; - }); - - const totalTasks = Object.values(projectStats).reduce( - (sum, s) => sum + s.total_tasks, - 0 - ); - const totalPending = Object.values(projectStats).reduce( - (sum, s) => sum + s.pending_review, - 0 - ); - - // Find the project with the most pending work for "Continue" button - const lastActiveProject = - projects.length > 0 - ? projects.reduce((best, p) => { - const s = (projectStats as Record)[ - p.id - ]; - const bS = (projectStats as Record)[ - best.id - ]; - if (!s) return best; - if (!bS) return p; - if ( - s.total_tasks - s.approved_tasks > - bS.total_tasks - bS.approved_tasks - ) - return p; - return best; - }) - : null; - - return ( - - {/* Continue button */} - {lastActiveProject && ( - - )} - - {/* Personal metrics */} - {overview && ( - - {[ - ...(authStore.isAdmin - ? [ - { - title: 'Users', - value: overview.total_users, - color: themeToken.colorPrimary, - }, - ] - : []), - { - title: 'Projects', - value: projects.length, - color: themeToken.colorInfo, - }, - { - title: 'Tasks', - value: totalTasks, - color: themeToken.colorSuccess, - }, - { - title: 'Pending Review', - value: totalPending, - color: themeToken.colorWarning, - }, - ].map((stat, i) => ( -
- - - - {stat.title} - - } - value={stat.value} - styles={{ content: { fontWeight: 700 } }} - /> - - - - ))} - - )} - - {/* Header with search and filters */} - - - Projects - - - } - placeholder="Search projects..." - value={searchText} - onChange={(e) => setSearchText(e.target.value)} - allowClear - style={{ width: 200 }} - /> - - - - - - - } + placeholder="Search projects..." + value={searchText} + onChange={(e) => setSearchText(e.target.value)} + allowClear + style={{ width: 200 }} + /> + + + + + + + - void updateProjectConfig({ - ...project.config, - source_language: val, - }) - } - options={LANGUAGE_OPTIONS} - /> - -
- - Target Languages - - - setAutoApproveThreshold(parseInt(e.target.value) || 0) - } - style={{ width: 80 }} - size="small" - /> - - upvotes to auto-approve - - - - - - {/* Label config (NER projects) */} - {project?.type === ProjectTypes.NER && ( - - {canManage && ( -
- setNewLabel(e.target.value)} - onPressEnter={handleAddLabel} - style={{ width: 160 }} - /> - -
- )} -
- {(project.config.labels ?? []).map( - (label: string, idx: number) => ( -
- - {label} - - - Hotkey: {idx + 1 <= 9 ? idx + 1 : '-'} - - {canManage && ( - <> -
- ) - )} -
- {(project.config.labels ?? []).length === 0 && ( - - No labels configured. Default labels will be used. - - )} -
- )} -
- ), - }, - - // --- Import/Export (Manager/Admin only) --- - ...(canManage - ? [ - { - key: 'import-export', - label: 'Import / Export', - children: ( -
- - - Upload INI files for Translation or JSON for NER. - - {projectRoleStore.isManager && !authStore.isAdmin && ( - - Note: As a Manager, your imports will require admin - approval before being applied. - - )} - setReplaceOnImport(e.target.checked)} - style={{ marginBottom: 12 }} - > - Replace existing tasks - - -

- -

-

- Click or drag file to upload -

-
- {uploading && ( - - )} - - {/* Import preview */} - projectSettingsStore.clearImportPreview()} - onOk={() => void handleConfirmImport()} - okText={`Import ${String(importPreview?.total_tasks ?? 0)} tasks`} - confirmLoading={uploading} - > - {importPreview && ( -
- - File: {importPreview.filename} - - - - {importPreview.total_tasks} tasks - found - - {importPreview.with_entities > 0 && ( - - {importPreview.with_entities} tasks with entities - ({importPreview.entities_count} total) - - )} - - - Sample (first{' '} - {Math.min(10, importPreview.sample.length)}): - -
- {importPreview.sample.map((s, i) => ( -
- {s.id && ( - - {s.id} - - )} - {s.key && ( - - {s.key} - - )} - - {s.text} - - {(s.entities_count ?? 0) > 0 && ( - - {s.entities_count} entities - - )} -
- ))} -
- setReplaceOnImport(e.target.checked)} - style={{ marginTop: 12 }} - > - Replace existing tasks - -
- )} -
-
- - - Download approved annotations for VerseBridge training. - - - - - - -
- ), - }, - ] - : []), - - // --- Members (Manager/Admin only) --- - ...(canManage - ? [ - { - key: 'members', - label: 'Members', - children: ( - <> -
- -
-
v ?? '\u2014', - }, - { title: 'Email', dataIndex: 'email' }, - { - title: 'Role', - dataIndex: 'role', - render: (_value: RoleProject, record: ProjectMember) => { - const isTargetManager = - record.role === ProjectRoles.MANAGER; - const locked = isTargetManager && !authStore.isAdmin; - return ( - ({ - key: u.id, - value: u.id, - label: `${u.full_name ?? u.email} (${u.email})`, - }) - )} - /> - - - + void updateProjectConfig({ + ...project.config, + source_language: val, + }) + } + options={LANGUAGE_OPTIONS} + /> + +
+ + Target Languages + + + setAutoApproveThreshold(parseInt(e.target.value) || 0) + } + style={{ width: 80 }} + size="small" + /> + + upvotes to auto-approve + + + + + + {/* Label config (NER projects) */} + {project.type === ProjectTypes.NER && ( + + {canManage && ( +
+ setNewLabel(e.target.value)} + onPressEnter={handleAddLabel} + style={{ width: 160 }} + /> + +
+ )} +
+ {(project.config.labels ?? []).map( + (label: string, idx: number) => ( +
+ + {label} + + + Hotkey: {idx + 1 <= 9 ? idx + 1 : '-'} + + {canManage && ( + <> +
+ ) + )} +
+ {(project.config.labels ?? []).length === 0 && ( + + No labels configured. Default labels will be used. + + )} +
+ )} +
+ ); + } +); + +export default GeneralTab; diff --git a/frontend/src/pages/ProjectSettings/ImportExportTab.tsx b/frontend/src/pages/ProjectSettings/ImportExportTab.tsx new file mode 100644 index 0000000..3dc81a1 --- /dev/null +++ b/frontend/src/pages/ProjectSettings/ImportExportTab.tsx @@ -0,0 +1,231 @@ +import React, { useState } from 'react'; +import { + Typography, + Card, + Button, + Upload, + Progress, + Tag, + Space, + Modal, + Checkbox, + App, + theme, +} from 'antd'; +import { CloudUploadOutlined, DownloadOutlined } from '@ant-design/icons'; +import { observer } from 'mobx-react-lite'; +import { authStore } from '../../store/authStore'; +import { projectRoleStore } from '../../store/projectRoleStore'; +import { projectSettingsStore } from '../../store/projectSettingsStore'; + +interface Props { + projectId: string; +} + +const ImportExportTab: React.FC = observer(({ projectId }) => { + const { notification } = App.useApp(); + const { token: themeToken } = theme.useToken(); + const [replaceOnImport, setReplaceOnImport] = useState(false); + + const { importPreview, uploading } = projectSettingsStore; + + const handleFilePreview = async (file: File) => { + try { + await projectSettingsStore.importPreviewFile(projectId, file); + } catch (err) { + const errObj = err as { response?: { data?: { detail?: string } } }; + const detail = errObj.response?.data?.detail; + notification.error({ message: detail ?? 'Failed to preview file' }); + } + return false; + }; + + const handleConfirmImport = async () => { + try { + const count = await projectSettingsStore.confirmImport( + projectId, + replaceOnImport + ); + notification.success({ + message: `Import successful! ${String(count)} tasks imported.`, + }); + } catch (err) { + const errObj = err as { response?: { data?: { detail?: string } } }; + const detail = errObj.response?.data?.detail; + notification.error({ message: detail ?? 'Import failed' }); + } + }; + + const handleExport = async (format: 'json' | 'ini') => { + try { + await projectSettingsStore.exportData(projectId, format); + } catch { + notification.error({ message: 'Export failed' }); + } + }; + + return ( +
+ + + Upload INI files for Translation or JSON for NER. + + {projectRoleStore.isManager && !authStore.isAdmin && ( + + Note: As a Manager, your imports will require admin approval before + being applied. + + )} + setReplaceOnImport(e.target.checked)} + style={{ marginBottom: 12 }} + > + Replace existing tasks + + +

+ +

+

Click or drag file to upload

+
+ {uploading && ( + + )} + + {/* Import preview */} + projectSettingsStore.clearImportPreview()} + onOk={() => void handleConfirmImport()} + okText={`Import ${String(importPreview?.total_tasks ?? 0)} tasks`} + confirmLoading={uploading} + > + {importPreview && ( +
+ + File: {importPreview.filename} + + + + {importPreview.total_tasks} tasks found + + {importPreview.with_entities > 0 && ( + + {importPreview.with_entities} tasks with entities ( + {importPreview.entities_count} total) + + )} + + + Sample (first {Math.min(10, importPreview.sample.length)}): + +
+ {importPreview.sample.map((s, i) => ( +
+ {s.id && ( + + {s.id} + + )} + {s.key && ( + + {s.key} + + )} + + {s.text} + + {(s.entities_count ?? 0) > 0 && ( + + {s.entities_count} entities + + )} +
+ ))} +
+ setReplaceOnImport(e.target.checked)} + style={{ marginTop: 12 }} + > + Replace existing tasks + +
+ )} +
+
+ + + Download approved annotations for VerseBridge training. + + + + + + +
+ ); +}); + +export default ImportExportTab; diff --git a/frontend/src/pages/ProjectSettings/MembersTab.tsx b/frontend/src/pages/ProjectSettings/MembersTab.tsx new file mode 100644 index 0000000..2044410 --- /dev/null +++ b/frontend/src/pages/ProjectSettings/MembersTab.tsx @@ -0,0 +1,178 @@ +import React, { useState } from 'react'; +import { Button, Table, Modal, Form, Select, Popconfirm, App } from 'antd'; +import { DeleteOutlined, PlusOutlined } from '@ant-design/icons'; +import { observer } from 'mobx-react-lite'; +import { authStore } from '../../store/authStore'; +import { projectSettingsStore } from '../../store/projectSettingsStore'; +import type { ProjectMember, RoleProject } from '../../types/api'; +import { ProjectRoles } from '../../types'; + +interface Props { + projectId: string; +} + +const MembersTab: React.FC = observer(({ projectId }) => { + const { notification } = App.useApp(); + const [openAddMember, setOpenAddMember] = useState(false); + const [addForm] = Form.useForm(); + + const handleAddMember = async (values: { + user_id: number; + role: RoleProject; + }) => { + try { + await projectSettingsStore.addMember( + projectId, + values.user_id, + values.role + ); + setOpenAddMember(false); + addForm.resetFields(); + } catch { + notification.error({ message: 'Failed to add member' }); + } + }; + + const handleRemoveMember = async (userId: number) => { + try { + await projectSettingsStore.removeMember(projectId, userId); + } catch { + notification.error({ message: 'Failed to remove member' }); + } + }; + + const handleChangeRole = async (userId: number, role: RoleProject) => { + try { + await projectSettingsStore.changeRole(projectId, userId, role); + } catch { + notification.error({ message: 'Failed to update role' }); + } + }; + + return ( + <> +
+ +
+
v ?? '\u2014', + }, + { title: 'Email', dataIndex: 'email' }, + { + title: 'Role', + dataIndex: 'role', + render: (_value: RoleProject, record: ProjectMember) => { + const isTargetManager = record.role === ProjectRoles.MANAGER; + const locked = isTargetManager && !authStore.isAdmin; + return ( + ({ + key: u.id, + value: u.id, + label: `${u.full_name ?? u.email} (${u.email})`, + }))} + /> + + +
- r.full_name ?? r.email, - }, - { - title: 'Total', - dataIndex: 'total_annotations', - width: 60, - align: 'center', - }, - { - title: 'Status', - width: 180, - render: (_value: string | undefined, r: AnnotatorStats) => ( - <> - {r.approved} - {r.rejected} - {r.pending} - - ), - }, - ]} - /> - - - - - {/* Timeline chart (simple CSS bar chart) */} - {timeline.length > 0 && ( - -
- {timeline.map((entry) => ( -
-
-
0 ? 2 : 0, - }} - /> -
0 ? 2 : 0, - }} - /> -
- - {entry.date.slice(5)} - -
- ))} -
-
-
-
- - Approved - -
-
-
- - Other - -
-
- - )} - - {/* Label distribution (NER) */} - {labelStats.length > 0 && ( - -
- {labelStats.map((entry) => { - const maxCount = labelStats[0]?.count ?? 1; - return ( -
- - {entry.label} - -
-
-
- - {entry.count} - -
- ); - })} -
- - )} -
- ); -}); - -export default ProjectStats; diff --git a/frontend/src/pages/ProjectStats/KPICards.tsx b/frontend/src/pages/ProjectStats/KPICards.tsx new file mode 100644 index 0000000..b474b11 --- /dev/null +++ b/frontend/src/pages/ProjectStats/KPICards.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import { observer } from 'mobx-react-lite'; +import { Card, Row, Col, Statistic, theme } from 'antd'; +import { + CheckCircleOutlined, + CloseCircleOutlined, + ClockCircleOutlined, + FileTextOutlined, +} from '@ant-design/icons'; +import { motion } from 'framer-motion'; +import type { ProjectStats } from '../../types/api'; + +interface Props { + stats: ProjectStats; +} + +const KPICards: React.FC = observer(({ stats }) => { + const { token: themeToken } = theme.useToken(); + + const kpis = [ + { + title: 'Total Tasks', + value: stats.total_tasks, + icon: , + color: themeToken.colorPrimary, + }, + { + title: 'Approved', + value: stats.approved_tasks, + icon: , + color: themeToken.colorSuccess, + }, + { + title: 'Rejected', + value: stats.rejected_tasks, + icon: , + color: themeToken.colorError, + }, + { + title: 'Pending Review', + value: stats.pending_review, + icon: , + color: themeToken.colorWarning, + }, + ]; + + return ( + + {kpis.map((kpi, i) => ( +
+ + + {kpi.icon}} + styles={{ content: { fontWeight: 700 } }} + /> + + + + ))} + + ); +}); + +export default KPICards; diff --git a/frontend/src/pages/ProjectStats/LabelDistribution.tsx b/frontend/src/pages/ProjectStats/LabelDistribution.tsx new file mode 100644 index 0000000..42ef1eb --- /dev/null +++ b/frontend/src/pages/ProjectStats/LabelDistribution.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { observer } from 'mobx-react-lite'; +import { Typography, Card, Tag, theme } from 'antd'; +import { LABEL_COLORS } from '../../types'; + +interface LabelEntry { + label: string; + count: number; +} + +interface Props { + labelStats: LabelEntry[]; +} + +const LabelDistribution: React.FC = observer(({ labelStats }) => { + const { token: themeToken } = theme.useToken(); + const maxCount = labelStats[0]?.count ?? 1; + + return ( + +
+ {labelStats.map((entry) => ( +
+ + {entry.label} + +
+
+
+ + {entry.count} + +
+ ))} +
+ + ); +}); + +export default LabelDistribution; diff --git a/frontend/src/pages/ProjectStats/ProjectStats.tsx b/frontend/src/pages/ProjectStats/ProjectStats.tsx new file mode 100644 index 0000000..9f875e9 --- /dev/null +++ b/frontend/src/pages/ProjectStats/ProjectStats.tsx @@ -0,0 +1,177 @@ +import React from 'react'; +import { observer } from 'mobx-react-lite'; +import { useParams } from 'react-router'; +import { + Typography, + Card, + Row, + Col, + Progress, + Table, + Tag, + Skeleton, + theme, +} from 'antd'; +import { useQuery } from '@tanstack/react-query'; +import api from '../../api/client'; +import type { + ProjectStats as ProjectStatsType, + AnnotatorStats, +} from '../../types/api'; +import KPICards from './KPICards'; +import TimelineChart from './TimelineChart'; +import LabelDistribution from './LabelDistribution'; + +interface TimelineEntry { + date: string; + total: number; + approved: number; + rejected: number; +} + +interface LabelEntry { + label: string; + count: number; +} + +const ProjectStats: React.FC = observer(() => { + const { projectId } = useParams(); + const { token: themeToken } = theme.useToken(); + + const { data: stats, isLoading } = useQuery({ + queryKey: ['project-stats', projectId], + queryFn: () => + api + .get(`/projects/${String(projectId)}/stats`) + .then((r) => r.data), + }); + + const { data: annotatorStats = [] } = useQuery({ + queryKey: ['annotator-stats', projectId], + queryFn: () => + api + .get(`/projects/${String(projectId)}/annotator-stats`) + .then((r) => r.data), + }); + + const { data: timeline = [] } = useQuery({ + queryKey: ['stats-timeline', projectId], + queryFn: () => + api + .get(`/projects/${String(projectId)}/stats/timeline`) + .then((r) => r.data), + }); + + const { data: labelStats = [] } = useQuery({ + queryKey: ['stats-labels', projectId], + queryFn: () => + api + .get(`/projects/${String(projectId)}/stats/labels`) + .then((r) => r.data), + }); + + if (isLoading) { + return ; + } + + if (!stats) return null; + + // ETA calculation + const remainingTasks = stats.total_tasks - stats.approved_tasks; + const recentDays = timeline.filter((t) => t.approved > 0); + const avgApprovedPerDay = + recentDays.length > 0 + ? recentDays.reduce((sum, t) => sum + t.approved, 0) / recentDays.length + : 0; + const etaDays = + avgApprovedPerDay > 0 + ? Math.ceil(remainingTasks / avgApprovedPerDay) + : null; + + return ( +
+ + Project Statistics + + + + + +
+ + `${String(pct)}%`} + strokeColor={themeToken.colorPrimary} + size={160} + /> +
+ + {stats.annotated_tasks} of {stats.total_tasks} tasks annotated + +
+ + Avg. {stats.avg_annotations_per_task} annotations per task + + {etaDays !== null && ( + <> +
+ + ETA: ~{etaDays} day{etaDays !== 1 ? 's' : ''} remaining + + + ({avgApprovedPerDay.toFixed(1)} approved/day avg) + + + )} +
+
+ +
+ +
+ r.full_name ?? r.email, + }, + { + title: 'Total', + dataIndex: 'total_annotations', + width: 60, + align: 'center', + }, + { + title: 'Status', + width: 180, + render: (_value: string | undefined, r: AnnotatorStats) => ( + <> + {r.approved} + {r.rejected} + {r.pending} + + ), + }, + ]} + /> + + + + + {timeline.length > 0 && } + + {labelStats.length > 0 && } + + ); +}); + +export default ProjectStats; diff --git a/frontend/src/pages/ProjectStats/TimelineChart.tsx b/frontend/src/pages/ProjectStats/TimelineChart.tsx new file mode 100644 index 0000000..dbe0885 --- /dev/null +++ b/frontend/src/pages/ProjectStats/TimelineChart.tsx @@ -0,0 +1,111 @@ +import React from 'react'; +import { observer } from 'mobx-react-lite'; +import { Typography, Card, theme } from 'antd'; + +interface TimelineEntry { + date: string; + total: number; + approved: number; + rejected: number; +} + +interface Props { + timeline: TimelineEntry[]; +} + +const TimelineChart: React.FC = observer(({ timeline }) => { + const { token: themeToken } = theme.useToken(); + const timelineMax = Math.max(...timeline.map((t) => t.total), 1); + + return ( + +
+ {timeline.map((entry) => ( +
+
+
0 ? 2 : 0, + }} + /> +
0 ? 2 : 0, + }} + /> +
+ + {entry.date.slice(5)} + +
+ ))} +
+
+
+
+ + Approved + +
+
+
+ + Other + +
+
+ + ); +}); + +export default TimelineChart; diff --git a/frontend/src/pages/ProjectStats/index.ts b/frontend/src/pages/ProjectStats/index.ts new file mode 100644 index 0000000..ac05817 --- /dev/null +++ b/frontend/src/pages/ProjectStats/index.ts @@ -0,0 +1 @@ +export { default } from './ProjectStats'; diff --git a/frontend/src/pages/Workspace.tsx b/frontend/src/pages/Workspace.tsx deleted file mode 100644 index d1bdb69..0000000 --- a/frontend/src/pages/Workspace.tsx +++ /dev/null @@ -1,554 +0,0 @@ -import { useEffect, useState } from 'react'; -import { observer } from 'mobx-react-lite'; -import { useParams } from 'react-router'; -import { - Card, - Spin, - Empty, - Input, - Select, - Tag, - Badge, - Button, - Space, - Tooltip, - Pagination, - theme, - Drawer, -} from 'antd'; -import { - SearchOutlined, - LeftOutlined, - RightOutlined, - MenuOutlined, - InfoCircleOutlined, -} from '@ant-design/icons'; - -const STATUS_STYLES: Record = { - success: { color: '#52c41a', bg: '#f6ffed' }, - processing: { color: '#1677ff', bg: '#e6f4ff' }, - default: { color: '#8c8c8c', bg: '#fafafa' }, -}; -import { useQuery, useQueryClient } from '@tanstack/react-query'; -import TranslationEditor from '../components/editors/TranslationEditor'; -import NEREditor from '../components/editors/NEREditor'; -import ContextPanel from '../components/workspace/ContextPanel'; -import VotingPanel from '../components/workspace/VotingPanel'; -import { usePresence } from '../hooks/usePresence'; -import api from '../api/client'; -import { - isProject, - getTaskText, - type NERAnnotationResult, - type TranslationAnnotationResult, - type Project, - type TaskListItem, - type TaskListResponse, -} from '../types/api'; -import { ProjectTypes, projectTypeColor, taskStatus } from '../types'; -import { workspaceStore } from '../store/workspaceStore'; - -const Workspace: React.FC = observer(() => { - const { projectId } = useParams(); - const { token: themeToken } = theme.useToken(); - const queryClient = useQueryClient(); - - const [taskListOpen, setTaskListOpen] = useState(true); - const [contextOpen, setContextOpen] = useState(true); - const pageSize = 50; - const { - users: presenceUsers, - sendEditing, - sendSubmitted, - } = usePresence(projectId); - - // Responsive - const [isNarrow, setIsNarrow] = useState(window.innerWidth < 1024); - useEffect(() => { - const handler = () => setIsNarrow(window.innerWidth < 1024); - window.addEventListener('resize', handler); - return () => window.removeEventListener('resize', handler); - }, []); - - // Reset store on unmount - useEffect(() => { - return () => workspaceStore.reset(); - }, []); - - // Fetch project - const { data: project } = useQuery({ - queryKey: ['project', projectId], - queryFn: async () => { - const res = await api.get(`/projects/${String(projectId)}`); - if (!isProject(res.data)) throw new Error('Invalid project'); - return res.data; - }, - }); - - // Fetch tasks - const { data: tasksData, isLoading: tasksLoading } = - useQuery({ - queryKey: ['workspace-tasks', projectId, workspaceStore.page], - queryFn: () => - api - .get( - `/tasks/projects/${String(projectId)}/tasks?page=${String(workspaceStore.page)}&page_size=${String(pageSize)}` - ) - .then((r) => r.data), - enabled: !!projectId, - }); - - // Bridge React Query data to store - useEffect(() => { - if (tasksData) { - workspaceStore.setTasks(tasksData.items, tasksData.total); - } - }, [tasksData]); - - // Auto-select first task - useEffect(() => { - workspaceStore.autoSelectFirst(); - // eslint-disable-next-line react-hooks/exhaustive-deps -- MobX observer handles reactivity - }, [workspaceStore.filteredTasks.length, workspaceStore.selectedTaskId]); - - // Send presence when editing a task - useEffect(() => { - if (workspaceStore.selectedTaskId) - sendEditing(workspaceStore.selectedTaskId); - // eslint-disable-next-line react-hooks/exhaustive-deps -- MobX observer handles reactivity - }, [workspaceStore.selectedTaskId, sendEditing]); - - // Keyboard navigation - useEffect(() => { - const handler = (e: KeyboardEvent) => { - if ( - e.target instanceof HTMLInputElement || - e.target instanceof HTMLTextAreaElement - ) - return; - if (e.key === 'ArrowUp' || (e.key === 'k' && !e.ctrlKey)) { - e.preventDefault(); - workspaceStore.selectPrev(); - } else if (e.key === 'ArrowDown' || (e.key === 'j' && !e.ctrlKey)) { - e.preventDefault(); - workspaceStore.selectNext(); - } - }; - window.addEventListener('keydown', handler); - return () => window.removeEventListener('keydown', handler); - }); - - const handleSubmit = async ( - result: NERAnnotationResult | TranslationAnnotationResult - ) => { - await workspaceStore.submitAnnotation( - result, - queryClient, - String(projectId), - sendSubmitted - ); - }; - - const getTaskPreview = (t: TaskListItem) => { - return getTaskText(t.data) || JSON.stringify(t.data).slice(0, 80); - }; - - if (!project) { - return
Loading...
; - } - - const { filteredTasks, selectedTask, completedCount, progressPercent } = - workspaceStore; - - // --- Task List Panel --- - const taskListPanel = ( -
- {/* Progress bar */} -
-
- - {completedCount} / {workspaceStore.totalTasks} - - - {progressPercent}% - -
-
-
-
-
- - {/* Search & Filter */} -
- } - value={workspaceStore.search} - onChange={(e) => workspaceStore.setSearch(e.target.value)} - allowClear - style={{ flex: 1 }} - /> - } + value={workspaceStore.search} + onChange={(e) => workspaceStore.setSearch(e.target.value)} + allowClear + style={{ flex: 1 }} + /> + } @@ -157,6 +169,7 @@ const Dashboard: React.FC = observer(() => { allowClear style={{ width: 200 }} /> + + + @@ -94,6 +95,7 @@ const Profile: React.FC = observer(() => { style={{ marginBottom: 16 }} /> )} +
{ > + { > + diff --git a/frontend/src/pages/ProjectGlossary.tsx b/frontend/src/pages/ProjectGlossary.tsx index af038ac..011fa8d 100644 --- a/frontend/src/pages/ProjectGlossary.tsx +++ b/frontend/src/pages/ProjectGlossary.tsx @@ -45,6 +45,7 @@ const ProjectGlossary: React.FC = observer(() => { const translationsMap: Record = {}; (values.translations || '').split('\n').forEach((line: string) => { const [lang, ...rest] = line.split(':'); + if (lang && rest.length > 0) translationsMap[lang.trim()] = rest.join(':').trim(); }); @@ -98,6 +99,7 @@ const ProjectGlossary: React.FC = observer(() => { Glossary +
{ disabled={!!editingTerm} /> + { rows={3} /> + diff --git a/frontend/src/pages/ProjectReview.tsx b/frontend/src/pages/ProjectReview.tsx index b585720..95fea31 100644 --- a/frontend/src/pages/ProjectReview.tsx +++ b/frontend/src/pages/ProjectReview.tsx @@ -137,16 +137,19 @@ const ProjectReview: React.FC = observer(() => { { value: 'REJECTED', label: 'Rejected' }, ]} /> + {reviewStore.annotationsTotal} annotation {reviewStore.annotationsTotal !== 1 ? 's' : ''} + {reviewStore.selectedRowKeys.length > 0 && ( {reviewStore.selectedRowKeys.length} selected + +
{ > Source text: + {getTaskText(record.task_data)} @@ -309,6 +315,7 @@ const ProjectReview: React.FC = observer(() => { size="small" /> + )} +
{(project.config.labels ?? []).map( (label: string, idx: number) => ( @@ -241,12 +262,14 @@ const GeneralTab: React.FC = observer( {label} + Hotkey: {idx + 1 <= 9 ? idx + 1 : '-'} + {canManage && ( <>
+ {(project.config.labels ?? []).length === 0 && ( No labels configured. Default labels will be used. diff --git a/frontend/src/pages/ProjectSettings/ImportExportTab.tsx b/frontend/src/pages/ProjectSettings/ImportExportTab.tsx index 3dc81a1..eae2617 100644 --- a/frontend/src/pages/ProjectSettings/ImportExportTab.tsx +++ b/frontend/src/pages/ProjectSettings/ImportExportTab.tsx @@ -35,8 +35,10 @@ const ImportExportTab: React.FC = observer(({ projectId }) => { } catch (err) { const errObj = err as { response?: { data?: { detail?: string } } }; const detail = errObj.response?.data?.detail; + notification.error({ message: detail ?? 'Failed to preview file' }); } + return false; }; @@ -46,12 +48,14 @@ const ImportExportTab: React.FC = observer(({ projectId }) => { projectId, replaceOnImport ); + notification.success({ message: `Import successful! ${String(count)} tasks imported.`, }); } catch (err) { const errObj = err as { response?: { data?: { detail?: string } } }; const detail = errObj.response?.data?.detail; + notification.error({ message: detail ?? 'Import failed' }); } }; @@ -73,6 +77,7 @@ const ImportExportTab: React.FC = observer(({ projectId }) => { > Upload INI files for Translation or JSON for NER. + {projectRoleStore.isManager && !authStore.isAdmin && ( = observer(({ projectId }) => { being applied. )} + setReplaceOnImport(e.target.checked)} @@ -93,6 +99,7 @@ const ImportExportTab: React.FC = observer(({ projectId }) => { > Replace existing tasks + = observer(({ projectId }) => {

Click or drag file to upload

+ {uploading && ( )} @@ -124,6 +132,7 @@ const ImportExportTab: React.FC = observer(({ projectId }) => { > File: {importPreview.filename} + = observer(({ projectId }) => { {importPreview.total_tasks} tasks found + {importPreview.with_entities > 0 && ( {importPreview.with_entities} tasks with entities ( @@ -138,6 +148,7 @@ const ImportExportTab: React.FC = observer(({ projectId }) => { )} + = observer(({ projectId }) => { > Sample (first {Math.min(10, importPreview.sample.length)}): +
{importPreview.sample.map((s, i) => (
= observer(({ projectId }) => { {s.key} )} + {s.text} + {(s.entities_count ?? 0) > 0 && ( {s.entities_count} entities @@ -191,6 +205,7 @@ const ImportExportTab: React.FC = observer(({ projectId }) => {
))}
+ setReplaceOnImport(e.target.checked)} @@ -202,6 +217,7 @@ const ImportExportTab: React.FC = observer(({ projectId }) => { )} + = observer(({ projectId }) => { > Download approved annotations for VerseBridge training. + +
= observer(({ timeline }) => { minHeight: entry.approved > 0 ? 2 : 0, }} /> +
= observer(({ timeline }) => { }} />
+ = observer(({ timeline }) => { ))} +
= observer(({ timeline }) => { background: themeToken.colorSuccess, }} /> + Approved
+
= observer(({ timeline }) => { background: themeToken.colorBorderSecondary, }} /> + Other diff --git a/frontend/src/pages/Workspace/TaskListPanel.tsx b/frontend/src/pages/Workspace/TaskListPanel.tsx index 371a677..2ec849e 100644 --- a/frontend/src/pages/Workspace/TaskListPanel.tsx +++ b/frontend/src/pages/Workspace/TaskListPanel.tsx @@ -58,12 +58,14 @@ const TaskListPanel: React.FC = observer( {completedCount} / {workspaceStore.totalTasks} + {progressPercent}%
+
= observer( allowClear style={{ flex: 1 }} /> + { const copyLink = (inv: Invitation) => { const url = `${window.location.origin}/auth?token=${inv.token}`; + void navigator.clipboard.writeText(url); setCopiedId(inv.id); setTimeout(() => setCopiedId(null), 2000); @@ -100,6 +101,7 @@ const InvitationsManagement: React.FC = observer(() => { Invitations +