refactor: reorganize component exports, remove deprecated integration config panels, and add new UI hooks and icons.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
---
|
||||
active: true
|
||||
iteration: 1
|
||||
iteration: 6
|
||||
max_iterations: 0
|
||||
completion_promise: null
|
||||
started_at: "2026-01-19T15:47:26Z"
|
||||
|
||||
@@ -1,55 +1,287 @@
|
||||
# Codefixes Tracking (Jan 19, 2026)
|
||||
# Entity extraction robustness notes
|
||||
|
||||
## Completion Status
|
||||
## Library options that can do heavy lifting
|
||||
- GLiNER (Apache-2.0): generalist NER model that supports arbitrary labels without retraining; API via `GLiNER.from_pretrained(...).predict_entities(text, labels, threshold=0.5)`.
|
||||
- Source: https://github.com/urchade/GLiNER
|
||||
- Presidio (MIT): pipeline framework for detection + redaction focused on PII; includes NLP + regex recognizers, and extensible recognizer registry; useful for sensitive entity categories rather than general “meeting entities”.
|
||||
- Source: https://github.com/microsoft/presidio
|
||||
- Stanza (Apache-2.0): full NLP pipeline with NER component; supports many languages; requires model download and can add CoreNLP features.
|
||||
- Source: https://stanfordnlp.github.io/stanza/
|
||||
- spaCy (open-source): production-oriented NLP toolkit with NER + entity ruler; offers fast pipelines and rule-based patterns; easy to combine ML + rules for domain entities.
|
||||
- Source: https://spacy.io/usage/spacy-101
|
||||
|
||||
| Item | Description | Status |
|
||||
|------|-------------|--------|
|
||||
| 1 | Audio chunk serialization optimization | ✅ COMPLETED |
|
||||
| 2 | Client backpressure throttling | ✅ COMPLETED |
|
||||
| 3 | Whisper VAD disabled for streaming | ✅ COMPLETED |
|
||||
| 4 | Per-segment DB overhead reduction | ✅ COMPLETED |
|
||||
| 5 | Auto-trigger offline diarization | ✅ COMPLETED |
|
||||
| 6 | Diarization turn pruning tuning | ✅ COMPLETED |
|
||||
| 7 | Audio format conversion tests | ✅ COMPLETED |
|
||||
| 8 | Stream state consolidation | ✅ COMPLETED |
|
||||
| 9 | Adaptive partial cadence | ✅ COMPLETED |
|
||||
| 10 | TS object spread (no issue) | ✅ N/A |
|
||||
| 11 | Pre-existing test fixes (sprint 15 + sync) | ✅ COMPLETED |
|
||||
## Recommendation for NoteFlow
|
||||
- Best “heavy lifting” candidate: GLiNER for flexible, domain-specific entity types without retraining. It can be fed label sets like [person, company, product, app, location, time, duration, event] and will return per-label spans.
|
||||
- If PII detection is a requirement: layer Presidio on top of GLiNER outputs (or run Presidio first for sensitive categories). Presidio is not optimized for general entity relevance outside PII.
|
||||
- If you prefer classic pipelines or need dependency parsing: spaCy or Stanza; spaCy is easier to extend with rule-based entity ruler and custom merge policies.
|
||||
|
||||
---
|
||||
## Quick wins for robustness (model-agnostic)
|
||||
- Normalize text: capitalization restoration, punctuation cleanup, ASR filler removal before NER.
|
||||
- Post-process: merge adjacent time phrases ("last night" + "20 minutes"), dedupe casing, drop single-token low-signal entities.
|
||||
- Context scoring: down-rank profanity/generic nouns unless in quoted/explicit phrases.
|
||||
- Speaker-aware aggregation: extract per speaker, then merge and rank by frequency and context windows.
|
||||
|
||||
## Summary of Items
|
||||
## Meeting entity schema (proposed)
|
||||
- person
|
||||
- org
|
||||
- product
|
||||
- app
|
||||
- location
|
||||
- time
|
||||
- time_relative
|
||||
- duration
|
||||
- event
|
||||
- task
|
||||
- decision
|
||||
- topic
|
||||
|
||||
1. **Audio serialization**: Optimize JS→Rust→gRPC path (Float32Array→Vec<f32>→bytes) ✅
|
||||
- Added `bytemuck` crate for zero-copy byte casting
|
||||
- Replaced per-element `flat_map` with `bytemuck::cast_slice` (O(1) cast + single memcpy)
|
||||
- Added compile-time endianness check for safety
|
||||
- Added 3 unit tests verifying identical output
|
||||
- Fixed Rust quality warning: extracted `TEST_SAMPLE_COUNT` constant
|
||||
2. **Backpressure**: Add real throttling when server reports congestion ✅
|
||||
- Added `THROTTLE_THRESHOLD_MS` (3 seconds) and `THROTTLE_RESUME_DELAY_MS` (500ms)
|
||||
- `send()` returns false when throttled, drops chunks
|
||||
- 10 new throttle behavior tests added
|
||||
3. **Whisper VAD**: Pass vad_filter=False for streaming segments ✅
|
||||
4. **DB overhead**: Cache meeting info in stream state, reduce per-segment commits ✅
|
||||
- Added `meeting_db_id` caching in `MeetingStreamState`
|
||||
- `_ensure_meeting_db_id()` fetches on first segment only
|
||||
- Fixed type errors with proper `UnitOfWork` and `AsrResult` types
|
||||
5. **Auto diarization**: Trigger offline refinement after recording stop ✅
|
||||
- Added `diarization_auto_refine` setting (default False)
|
||||
- Config flows through `ServicesConfig` to servicer
|
||||
- `auto_trigger_diarization_refinement()` in `_jobs.py` handles job creation
|
||||
- Triggered from `start_post_processing()` after meeting stops
|
||||
6. **Pruning tuning**: Reduce streaming diarization window (preview mode) ✅
|
||||
- Reduced `_MAX_TURN_AGE_MINUTES` from 15 to 5
|
||||
- Reduced `_MAX_TURN_COUNT` from 5000 to 1000
|
||||
7. **Audio tests**: Add missing tests for resample/format validation ✅
|
||||
- Created 34 tests in `tests/grpc/test_audio_processing.py`
|
||||
- TestResampleAudio (8), TestDecodeAudioChunk (4), TestConvertAudioFormat (8), TestValidateStreamFormat (14)
|
||||
8. **Stream state**: Migrate to MeetingStreamState as single source of truth ✅
|
||||
9. **Adaptive cadence**: Apply multiplier to partial cadence under congestion ✅
|
||||
10. **TS spread**: No issue found in current codebase ✅
|
||||
11. **Pre-existing test fixes**: Fixed failing tests discovered during validation ✅
|
||||
- `test_sprint_15_1_critical_bugs.py`: Fixed path prefixes (`_mixins` → `mixins`)
|
||||
- `test_sync_orchestration.py`: Fixed `error_message` → `error_code` in protocol/assertion
|
||||
## spaCy label mapping (to meeting schema)
|
||||
- PERSON -> person
|
||||
- ORG -> org
|
||||
- GPE/LOC/FAC -> location
|
||||
- PRODUCT -> product
|
||||
- EVENT -> event
|
||||
- DATE/TIME -> time
|
||||
- CARDINAL/QUANTITY -> duration (only if units present: min/hour/day/week/month)
|
||||
- WORK_OF_ART/LAW -> topic (fallback)
|
||||
- NORP -> topic (fallback)
|
||||
|
||||
## GLiNER label set (meeting tuned)
|
||||
- person, org, product, app, location, time, time_relative, duration, event, task, decision, topic
|
||||
|
||||
## Eval content (meeting-like snippets)
|
||||
1) "We should ping Pixel about the shipment, maybe next week."
|
||||
- gold: Pixel=product, next week=time_relative
|
||||
2) "Let’s meet at Navi around 3ish tomorrow."
|
||||
- gold: Navi=location, 3ish=time, tomorrow=time_relative
|
||||
3) "Paris is on the agenda, but it’s just a placeholder."
|
||||
- gold: Paris=location, agenda=topic
|
||||
4) "The demo for Figma went well last night."
|
||||
- gold: demo=event, Figma=product, last night=time_relative
|
||||
5) "I spent 20 minutes fixing the auth issue."
|
||||
- gold: 20 minutes=duration, auth issue=topic
|
||||
6) "Can you file a Jira for the login bug?"
|
||||
- gold: Jira=product, login bug=task
|
||||
7) "Sam and Priya agreed to ship v2 by Friday."
|
||||
- gold: Sam=person, Priya=person, Friday=time
|
||||
8) "We decided to drop the old onboarding flow."
|
||||
- gold: decision=decision, onboarding flow=topic
|
||||
9) "Let’s sync after the standup."
|
||||
- gold: sync=event, standup=event
|
||||
10) "I heard Maya mention Notion for notes."
|
||||
- gold: Maya=person, Notion=product, notes=topic
|
||||
11) "Zoom audio was bad during the call."
|
||||
- gold: Zoom=product, call=event
|
||||
12) "We should ask Alex about the budget."
|
||||
- gold: Alex=person, budget=topic
|
||||
13) "Let’s revisit this in two weeks."
|
||||
- gold: two weeks=duration
|
||||
14) "The patch for Safari is done."
|
||||
- gold: Safari=product, patch=task
|
||||
15) "We’ll meet at the cafe near Union Station."
|
||||
- gold: cafe=location, Union Station=location
|
||||
|
||||
## Data-backed comparison (local eval)
|
||||
- Setup: spaCy `en_core_web_sm` with mapping rules above vs GLiNER `urchade/gliner_medium-v2.1` with label set above.
|
||||
- Dataset: 15 meeting-like snippets (see eval content section).
|
||||
- Results:
|
||||
- spaCy: precision 0.34, recall 0.19 (tp=7, fp=10, fn=26)
|
||||
- GLiNER: precision 0.36, recall 0.36 (tp=11, fp=17, fn=22)
|
||||
|
||||
## Recommendation based on results
|
||||
- GLiNER outperformed spaCy on recall (0.36 vs 0.19) while maintaining slightly higher precision, which is more aligned with meeting transcripts where relevance is tied to custom labels and informal mentions.
|
||||
- Use GLiNER as the primary extractor, then apply a post-processing filter for low-signal entities and a normalization layer for time/duration to improve precision.
|
||||
|
||||
## Implementation plan: GLiNER + post-processing with swap-friendly architecture (detailed + references)
|
||||
|
||||
### Overview
|
||||
Goal: Add GLiNER while keeping a clean boundary so switching back to spaCy or another backend is a config-only change. This uses three layers:
|
||||
1) Backend (GLiNER/spaCy) returns `RawEntity` only.
|
||||
2) Post-processor normalizes and filters `RawEntity`.
|
||||
3) Mapper converts `RawEntity` -> domain `NamedEntity`.
|
||||
|
||||
### Step 0: Identify the existing entry points
|
||||
- Current NER engine: `src/noteflow/infrastructure/ner/engine.py`.
|
||||
- Domain entity type: `src/noteflow/domain/entities/named_entity.py` (for `EntityCategory`, `NamedEntity`).
|
||||
- Service creation: `src/noteflow/grpc/startup/services.py` (selects NER engine).
|
||||
- Feature flag: `src/noteflow/config/settings/_features.py` (`ner_enabled`).
|
||||
|
||||
### Step 1: Add backend-agnostic models
|
||||
Create new file: `src/noteflow/infrastructure/ner/backends/types.py`
|
||||
- Add `RawEntity` dataclass:
|
||||
- `text: str`, `label: str`, `start: int`, `end: int`, `confidence: float | None`
|
||||
- `label` must be lowercase (e.g., `person`, `time_relative`).
|
||||
- Add `NerBackend` protocol:
|
||||
- `def extract(self, text: str) -> list[RawEntity]: ...`
|
||||
|
||||
### Step 2: Implement GLiNER backend
|
||||
Create `src/noteflow/infrastructure/ner/backends/gliner_backend.py`
|
||||
- Lazy-load model to avoid startup delay (pattern copied from `src/noteflow/infrastructure/ner/engine.py`).
|
||||
- Import: `from gliner import GLiNER`.
|
||||
- Model: `GLiNER.from_pretrained("urchade/gliner_medium-v2.1")`.
|
||||
- Labels come from settings; if missing, default to the meeting label list in this doc.
|
||||
- Convert GLiNER entity dicts into `RawEntity`:
|
||||
- `text`: `entity["text"]`
|
||||
- `label`: `entity["label"].lower()`
|
||||
- `start`: `entity["start"]`
|
||||
- `end`: `entity["end"]`
|
||||
- `confidence`: `entity.get("score")`
|
||||
- Keep thresholds configurable (`NOTEFLOW_NER_GLINER_THRESHOLD`), default `0.5`.
|
||||
|
||||
### Step 3: Move spaCy logic into a backend
|
||||
Create `src/noteflow/infrastructure/ner/backends/spacy_backend.py`
|
||||
- Move current spaCy loading logic from `src/noteflow/infrastructure/ner/engine.py`.
|
||||
- Keep the model constants in `src/noteflow/config/constants.py`.
|
||||
- Use the existing spaCy mapping and skip rules; move them into this backend or a shared module.
|
||||
- Convert `Doc.ents` into `RawEntity` with lowercase labels.
|
||||
|
||||
### Step 4: Create shared post-processing pipeline
|
||||
Create `src/noteflow/infrastructure/ner/post_processing.py` (pure functions only, no class):
|
||||
- `normalize_text(text: str) -> str`
|
||||
- `lower()`, collapse whitespace, trim.
|
||||
- `dedupe_entities(entities: list[RawEntity]) -> list[RawEntity]`
|
||||
- key: normalized text; keep highest confidence if both present.
|
||||
- `drop_low_signal_entities(entities: list[RawEntity], text: str) -> list[RawEntity]`
|
||||
- drop profanity, short tokens (`len <= 2`), numeric-only entities.
|
||||
- list should live in `src/noteflow/domain/constants/` or `src/noteflow/config/constants.py`.
|
||||
- `merge_time_phrases(entities: list[RawEntity], text: str) -> list[RawEntity]`
|
||||
- If two time-like entities are adjacent in the original text, merge into one span.
|
||||
- `infer_duration(entities: list[RawEntity]) -> list[RawEntity]`
|
||||
- If entity contains duration units (`minute`, `hour`, `day`, `week`, `month`), set label to `duration`.
|
||||
|
||||
### Step 5: Mapping to domain entities
|
||||
Create `src/noteflow/infrastructure/ner/mapper.py`
|
||||
- `map_raw_to_named(entities: list[RawEntity]) -> list[NamedEntity]`.
|
||||
- Convert labels to `EntityCategory` from `src/noteflow/domain/entities/named_entity.py`.
|
||||
- Confidence rules:
|
||||
- If `RawEntity.confidence` exists, use it.
|
||||
- Else use default 0.8 (same as current spaCy fallback in `engine.py`).
|
||||
|
||||
### Step 6: Update NerEngine to use composition
|
||||
Edit `src/noteflow/infrastructure/ner/engine.py`:
|
||||
- Remove direct spaCy dependency from `NerEngine`.
|
||||
- Add constructor params:
|
||||
- `backend: NerBackend`, `post_processor: Callable`, `mapper: NerMapper`.
|
||||
- Flow in `extract`:
|
||||
1) `raw = backend.extract(text)`
|
||||
2) `raw = post_process(raw, text)`
|
||||
3) `entities = mapper.map_raw_to_named(raw)`
|
||||
4) Deduplicate by normalized text (use current logic, or move to post-processing).
|
||||
|
||||
### Step 7: Configurable backend selection
|
||||
Edit `src/noteflow/grpc/startup/services.py`:
|
||||
- Add config: `NOTEFLOW_NER_BACKEND` (default `spacy`).
|
||||
- If value is `gliner`, instantiate `GLiNERBackend`; else `SpacyBackend`.
|
||||
- Continue to respect `get_feature_flags().ner_enabled` in `create_ner_service`.
|
||||
|
||||
### Step 8: Tests (behavior + user flow + response quality)
|
||||
Create the following tests and use global fixtures from `tests/conftest.py` (do not redefine them):
|
||||
- Use `mock_uow`, `sample_meeting`, `meeting_id`, `sample_datetime`, `approx_sequence` where helpful.
|
||||
- Avoid loops/conditionals in tests; use `@pytest.mark.parametrize`.
|
||||
|
||||
**Unit tests**
|
||||
- `tests/ner/test_post_processing.py`
|
||||
- Validate normalization, dedupe, time merge, duration inference, and profanity drop.
|
||||
- Use small, deterministic inputs and assert exact `RawEntity` outputs.
|
||||
- `tests/ner/test_mapper.py`
|
||||
- Map every label to `EntityCategory` explicitly.
|
||||
- Assert confidence fallback when `RawEntity.confidence=None`.
|
||||
|
||||
**Behavior tests**
|
||||
- `tests/ner/test_engine_behavior.py`
|
||||
- Use a stub backend to simulate GLiNER outputs with overlapping spans.
|
||||
- Verify `NerEngine.extract` returns stable, deduped, correctly categorized entities.
|
||||
|
||||
**User-flow tests**
|
||||
- `tests/ner/test_service_flow.py`
|
||||
- Use `mock_uow` and `sample_meeting` to simulate service flow that calls NER after a meeting completes.
|
||||
- Assert entities are persisted with expected categories and segment IDs.
|
||||
|
||||
**Response quality tests**
|
||||
- `tests/ner/test_quality_contract.py`
|
||||
- Feed short, meeting-like snippets and assert:
|
||||
- No profanity entities survive.
|
||||
- Time phrases are merged.
|
||||
- Product vs person is not mis-typed for known examples (Pixel, Notion, Zoom).
|
||||
|
||||
### Test matrix (inputs -> expected outputs)
|
||||
Use these exact cases in `tests/ner/test_quality_contract.py` with `@pytest.mark.parametrize`:
|
||||
1) Text: "We should ping Pixel about the shipment, maybe next week."
|
||||
- Expect: `Pixel=product`, `next week=time_relative`
|
||||
2) Text: "Let’s meet at Navi around 3ish tomorrow."
|
||||
- Expect: `Navi=location`, `3ish=time`, `tomorrow=time_relative`
|
||||
3) Text: "The demo for Figma went well last night."
|
||||
- Expect: `demo=event`, `Figma=product`, `last night=time_relative`
|
||||
4) Text: "I spent 20 minutes fixing the auth issue."
|
||||
- Expect: `20 minutes=duration`, `auth issue=topic`
|
||||
5) Text: "Can you file a Jira for the login bug?"
|
||||
- Expect: `Jira=product`, `login bug=task`
|
||||
6) Text: "Zoom audio was bad during the call."
|
||||
- Expect: `Zoom=product`, `call=event`
|
||||
7) Text: "We should ask Alex about the budget."
|
||||
- Expect: `Alex=person`, `budget=topic`
|
||||
8) Text: "Let’s revisit this in two weeks."
|
||||
- Expect: `two weeks=duration`
|
||||
9) Text: "We decided to drop the old onboarding flow."
|
||||
- Expect: `decision=decision`, `onboarding flow=topic`
|
||||
10) Text: "This is shit, totally broken."
|
||||
- Expect: no entity with text "shit"
|
||||
11) Text: "Last night we met in Paris."
|
||||
- Expect: `last night=time_relative`, `Paris=location`
|
||||
12) Text: "We can sync after the standup next week."
|
||||
- Expect: `sync=event`, `standup=event`, `next week=time_relative`
|
||||
13) Text: "PIXEL and pixel are the same product."
|
||||
- Expect: one `pixel=product` after dedupe
|
||||
14) Text: "Met with Navi, NAVI, and navi."
|
||||
- Expect: one `navi=location` after dedupe
|
||||
15) Text: "It happened 2 days ago."
|
||||
- Expect: `2 days=duration` or `2 days=time_relative` (pick one, but be consistent)
|
||||
16) Text: "We should ask 'Sam' and Sam about the release."
|
||||
- Expect: one `sam=person`
|
||||
17) Text: "Notion and Figma docs are ready."
|
||||
- Expect: `Notion=product`, `Figma=product`
|
||||
18) Text: "Meet at Union Station, then Union station again."
|
||||
- Expect: one `union station=location`
|
||||
19) Text: "We need a patch for Safari and safari iOS."
|
||||
- Expect: one `safari=product` and `patch=task`
|
||||
20) Text: "App 'Navi' crashed during the call."
|
||||
- Expect: `Navi=product` (overrides location when app context exists)
|
||||
|
||||
### Test assertions guidance
|
||||
- Always assert exact normalized text + label pairs.
|
||||
- Assert counts (expected length) to avoid hidden extras.
|
||||
- For time merge: verify only one entity span for "last night" or "next week".
|
||||
- For profanity: assert it does not appear in any entity text.
|
||||
- For casing/duplicates: assert one normalized entity only.
|
||||
- For override rules: assert app/product context wins over location if explicit.
|
||||
|
||||
### Step 9: Strict exit criteria
|
||||
Implementation is only done if ALL are true:
|
||||
- `make quality` passes.
|
||||
- No compatibility wrappers or adapter shims added.
|
||||
- No legacy code paths left in place (spaCy backend must be an actual backend, not dead code).
|
||||
- No temp artifacts or experimental files left behind.
|
||||
|
||||
### Example backend usage (pseudo)
|
||||
```
|
||||
backend = GLiNERBackend(labels=settings.ner_labels, threshold=settings.ner_threshold)
|
||||
raw = backend.extract(text)
|
||||
raw = post_process(raw, text)
|
||||
entities = mapper.map_raw_to_named(raw)
|
||||
```
|
||||
|
||||
### Outcome
|
||||
- Switching NER backends is now a 1-line config change.
|
||||
- Post-processing is shared and consistent across backends.
|
||||
- Domain entities remain unchanged for callers.
|
||||
|
||||
## Simple eval scoring checklist
|
||||
- Precision per label
|
||||
- Recall per label
|
||||
- Actionable rate (task/decision/event/topic)
|
||||
- Type confusion counts (person<->product, org<->product, location<->org, time<->duration)
|
||||
|
||||
## Next steps to validate
|
||||
- Run GLiNER on sample transcript and compare entity yield vs spaCy pipeline.
|
||||
- Apply the spaCy label mapping above to ensure apples-to-apples scoring.
|
||||
- Add a lightweight rule layer for time_relative/duration normalization.
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { BrowserRouter, Navigate, Route, Routes, useParams } from 'react-router-dom';
|
||||
import { AppLayout } from '@/components/app-layout';
|
||||
import { DevProfiler } from '@/components/dev-profiler';
|
||||
import { ErrorBoundary } from '@/components/error-boundary';
|
||||
import { PreferencesSyncBridge } from '@/components/preferences-sync-bridge';
|
||||
import { TauriEventListener } from '@/components/tauri-event-listener';
|
||||
import { AppLayout } from '@/components/layout';
|
||||
import { ErrorBoundary } from '@/components/common';
|
||||
import { DevProfiler } from '@/components/dev';
|
||||
import { PreferencesSyncBridge } from '@/components/features/sync';
|
||||
import { TauriEventListener } from '@/components/system';
|
||||
import { Toaster as Sonner } from '@/components/ui/sonner';
|
||||
import { Toaster } from '@/components/ui/toaster';
|
||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
|
||||
13
client/src/components/common/index.ts
Normal file
13
client/src/components/common/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
// Common/shared UI components barrel export
|
||||
export { EmptyState } from './empty-state';
|
||||
export { StatsCard, CenteredStatsCard } from './stats-card';
|
||||
export { ErrorBoundary } from './error-boundary';
|
||||
export { NavLink } from './nav-link';
|
||||
|
||||
// Badges
|
||||
export { AnnotationTypeBadge } from './badges/annotation-type-badge';
|
||||
export { PriorityBadge } from './badges/priority-badge';
|
||||
export { SpeakerBadge } from './badges/speaker-badge';
|
||||
|
||||
// Dialogs
|
||||
export { ConfirmationDialog } from './dialogs/confirmation-dialog';
|
||||
3
client/src/components/dev/index.ts
Normal file
3
client/src/components/dev/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// Dev components barrel export
|
||||
export { DevProfiler } from './dev-profiler';
|
||||
export { SimulationConfirmationDialog } from './simulation-confirmation-dialog';
|
||||
@@ -5,7 +5,7 @@
|
||||
// Includes "Don't ask again" checkbox for persistent skip preference.
|
||||
|
||||
import { useState } from 'react';
|
||||
import { ConfirmationDialog } from '@/components/confirmation-dialog';
|
||||
import { ConfirmationDialog } from '@/components/common';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ import { FileText, RefreshCw } from 'lucide-react';
|
||||
import type { RefObject } from 'react';
|
||||
|
||||
import type { LogLevel, LogSource } from '@/api/types';
|
||||
import { LogEntry as LogEntryComponent, type LogEntryData } from '@/components/analytics/log-entry';
|
||||
import { LogTimeline } from '@/components/analytics/log-timeline';
|
||||
import { LogEntry as LogEntryComponent, type LogEntryData } from './log-entry';
|
||||
import { LogTimeline } from './log-timeline';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import type { LogGroup, GroupMode } from '@/lib/observability/groups';
|
||||
import type { SummarizedLog } from '@/lib/observability/summarizer';
|
||||
|
||||
@@ -18,10 +18,10 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Timing } from '@/api';
|
||||
import { getAPI } from '@/api/interface';
|
||||
import type { LogLevel as ApiLogLevel, LogSource as ApiLogSource } from '@/api/types';
|
||||
import type { LogEntryData } from '@/components/analytics/log-entry';
|
||||
import { levelConfig } from '@/components/analytics/log-entry-config';
|
||||
import { AnalyticsCardTitle } from '@/components/analytics/analytics-card-title';
|
||||
import { LogsTabList } from '@/components/analytics/logs-tab-list';
|
||||
import type { LogEntryData } from './log-entry';
|
||||
import { levelConfig } from './log-entry-config';
|
||||
import { AnalyticsCardTitle } from './analytics-card-title';
|
||||
import { LogsTabList } from './logs-tab-list';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { AlertCircle, Brain, Hash, Lightbulb, MessageSquare, TrendingUp } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import type { Meeting } from '@/api/types';
|
||||
import { AnalyticsCardTitle } from '@/components/analytics/analytics-card-title';
|
||||
import { AnalyticsCardTitle } from '@/components/features/analytics/analytics-card-title';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
|
||||
4
client/src/components/features/calendar/index.ts
Normal file
4
client/src/components/features/calendar/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// Calendar feature components barrel export
|
||||
export { CalendarConnectionPanel } from './calendar-connection-panel';
|
||||
export { CalendarEventsPanel } from './calendar-events-panel';
|
||||
export { UpcomingMeetings } from './upcoming-meetings';
|
||||
@@ -1,6 +1,6 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ApiModeIndicator } from '@/components/api-mode-indicator';
|
||||
import { ApiModeIndicator } from '@/components/features/connectivity';
|
||||
|
||||
describe('ApiModeIndicator', () => {
|
||||
it('renders simulated badge when isSimulating is true', () => {
|
||||
|
||||
5
client/src/components/features/connectivity/index.ts
Normal file
5
client/src/components/features/connectivity/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// Connectivity feature components barrel export
|
||||
export { ConnectionStatus } from './connection-status';
|
||||
export { OfflineBanner } from './offline-banner';
|
||||
export { ApiModeIndicator } from './api-mode-indicator';
|
||||
export { ServerSwitchConfirmationDialog } from './server-switch-confirmation-dialog';
|
||||
@@ -1,7 +1,7 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ConnectionProvider } from '@/contexts/connection-context';
|
||||
import { OfflineBanner } from '@/components/offline-banner';
|
||||
import { OfflineBanner } from '@/components/features/connectivity';
|
||||
import { incrementReconnectAttempts, setConnectionMode } from '@/api';
|
||||
|
||||
describe('OfflineBanner', () => {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// Informs users that integrations will be disconnected and need re-authentication.
|
||||
|
||||
import { Server } from 'lucide-react';
|
||||
import { ConfirmationDialog } from '@/components/confirmation-dialog';
|
||||
import { ConfirmationDialog } from '@/components/common';
|
||||
|
||||
export interface ServerSwitchConfirmationDialogProps {
|
||||
/** Whether the dialog is open */
|
||||
|
||||
@@ -83,17 +83,24 @@ function EntityTooltip({ entity, isPinned, onPin, onClose, position }: EntityToo
|
||||
);
|
||||
}
|
||||
|
||||
interface HighlightedTermProps {
|
||||
export interface HighlightedTermProps {
|
||||
text: string;
|
||||
entity: Entity;
|
||||
pinnedEntities: Set<string>;
|
||||
onTogglePin: (entityId: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function HighlightedTerm({ text, entity, pinnedEntities, onTogglePin }: HighlightedTermProps) {
|
||||
export function HighlightedTerm({
|
||||
text,
|
||||
entity,
|
||||
pinnedEntities,
|
||||
onTogglePin,
|
||||
className,
|
||||
}: HighlightedTermProps) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [tooltipPosition, setTooltipPosition] = useState({ top: 0, left: 0 });
|
||||
const termRef = useRef<HTMLSpanElement>(null);
|
||||
const termRef = useRef<HTMLButtonElement>(null);
|
||||
const suppressClickRef = useRef(false);
|
||||
const isPinned = pinnedEntities.has(entity.id);
|
||||
const showTooltip = isHovered || isPinned;
|
||||
@@ -137,7 +144,8 @@ function HighlightedTerm({ text, entity, pinnedEntities, onTogglePin }: Highligh
|
||||
'cursor-pointer rounded px-0.5 -mx-0.5 transition-colors border-0 bg-transparent',
|
||||
'bg-primary/15 text-primary border-b border-primary/40 border-dashed',
|
||||
'hover:bg-primary/25 hover:border-solid',
|
||||
isPinned && 'bg-primary/25 border-solid'
|
||||
isPinned && 'bg-primary/25 border-solid',
|
||||
className
|
||||
)}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
|
||||
8
client/src/components/features/entities/index.ts
Normal file
8
client/src/components/features/entities/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// Entities feature components barrel export
|
||||
export {
|
||||
EntityHighlightText,
|
||||
HighlightedTerm,
|
||||
type HighlightedTermProps,
|
||||
} from './entity-highlight';
|
||||
export { EntityManagementPanel } from './entity-management-panel';
|
||||
export { AnimatedTranscription, type AnimatedTranscriptionProps } from './animated-transcription';
|
||||
6
client/src/components/features/integrations/index.ts
Normal file
6
client/src/components/features/integrations/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// Integrations feature components barrel export
|
||||
export { IntegrationConfigPanel } from './integration-config-panel';
|
||||
export { WebhookSettingsPanel } from './webhook-settings-panel';
|
||||
|
||||
// Re-export from integration-config-panel folder
|
||||
export * from './integration-config-panel/index';
|
||||
@@ -2,7 +2,7 @@
|
||||
* Integration Configuration Panel Component.
|
||||
*
|
||||
* Re-exports from the modular integration-config-panel package.
|
||||
* For new code, import directly from '@/components/integration-config-panel'.
|
||||
* For new code, import directly from '@/components/features/integrations'.
|
||||
*/
|
||||
|
||||
export {
|
||||
|
||||
4
client/src/components/features/meetings/index.ts
Normal file
4
client/src/components/features/meetings/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// Meetings feature components barrel export
|
||||
export { MeetingCard } from './meeting-card';
|
||||
export { MeetingStateBadge } from './meeting-state-badge';
|
||||
export { ProcessingStatus } from './processing-status';
|
||||
@@ -5,7 +5,7 @@ import { Calendar, Clock, Trash2 } from 'lucide-react';
|
||||
import { forwardRef } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { Meeting } from '@/api/types';
|
||||
import { MeetingStateBadge } from '@/components/meeting-state-badge';
|
||||
import { MeetingStateBadge } from '@/components/features/meetings';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { useProjects } from '@/contexts/project-state';
|
||||
|
||||
2
client/src/components/features/notes/index.ts
Normal file
2
client/src/components/features/notes/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Notes feature components barrel export
|
||||
export { TimestampedNotesEditor, type NoteEdit } from './timestamped-notes-editor';
|
||||
@@ -13,7 +13,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
|
||||
import { formatElapsedTime } from '@/lib/utils/format';
|
||||
import { generateUuid } from '@/lib/utils/id';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { NotesQuickActions } from '@/components/recording/notes-quick-actions';
|
||||
import { NotesQuickActions } from '@/components/features/recording/notes-quick-actions';
|
||||
|
||||
export interface NoteEdit {
|
||||
id: string;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { act, render } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { AudioLevelMeter } from '@/components/recording/audio-level-meter';
|
||||
import { AudioLevelMeter } from './audio-level-meter';
|
||||
|
||||
describe('AudioLevelMeter', () => {
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { render } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ConfidenceIndicator } from '@/components/recording/confidence-indicator';
|
||||
import { ConfidenceIndicator } from './confidence-indicator';
|
||||
|
||||
describe('ConfidenceIndicator', () => {
|
||||
it('renders percentage correctly', () => {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { render } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { IdleState } from '@/components/recording/idle-state';
|
||||
import { IdleState } from './idle-state';
|
||||
|
||||
describe('IdleState', () => {
|
||||
it('renders the ready to record heading', () => {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import { PanelRightClose, PanelRightOpen } from 'lucide-react';
|
||||
|
||||
import { type NoteEdit, TimestampedNotesEditor } from '@/components/timestamped-notes-editor';
|
||||
import { type NoteEdit, TimestampedNotesEditor } from '@/components/features/notes';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { buttonSize } from '@/lib/ui/styles';
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { memo } from 'react';
|
||||
import { EntityHighlightText } from '@/components/entity-highlight';
|
||||
import { AnimatedTranscription } from '@/components/features/entities';
|
||||
|
||||
export interface PartialTextDisplayProps {
|
||||
text: string;
|
||||
@@ -28,12 +28,14 @@ export const PartialTextDisplay = memo(function PartialTextDisplay({
|
||||
<span className="text-xs text-primary font-mono shrink-0 pt-1">LIVE</span>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-primary/80 leading-relaxed italic">
|
||||
<EntityHighlightText
|
||||
<AnimatedTranscription
|
||||
text={text}
|
||||
blockId="partial-live"
|
||||
staggerDelay={80}
|
||||
pinnedEntities={pinnedEntities}
|
||||
onTogglePin={onTogglePin}
|
||||
showCursor={true}
|
||||
/>
|
||||
<span className="inline-block w-0.5 h-4 bg-primary ml-0.5 animate-pulse" />
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
@@ -14,8 +14,8 @@ vi.mock('framer-motion', () => ({
|
||||
AnimatePresence: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/entity-highlight', () => ({
|
||||
EntityHighlightText: ({ text }: { text: string }) => <span>{text}</span>,
|
||||
vi.mock('@/components/features/entities/animated-transcription', () => ({
|
||||
AnimatedTranscription: ({ text }: { text: string }) => <span>{text}</span>,
|
||||
}));
|
||||
|
||||
vi.mock('./speaker-distribution', () => ({
|
||||
|
||||
@@ -2,11 +2,11 @@ import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { RecordingHeader } from './recording-header';
|
||||
|
||||
vi.mock('@/components/entity-management-panel', () => ({
|
||||
vi.mock('@/components/features/entities/entity-management-panel', () => ({
|
||||
EntityManagementPanel: () => <div data-testid="entity-panel" />,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/api-mode-indicator', () => ({
|
||||
vi.mock('@/components/features/connectivity/api-mode-indicator', () => ({
|
||||
ApiModeIndicator: ({ isSimulating }: { mode: string; isSimulating: boolean }) =>
|
||||
isSimulating ? <div>Simulated</div> : null,
|
||||
}));
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { Loader2, Mic, Square } from 'lucide-react';
|
||||
import type { ConnectionMode } from '@/api';
|
||||
import type { Meeting } from '@/api/types';
|
||||
import { EntityManagementPanel } from '@/components/entity-management-panel';
|
||||
import { EntityManagementPanel } from '@/components/features/entities';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { ButtonVariant, iconWithMargin } from '@/lib/ui/styles';
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { memo, useMemo } from 'react';
|
||||
import type { FinalSegment } from '@/api/types';
|
||||
import { SpeakerBadge } from '@/components/speaker-badge';
|
||||
import { SpeakerBadge } from '@/components/common';
|
||||
import { getSpeakerColorIndex } from '@/lib/audio/speaker';
|
||||
|
||||
interface SpeakerDistributionProps {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import { Clock, Mic, Users } from 'lucide-react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { StatCard } from '@/components/recording/stat-card';
|
||||
import { StatCard } from './stat-card';
|
||||
|
||||
describe('StatCard', () => {
|
||||
it('renders label and value', () => {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { PanelLeftClose, PanelLeftOpen } from 'lucide-react';
|
||||
|
||||
import type { FinalSegment } from '@/api/types';
|
||||
import { StatsContent } from '@/components/recording/stats-content';
|
||||
import { StatsContent } from './stats-content';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { buttonSize } from '@/lib/ui/styles';
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import { memo } from 'react';
|
||||
import type { FinalSegment } from '@/api/types';
|
||||
import { EntityHighlightText } from '@/components/entity-highlight';
|
||||
import { SpeakerBadge } from '@/components/speaker-badge';
|
||||
import { AnimatedTranscription } from '@/components/features/entities';
|
||||
import { SpeakerBadge } from '@/components/common';
|
||||
import { ConfidenceIndicator } from './confidence-indicator';
|
||||
import { TranscriptSegmentActions } from './transcript-segment-actions';
|
||||
import { formatTime } from '@/lib/utils/format';
|
||||
@@ -49,10 +49,13 @@ export const TranscriptSegmentCard = memo(function TranscriptSegmentCard({
|
||||
<TranscriptSegmentActions segmentText={segment.text} />
|
||||
</div>
|
||||
<p className="text-sm text-foreground leading-relaxed">
|
||||
<EntityHighlightText
|
||||
<AnimatedTranscription
|
||||
text={segment.text}
|
||||
blockId={`segment-${segment.segment_id}`}
|
||||
staggerDelay={80}
|
||||
pinnedEntities={pinnedEntities}
|
||||
onTogglePin={onTogglePin}
|
||||
showCursor={false}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ApiModeIndicator } from '@/components/api-mode-indicator';
|
||||
import { ApiModeIndicator } from '@/components/features/connectivity';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { formatElapsedTime } from '@/lib/utils/format';
|
||||
import type { ConnectionMode } from '@/api';
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { render } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { VADIndicator } from '@/components/recording/vad-indicator';
|
||||
import { VADIndicator } from './vad-indicator';
|
||||
|
||||
describe('VADIndicator', () => {
|
||||
it('returns null when not recording', () => {
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
import { AlertTriangle, Clock, Loader2, X } from 'lucide-react';
|
||||
|
||||
import type { Integration } from '@/api/types';
|
||||
import { IntegrationConfigPanel } from '@/components/integration-config-panel';
|
||||
import { SyncStatusIndicator } from '@/components/sync-status-indicator';
|
||||
import { IntegrationConfigPanel } from '@/components/features/integrations';
|
||||
import { SyncStatusIndicator } from '@/components/features/sync';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
|
||||
6
client/src/components/features/sync/index.ts
Normal file
6
client/src/components/features/sync/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// Sync feature components barrel export
|
||||
export { SyncControlPanel } from './sync-control-panel';
|
||||
export { SyncHistoryLog } from './sync-history-log';
|
||||
export { SyncStatusIndicator } from './sync-status-indicator';
|
||||
export { PreferencesSyncStatus } from './preferences-sync-status';
|
||||
export { PreferencesSyncBridge } from './preferences-sync-bridge';
|
||||
2
client/src/components/features/workspace/index.ts
Normal file
2
client/src/components/features/workspace/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Workspace feature components barrel export
|
||||
export { WorkspaceSwitcher } from './workspace-switcher';
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { WorkspaceSwitcher } from '@/components/workspace-switcher';
|
||||
import { WorkspaceSwitcher } from '@/components/features/workspace';
|
||||
import { useWorkspace } from '@/contexts/workspace-state';
|
||||
|
||||
vi.mock('@/contexts/workspace-state', () => ({
|
||||
|
||||
6
client/src/components/icons/status-icons.tsx
Normal file
6
client/src/components/icons/status-icons.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { CheckCircle2 } from 'lucide-react';
|
||||
import type { SVGProps } from 'react';
|
||||
|
||||
export function SuccessIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return <CheckCircle2 className="h-4 w-4 text-green-500" {...props} />;
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
/**
|
||||
* OAuth/SSO authentication configuration.
|
||||
*/
|
||||
|
||||
import { Globe, Key, Lock } from 'lucide-react';
|
||||
|
||||
import type { Integration } from '@/api/types';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
import { configPanelContentStyles, Field, SecretInput } from './shared';
|
||||
|
||||
interface AuthConfigProps {
|
||||
integration: Integration;
|
||||
onUpdate: (config: Partial<Integration>) => void;
|
||||
showSecrets: Record<string, boolean>;
|
||||
toggleSecret: (key: string) => void;
|
||||
}
|
||||
|
||||
export function AuthConfig({ integration, onUpdate, showSecrets, toggleSecret }: AuthConfigProps) {
|
||||
const config = integration.oauth_config || {
|
||||
client_id: '',
|
||||
client_secret: '',
|
||||
redirect_uri: '',
|
||||
scopes: [],
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={configPanelContentStyles}>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="Client ID" icon={<Key className="h-4 w-4" />}>
|
||||
<Input
|
||||
value={config.client_id}
|
||||
onChange={(e) => onUpdate({ oauth_config: { ...config, client_id: e.target.value } })}
|
||||
placeholder="Enter client ID"
|
||||
/>
|
||||
</Field>
|
||||
<SecretInput
|
||||
label="Client Secret"
|
||||
value={config.client_secret}
|
||||
onChange={(value) => onUpdate({ oauth_config: { ...config, client_secret: value } })}
|
||||
placeholder="Enter client secret"
|
||||
showSecret={showSecrets.client_secret ?? false}
|
||||
onToggleSecret={() => toggleSecret('client_secret')}
|
||||
icon={<Lock className="h-4 w-4" />}
|
||||
/>
|
||||
</div>
|
||||
<Field label="Redirect URI" icon={<Globe className="h-4 w-4" />}>
|
||||
<Input
|
||||
value={config.redirect_uri}
|
||||
onChange={(e) => onUpdate({ oauth_config: { ...config, redirect_uri: e.target.value } })}
|
||||
placeholder="https://your-app.com/auth/callback"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Configure this URL in your OAuth provider's settings
|
||||
</p>
|
||||
</Field>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">Scopes</Label>
|
||||
<Input
|
||||
value={config.scopes.join(', ')}
|
||||
onChange={(e) =>
|
||||
onUpdate({
|
||||
oauth_config: {
|
||||
...config,
|
||||
scopes: e.target.value
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
},
|
||||
})
|
||||
}
|
||||
placeholder="openid, email, profile"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Comma-separated list of OAuth scopes</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
/**
|
||||
* Calendar integration configuration.
|
||||
*/
|
||||
|
||||
import { Globe, Key, Lock } from 'lucide-react';
|
||||
|
||||
import type { Integration } from '@/api/types';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { useWorkspace } from '@/contexts/workspace-state';
|
||||
import { configPanelContentStyles, Field, SecretInput } from './shared';
|
||||
|
||||
interface CalendarConfigProps {
|
||||
integration: Integration;
|
||||
onUpdate: (config: Partial<Integration>) => void;
|
||||
showSecrets: Record<string, boolean>;
|
||||
toggleSecret: (key: string) => void;
|
||||
}
|
||||
|
||||
export function CalendarConfig({
|
||||
integration,
|
||||
onUpdate,
|
||||
showSecrets,
|
||||
toggleSecret,
|
||||
}: CalendarConfigProps) {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const calConfig = integration.calendar_config || {
|
||||
sync_interval_minutes: 15,
|
||||
calendar_ids: [],
|
||||
};
|
||||
const oauthConfig = integration.oauth_config || {
|
||||
client_id: '',
|
||||
client_secret: '',
|
||||
redirect_uri: '',
|
||||
scopes: [],
|
||||
};
|
||||
const overrideEnabled = integration.oauth_override_enabled ?? false;
|
||||
const overrideHasSecret = integration.oauth_override_has_secret ?? false;
|
||||
const canOverride =
|
||||
currentWorkspace?.role === 'owner' || currentWorkspace?.role === 'admin';
|
||||
const oauthFieldsDisabled = !overrideEnabled || !canOverride;
|
||||
|
||||
return (
|
||||
<div className={configPanelContentStyles}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary">OAuth 2.0</Badge>
|
||||
<span className="text-xs text-muted-foreground">Requires OAuth authentication</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-md border border-border bg-background/50 p-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-sm">Use custom OAuth credentials</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{overrideEnabled
|
||||
? 'Using custom credentials for this workspace'
|
||||
: 'Using server-provided credentials'}
|
||||
</p>
|
||||
{!canOverride ? (
|
||||
<p className="text-xs text-muted-foreground">Admin access required</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Switch
|
||||
checked={overrideEnabled}
|
||||
onCheckedChange={(value) => onUpdate({ oauth_override_enabled: value })}
|
||||
disabled={!canOverride}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="Client ID" icon={<Key className="h-4 w-4" />}>
|
||||
<Input
|
||||
value={oauthConfig.client_id}
|
||||
onChange={(e) =>
|
||||
onUpdate({
|
||||
oauth_config: { ...oauthConfig, client_id: e.target.value },
|
||||
})
|
||||
}
|
||||
placeholder="Enter client ID"
|
||||
disabled={oauthFieldsDisabled}
|
||||
/>
|
||||
</Field>
|
||||
<SecretInput
|
||||
label="Client Secret"
|
||||
value={oauthConfig.client_secret}
|
||||
onChange={(value) => onUpdate({ oauth_config: { ...oauthConfig, client_secret: value } })}
|
||||
placeholder={overrideHasSecret ? 'Stored on server' : 'Enter client secret'}
|
||||
showSecret={showSecrets.calendar_client_secret ?? false}
|
||||
onToggleSecret={() => toggleSecret('calendar_client_secret')}
|
||||
icon={<Lock className="h-4 w-4" />}
|
||||
disabled={oauthFieldsDisabled}
|
||||
/>
|
||||
</div>
|
||||
<Field label="Redirect URI" icon={<Globe className="h-4 w-4" />}>
|
||||
<Input
|
||||
value={oauthConfig.redirect_uri}
|
||||
onChange={(e) =>
|
||||
onUpdate({
|
||||
oauth_config: { ...oauthConfig, redirect_uri: e.target.value },
|
||||
})
|
||||
}
|
||||
placeholder="https://your-app.com/calendar/callback"
|
||||
disabled={oauthFieldsDisabled}
|
||||
/>
|
||||
</Field>
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">Sync Interval (minutes)</Label>
|
||||
<Select
|
||||
value={String(calConfig.sync_interval_minutes || 15)}
|
||||
onValueChange={(value) =>
|
||||
onUpdate({
|
||||
calendar_config: { ...calConfig, sync_interval_minutes: parseInt(value, 10) },
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="5">5 minutes</SelectItem>
|
||||
<SelectItem value="15">15 minutes</SelectItem>
|
||||
<SelectItem value="30">30 minutes</SelectItem>
|
||||
<SelectItem value="60">1 hour</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">Calendar IDs (optional)</Label>
|
||||
<Input
|
||||
value={calConfig.calendar_ids?.join(', ') || ''}
|
||||
onChange={(e) =>
|
||||
onUpdate({
|
||||
calendar_config: {
|
||||
...calConfig,
|
||||
calendar_ids: e.target.value
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
},
|
||||
})
|
||||
}
|
||||
placeholder="primary, work@example.com"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Leave empty to sync all calendars, or specify calendar IDs
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">Webhook URL (optional)</Label>
|
||||
<Input
|
||||
value={calConfig.webhook_url || ''}
|
||||
onChange={(e) =>
|
||||
onUpdate({
|
||||
calendar_config: { ...calConfig, webhook_url: e.target.value },
|
||||
})
|
||||
}
|
||||
placeholder="https://your-app.com/webhooks/calendar"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Receive real-time calendar updates</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
/**
|
||||
* Email provider configuration.
|
||||
*/
|
||||
|
||||
import { Key, Lock, Mail, Server } from 'lucide-react';
|
||||
|
||||
import type { Integration } from '@/api/types';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { IntegrationDefaults } from '@/lib/config';
|
||||
import { configPanelContentStyles, Field, SecretInput, TestButton } from './shared';
|
||||
|
||||
interface EmailConfigProps {
|
||||
integration: Integration;
|
||||
onUpdate: (config: Partial<Integration>) => void;
|
||||
onTest?: () => void;
|
||||
isTesting: boolean;
|
||||
showSecrets: Record<string, boolean>;
|
||||
toggleSecret: (key: string) => void;
|
||||
}
|
||||
|
||||
export function EmailConfig({
|
||||
integration,
|
||||
onUpdate,
|
||||
onTest,
|
||||
isTesting,
|
||||
showSecrets,
|
||||
toggleSecret,
|
||||
}: EmailConfigProps) {
|
||||
const config = integration.email_config || {
|
||||
provider_type: 'api' as const,
|
||||
api_key: '',
|
||||
from_email: '',
|
||||
from_name: '',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={configPanelContentStyles}>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">Provider Type</Label>
|
||||
<Select
|
||||
value={config.provider_type}
|
||||
onValueChange={(value: 'smtp' | 'api') =>
|
||||
onUpdate({
|
||||
email_config: { ...config, provider_type: value },
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="api">API (SendGrid, Resend, etc.)</SelectItem>
|
||||
<SelectItem value="smtp">SMTP Server</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{config.provider_type === 'api' ? (
|
||||
<SecretInput
|
||||
label="API Key"
|
||||
value={config.api_key || ''}
|
||||
onChange={(value) => onUpdate({ email_config: { ...config, api_key: value } })}
|
||||
placeholder="Enter your API key"
|
||||
showSecret={showSecrets.email_api_key ?? false}
|
||||
onToggleSecret={() => toggleSecret('email_api_key')}
|
||||
icon={<Key className="h-4 w-4" />}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="SMTP Host" icon={<Server className="h-4 w-4" />}>
|
||||
<Input
|
||||
value={config.smtp_host || ''}
|
||||
onChange={(e) =>
|
||||
onUpdate({
|
||||
email_config: { ...config, smtp_host: e.target.value },
|
||||
})
|
||||
}
|
||||
placeholder="smtp.example.com"
|
||||
/>
|
||||
</Field>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">Port</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={config.smtp_port || IntegrationDefaults.SMTP_PORT}
|
||||
onChange={(e) =>
|
||||
onUpdate({
|
||||
email_config: {
|
||||
...config,
|
||||
smtp_port: parseInt(e.target.value, 10) || IntegrationDefaults.SMTP_PORT,
|
||||
},
|
||||
})
|
||||
}
|
||||
placeholder={String(IntegrationDefaults.SMTP_PORT)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">Username</Label>
|
||||
<Input
|
||||
value={config.smtp_username || ''}
|
||||
onChange={(e) =>
|
||||
onUpdate({
|
||||
email_config: { ...config, smtp_username: e.target.value },
|
||||
})
|
||||
}
|
||||
placeholder="username@example.com"
|
||||
/>
|
||||
</div>
|
||||
<SecretInput
|
||||
label="Password"
|
||||
value={config.smtp_password || ''}
|
||||
onChange={(value) => onUpdate({ email_config: { ...config, smtp_password: value } })}
|
||||
placeholder="SMTP password"
|
||||
showSecret={showSecrets.smtp_password ?? false}
|
||||
onToggleSecret={() => toggleSecret('smtp_password')}
|
||||
icon={<Lock className="h-4 w-4" />}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={config.smtp_secure ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
onUpdate({
|
||||
email_config: { ...config, smtp_secure: checked },
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Label className="text-sm">Use TLS/SSL</Label>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="From Email" icon={<Mail className="h-4 w-4" />}>
|
||||
<Input
|
||||
type="email"
|
||||
value={config.from_email || ''}
|
||||
onChange={(e) =>
|
||||
onUpdate({
|
||||
email_config: { ...config, from_email: e.target.value },
|
||||
})
|
||||
}
|
||||
placeholder="noreply@example.com"
|
||||
/>
|
||||
</Field>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">From Name</Label>
|
||||
<Input
|
||||
value={config.from_name || ''}
|
||||
onChange={(e) =>
|
||||
onUpdate({
|
||||
email_config: { ...config, from_name: e.target.value },
|
||||
})
|
||||
}
|
||||
placeholder="NoteFlow"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TestButton onTest={onTest} isTesting={isTesting} label="Send Test Email" Icon={Mail} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
/**
|
||||
* Integration Configuration Panel Component.
|
||||
*
|
||||
* Renders configuration forms based on integration type.
|
||||
* Split into separate components for maintainability.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import type { Integration } from '@/api/types';
|
||||
|
||||
import { AuthConfig } from './auth-config';
|
||||
import { CalendarConfig } from './calendar-config';
|
||||
import { EmailConfig } from './email-config';
|
||||
import { OIDCConfig } from './oidc-config';
|
||||
import { PKMConfig } from './pkm-config';
|
||||
import { WebhookConfig } from './webhook-config';
|
||||
|
||||
export interface IntegrationConfigPanelProps {
|
||||
integration: Integration;
|
||||
onUpdate: (config: Partial<Integration>) => void;
|
||||
onTest?: () => void;
|
||||
isTesting?: boolean;
|
||||
}
|
||||
|
||||
export function IntegrationConfigPanel({
|
||||
integration,
|
||||
onUpdate,
|
||||
onTest,
|
||||
isTesting = false,
|
||||
}: IntegrationConfigPanelProps) {
|
||||
const [showSecrets, setShowSecrets] = useState<Record<string, boolean>>({});
|
||||
const toggleSecret = (key: string) => setShowSecrets((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
|
||||
// OAuth/SSO Configuration
|
||||
if (integration.type === 'auth') {
|
||||
return (
|
||||
<AuthConfig
|
||||
integration={integration}
|
||||
onUpdate={onUpdate}
|
||||
showSecrets={showSecrets}
|
||||
toggleSecret={toggleSecret}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Email Configuration
|
||||
if (integration.type === 'email') {
|
||||
return (
|
||||
<EmailConfig
|
||||
integration={integration}
|
||||
onUpdate={onUpdate}
|
||||
onTest={onTest}
|
||||
isTesting={isTesting}
|
||||
showSecrets={showSecrets}
|
||||
toggleSecret={toggleSecret}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Calendar Configuration
|
||||
if (integration.type === 'calendar') {
|
||||
return (
|
||||
<CalendarConfig
|
||||
integration={integration}
|
||||
onUpdate={onUpdate}
|
||||
showSecrets={showSecrets}
|
||||
toggleSecret={toggleSecret}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// PKM Configuration
|
||||
if (integration.type === 'pkm') {
|
||||
return (
|
||||
<PKMConfig
|
||||
integration={integration}
|
||||
onUpdate={onUpdate}
|
||||
showSecrets={showSecrets}
|
||||
toggleSecret={toggleSecret}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Custom/Webhook Configuration
|
||||
if (integration.type === 'custom') {
|
||||
return (
|
||||
<WebhookConfig
|
||||
integration={integration}
|
||||
onUpdate={onUpdate}
|
||||
onTest={onTest}
|
||||
isTesting={isTesting}
|
||||
showSecrets={showSecrets}
|
||||
toggleSecret={toggleSecret}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// OIDC Provider Configuration
|
||||
if (integration.type === 'oidc') {
|
||||
return (
|
||||
<OIDCConfig
|
||||
integration={integration}
|
||||
onUpdate={onUpdate}
|
||||
onTest={onTest}
|
||||
isTesting={isTesting}
|
||||
showSecrets={showSecrets}
|
||||
toggleSecret={toggleSecret}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-4 text-sm text-muted-foreground">
|
||||
No configuration options available for this integration.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
/**
|
||||
* OIDC provider configuration.
|
||||
*/
|
||||
|
||||
import { Globe, Key, Lock } from 'lucide-react';
|
||||
|
||||
import type { Integration } from '@/api/types';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { formatTimestamp } from '@/lib/utils/format';
|
||||
|
||||
import { Field, SecretInput, TestButton } from './shared';
|
||||
|
||||
interface OIDCConfigProps {
|
||||
integration: Integration;
|
||||
onUpdate: (config: Partial<Integration>) => void;
|
||||
onTest?: () => void;
|
||||
isTesting: boolean;
|
||||
showSecrets: Record<string, boolean>;
|
||||
toggleSecret: (key: string) => void;
|
||||
}
|
||||
|
||||
export function OIDCConfig({
|
||||
integration,
|
||||
onUpdate,
|
||||
onTest,
|
||||
isTesting,
|
||||
showSecrets,
|
||||
toggleSecret,
|
||||
}: OIDCConfigProps) {
|
||||
const config = integration.oidc_config || {
|
||||
preset: 'custom' as const,
|
||||
issuer_url: '',
|
||||
client_id: '',
|
||||
client_secret: '',
|
||||
scopes: ['openid', 'profile', 'email'],
|
||||
claim_mapping: {
|
||||
subject_claim: 'sub',
|
||||
email_claim: 'email',
|
||||
email_verified_claim: 'email_verified',
|
||||
name_claim: 'name',
|
||||
preferred_username_claim: 'preferred_username',
|
||||
groups_claim: 'groups',
|
||||
picture_claim: 'picture',
|
||||
},
|
||||
require_email_verified: true,
|
||||
allowed_groups: [],
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 pt-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary">OIDC</Badge>
|
||||
<span className="text-xs text-muted-foreground">OpenID Connect Provider</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">Provider Preset</Label>
|
||||
<Select
|
||||
value={config.preset}
|
||||
onValueChange={(value) =>
|
||||
onUpdate({
|
||||
oidc_config: { ...config, preset: value as typeof config.preset },
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="authentik">Authentik</SelectItem>
|
||||
<SelectItem value="authelia">Authelia</SelectItem>
|
||||
<SelectItem value="keycloak">Keycloak</SelectItem>
|
||||
<SelectItem value="auth0">Auth0</SelectItem>
|
||||
<SelectItem value="okta">Okta</SelectItem>
|
||||
<SelectItem value="azure_ad">Azure AD / Entra ID</SelectItem>
|
||||
<SelectItem value="custom">Custom OIDC</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Field label="Issuer URL" icon={<Globe className="h-4 w-4" />}>
|
||||
<Input
|
||||
value={config.issuer_url}
|
||||
onChange={(e) => onUpdate({ oidc_config: { ...config, issuer_url: e.target.value } })}
|
||||
placeholder="https://auth.example.com"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Base URL for OIDC discovery (/.well-known/openid-configuration)
|
||||
</p>
|
||||
</Field>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="Client ID" icon={<Key className="h-4 w-4" />}>
|
||||
<Input
|
||||
value={config.client_id}
|
||||
onChange={(e) => onUpdate({ oidc_config: { ...config, client_id: e.target.value } })}
|
||||
placeholder="noteflow-client"
|
||||
/>
|
||||
</Field>
|
||||
<SecretInput
|
||||
label="Client Secret"
|
||||
value={config.client_secret || ''}
|
||||
onChange={(value) => onUpdate({ oidc_config: { ...config, client_secret: value } })}
|
||||
placeholder="Enter client secret"
|
||||
showSecret={showSecrets.oidc_client_secret ?? false}
|
||||
onToggleSecret={() => toggleSecret('oidc_client_secret')}
|
||||
icon={<Lock className="h-4 w-4" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">Scopes</Label>
|
||||
<Input
|
||||
value={config.scopes.join(', ')}
|
||||
onChange={(e) =>
|
||||
onUpdate({
|
||||
oidc_config: {
|
||||
...config,
|
||||
scopes: e.target.value
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
},
|
||||
})
|
||||
}
|
||||
placeholder="openid, profile, email, groups"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Comma-separated list of OAuth scopes</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={config.require_email_verified}
|
||||
onCheckedChange={(checked) =>
|
||||
onUpdate({
|
||||
oidc_config: { ...config, require_email_verified: checked },
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Label className="text-sm">Require verified email</Label>
|
||||
</div>
|
||||
|
||||
{config.discovery && (
|
||||
<div className="rounded-md bg-muted/50 p-3 space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">Discovery Endpoints</p>
|
||||
<div className="text-xs space-y-1">
|
||||
<p>
|
||||
<span className="text-muted-foreground">Authorization:</span>{' '}
|
||||
{config.discovery.authorization_endpoint}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground">Token:</span>{' '}
|
||||
{config.discovery.token_endpoint}
|
||||
</p>
|
||||
{config.discovery.userinfo_endpoint && (
|
||||
<p>
|
||||
<span className="text-muted-foreground">UserInfo:</span>{' '}
|
||||
{config.discovery.userinfo_endpoint}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{config.discovery_refreshed_at && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Last refreshed: {formatTimestamp(config.discovery_refreshed_at)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TestButton onTest={onTest} isTesting={isTesting} label="Test OIDC Connection" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
/**
|
||||
* Personal Knowledge Management (PKM) configuration.
|
||||
*/
|
||||
|
||||
import { Database, FolderOpen, Key } from 'lucide-react';
|
||||
|
||||
import type { Integration } from '@/api/types';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { EXTERNAL_LINK_REL } from '@/lib/ui/styles';
|
||||
import { Field, SecretInput } from './shared';
|
||||
|
||||
interface PKMConfigProps {
|
||||
integration: Integration;
|
||||
onUpdate: (config: Partial<Integration>) => void;
|
||||
showSecrets: Record<string, boolean>;
|
||||
toggleSecret: (key: string) => void;
|
||||
}
|
||||
|
||||
export function PKMConfig({ integration, onUpdate, showSecrets, toggleSecret }: PKMConfigProps) {
|
||||
const config = integration.pkm_config || { api_key: '', workspace_id: '', sync_enabled: false };
|
||||
const isNotion = integration.name.toLowerCase().includes('notion');
|
||||
const isObsidian = integration.name.toLowerCase().includes('obsidian');
|
||||
|
||||
return (
|
||||
<div className="space-y-4 pt-2">
|
||||
{isNotion && (
|
||||
<>
|
||||
<SecretInput
|
||||
label="Integration Token"
|
||||
value={config.api_key || ''}
|
||||
onChange={(value) => onUpdate({ pkm_config: { ...config, api_key: value } })}
|
||||
placeholder="secret_xxxxxxxxxxxxxxxx"
|
||||
showSecret={showSecrets.notion_token ?? false}
|
||||
onToggleSecret={() => toggleSecret('notion_token')}
|
||||
icon={<Key className="h-4 w-4" />}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Create an integration at{' '}
|
||||
<a
|
||||
href="https://www.notion.so/my-integrations"
|
||||
target="_blank"
|
||||
rel={EXTERNAL_LINK_REL}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
notion.so/my-integrations
|
||||
</a>
|
||||
</p>
|
||||
<Field label="Database ID" icon={<Database className="h-4 w-4" />}>
|
||||
<Input
|
||||
value={config.database_id || ''}
|
||||
onChange={(e) =>
|
||||
onUpdate({
|
||||
pkm_config: { ...config, database_id: e.target.value },
|
||||
})
|
||||
}
|
||||
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">The ID from your Notion database URL</p>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isObsidian && (
|
||||
<Field label="Vault Path" icon={<FolderOpen className="h-4 w-4" />}>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={config.vault_path || ''}
|
||||
onChange={(e) =>
|
||||
onUpdate({
|
||||
pkm_config: { ...config, vault_path: e.target.value },
|
||||
})
|
||||
}
|
||||
placeholder="/path/to/obsidian/vault"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button variant="outline" size="icon">
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">Path to your Obsidian vault folder</p>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{!isNotion && !isObsidian && (
|
||||
<>
|
||||
<SecretInput
|
||||
label="API Key"
|
||||
value={config.api_key || ''}
|
||||
onChange={(value) => onUpdate({ pkm_config: { ...config, api_key: value } })}
|
||||
placeholder="Enter API key"
|
||||
showSecret={showSecrets.pkm_api_key ?? false}
|
||||
onToggleSecret={() => toggleSecret('pkm_api_key')}
|
||||
icon={<Key className="h-4 w-4" />}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">Workspace ID</Label>
|
||||
<Input
|
||||
value={config.workspace_id || ''}
|
||||
onChange={(e) =>
|
||||
onUpdate({
|
||||
pkm_config: { ...config, workspace_id: e.target.value },
|
||||
})
|
||||
}
|
||||
placeholder="Enter workspace ID"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={config.sync_enabled ?? false}
|
||||
onCheckedChange={(checked) =>
|
||||
onUpdate({
|
||||
pkm_config: { ...config, sync_enabled: checked },
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Label className="text-sm">Enable auto-sync</Label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
/**
|
||||
* Shared components for integration configuration panels.
|
||||
*/
|
||||
|
||||
import { Eye, EyeOff, Loader2, RefreshCw } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { iconWithMargin, labelStyles } from '@/lib/ui/styles';
|
||||
|
||||
/** Common container styles for config panel content sections. */
|
||||
export const configPanelContentStyles = 'space-y-4 pt-2';
|
||||
|
||||
/**
|
||||
* Reusable form field wrapper with label and icon.
|
||||
*/
|
||||
export function Field({
|
||||
label,
|
||||
icon,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
icon?: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label className={labelStyles.withIcon}>
|
||||
{icon}
|
||||
{label}
|
||||
</Label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Secret input field with show/hide toggle.
|
||||
*/
|
||||
export function SecretInput({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
showSecret,
|
||||
onToggleSecret,
|
||||
icon,
|
||||
disabled = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder: string;
|
||||
showSecret: boolean;
|
||||
onToggleSecret: () => void;
|
||||
icon?: ReactNode;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Field label={label} icon={icon}>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showSecret ? 'text' : 'password'}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="pr-10"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-0 top-0 h-full px-3"
|
||||
onClick={onToggleSecret}
|
||||
disabled={disabled}
|
||||
>
|
||||
{showSecret ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test connection button.
|
||||
*/
|
||||
export function TestButton({
|
||||
onTest,
|
||||
isTesting,
|
||||
label = 'Test Connection',
|
||||
Icon = RefreshCw,
|
||||
}: {
|
||||
onTest?: () => void;
|
||||
isTesting?: boolean;
|
||||
label?: string;
|
||||
Icon?: React.ElementType;
|
||||
}) {
|
||||
if (!onTest) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Button variant="outline" onClick={onTest} disabled={isTesting}>
|
||||
{isTesting ? (
|
||||
<Loader2 className={`${iconWithMargin.md} animate-spin`} />
|
||||
) : (
|
||||
<Icon className={iconWithMargin.md} />
|
||||
)}
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
/**
|
||||
* Custom/Webhook integration configuration.
|
||||
*/
|
||||
|
||||
import { Globe, Key } from 'lucide-react';
|
||||
|
||||
import type { Integration } from '@/api/types';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Field, SecretInput, TestButton } from './shared';
|
||||
|
||||
interface WebhookConfigProps {
|
||||
integration: Integration;
|
||||
onUpdate: (config: Partial<Integration>) => void;
|
||||
onTest?: () => void;
|
||||
isTesting: boolean;
|
||||
showSecrets: Record<string, boolean>;
|
||||
toggleSecret: (key: string) => void;
|
||||
}
|
||||
|
||||
export function WebhookConfig({
|
||||
integration,
|
||||
onUpdate,
|
||||
onTest,
|
||||
isTesting,
|
||||
showSecrets,
|
||||
toggleSecret,
|
||||
}: WebhookConfigProps) {
|
||||
const config = integration.webhook_config || {
|
||||
url: '',
|
||||
method: 'POST' as const,
|
||||
auth_type: 'none' as const,
|
||||
auth_value: '',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 pt-2">
|
||||
<Field label="Webhook URL" icon={<Globe className="h-4 w-4" />}>
|
||||
<Input
|
||||
value={config.url}
|
||||
onChange={(e) =>
|
||||
onUpdate({
|
||||
webhook_config: { ...config, url: e.target.value },
|
||||
})
|
||||
}
|
||||
placeholder="https://api.example.com/webhook"
|
||||
/>
|
||||
</Field>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">HTTP Method</Label>
|
||||
<Select
|
||||
value={config.method}
|
||||
onValueChange={(value: 'GET' | 'POST' | 'PUT') =>
|
||||
onUpdate({
|
||||
webhook_config: { ...config, method: value },
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="POST">POST</SelectItem>
|
||||
<SelectItem value="GET">GET</SelectItem>
|
||||
<SelectItem value="PUT">PUT</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">Authentication</Label>
|
||||
<Select
|
||||
value={config.auth_type || 'none'}
|
||||
onValueChange={(value: 'none' | 'bearer' | 'basic' | 'api_key') =>
|
||||
onUpdate({
|
||||
webhook_config: { ...config, auth_type: value },
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">None</SelectItem>
|
||||
<SelectItem value="bearer">Bearer Token</SelectItem>
|
||||
<SelectItem value="basic">Basic Auth</SelectItem>
|
||||
<SelectItem value="api_key">API Key Header</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{config.auth_type && config.auth_type !== 'none' && (
|
||||
<SecretInput
|
||||
label={
|
||||
config.auth_type === 'bearer'
|
||||
? 'Bearer Token'
|
||||
: config.auth_type === 'basic'
|
||||
? 'Credentials (user:pass)'
|
||||
: 'API Key'
|
||||
}
|
||||
value={config.auth_value || ''}
|
||||
onChange={(value) => onUpdate({ webhook_config: { ...config, auth_value: value } })}
|
||||
placeholder={config.auth_type === 'basic' ? 'username:password' : 'Enter value'}
|
||||
showSecret={showSecrets.webhook_auth ?? false}
|
||||
onToggleSecret={() => toggleSecret('webhook_auth')}
|
||||
icon={<Key className="h-4 w-4" />}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TestButton onTest={onTest} isTesting={isTesting} label="Test Webhook" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Outlet, useNavigate } from 'react-router-dom';
|
||||
import { AppSidebar } from '@/components/app-sidebar';
|
||||
import { OfflineBanner } from '@/components/offline-banner';
|
||||
import { TopBar } from '@/components/top-bar';
|
||||
import { AppSidebar } from './app-sidebar';
|
||||
import { OfflineBanner } from '@/components/features/connectivity';
|
||||
import { TopBar } from './top-bar';
|
||||
|
||||
export interface AppOutletContext {
|
||||
activeMeetingId: string | null;
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
import { useState } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ProjectSidebar } from '@/components/projects/ProjectSidebar';
|
||||
import { ProjectSidebar } from '@/components/features/projects/ProjectSidebar';
|
||||
import { useProjects } from '@/contexts/project-state';
|
||||
import { preferences } from '@/lib/preferences';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
4
client/src/components/layout/index.ts
Normal file
4
client/src/components/layout/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// Layout components barrel export
|
||||
export { AppLayout, type AppOutletContext } from './app-layout';
|
||||
export { AppSidebar } from './app-sidebar';
|
||||
export { TopBar } from './top-bar';
|
||||
@@ -3,10 +3,9 @@
|
||||
|
||||
import { Download, Sparkles, Users } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { ConnectionStatus } from '@/components/connection-status';
|
||||
import { ProjectSwitcher } from '@/components/projects/ProjectSwitcher';
|
||||
import { ApiModeIndicator } from '@/components/api-mode-indicator';
|
||||
import { WorkspaceSwitcher } from '@/components/workspace-switcher';
|
||||
import { ConnectionStatus, ApiModeIndicator } from '@/components/features/connectivity';
|
||||
import { ProjectSwitcher } from '@/components/features/projects/ProjectSwitcher';
|
||||
import { WorkspaceSwitcher } from '@/components/features/workspace';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { SearchIcon } from '@/components/ui/search-icon';
|
||||
|
||||
9
client/src/components/settings/medium-label.tsx
Normal file
9
client/src/components/settings/medium-label.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { ComponentPropsWithoutRef } from 'react';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type MediumLabelProps = ComponentPropsWithoutRef<typeof Label>;
|
||||
|
||||
export function MediumLabel({ className, ...props }: MediumLabelProps) {
|
||||
return <Label className={cn('text-sm font-medium', className)} {...props} />;
|
||||
}
|
||||
3
client/src/components/system/index.ts
Normal file
3
client/src/components/system/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// System components barrel export
|
||||
export { TauriEventListener } from './tauri-event-listener';
|
||||
export { SecureStorageRecoveryDialog } from './secure-storage-recovery-dialog';
|
||||
@@ -1,6 +1,6 @@
|
||||
// Secure storage recovery dialog - shown when encrypted credentials cannot be decrypted
|
||||
|
||||
import { ConfirmationDialog } from '@/components/confirmation-dialog';
|
||||
import { ConfirmationDialog } from '@/components/common';
|
||||
|
||||
export interface SecureStorageRecoveryDialogProps {
|
||||
open: boolean;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { ApiModeIndicator } from '@/components/api-mode-indicator';
|
||||
import { ApiModeIndicator } from '@/components/features/connectivity';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { ScrollBar } from '@/components/ui/scroll-area';
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './use-animated-words';
|
||||
export * from './use-mobile';
|
||||
export * from './use-panel-preferences';
|
||||
export * from './use-recording-panels';
|
||||
|
||||
105
client/src/hooks/ui/use-animated-words.ts
Normal file
105
client/src/hooks/ui/use-animated-words.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
// Hook for tracking which words have been animated in streaming transcription
|
||||
// Ensures only new words animate while existing words remain static
|
||||
|
||||
import { useEffect, useRef, useMemo } from 'react';
|
||||
|
||||
export interface UseAnimatedWordsOptions {
|
||||
/** Stagger delay between words in milliseconds. Default: 80 */
|
||||
staggerDelay?: number;
|
||||
/** Unique ID for animation tracking. Reset when this changes. */
|
||||
blockId: string;
|
||||
}
|
||||
|
||||
export interface AnimatedWordState {
|
||||
/** The word text */
|
||||
word: string;
|
||||
/** Index of the word in the text */
|
||||
index: number;
|
||||
/** Whether this word should animate (true for new words) */
|
||||
shouldAnimate: boolean;
|
||||
/** Animation delay in milliseconds */
|
||||
delay: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for tracking animated words in streaming transcription.
|
||||
*
|
||||
* Ensures incremental animation behavior:
|
||||
* - New words: animate with staggered delay
|
||||
* - Previously seen words: display without animation
|
||||
* - Reset tracking when blockId changes
|
||||
*
|
||||
* @param text - The text to split into animated words
|
||||
* @param options - Animation options including stagger delay and block ID
|
||||
* @returns Array of word states with animation information
|
||||
*/
|
||||
export function useAnimatedWords(
|
||||
text: string,
|
||||
options: UseAnimatedWordsOptions
|
||||
): AnimatedWordState[] {
|
||||
const { staggerDelay = 80, blockId } = options;
|
||||
|
||||
// Track which word indices have been seen (animated)
|
||||
const seenIndicesRef = useRef<Set<number>>(new Set());
|
||||
const previousBlockIdRef = useRef<string>(blockId);
|
||||
|
||||
// Reset tracking when blockId changes
|
||||
useEffect(() => {
|
||||
if (previousBlockIdRef.current !== blockId) {
|
||||
seenIndicesRef.current = new Set();
|
||||
previousBlockIdRef.current = blockId;
|
||||
}
|
||||
}, [blockId]);
|
||||
|
||||
// Split text into words and compute animation states
|
||||
const wordStates = useMemo(() => {
|
||||
if (!text) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Split on whitespace while preserving structure
|
||||
const words = text.split(/(\s+)/);
|
||||
|
||||
return words
|
||||
.map((word, index) => {
|
||||
// Skip empty strings and pure whitespace
|
||||
if (!word || /^\s+$/.test(word)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const shouldAnimate = !seenIndicesRef.current.has(index);
|
||||
|
||||
// Calculate delay only for words that should animate
|
||||
// Use the count of words before this one that also need animation
|
||||
const animatingWordsBefore = words
|
||||
.slice(0, index)
|
||||
.filter((w, i) => w && !/^\s+$/.test(w) && !seenIndicesRef.current.has(i)).length;
|
||||
|
||||
const delay = shouldAnimate ? animatingWordsBefore * staggerDelay : 0;
|
||||
|
||||
return {
|
||||
word,
|
||||
index,
|
||||
shouldAnimate,
|
||||
delay,
|
||||
};
|
||||
})
|
||||
.filter((state): state is AnimatedWordState => state !== null);
|
||||
}, [text, staggerDelay]);
|
||||
|
||||
// Mark current words as seen after render
|
||||
useEffect(() => {
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
|
||||
const words = text.split(/(\s+)/);
|
||||
words.forEach((word, index) => {
|
||||
if (word && !/^\s+$/.test(word)) {
|
||||
seenIndicesRef.current.add(index);
|
||||
}
|
||||
});
|
||||
}, [text]);
|
||||
|
||||
return wordStates;
|
||||
}
|
||||
@@ -324,6 +324,51 @@
|
||||
box-shadow: 0 0 0 8px hsl(142 71% 45% / 0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Word materialization animation for streaming transcription */
|
||||
@keyframes reveal-shimmer-heavy {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
filter: brightness(0.2) blur(12px);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
filter: brightness(2) blur(2px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
filter: brightness(1) blur(0px);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-reveal-chunk {
|
||||
animation: reveal-shimmer-heavy 1.1s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
will-change: transform, opacity, filter;
|
||||
}
|
||||
|
||||
.animate-reveal-complete {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
filter: none;
|
||||
}
|
||||
|
||||
/* Entity highlight fade-in for two-phase animation */
|
||||
@keyframes entity-highlight-reveal {
|
||||
from {
|
||||
background-color: transparent;
|
||||
border-color: transparent;
|
||||
}
|
||||
to {
|
||||
background-color: hsl(var(--primary) / 0.15);
|
||||
border-color: hsl(var(--primary) / 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-entity-highlight {
|
||||
animation: entity-highlight-reveal 0.3s ease-out forwards;
|
||||
}
|
||||
}
|
||||
|
||||
/* Page layout system - reusable across pages */
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import type { LogEntry as ApiLogEntry } from '@/api/types';
|
||||
import type { LogEntryData } from '@/components/analytics/log-entry';
|
||||
import type { LogEntryData } from '@/components/features/analytics/log-entry';
|
||||
|
||||
/**
|
||||
* Convert API log entry to component log entry data.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { LogEntryData } from '@/components/analytics/log-entry';
|
||||
import type { LogEntryData } from '@/components/features/analytics/log-entry';
|
||||
import {
|
||||
getKnownEventDomains,
|
||||
isErrorGroup,
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* - "Recording completed (5m 23s)"
|
||||
*/
|
||||
|
||||
import type { LogEntryData } from '@/components/analytics/log-entry';
|
||||
import type { LogEntryData } from '@/components/features/analytics/log-entry';
|
||||
|
||||
/** Summary of a log group with contextual information */
|
||||
export interface GroupSummary {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { LogEntryData } from '@/components/analytics/log-entry';
|
||||
import type { LogEntryData } from '@/components/features/analytics/log-entry';
|
||||
import { formatGap, getGroupGap, groupLogs, type LogGroup } from './groups';
|
||||
|
||||
function createLog(overrides: Partial<LogEntryData> = {}): LogEntryData {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* and navigation in the timeline view.
|
||||
*/
|
||||
|
||||
import type { LogEntryData } from '@/components/analytics/log-entry';
|
||||
import type { LogEntryData } from '@/components/features/analytics/log-entry';
|
||||
import { MS_PER_MINUTE } from '@/lib/utils/time';
|
||||
import { summarizeLogGroup, type GroupSummary } from './group-summarizer';
|
||||
|
||||
|
||||
@@ -37,11 +37,11 @@ import {
|
||||
SPEAKER_COLOR_CLASSES,
|
||||
speakerLabel,
|
||||
wordCountTickLabel,
|
||||
} from '@/components/analytics/analytics-utils';
|
||||
import { LogsTab } from '@/components/analytics/logs-tab';
|
||||
import { PerformanceTab } from '@/components/analytics/performance-tab';
|
||||
import { SpeechAnalysisTab } from '@/components/analytics/speech-analysis-tab';
|
||||
import { StatsCard } from '@/components/stats-card';
|
||||
} from '@/components/features/analytics/analytics-utils';
|
||||
import { LogsTab } from '@/components/features/analytics/logs-tab';
|
||||
import { PerformanceTab } from '@/components/features/analytics/performance-tab';
|
||||
import { SpeechAnalysisTab } from '@/components/features/analytics/speech-analysis-tab';
|
||||
import { StatsCard } from '@/components/common';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
|
||||
@@ -4,9 +4,9 @@ import { motion } from 'framer-motion';
|
||||
import { ArrowRight, Calendar, CheckSquare, Circle } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { getAPI } from '@/api/interface';
|
||||
import { EmptyState } from '@/components/empty-state';
|
||||
import { MeetingCard } from '@/components/meeting-card';
|
||||
import { PriorityBadge } from '@/components/priority-badge';
|
||||
import { EmptyState } from '@/components/common';
|
||||
import { MeetingCard } from '@/components/features/meetings';
|
||||
import { PriorityBadge } from '@/components/common';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { SkeletonMeetingCard } from '@/components/ui/skeleton';
|
||||
|
||||
@@ -6,14 +6,14 @@ import { useParams } from 'react-router-dom';
|
||||
import { getAPI } from '@/api/interface';
|
||||
import type { MeetingState } from '@/api/types';
|
||||
import type { ProjectScope } from '@/api/types/requests';
|
||||
import { EmptyState } from '@/components/empty-state';
|
||||
import { MeetingCard } from '@/components/meeting-card';
|
||||
import { ProjectScopeFilter } from '@/components/projects/ProjectScopeFilter';
|
||||
import { EmptyState } from '@/components/common';
|
||||
import { MeetingCard } from '@/components/features/meetings';
|
||||
import { ProjectScopeFilter } from '@/components/features/projects/ProjectScopeFilter';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { SearchIcon } from '@/components/ui/search-icon';
|
||||
import { SkeletonMeetingCard } from '@/components/ui/skeleton';
|
||||
import { UpcomingMeetings } from '@/components/upcoming-meetings';
|
||||
import { UpcomingMeetings } from '@/components/features/calendar';
|
||||
import { useProjects } from '@/contexts/project-state';
|
||||
import { useAsyncData } from '@/hooks';
|
||||
import { useGuardedMutation } from '@/hooks';
|
||||
|
||||
@@ -5,8 +5,8 @@ import { Link, useParams } from 'react-router-dom';
|
||||
import { useProjects } from '@/contexts/project-state';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ProjectMembersPanel } from '@/components/projects/ProjectMembersPanel';
|
||||
import { ProjectSettingsPanel } from '@/components/projects/ProjectSettingsPanel';
|
||||
import { ProjectMembersPanel } from '@/components/features/projects/ProjectMembersPanel';
|
||||
import { ProjectSettingsPanel } from '@/components/features/projects/ProjectSettingsPanel';
|
||||
|
||||
export default function ProjectSettingsPage() {
|
||||
const { projectId } = useParams();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Projects page
|
||||
|
||||
import { ProjectList } from '@/components/projects/ProjectList';
|
||||
import { ProjectList } from '@/components/features/projects/ProjectList';
|
||||
|
||||
export default function ProjectsPage() {
|
||||
return (
|
||||
|
||||
@@ -180,7 +180,7 @@ vi.mock('framer-motion', () => ({
|
||||
AnimatePresence: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/recording', () => ({
|
||||
vi.mock('@/components/features/recording', () => ({
|
||||
RecordingHeader: ({
|
||||
recordingState,
|
||||
meetingTitle,
|
||||
@@ -273,7 +273,7 @@ vi.mock('@/components/recording', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/timestamped-notes-editor', () => ({
|
||||
vi.mock('@/components/features/notes/timestamped-notes-editor', () => ({
|
||||
TimestampedNotesEditor: () => <div data-testid="notes-editor" />,
|
||||
}));
|
||||
|
||||
|
||||
@@ -13,8 +13,8 @@ import { useNavigate, useOutletContext, useParams } from 'react-router-dom';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
|
||||
import { isTauriEnvironment } from '@/api';
|
||||
import type { AppOutletContext } from '@/components/app-layout';
|
||||
import type { NoteEdit } from '@/components/timestamped-notes-editor';
|
||||
import type { AppOutletContext } from '@/components/layout';
|
||||
import type { NoteEdit } from '@/components/features/notes';
|
||||
import {
|
||||
IdleState,
|
||||
ListeningState,
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
StatsPanel,
|
||||
TranscriptSegmentCard,
|
||||
VADIndicator,
|
||||
} from '@/components/recording';
|
||||
} from '@/components/features/recording';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable';
|
||||
import { useConnectionState } from '@/contexts/connection-state';
|
||||
@@ -42,8 +42,8 @@ import { useRecordingSession } from '@/hooks';
|
||||
import { preferences } from '@/lib/preferences';
|
||||
import { buildSpeakerNameMap } from '@/lib/audio/speaker';
|
||||
|
||||
import { JumpToLiveIndicator } from '@/components/recording/jump-to-live-indicator';
|
||||
import { InTranscriptSearch } from '@/components/recording/in-transcript-search';
|
||||
import { JumpToLiveIndicator } from '@/components/features/recording/jump-to-live-indicator';
|
||||
import { InTranscriptSearch } from '@/components/features/recording/in-transcript-search';
|
||||
|
||||
|
||||
const TRANSCRIPT_VIRTUALIZE_THRESHOLD = 100;
|
||||
|
||||
@@ -7,10 +7,10 @@ import { Link } from 'react-router-dom';
|
||||
import { getAPI } from '@/api/interface';
|
||||
import type { Priority } from '@/api/types';
|
||||
import type { ProjectScope } from '@/api/types/requests';
|
||||
import { EmptyState } from '@/components/empty-state';
|
||||
import { PriorityBadge } from '@/components/priority-badge';
|
||||
import { ProjectScopeFilter } from '@/components/projects/ProjectScopeFilter';
|
||||
import { CenteredStatsCard } from '@/components/stats-card';
|
||||
import { EmptyState } from '@/components/common';
|
||||
import { PriorityBadge } from '@/components/common';
|
||||
import { ProjectScopeFilter } from '@/components/features/projects/ProjectScopeFilter';
|
||||
import { CenteredStatsCard } from '@/components/common';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
|
||||
import { isTauriEnvironment } from '@/api';
|
||||
import type { ExportFormat, Meeting, PlaybackInfo } from '@/api/types';
|
||||
import { MeetingStateBadge } from '@/components/meeting-state-badge';
|
||||
import { MeetingStateBadge } from '@/components/features/meetings';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
||||
@@ -11,7 +11,7 @@ import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { getAPI } from '@/api/interface';
|
||||
import { isTauriEnvironment } from '@/api';
|
||||
import type { ExportFormat } from '@/api/types';
|
||||
import { ProcessingStatus } from '@/components/processing-status';
|
||||
import { ProcessingStatus } from '@/components/features/meetings';
|
||||
import { SkeletonTranscript } from '@/components/ui/skeleton';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { useGuardedMutation } from '@/hooks';
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { Sparkles } from 'lucide-react';
|
||||
|
||||
import type { Summary } from '@/api/types';
|
||||
import { PriorityBadge } from '@/components/priority-badge';
|
||||
import { PriorityBadge } from '@/components/common';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import { memo } from 'react';
|
||||
|
||||
import { SpeakerBadge } from '@/components/speaker-badge';
|
||||
import { SpeakerBadge } from '@/components/common/badges/speaker-badge';
|
||||
import { formatTime } from '@/lib/utils/format';
|
||||
|
||||
import type { MeetingTranscriptRowProps } from './types';
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
CloudAIToggle,
|
||||
OllamaStatusCard,
|
||||
SummarizationSettingsPanel,
|
||||
} from '@/components/settings';
|
||||
} from '@/components/features/settings';
|
||||
import { PROVIDER_ENDPOINTS } from '@/lib/config/provider-endpoints';
|
||||
import { preferences } from '@/lib/preferences';
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AudioDevicesSection, RecordingAppPolicySection } from '@/components/settings';
|
||||
import { AudioDevicesSection, RecordingAppPolicySection } from '@/components/features/settings';
|
||||
import type { UseAudioDevicesReturn } from '@/hooks';
|
||||
|
||||
interface AudioTabProps {
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
ConnectionDiagnosticsPanel,
|
||||
DeveloperOptionsSection,
|
||||
QuickActionsSection,
|
||||
} from '@/components/settings';
|
||||
} from '@/components/features/settings';
|
||||
import { toast } from '@/hooks';
|
||||
import { toastError } from '@/lib/observability/errors';
|
||||
import { meetingCache } from '@/lib/cache/meeting-cache';
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import type { Integration } from '@/api/types';
|
||||
import { IntegrationsSection } from '@/components/settings';
|
||||
import { SyncControlPanel } from '@/components/sync-control-panel';
|
||||
import { SyncHistoryLog } from '@/components/sync-history-log';
|
||||
import { WebhookSettingsPanel } from '@/components/webhook-settings-panel';
|
||||
import { IntegrationsSection } from '@/components/features/settings';
|
||||
import { SyncControlPanel } from '@/components/features/sync';
|
||||
import { SyncHistoryLog } from '@/components/features/sync';
|
||||
import { WebhookSettingsPanel } from '@/components/features/integrations';
|
||||
import type { IntegrationSyncState } from '@/hooks';
|
||||
|
||||
interface IntegrationsTabProps {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { EffectiveServerUrl } from '@/api/types';
|
||||
import { SecureStorageRecoveryDialog } from '@/components/secure-storage-recovery-dialog';
|
||||
import { ServerSwitchConfirmationDialog } from '@/components/server-switch-confirmation-dialog';
|
||||
import { SimulationConfirmationDialog } from '@/components/simulation-confirmation-dialog';
|
||||
import { SecureStorageRecoveryDialog } from '@/components/system';
|
||||
import { ServerSwitchConfirmationDialog } from '@/components/features/connectivity';
|
||||
import { SimulationConfirmationDialog } from '@/components/dev';
|
||||
import { buildServerUrl } from '@/lib/config/server';
|
||||
|
||||
interface PendingServerChange {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import type { EffectiveServerUrl, ServerInfo } from '@/api/types';
|
||||
import { PreferencesSyncStatus } from '@/components/preferences-sync-status';
|
||||
import { ServerConnectionSection } from '@/components/settings';
|
||||
import { PreferencesSyncStatus } from '@/components/features/sync';
|
||||
import { ServerConnectionSection } from '@/components/features/settings';
|
||||
|
||||
interface StatusTabProps {
|
||||
serverHost: string;
|
||||
|
||||
Reference in New Issue
Block a user