diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..b5eda4459 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,86 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +```bash +npm run develop # Start Gatsby dev server +npm run build # Clean + production build +npm run lint # Run Biome linter +npm run lint:fix # Fix linting issues +npm run format # Format code with Biome +npm run typecheck # TypeScript type check (no emit) +npm run test # Run Jest unit tests once +npm run test:watch # Run Jest in watch mode +npm run e2e:open # Open Cypress UI +npm run e2e:all # Run all Cypress E2E tests +``` + +Node: `22.17.0`, npm: `10.9.2`. Install with `npm i --legacy-peer-deps`. + +Requires `.env.development` with Firebase credentials (`GATSBY_API_URL`, `GATSBY_API_KEY`, `GATSBY_AUTH_DOMAIN`, `GATSBY_PROJECT_ID`, `GATSBY_STORAGE_BUCKET`, `GATSBY_MESSAGING_SENDER_ID`, `GATSBY_APP_ID`, `GATSBY_MEASUREMENT_ID`). + +## Architecture + +**Stack:** Gatsby 5 (SSG) + React 18 + TypeScript (strict) + Firebase backend (Cloud Functions, Auth, Storage) + Zustand state + Tailwind CSS + Biome linter. + +**Branching:** `develop` is the default branch; `main` is production-only. + +### Directory Map + +| Path | Purpose | +|------|---------| +| `src/pages/` | Gatsby file-based routing | +| `src/features/` | Domain-driven feature directories (auth, creator, mindmap-creator, etc.) | +| `src/modules/` | Feature-specific UI modules with local state | +| `src/containers/` | Smart components that wire state to UI | +| `src/components/` | Stateless, reusable UI pieces | +| `src/design-system/` | Base UI components and design tokens | +| `src/store/` | Zustand stores, one folder per feature | +| `src/acts/` | Async multi-step operations that span multiple stores | +| `src/api-4markdown/` | Firebase API client | +| `src/api-4markdown-contracts/` | Typed API contracts and DTOs | +| `src/core/` | Auth utilities, analytics, shared models | +| `src/development-kit/` | Form utilities, Zustand wrapper (`state.ts`), helper functions | +| `src/models/` | Shared data model types | +| `src/providers/` | React context providers | +| `src/layouts/` | Page layout components | + +### State Management Pattern (from DS.md) + +Every store feature lives under `src/store/[feature-name]/` with four files: + +``` +index.ts # Creates and exports the Zustand hook: useFeatureNameState +models.ts # Defines the state shape: type FeatureNameState = ... +actions.ts # Sync-only state mutation functions (postfix: Action) +selectors.ts # Typed data extraction functions (postfix: Selector) +``` + +State is created via the custom wrapper in `development-kit/state.ts` which exposes `.swap()`, `.reset()`, and `.subscribe()`. + +**Acts** (`src/acts/name.act.ts`) orchestrate multi-step async flows that span multiple stores or require API calls + side effects. Use the `Act` postfix. + +### TypeScript Path Aliases + +Configured in `tsconfig.json` — use these instead of relative paths: + +`design-system/*`, `development-kit/*`, `store/*`, `features/*`, `modules/*`, `components/*`, `models/*`, `providers/*`, `core/*`, `layouts/*`, `acts/*`, `containers/*`, `api-4markdown`, `api-4markdown-contracts` + +### Conventions (from DS.md) + +- All exports go at the **bottom** of the file. +- State type names use the `State` postfix (e.g., `UploadImageState`). +- Action functions use the `Action` postfix; selectors use the `Selector` postfix. +- Actions are always synchronous and free of side effects. +- Acts always declare an explicit return type and use `AsyncResult` from `development-kit/utility-types`. +- Local single-use component props can be inlined without a named type: `({ content }: { content: string })`. + +### API Layer + +API calls go through `getAPI().call('methodName')(params)` from `api-4markdown`. Errors are parsed via `parseError()`. The `api-4markdown-contracts` package defines all method contracts and DTOs. + +### Graph/Mindmap + +`@xyflow/react` handles diagram rendering; `@dagrejs/dagre` provides the layout engine. Key features: `mindmap-creator`, `mindmap-preview`, `mindmap-display`. diff --git a/PROCESS.md b/PROCESS.md new file mode 100644 index 000000000..c86869eb2 --- /dev/null +++ b/PROCESS.md @@ -0,0 +1,588 @@ +# Content Quality Pipeline + +**Version:** 3.0.0 +**Last updated:** 2026-02-20 + +## Overview + +Automated content quality verification system powered by LLMs via OpenRouter. Content is **deterministically split into indexed blocks** before any LLM sees it. A configurable set of **Reviewer agents** evaluate blocks in parallel. A single **Resolver model** then outputs **only the block IDs it wants to change** with new content. Untouched blocks remain **byte-identical** — guaranteed by code, not by LLM behavior. + +### Core Principle: LLMs Never See Raw Content + +Every LLM in the pipeline receives **indexed blocks**, never raw Markdown. This makes every reference unambiguous and every assembly step deterministic. + +``` +Raw Markdown → [Deterministic Indexer] → Indexed Blocks → LLMs → Changed Block IDs → [Deterministic Assembler] → Result +``` + +## Architecture + +```mermaid +flowchart TD + subgraph "Stage 0 — Deterministic Indexing (no LLM)" + A[Raw Markdown content] --> IDX[Block Indexer
splits by headings, paragraphs,
code fences, lists] + IDX --> BLOCKS["Indexed blocks array
[B001] heading...
[B002] paragraph...
[B003] code fence...
[B004] paragraph..."] + end + + subgraph "Stage 1 — Parallel Review (configurable N agents)" + CFG[Metric Registry
enabled metrics only] -->|determines which agents run| PAR + BLOCKS --> PAR[Promise.allSettled] + PAR --> R1[Reviewer 1] + PAR --> R2[Reviewer 2] + PAR --> RN[Reviewer N] + end + + subgraph "Stage 1 Output — Review Notes per Block" + R1 --> N1["{ metric, score, issues[
{ block_id, severity,
description, suggestion }
], summary }"] + R2 --> N2["..."] + RN --> NN["..."] + end + + subgraph "Stage 2 — Resolver (single strong model)" + BLOCKS -->|original indexed blocks| RES[Resolver Model] + N1 & N2 & NN -->|all review notes| RES + RES --> PATCH["Patch map
{ B002: 'new content',
B003: 'new content' }
+ changelog"] + end + + subgraph "Stage 3 — Deterministic Assembly (no LLM)" + BLOCKS -->|original blocks| ASM[Block Assembler] + PATCH -->|changed blocks only| ASM + ASM --> VALID{Validation} + VALID -->|block count matches
no unknown IDs
untouched blocks identical| RESULT[Final Markdown
+ per-block diff] + VALID -->|validation failed| ERR[Reject — pipeline error] + end + + subgraph "Stage 4 — Human Review" + RESULT --> REVIEW[Human reviews
per-block diff + changelog] + REVIEW -->|Accept all| PUB[Publish] + REVIEW -->|Cherry-pick blocks| PARTIAL[Accept some, reject others] + REVIEW -->|Reject all| BACK[Return to author] + end +``` + +## Stage 0 — Deterministic Block Indexing + +Before any LLM is called, a **deterministic parser** (pure code, no AI) splits the Markdown into numbered blocks. This runs once and the result is used by all subsequent stages. + +### Splitting Rules + +The parser walks the Markdown line by line and splits on these boundaries: + +| Boundary | Rule | +|----------|------| +| **Heading** | Any line starting with `#` starts a new block | +| **Code fence** | Opening ` ``` ` through closing ` ``` ` is one block (including fences) | +| **Blank line separation** | Consecutive non-blank lines form one paragraph block | +| **List** | A contiguous list (ordered or unordered) is one block | +| **Blockquote** | Contiguous `>` lines form one block | +| **Horizontal rule** | `---` / `***` is its own block | +| **Table** | Contiguous table rows (with `|`) form one block | + +### Block Format + +Each block gets a sequential ID: `B001`, `B002`, ..., `B{NNN}`. + +``` +[B001] +# React Hooks Guide + +[B002] +React hooks let you use state and other React features in function components. They were introduced in React 16.8. + +[B003] +## useReducer + +[B004] +The useReducer hook accepts a reducer and returns the current state paired with a dispatch method. + +[B005] +```javascript +const [state, dispatch] = useReducer(reducer, initialState); +``` + +[B006] +Firebase v9 modular SDK is the recommended approach. + +[B007] +## Advanced Patterns + +[B008] +React context combined with useReducer provides a Redux-like pattern without external dependencies. This section covers the full implementation including provider setup, typed dispatch, and performance optimization with useMemo. +``` + +### Properties + +- **Deterministic** — same input always produces same blocks with same IDs +- **Lossless** — concatenating all blocks in order reproduces the original Markdown byte-for-byte (including blank lines between blocks) +- **Unique IDs** — no two blocks share an ID within a document +- **Self-contained** — each block is a meaningful unit (a heading, a paragraph, a code block, a list) — never a partial line + +### Why Not Line Numbers? + +Line numbers break when content has multi-line paragraphs, nested lists, or code blocks. A paragraph at lines 15-22 is one semantic unit — referring to "line 18" inside it is meaningless. Blocks match how humans and LLMs think about document structure. + +## Configurable Metric Registry + +Metrics are **not hardcoded**. They live in a configuration that can be modified without code changes. Start with 2, add more as you validate the pipeline. + +### Registry Schema + +```json +{ + "metrics": [ + { + "id": "weryfikacja_techniczna", + "name": "Weryfikacja Techniczna", + "description": "Are statements factually correct? Do code examples compile and behave as described?", + "model": "anthropic/claude-opus-4", + "enabled": true, + "prompt_template": "technical_correctness.md" + }, + { + "id": "aktualnosc", + "name": "Aktualność", + "description": "Is the content up-to-date with current library versions, APIs, and standards?", + "model": "anthropic/claude-sonnet-4", + "enabled": true, + "prompt_template": "currency.md" + }, + { + "id": "praktycznosc", + "name": "Praktyczność", + "description": "Are code examples tested, runnable, and solving real problems?", + "model": "anthropic/claude-sonnet-4", + "enabled": false, + "prompt_template": "practicality.md" + } + ], + "resolver": { + "model": "anthropic/claude-opus-4" + } +} +``` + +| Field | Description | +|-------|-------------| +| `id` | Unique key, lowercase, no diacritics | +| `name` | Display name | +| `description` | What this metric evaluates — also injected into the agent's system prompt | +| `model` | OpenRouter model identifier | +| `enabled` | `true` / `false` — toggle without removing config | +| `prompt_template` | Path to the prompt file for this metric | + +### Suggested Rollout + +| Phase | Active Metrics | Purpose | +|-------|---------------|---------| +| **Phase 1** | `weryfikacja_techniczna`, `aktualnosc` | Validate pipeline end-to-end with the two most impactful metrics | +| **Phase 2** | + `praktycznosc`, `przystepnosc` | Add content quality dimensions | +| **Phase 3** | + `kompletnosc`, `dostepnosc` | Structural completeness and formatting | +| **Phase 4** | + `feedback`, `aktualizacje` | Community-driven and maintenance metrics | + +### Full Metric Catalog + +| # | ID | Name | Suggested Model | +|---|-----|------|-----------------| +| 1 | `weryfikacja_techniczna` | Weryfikacja Techniczna (Technical Correctness) | `anthropic/claude-opus-4` | +| 2 | `aktualnosc` | Aktualność (Currency) | `anthropic/claude-sonnet-4` | +| 3 | `praktycznosc` | Praktyczność (Practicality) | `anthropic/claude-sonnet-4` | +| 4 | `przystepnosc` | Przystępność (Accessibility) | `anthropic/claude-haiku-4` | +| 5 | `kompletnosc` | Kompletność (Completeness) | `anthropic/claude-sonnet-4` | +| 6 | `dostepnosc` | Dostępność (Availability/Formatting) | `anthropic/claude-haiku-4` | +| 7 | `feedback` | Feedback (Community Responsiveness) | `anthropic/claude-haiku-4` | +| 8 | `aktualizacje` | Aktualizacje (Update Frequency) | `anthropic/claude-haiku-4` | + +## Stage 1 — Parallel Metric Review + +Only `enabled` metrics from the registry are executed. Each reviewer agent receives the **indexed blocks** (not raw Markdown) and its own prompt template. Agents produce **review notes referencing block IDs** — they never produce replacement content. + +### Execution + +```typescript +const enabledMetrics = registry.metrics.filter(m => m.enabled); + +const reviews = await Promise.allSettled( + enabledMetrics.map(metric => evaluateMetric(metric, indexedBlocks)) +); + +// Pipeline continues with successful reviews only. +// Failed metrics are logged and flagged in the final report. +``` + +### Reviewer Input + +The reviewer sees blocks in this format: + +``` +You are reviewing the following content for: {metric.description} + +Content blocks: + +[B001] +# React Hooks Guide + +[B002] +React hooks let you use state and other React features in function components. They were introduced in React 16.8. + +[B003] +## useReducer + +[B004] +The useReducer hook accepts a reducer and returns the current state paired with a dispatch method. + +... +``` + +### Reviewer Output Schema + +```json +{ + "metric": "weryfikacja_techniczna", + "score": 82, + "issues": [ + { + "block_id": "B004", + "severity": "critical", + "description": "Incorrect — useReducer returns an array [state, dispatch], not 'paired with'. The phrasing implies an object.", + "suggestion": "Clarify that useReducer returns a tuple: [state, dispatch]." + }, + { + "block_id": "B006", + "severity": "warning", + "description": "Firebase v9 has been superseded by v11. The statement is outdated.", + "suggestion": "Update to reference Firebase v11 modular SDK." + } + ], + "summary": "Two issues found: one factual inaccuracy in hooks description (B004), one outdated Firebase reference (B006)." +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `metric` | `string` | Metric ID from registry | +| `score` | `number` (0-100) | Numerical score for this dimension | +| `issues` | `Issue[]` | Detected problems | +| `issues[].block_id` | `string` | **Which block** the issue is in — unambiguous reference | +| `issues[].severity` | `"critical" \| "warning" \| "info"` | Urgency level | +| `issues[].description` | `string` | What is wrong | +| `issues[].suggestion` | `string` | Human-readable advice (not replacement text) | +| `summary` | `string` | One-sentence overview | + +**Why `block_id` works where `quote` and `location` failed:** +- A block ID is a **closed set** — the validator can check if `B004` actually exists. If the LLM references `B999`, it's immediately caught. +- No ambiguity — `B004` is exactly one block. A quote like `"useReducer returns"` could match multiple places. +- No structural path guessing — `section:X > paragraph:3` requires the LLM to count correctly. Block IDs are given to it explicitly. + +## Stage 2 — Resolver + +A single strong model (`anthropic/claude-opus-4`) receives: +1. The **indexed blocks** (full document in `[B001]...[B00N]` format) +2. All **reviewer outputs** (the structured JSON from Stage 1) + +It produces a **patch map** — only the blocks it wants to change — plus a changelog. + +### Resolver Prompt Structure + +``` +You are a content editor. Below is an article split into indexed blocks, followed by review notes from multiple reviewers. + +Your task: +1. Read all review notes carefully. +2. For each block that needs changes, output the COMPLETE new content for that block. +3. Do NOT output blocks that don't need changes. +4. If two reviewers disagree about a block, make a judgment call and explain in the changelog. +5. Never merge blocks, split blocks, add new blocks, or remove blocks. The block structure is fixed. + +IMPORTANT CONSTRAINTS: +- You may ONLY change the content within existing blocks. +- You MUST keep the same number of blocks. +- You MUST NOT reference block IDs that don't exist in the original. +- Each changed block must contain the FULL replacement content for that block. + +Content blocks: + +[B001] +# React Hooks Guide + +[B002] +React hooks let you use state and... + +... + +Review notes: + +[weryfikacja_techniczna] score: 82 +- B004 (critical): Incorrect — useReducer returns an array... +- B006 (warning): Firebase v9 has been superseded... + +[aktualnosc] score: 88 +- B006 (critical): Firebase v9 is EOL since 2024... +``` + +### Resolver Output Schema + +```json +{ + "patches": { + "B004": "The useReducer hook accepts a reducer function and an initial state value. It returns a tuple — an array of exactly two elements: the current state and a dispatch function.", + "B006": "Firebase v11 modular SDK is the recommended approach. It was released in 2025 and includes tree-shaking support and improved TypeScript types." + }, + "changelog": [ + { + "block_id": "B004", + "what": "Fixed useReducer return type description", + "why": "Original stated 'paired with' implying an object. useReducer returns a [state, dispatch] tuple.", + "triggered_by": ["weryfikacja_techniczna"], + "severity": "critical" + }, + { + "block_id": "B006", + "what": "Updated Firebase version from v9 to v11", + "why": "Firebase v9 is EOL. Both reviewers flagged this independently.", + "triggered_by": ["weryfikacja_techniczna", "aktualnosc"], + "severity": "critical" + }, + { + "block_id": null, + "what": "No changes to React context section (B008)", + "why": "Considered simplifying but the depth is appropriate for the target senior audience.", + "triggered_by": [], + "severity": "info" + } + ], + "scores": { + "weryfikacja_techniczna": 82, + "aktualnosc": 88 + }, + "average_score": 85.0, + "missing_metrics": [] +} +``` + +**Key design: `patches` is a map of `block_id → new content`.** Only changed blocks appear. The Resolver cannot add, remove, or reorder blocks — that constraint is enforced by code in Stage 3. + +## Stage 3 — Deterministic Assembly & Validation + +Pure code. No LLM. Takes the original indexed blocks + the patch map and produces the final document. + +### Algorithm + +```typescript +function assemble( + originalBlocks: Map, + patches: Record +): AssemblyResult { + const errors: string[] = []; + + // Validation 1: All patch IDs must exist in original + for (const blockId of Object.keys(patches)) { + if (!originalBlocks.has(blockId)) { + errors.push(`Patch references unknown block: ${blockId}`); + } + } + + if (errors.length > 0) { + return { success: false, errors }; + } + + // Assembly: walk original blocks in order, apply patches + const finalBlocks: string[] = []; + const diff: BlockDiff[] = []; + + for (const [blockId, originalContent] of originalBlocks) { + if (blockId in patches) { + finalBlocks.push(patches[blockId]); + diff.push({ + block_id: blockId, + status: "changed", + original: originalContent, + revised: patches[blockId], + }); + } else { + // Untouched — byte-identical, guaranteed + finalBlocks.push(originalContent); + diff.push({ + block_id: blockId, + status: "unchanged", + }); + } + } + + // Validation 2: Block count unchanged + if (finalBlocks.length !== originalBlocks.size) { + return { success: false, errors: ["Block count mismatch"] }; + } + + return { + success: true, + markdown: joinBlocks(finalBlocks), + diff, + stats: { + total_blocks: originalBlocks.size, + changed_blocks: Object.keys(patches).length, + unchanged_blocks: originalBlocks.size - Object.keys(patches).length, + }, + }; +} +``` + +### Guarantees + +| Property | How it's enforced | +|----------|-------------------| +| **Untouched blocks are byte-identical** | Code copies original content directly — LLM never touches them | +| **No blocks added** | Assembly iterates original block list only | +| **No blocks removed** | Every original block appears in output | +| **No block reordering** | Assembly preserves original iteration order | +| **No unknown block IDs** | Validation rejects patches referencing non-existent IDs | +| **No duplicates** | Patch map is a dict — keys are unique by definition | + +### Per-Block Diff Output + +For human review, each changed block produces a clear diff: + +``` +[B004] CHANGED (critical) +Triggered by: weryfikacja_techniczna +Reason: Fixed useReducer return type description + +--- original ++++ revised +- The useReducer hook accepts a reducer and returns the current state paired with a dispatch method. ++ The useReducer hook accepts a reducer function and an initial state value. It returns a tuple — an array of exactly two elements: the current state and a dispatch function. + +[B006] CHANGED (critical) +Triggered by: weryfikacja_techniczna, aktualnosc +Reason: Updated Firebase version from v9 to v11 + +--- original ++++ revised +- Firebase v9 modular SDK is the recommended approach. ++ Firebase v11 modular SDK is the recommended approach. It was released in 2025 and includes tree-shaking support and improved TypeScript types. + +[B001] unchanged +[B002] unchanged +[B003] unchanged +[B005] unchanged +[B007] unchanged +[B008] unchanged +``` + +## Stage 4 — Human Review + +The reviewer sees: +1. **Scores** — per-metric and average +2. **Changelog** — what changed and why, in plain language +3. **Per-block diff** — exact before/after for each changed block + +Options: +- **Accept all** — publish the assembled version +- **Cherry-pick** — accept/reject individual block patches (the assembler re-runs with only accepted patches) +- **Reject all** — discard, return changelog to author as feedback + +Cherry-picking is trivial: just remove rejected block IDs from the patch map and re-run assembly. No cascading side effects because blocks are independent. + +## OpenRouter Integration + +All LLM calls go through OpenRouter (`https://openrouter.ai/api/v1`). + +### Request Pattern + +``` +POST /api/v1/chat/completions +Authorization: Bearer $OPENROUTER_API_KEY +Content-Type: application/json + +{ + "model": "anthropic/claude-sonnet-4", + "messages": [...], + "response_format": { "type": "json_schema", "json_schema": { ... } } +} +``` + +### Parallel Execution + +```typescript +const results = await Promise.allSettled( + enabledMetrics.map(metric => + fetch("https://openrouter.ai/api/v1/chat/completions", { + method: "POST", + headers: { + "Authorization": `Bearer ${OPENROUTER_API_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: metric.model, + messages: buildReviewerPrompt(metric, indexedBlocks), + response_format: { type: "json_schema", json_schema: reviewerSchema }, + }), + }) + ) +); +``` + +### Estimated Cost per Article + +#### Phase 1 (2 metrics) + +| Stage | Model | Est. tokens (in/out) | Est. cost | +|-------|-------|----------------------|-----------| +| Technical Correctness | claude-opus-4 | ~6K/2K | $0.12 | +| Currency | claude-sonnet-4 | ~4K/2K | $0.03 | +| Resolver | claude-opus-4 | ~14K/4K | $0.30 | +| **Total** | | | **~$0.45/article** | + +#### Phase 4 (all 8 metrics) + +| Stage | Model | Est. tokens (in/out) | Est. cost | +|-------|-------|----------------------|-----------| +| 4x Haiku agents | claude-haiku-4 | ~4K/1K each | $0.01 | +| 3x Sonnet agents | claude-sonnet-4 | ~4K/2K each | $0.05 | +| 1x Opus agent | claude-opus-4 | ~6K/2K | $0.12 | +| Resolver (Opus) | claude-opus-4 | ~24K/6K | $0.48 | +| **Total** | | | **~$0.66/article** | + +## Contribution Pipeline + +External contributions follow the identical pipeline. An additional **Gate Agent** (Haiku) runs before Stage 0: + +```mermaid +flowchart LR + A[Contribution submitted] --> B[Gate Agent
originality + relevance + minimum quality] + B -->|Pass| C[Standard Pipeline
Stage 0-4] + B -->|Fail| D[Rejected with feedback] +``` + +## Re-evaluation Pipeline + +Existing published content is periodically re-evaluated when: +- New comments/feedback are submitted on the content +- A configured time interval passes (e.g., 90 days) +- A technology mentioned in the content releases a new major version + +The re-evaluation runs the same Stage 0-4 pipeline with user comments appended as additional context to each reviewer's prompt. + +## Scoring Scale + +| Range | Label | Action | +|-------|-------|--------| +| 90-100 | Excellent | No action needed | +| 80-89 | High quality | Minor improvements suggested | +| 70-79 | Good quality | Updates planned | +| Below 70 | Needs update | Priority review triggered | + +## Design Decision Log + +### Why indexed blocks instead of full rewrite? (v2 → v3) + +v2 let the Resolver rewrite the entire document and used a diff tool to find changes. Problem: the LLM could silently drop paragraphs, duplicate sections, or subtly alter text it wasn't supposed to touch — and detecting these silent mutations in a large diff is hard for a human reviewer. + +v3 constrains the LLM to output **only changed blocks by ID**. Untouched blocks are preserved by code, not by LLM discipline. The assembler validates that no blocks were added/removed/reordered. This makes unintended changes **structurally impossible**, not just unlikely. + +### Why not line numbers? + +Line numbers break with multi-line paragraphs, nested lists, and code blocks. Line 18 inside a 10-line paragraph is meaningless. Blocks match semantic document structure — a heading, a paragraph, a code fence — which is how both humans and LLMs reason about content. + +### Why not surgical diffs from LLM? (v1 post-mortem) + +v1 asked the Resolver to produce `replace`/`insert`/`delete` operations with exact text. This failed because: LLMs hallucinate locations, misquote originals, and sequential operations cascade (operation 1 shifts content for operation 2). The block-based approach eliminates all three problems. diff --git a/cypress/scenarios/docs-management.ts b/cypress/scenarios/docs-management.ts index 65cb3741b..80bcf969e 100644 --- a/cypress/scenarios/docs-management.ts +++ b/cypress/scenarios/docs-management.ts @@ -29,7 +29,7 @@ const DOCS_MANAGEMENT_SCENARIOS = { `Cancel document removal`, `Close document removal`, ]) - .and(`I not see text`, [`Delete current document`, name]); + .and(`I not see text`, [`Document Removal`]); }, "I change document visiblity": () => { const documentName = `${uid(`S`)} next next`; @@ -141,8 +141,8 @@ const DOCS_MANAGEMENT_SCENARIOS = { .and(`I see text in creator`, `# Markdown Cheatsheet`); }, "I create, edit and delete document": () => { - const documentName = `Test document`; - const documentNameEdited = `Doc 2`; + const documentName = `${uid("D")} test document`; + const documentNameEdited = `${uid("D")} edited document`; return given(`I click button`, [`Create new document`]) .and(`I click button`, [`Go to manual document creation form`]) @@ -214,7 +214,7 @@ const DOCS_MANAGEMENT_SCENARIOS = { `Cancel document removal`, `Close document removal`, ]) - .and(`I not see text`, [`Delete current document`, documentNameEdited]) + .and(`I not see text`, [`Document Removal`]) .and(`I see text`, [`Markdown Cheatsheet`]) .and(`I see text in creator`, `# Markdown Cheatsheet`) .and(`I see button`, [`User details and options`]); diff --git a/cypress/snapshots/creator-sync.cy.ts/after-change-theme.snap.png b/cypress/snapshots/creator-sync.cy.ts/after-change-theme.snap.png index 49b6b2bf6..47682c5c1 100644 Binary files a/cypress/snapshots/creator-sync.cy.ts/after-change-theme.snap.png and b/cypress/snapshots/creator-sync.cy.ts/after-change-theme.snap.png differ diff --git a/cypress/snapshots/creator-sync.cy.ts/before-change-theme.snap.png b/cypress/snapshots/creator-sync.cy.ts/before-change-theme.snap.png index d4b5c8771..6f963d099 100644 Binary files a/cypress/snapshots/creator-sync.cy.ts/before-change-theme.snap.png and b/cypress/snapshots/creator-sync.cy.ts/before-change-theme.snap.png differ diff --git a/cypress/snapshots/docs-display.cy.ts/document-in-creator.snap.png b/cypress/snapshots/docs-display.cy.ts/document-in-creator.snap.png index 7ec61b487..0651f8b74 100644 Binary files a/cypress/snapshots/docs-display.cy.ts/document-in-creator.snap.png and b/cypress/snapshots/docs-display.cy.ts/document-in-creator.snap.png differ diff --git a/cypress/snapshots/docs-display.cy.ts/document-preview-heading-Mediator Definition and Theory.snap.png b/cypress/snapshots/docs-display.cy.ts/document-preview-heading-Mediator Definition and Theory.snap.png index 9eb1733c8..7080041ef 100644 Binary files a/cypress/snapshots/docs-display.cy.ts/document-preview-heading-Mediator Definition and Theory.snap.png and b/cypress/snapshots/docs-display.cy.ts/document-preview-heading-Mediator Definition and Theory.snap.png differ diff --git a/cypress/snapshots/docs-display.cy.ts/document-preview-heading-Mediator Implementation.snap.png b/cypress/snapshots/docs-display.cy.ts/document-preview-heading-Mediator Implementation.snap.png index 2e87d9315..8f3ed9459 100644 Binary files a/cypress/snapshots/docs-display.cy.ts/document-preview-heading-Mediator Implementation.snap.png and b/cypress/snapshots/docs-display.cy.ts/document-preview-heading-Mediator Implementation.snap.png differ diff --git a/cypress/snapshots/docs-display.cy.ts/document-preview-heading-Mediator Pattern In TypeScript.snap.png b/cypress/snapshots/docs-display.cy.ts/document-preview-heading-Mediator Pattern In TypeScript.snap.png index 0f1940e2e..b9b11cca5 100644 Binary files a/cypress/snapshots/docs-display.cy.ts/document-preview-heading-Mediator Pattern In TypeScript.snap.png and b/cypress/snapshots/docs-display.cy.ts/document-preview-heading-Mediator Pattern In TypeScript.snap.png differ diff --git a/cypress/snapshots/docs-display.cy.ts/document-preview-heading-Notifications Management with Mediator.snap.png b/cypress/snapshots/docs-display.cy.ts/document-preview-heading-Notifications Management with Mediator.snap.png index 2c0ecb2ea..17c467eaa 100644 Binary files a/cypress/snapshots/docs-display.cy.ts/document-preview-heading-Notifications Management with Mediator.snap.png and b/cypress/snapshots/docs-display.cy.ts/document-preview-heading-Notifications Management with Mediator.snap.png differ diff --git a/cypress/snapshots/docs-display.cy.ts/document-preview-heading-Other Use Cases Ideas.snap.png b/cypress/snapshots/docs-display.cy.ts/document-preview-heading-Other Use Cases Ideas.snap.png index e4f91d62a..4ecfdeabb 100644 Binary files a/cypress/snapshots/docs-display.cy.ts/document-preview-heading-Other Use Cases Ideas.snap.png and b/cypress/snapshots/docs-display.cy.ts/document-preview-heading-Other Use Cases Ideas.snap.png differ diff --git a/cypress/snapshots/docs-display.cy.ts/document-preview-heading-Summary.snap.png b/cypress/snapshots/docs-display.cy.ts/document-preview-heading-Summary.snap.png index 0abe416d1..bb84646fa 100644 Binary files a/cypress/snapshots/docs-display.cy.ts/document-preview-heading-Summary.snap.png and b/cypress/snapshots/docs-display.cy.ts/document-preview-heading-Summary.snap.png differ diff --git a/cypress/snapshots/docs-display.cy.ts/document-preview-heading-Too Big Mediators - God Classes Issue.snap.png b/cypress/snapshots/docs-display.cy.ts/document-preview-heading-Too Big Mediators - God Classes Issue.snap.png index 6122e0276..f0dc450fe 100644 Binary files a/cypress/snapshots/docs-display.cy.ts/document-preview-heading-Too Big Mediators - God Classes Issue.snap.png and b/cypress/snapshots/docs-display.cy.ts/document-preview-heading-Too Big Mediators - God Classes Issue.snap.png differ diff --git a/cypress/snapshots/docs-loading.cy.ts/old-documents-after-sync.snap.png b/cypress/snapshots/docs-loading.cy.ts/old-documents-after-sync.snap.png index b3429df25..524098c62 100644 Binary files a/cypress/snapshots/docs-loading.cy.ts/old-documents-after-sync.snap.png and b/cypress/snapshots/docs-loading.cy.ts/old-documents-after-sync.snap.png differ diff --git a/cypress/snapshots/docs-loading.cy.ts/old-documents.snap.png b/cypress/snapshots/docs-loading.cy.ts/old-documents.snap.png index ff4ef19cc..4a05edf9a 100644 Binary files a/cypress/snapshots/docs-loading.cy.ts/old-documents.snap.png and b/cypress/snapshots/docs-loading.cy.ts/old-documents.snap.png differ diff --git a/cypress/snapshots/docs-loading.cy.ts/older-documents.snap.png b/cypress/snapshots/docs-loading.cy.ts/older-documents.snap.png index a35ad436d..911f893ec 100644 Binary files a/cypress/snapshots/docs-loading.cy.ts/older-documents.snap.png and b/cypress/snapshots/docs-loading.cy.ts/older-documents.snap.png differ diff --git a/cypress/snapshots/docs-loading.cy.ts/recent-documents.snap.png b/cypress/snapshots/docs-loading.cy.ts/recent-documents.snap.png index efc828aab..e84387616 100644 Binary files a/cypress/snapshots/docs-loading.cy.ts/recent-documents.snap.png and b/cypress/snapshots/docs-loading.cy.ts/recent-documents.snap.png differ diff --git a/cypress/snapshots/privacy-policy.cy.ts/privacy-policy-page-content.snap.png b/cypress/snapshots/privacy-policy.cy.ts/privacy-policy-page-content.snap.png index 608ab26e9..a02f85fd5 100644 Binary files a/cypress/snapshots/privacy-policy.cy.ts/privacy-policy-page-content.snap.png and b/cypress/snapshots/privacy-policy.cy.ts/privacy-policy-page-content.snap.png differ diff --git a/cypress/snapshots/user-profile-management.cy.ts/no-profile-yet.snap.png b/cypress/snapshots/user-profile-management.cy.ts/no-profile-yet.snap.png index 3cfad2115..be9165880 100644 Binary files a/cypress/snapshots/user-profile-management.cy.ts/no-profile-yet.snap.png and b/cypress/snapshots/user-profile-management.cy.ts/no-profile-yet.snap.png differ diff --git a/cypress/snapshots/user-profile-management.cy.ts/profile-ready.snap.png b/cypress/snapshots/user-profile-management.cy.ts/profile-ready.snap.png index 05edb8e66..f61ce8935 100644 Binary files a/cypress/snapshots/user-profile-management.cy.ts/profile-ready.snap.png and b/cypress/snapshots/user-profile-management.cy.ts/profile-ready.snap.png differ diff --git a/cypress/snapshots/user-profile.cy.ts/cleaned-user-profile-form.snap.png b/cypress/snapshots/user-profile.cy.ts/cleaned-user-profile-form.snap.png index fde2d1c97..33a4c97de 100644 Binary files a/cypress/snapshots/user-profile.cy.ts/cleaned-user-profile-form.snap.png and b/cypress/snapshots/user-profile.cy.ts/cleaned-user-profile-form.snap.png differ diff --git a/cypress/snapshots/user-profile.cy.ts/no-profile-section.snap.png b/cypress/snapshots/user-profile.cy.ts/no-profile-section.snap.png index 3cfad2115..be9165880 100644 Binary files a/cypress/snapshots/user-profile.cy.ts/no-profile-section.snap.png and b/cypress/snapshots/user-profile.cy.ts/no-profile-section.snap.png differ diff --git a/cypress/utils/commands.ts b/cypress/utils/commands.ts index 5bc369513..6b8685f46 100644 --- a/cypress/utils/commands.ts +++ b/cypress/utils/commands.ts @@ -16,7 +16,10 @@ type ClickableControls = | `Change theme` | `Close navigation` | `Sign in` + | `Sign In` + | `Continue with Google` | `User details and options` + | `User Details & Options` | `Sign out` | `Close your account panel` | `Confirm document creation` @@ -78,6 +81,15 @@ type Section = | `[user-profile-form]:container` | `[docs-list-modal]:loader`; +const TITLE_ALIASES: Partial> = { + "Sign in": "Sign In", + "User details and options": "User Details & Options", + "Document preview": "Open dynamic URL", +}; + +const toSelectorTitle = (title: ClickableControls): string => + TITLE_ALIASES[title] ?? title; + const BASE_COMMANDS = { "I see text in creator": (value: string) => { cy.get(`textarea[aria-label="creator"]`) @@ -101,7 +113,7 @@ const BASE_COMMANDS = { }, "I click button": (titles: ClickableControls[]) => { titles.forEach((title) => { - cy.get(`button[title="${title}"]`).click(); + cy.get(`button[title="${toSelectorTitle(title)}"]`).click(); }); }, "I click elements": (elements: Element[]) => { @@ -122,7 +134,7 @@ const BASE_COMMANDS = { }, "I see button": (titles: ClickableControls[]) => { titles.forEach((title) => { - cy.get(`button[title="${title}"]`); + cy.get(`button[title="${toSelectorTitle(title)}"]`); }); }, "I clear input": (placeholders: string[]) => { @@ -187,7 +199,8 @@ const BASE_COMMANDS = { BASE_COMMANDS[`I see disabled button`]([`Sign in`]); BASE_COMMANDS[`I see not disabled button`]([`Sign in`]); BASE_COMMANDS[`I click button`]([`Sign in`]); - BASE_COMMANDS[`I not see button`]([`Sign in`]); + BASE_COMMANDS[`I see button`]([`Continue with Google`]); + BASE_COMMANDS[`I click button`]([`Continue with Google`]); BASE_COMMANDS[`I see disabled button`]([`Your documents`]); BASE_COMMANDS[`I click button`]([`User details and options`]); BASE_COMMANDS[`I see text`]([`Your Account & Profile`]); @@ -197,16 +210,18 @@ const BASE_COMMANDS = { }, "I see disabled button": (titles: ClickableControls[]) => { titles.forEach((title) => { - cy.get(`button[title="${title}"]`).should(`be.disabled`); + cy.get(`button[title="${toSelectorTitle(title)}"]`).should(`be.disabled`); }); }, "I see not disabled button": (titles: ClickableControls[]) => { titles.forEach((title) => { - cy.get(`button[title="${title}"]`).should(`be.enabled`); + cy.get(`button[title="${toSelectorTitle(title)}"]`).should(`be.enabled`); }); }, - "I not see button": (title: ClickableControls[]) => { - cy.get(`button[title="${title}"]`).should(`not.exist`); + "I not see button": (titles: ClickableControls[]) => { + titles.forEach((title) => { + cy.get(`button[title="${toSelectorTitle(title)}"]`).should(`not.exist`); + }); }, "I type in input": (placeholder: string, value: string) => { cy.get( @@ -231,12 +246,12 @@ const BASE_COMMANDS = { }, "I see text": (values: string[]) => { values.forEach((text) => { - cy.contains(text, { matchCase: true }).should(`exist`); + cy.get(`body`).should(`contain.text`, text); }); }, "I not see text": (values: string[]) => { values.forEach((text) => { - cy.contains(text, { matchCase: true }).should(`not.exist`); + cy.get(`body`).should(`not.contain.text`, text); }); }, "Im on page": (name: "home" | `education-zone`) => { diff --git a/gatsby-browser.tsx b/gatsby-browser.tsx index 0a7bec8b1..9576757df 100644 --- a/gatsby-browser.tsx +++ b/gatsby-browser.tsx @@ -4,6 +4,7 @@ import ErrorBoundary from "./src/development-kit/error-boundary"; import { useAuth } from "./src/core/use-auth"; import { CookiesModalLoader } from "./src/components/cookies-modal-loader"; import { ToastSlot } from "./src/design-system/toast"; +import { PreviousWorkModule } from "./src/modules/previous-work"; import "katex/dist/katex.min.css"; import "prismjs/themes/prism-okaidia.css"; import "./src/style/index.css"; @@ -27,6 +28,7 @@ export const wrapPageElement = ({ element }) => { return ( {element} + diff --git a/gatsby-node.ts b/gatsby-node.ts index a4c188db4..adc1db88a 100644 --- a/gatsby-node.ts +++ b/gatsby-node.ts @@ -26,6 +26,7 @@ const createSearchDataFile = (documents: PermanentDocumentDto[]): void => { title: doc.name, description: doc.description, url: doc.path, + mdate: doc.mdate, })); const filePath = path.join(__dirname, `public`, `search-data.json`); diff --git a/jest.config.js b/jest.config.js index 4de3834f4..ca779c140 100644 --- a/jest.config.js +++ b/jest.config.js @@ -19,7 +19,6 @@ module.exports = { "^design-system/(.*)": `/src/design-system/$1`, "^api-4markdown$": `/src/api-4markdown`, "^api-4markdown-contracts$": `/src/api-4markdown-contracts`, - "^actions/(.*)": `/src/actions/$1`, "^acts/(.*)": `/src/acts/$1`, }, testPathIgnorePatterns: [`node_modules`, `\\.cache`, `.*/public`], diff --git a/meta.ts b/meta.ts index cda83df13..83e784ab6 100644 --- a/meta.ts +++ b/meta.ts @@ -23,10 +23,20 @@ export const meta = { accessGroups: { management: `/access-groups/`, }, + assets: { + management: `/assets/`, + }, + likedResources: { + management: `/starred/`, + }, + completed: { + management: `/completed/`, + }, mindmaps: { mindmap: `/mindmap/`, creator: `/mindmap-creator/`, preview: `/mindmap-preview/`, + management: `/mindmaps/`, }, sandbox: `/sandbox/`, creator: { @@ -39,9 +49,14 @@ export const meta = { }, documents: { preview: `/document-preview/`, + management: `/documents/`, }, notFound: `/404/`, privacyPolicy: `/privacy-policy/`, + auth: { + login: `/login/`, + register: `/register/`, + }, userProfile: { preview: `/user-profile-preview/`, }, diff --git a/package-lock.json b/package-lock.json index f5ec7e4a5..412a82d47 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,8 @@ "version": "1.0.0", "dependencies": { "@dagrejs/dagre": "^1.1.5", + "@git-diff-view/file": "^0.0.36", + "@git-diff-view/react": "^0.0.36", "@greenonsoftware/react-kit": "^0.1.0", "@tailwindcss/typography": "^0.5.16", "@xyflow/react": "^12.9.0", @@ -21,6 +23,7 @@ "gatsby-plugin-mdx": "^5.15.0", "gatsby-plugin-sitemap": "^6.15.0", "gatsby-source-filesystem": "^5.15.0", + "jszip": "^3.10.1", "katex": "^0.16.25", "lodash.debounce": "^4.0.8", "lodash.throttle": "^4.1.1", @@ -3419,6 +3422,79 @@ "strip-ansi": "^6.0.0" } }, + "node_modules/@git-diff-view/core": { + "version": "0.0.36", + "resolved": "https://registry.npmjs.org/@git-diff-view/core/-/core-0.0.36.tgz", + "integrity": "sha512-A3mN21C/ZdIJz0J9/PSZvLR1dLBu8BG13tVE+w4A9v8dLS5omkoQVjHM9K5GaoJideMhK/11y6V7CMbMXCPQoQ==", + "license": "MIT", + "dependencies": { + "@git-diff-view/lowlight": "^0.0.36", + "fast-diff": "^1.3.0", + "highlight.js": "^11.11.0", + "lowlight": "^3.3.0" + } + }, + "node_modules/@git-diff-view/file": { + "version": "0.0.36", + "resolved": "https://registry.npmjs.org/@git-diff-view/file/-/file-0.0.36.tgz", + "integrity": "sha512-3I5GMpZzdTwSurzbz8ZT9HL2xo74W5/o/SgWhUpEWjn2Z6pCZrHLAEzWeYyqewv30YM25PuK/JMODT4S5zhvzw==", + "license": "MIT", + "dependencies": { + "@git-diff-view/core": "^0.0.36", + "diff": "^8.0.2", + "fast-diff": "^1.3.0", + "highlight.js": "^11.11.0", + "lowlight": "^3.3.0" + } + }, + "node_modules/@git-diff-view/lowlight": { + "version": "0.0.36", + "resolved": "https://registry.npmjs.org/@git-diff-view/lowlight/-/lowlight-0.0.36.tgz", + "integrity": "sha512-8H3fUfnW+jw8EEbhEOr0vq5jXxZfbhNgvf9ruY6GljQdymj8j2R69k83gwMB/GltWEMt5o5624d8MxD21vqsZA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "highlight.js": "^11.11.0", + "lowlight": "^3.3.0" + } + }, + "node_modules/@git-diff-view/lowlight/node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@git-diff-view/react": { + "version": "0.0.36", + "resolved": "https://registry.npmjs.org/@git-diff-view/react/-/react-0.0.36.tgz", + "integrity": "sha512-RqM4vkU2uoiav8di5LavR8UDkSP+fNDftlNOfAMQmcuLDFpDnLhtqcgot4+Q5rflkwK04UlYL6G72ps9XSGgDw==", + "license": "MIT", + "dependencies": { + "@git-diff-view/core": "^0.0.36", + "@types/hast": "^3.0.0", + "fast-diff": "^1.3.0", + "highlight.js": "^11.11.0", + "lowlight": "^3.3.0", + "reactivity-store": "^0.3.12", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@git-diff-view/react/node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@graphql-codegen/add": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/@graphql-codegen/add/-/add-3.2.3.tgz", @@ -8188,6 +8264,21 @@ "resolve": "^1.10.0" } }, + "node_modules/@vue/reactivity": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.27.tgz", + "integrity": "sha512-vvorxn2KXfJ0nBEnj4GYshSgsyMNFnIQah/wczXlsNXt+ijhugmW+PpJ2cNPe4V6jpnBcs0MhCODKllWG+nvoQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.27" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.27.tgz", + "integrity": "sha512-dXr/3CgqXsJkZ0n9F3I4elY8wM9jMJpP3pvRG52r6m0tu/MsAFIe6JpXVGeNMd/D9F4hQynWT8Rfuj0bdm9kFQ==", + "license": "MIT" + }, "node_modules/@webassemblyjs/ast": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", @@ -10993,7 +11084,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", - "dev": true, "license": "MIT" }, "node_modules/cors": { @@ -12337,6 +12427,15 @@ "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", "dev": true }, + "node_modules/diff": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz", + "integrity": "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -14016,6 +14115,12 @@ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "license": "Apache-2.0" + }, "node_modules/fast-fifo": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", @@ -18044,6 +18149,15 @@ "tslib": "^2.0.3" } }, + "node_modules/highlight.js": { + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/hosted-git-info": { "version": "3.0.8", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-3.0.8.tgz", @@ -18309,6 +18423,12 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/immer": { "version": "9.0.21", "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz", @@ -22064,6 +22184,54 @@ "node": ">=4.0" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/katex": { "version": "0.16.25", "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.25.tgz", @@ -22182,6 +22350,15 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lilconfig": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", @@ -22607,6 +22784,30 @@ "node": ">=8" } }, + "node_modules/lowlight": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz", + "integrity": "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.0.0", + "highlight.js": "~11.11.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/lowlight/node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -29520,6 +29721,12 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/param-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", @@ -30810,6 +31017,12 @@ "node": ">= 0.6.0" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/progress": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", @@ -32058,6 +32271,20 @@ "node": ">=0.4.0" } }, + "node_modules/reactivity-store": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/reactivity-store/-/reactivity-store-0.3.12.tgz", + "integrity": "sha512-Idz9EL4dFUtQbHySZQzckWOTUfqjdYpUtNW0iOysC32mG7IjiUGB77QrsyR5eAWBkRiS9JscF6A3fuQAIy+LrQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "~3.5.22", + "@vue/shared": "~3.5.22", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/read": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/read/-/read-1.0.7.tgz", @@ -36790,9 +37017,9 @@ } }, "node_modules/use-sync-external-store": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", - "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", "license": "MIT", "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" diff --git a/package.json b/package.json index 42f62054e..15a2bc09e 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,8 @@ }, "dependencies": { "@dagrejs/dagre": "^1.1.5", + "@git-diff-view/file": "^0.0.36", + "@git-diff-view/react": "^0.0.36", "@greenonsoftware/react-kit": "^0.1.0", "@tailwindcss/typography": "^0.5.16", "@xyflow/react": "^12.9.0", @@ -47,6 +49,7 @@ "gatsby-plugin-mdx": "^5.15.0", "gatsby-plugin-sitemap": "^6.15.0", "gatsby-source-filesystem": "^5.15.0", + "jszip": "^3.10.1", "katex": "^0.16.25", "lodash.debounce": "^4.0.8", "lodash.throttle": "^4.1.1", diff --git a/seo-plugins.ts b/seo-plugins.ts index 7188f0952..9b0ff2b0d 100644 --- a/seo-plugins.ts +++ b/seo-plugins.ts @@ -30,7 +30,12 @@ const disallowedPaths = [ meta.routes.creator.preview, meta.routes.sandbox, meta.routes.mindmaps.preview, + meta.routes.assets.management, + meta.routes.likedResources.management, + meta.routes.completed.management, meta.routes.accessGroups.management, + meta.routes.documents.management, + meta.routes.mindmaps.management, legacyRoutes.documents.preview, legacyRoutes.documents.browse, ]; diff --git a/src/actions/log-in.action.ts b/src/actions/log-in.action.ts deleted file mode 100644 index 30acc557a..000000000 --- a/src/actions/log-in.action.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { getAPI } from "api-4markdown"; - -const logIn = async (): Promise => { - try { - await getAPI().logIn(); - } catch (error: unknown) {} -}; - -export { logIn }; diff --git a/src/actions/log-out.action.ts b/src/actions/log-out.action.ts deleted file mode 100644 index 27d36ae91..000000000 --- a/src/actions/log-out.action.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { getAPI, removeCache } from "api-4markdown"; - -// @TODO[PRIO=2]: [Add error handling here...]. -const logOut = async (): Promise => { - try { - await getAPI().logOut(); - removeCache( - `getYourUserProfile`, - `getYourDocuments`, - `getYourAccount`, - `getYourMindmaps`, - `getUserResourceCompletions`, - ); - } catch {} -}; - -export { logOut }; diff --git a/src/acts/create-document.act.ts b/src/acts/create-document.act.ts index ded67f9fc..76d7eb3f3 100644 --- a/src/acts/create-document.act.ts +++ b/src/acts/create-document.act.ts @@ -6,6 +6,8 @@ import { docStoreActions } from "store/doc/doc.store"; import { docsStoreActions, docsStoreSelectors } from "store/docs/docs.store"; import { useDocumentCreatorState } from "store/document-creator"; import { markAsUnchangedAction } from "store/document-creator/actions"; +import { addOrBumpEntryAction } from "modules/previous-work"; +import type { Atoms } from "api-4markdown-contracts"; const createDocumentAct = async ( payload: Pick, "name">, @@ -25,6 +27,13 @@ const createDocumentAct = async ( setCache(`getYourDocuments`, docsStoreSelectors.ok().docs); + addOrBumpEntryAction({ + type: `document`, + resourceId: createdDocument.id as Atoms["DocumentId"], + title: createdDocument.name, + lastTouched: Date.now(), + }); + return { is: `ok` }; } catch (error: unknown) { docManagementStoreActions.fail(error); diff --git a/src/acts/create-empty-document.act.ts b/src/acts/create-empty-document.act.ts new file mode 100644 index 000000000..b5f1c4e0f --- /dev/null +++ b/src/acts/create-empty-document.act.ts @@ -0,0 +1,50 @@ +import { getAPI, parseError, setCache } from "api-4markdown"; +import type { + API4MarkdownPayload, + DocumentDto, + Atoms, +} from "api-4markdown-contracts"; +import { docManagementStoreActions } from "store/doc-management/doc-management.store"; +import { docStoreActions } from "store/doc/doc.store"; +import { docsStoreActions, docsStoreSelectors } from "store/docs/docs.store"; +import { addOrBumpEntryAction } from "modules/previous-work"; + +const buildDefaultName = (): API4MarkdownPayload<"createDocument">["name"] => { + const today = new Date(); + const isoDate = today.toISOString().slice(0, 10); + + return `new-${isoDate}`; +}; + +const createEmptyDocumentAct = async (): Promise => { + try { + docManagementStoreActions.busy(); + + const createdDocument = await getAPI().call("createDocument")({ + name: buildDefaultName(), + code: ``, + }); + + docManagementStoreActions.ok(); + + docStoreActions.setActive(createdDocument); + docsStoreActions.addDoc(createdDocument); + + setCache("getYourDocuments", docsStoreSelectors.ok().docs); + + addOrBumpEntryAction({ + type: "document", + resourceId: createdDocument.id as Atoms["DocumentId"], + title: createdDocument.name, + lastTouched: Date.now(), + }); + + return createdDocument; + } catch (error: unknown) { + docManagementStoreActions.fail(error); + + return null; + } +}; + +export { createEmptyDocumentAct }; diff --git a/src/acts/create-empty-mindmap.act.ts b/src/acts/create-empty-mindmap.act.ts new file mode 100644 index 000000000..fd3bdda0e --- /dev/null +++ b/src/acts/create-empty-mindmap.act.ts @@ -0,0 +1,71 @@ +import { getAPI, parseError, setCache } from "api-4markdown"; +import type { + API4MarkdownPayload, + Atoms, + MindmapDto, +} from "api-4markdown-contracts"; +import { useMindmapCreatorState } from "store/mindmap-creator"; +import { readyMindmapsSelector } from "store/mindmap-creator/selectors"; +import { addOrBumpEntryAction } from "modules/previous-work"; + +const buildDefaultName = (): API4MarkdownPayload<`createMindmap`>[`name`] => { + const today = new Date(); + const isoDate = today.toISOString().slice(0, 10); + + return `new-${isoDate}`; +}; + +const createEmptyMindmapAct = async (): Promise => { + try { + useMindmapCreatorState.set({ operation: { is: `busy` } }); + + const { mindmaps, orientation } = useMindmapCreatorState.get(); + + const safeMindmaps = readyMindmapsSelector(mindmaps); + + const mindmap = await getAPI().call(`createMindmap`)({ + name: buildDefaultName(), + description: null, + tags: null, + nodes: [], + edges: [], + orientation, + }); + + const newMindmaps = [mindmap, ...safeMindmaps.data]; + + useMindmapCreatorState.set({ + mindmapForm: { is: `closed` }, + activeMindmapId: mindmap.id, + nodes: mindmap.nodes, + edges: mindmap.edges, + orientation: mindmap.orientation, + mindmaps: { + is: `ok`, + data: newMindmaps, + }, + operation: { is: `ok` }, + changesCount: 0, + }); + + setCache(`getYourMindmaps`, { + mindmaps: newMindmaps, + mindmapsCount: newMindmaps.length, + }); + + addOrBumpEntryAction({ + type: `mindmap`, + resourceId: mindmap.id as Atoms["MindmapId"], + title: mindmap.name, + lastTouched: Date.now(), + }); + return mindmap; + } catch (error: unknown) { + useMindmapCreatorState.set({ + operation: { is: `fail`, error: parseError(error) }, + }); + return null; + } +}; + +export { createEmptyMindmapAct }; diff --git a/src/acts/create-mindmap.act.ts b/src/acts/create-mindmap.act.ts index a3c53339f..c5399e71b 100644 --- a/src/acts/create-mindmap.act.ts +++ b/src/acts/create-mindmap.act.ts @@ -1,7 +1,10 @@ import { getAPI, parseError, setCache } from "api-4markdown"; import type { API4MarkdownPayload } from "api-4markdown-contracts"; +import type { Atoms } from "api-4markdown-contracts"; import { useMindmapCreatorState } from "store/mindmap-creator"; import { readyMindmapsSelector } from "store/mindmap-creator/selectors"; +import { addOrBumpEntryAction } from "modules/previous-work"; +import { remapMindmapStructure } from "./remap-mindmap-structure.act"; const createMindmapAct = async ( payload: Pick< @@ -16,11 +19,12 @@ const createMindmapAct = async ( useMindmapCreatorState.get(); const safeMindmaps = readyMindmapsSelector(mindmaps); + const remappedStructure = remapMindmapStructure({ nodes, edges }); const mindmap = await getAPI().call(`createMindmap`)({ ...payload, - nodes, - edges, + nodes: remappedStructure.nodes, + edges: remappedStructure.edges, orientation, }); @@ -41,6 +45,13 @@ const createMindmapAct = async ( mindmaps: newMindmaps, mindmapsCount: newMindmaps.length, }); + + addOrBumpEntryAction({ + type: `mindmap`, + resourceId: mindmap.id as Atoms["MindmapId"], + title: mindmap.name, + lastTouched: Date.now(), + }); } catch (error: unknown) { useMindmapCreatorState.set({ operation: { is: `fail`, error: parseError(error) }, diff --git a/src/actions/delete-document.action.ts b/src/acts/delete-document.act.ts similarity index 88% rename from src/actions/delete-document.action.ts rename to src/acts/delete-document.act.ts index 03c768dc4..5e6e4d85c 100644 --- a/src/actions/delete-document.action.ts +++ b/src/acts/delete-document.act.ts @@ -4,7 +4,7 @@ import { docStoreActions, docStoreSelectors } from "store/doc/doc.store"; import { docsStoreActions, docsStoreSelectors } from "store/docs/docs.store"; import { resetAction } from "store/document-creator/actions"; -const deleteDocument = async (onOk?: () => void): Promise => { +const deleteDocumentAct = async (onOk?: () => void): Promise => { try { const id = docStoreSelectors.active().id; @@ -24,4 +24,4 @@ const deleteDocument = async (onOk?: () => void): Promise => { } }; -export { deleteDocument }; +export { deleteDocumentAct }; diff --git a/src/acts/get-accessible-mindmap.act.ts b/src/acts/get-accessible-mindmap.act.ts index a28170082..e75b1d864 100644 --- a/src/acts/get-accessible-mindmap.act.ts +++ b/src/acts/get-accessible-mindmap.act.ts @@ -1,6 +1,7 @@ import { getAPI, parseError } from "api-4markdown"; import { useMindmapPreviewState } from "store/mindmap-preview"; import { Atoms } from "api-4markdown-contracts"; +import { addOrBumpEntryAction } from "modules/previous-work"; const getAccessibleMindmapAct = async (): Promise => { try { @@ -15,16 +16,15 @@ const getAccessibleMindmapAct = async (): Promise => { const params = new URLSearchParams(window.location.search); const mindmapId = params.get(`mindmapId`); - const authorId = params.get(`authorId`); - if (!mindmapId || !authorId) { + if (!mindmapId) { useMindmapPreviewState.set({ mindmap: { is: `fail`, error: { symbol: `bad-request`, - content: `Mindmap ID and author ID are required`, - message: `Mindmap ID and author ID are required`, + content: `Mindmap ID is required`, + message: `Mindmap ID is required`, }, }, }); @@ -33,7 +33,6 @@ const getAccessibleMindmapAct = async (): Promise => { const data = await getAPI().call(`getAccessibleMindmap`)({ mindmapId: mindmapId as Atoms["MindmapId"], - authorId: authorId as Atoms["UserProfileId"], }); useMindmapPreviewState.set({ @@ -42,6 +41,14 @@ const getAccessibleMindmapAct = async (): Promise => { is: `ok`, }, }); + + const mindmapIdTyped = data.id as Atoms["MindmapId"]; + addOrBumpEntryAction({ + type: `mindmap`, + resourceId: mindmapIdTyped, + title: data.name, + lastTouched: Date.now(), + }); } catch (error: unknown) { useMindmapPreviewState.set({ mindmap: { diff --git a/src/actions/get-your-documents.action.ts b/src/acts/get-your-documents.act.ts similarity index 89% rename from src/actions/get-your-documents.action.ts rename to src/acts/get-your-documents.act.ts index 8856bb5bd..613560737 100644 --- a/src/actions/get-your-documents.action.ts +++ b/src/acts/get-your-documents.act.ts @@ -2,7 +2,7 @@ import { getAPI, getCache, setCache } from "api-4markdown"; import type { API4MarkdownContractKey } from "api-4markdown-contracts"; import { docsStoreActions, docsStoreSelectors } from "store/docs/docs.store"; -const getYourDocuments = async (): Promise => { +const getYourDocumentsAct = async (): Promise => { try { const key: API4MarkdownContractKey = `getYourDocuments`; @@ -29,4 +29,4 @@ const getYourDocuments = async (): Promise => { } }; -export { getYourDocuments }; +export { getYourDocumentsAct }; diff --git a/src/acts/log-in-with-credentials.act.ts b/src/acts/log-in-with-credentials.act.ts new file mode 100644 index 000000000..11b073c8c --- /dev/null +++ b/src/acts/log-in-with-credentials.act.ts @@ -0,0 +1,19 @@ +import { getAPI, parseError } from "api-4markdown"; +import type { AsyncResult } from "development-kit/utility-types"; + +const logInWithCredentialsAct = async ({ + email, + password, +}: { + email: string; + password: string; +}): AsyncResult => { + try { + await getAPI().logInWithCredentials(email, password); + return { is: `ok` }; + } catch (rawError: unknown) { + return { is: `fail`, error: parseError(rawError) }; + } +}; + +export { logInWithCredentialsAct }; diff --git a/src/acts/log-in.act.ts b/src/acts/log-in.act.ts new file mode 100644 index 000000000..cfc7200b2 --- /dev/null +++ b/src/acts/log-in.act.ts @@ -0,0 +1,13 @@ +import { getAPI, parseError } from "api-4markdown"; +import type { AsyncResult } from "development-kit/utility-types"; + +const logInAct = async (): AsyncResult => { + try { + await getAPI().logIn(); + return { is: `ok` }; + } catch (rawError: unknown) { + return { is: `fail`, error: parseError(rawError) }; + } +}; + +export { logInAct }; diff --git a/src/acts/log-out.act.ts b/src/acts/log-out.act.ts new file mode 100644 index 000000000..72d13cf62 --- /dev/null +++ b/src/acts/log-out.act.ts @@ -0,0 +1,21 @@ +import { getAPI, parseError, removeCache } from "api-4markdown"; +import type { AsyncResult } from "development-kit/utility-types"; + +const logOutAct = async (): AsyncResult => { + try { + await getAPI().logOut(); + removeCache( + `getYourUserProfile`, + `getYourDocuments`, + `getYourAccount`, + `getYourMindmaps`, + `getUserResourceCompletions`, + `getUserResourceLikes`, + ); + return { is: `ok` }; + } catch (rawError: unknown) { + return { is: `fail`, error: parseError(rawError) }; + } +}; + +export { logOutAct }; diff --git a/src/acts/rate-mindmap-node.act.ts b/src/acts/rate-mindmap-node.act.ts new file mode 100644 index 000000000..1968c0b74 --- /dev/null +++ b/src/acts/rate-mindmap-node.act.ts @@ -0,0 +1,20 @@ +import { getAPI, parseError } from "api-4markdown"; +import type { + API4MarkdownDto, + API4MarkdownPayload, +} from "api-4markdown-contracts"; +import type { AsyncResult } from "development-kit/utility-types"; + +const rateMindmapNodeAct = async ( + payload: API4MarkdownPayload<"rateMindmapNode">, +): AsyncResult> => { + try { + const data = await getAPI().call("rateMindmapNode")(payload); + return { is: "ok", data }; + } catch (rawError: unknown) { + const error = parseError(rawError); + return { is: "fail", error }; + } +}; + +export { rateMindmapNodeAct }; diff --git a/src/acts/register-with-credentials.act.ts b/src/acts/register-with-credentials.act.ts new file mode 100644 index 000000000..2f2620f4a --- /dev/null +++ b/src/acts/register-with-credentials.act.ts @@ -0,0 +1,19 @@ +import { getAPI, parseError } from "api-4markdown"; +import type { AsyncResult } from "development-kit/utility-types"; + +const registerWithCredentialsAct = async ({ + email, + password, +}: { + email: string; + password: string; +}): AsyncResult => { + try { + await getAPI().registerWithCredentials(email, password); + return { is: `ok` }; + } catch (rawError: unknown) { + return { is: `fail`, error: parseError(rawError) }; + } +}; + +export { registerWithCredentialsAct }; diff --git a/src/actions/reload-your-documents.action.ts b/src/acts/reload-your-documents.act.ts similarity index 88% rename from src/actions/reload-your-documents.action.ts rename to src/acts/reload-your-documents.act.ts index 912d7aad2..9bf14a039 100644 --- a/src/actions/reload-your-documents.action.ts +++ b/src/acts/reload-your-documents.act.ts @@ -1,12 +1,13 @@ -import { docStoreActions } from "store/doc/doc.store"; -import { docsStoreActions } from "store/docs/docs.store"; -import { docManagementStoreActions } from "store/doc-management/doc-management.store"; import { getAPI, setCache } from "api-4markdown"; import type { API4MarkdownContractKey } from "api-4markdown-contracts"; +import { docManagementStoreActions } from "store/doc-management/doc-management.store"; +import { docStoreActions } from "store/doc/doc.store"; +import { docsStoreActions } from "store/docs/docs.store"; -const reloadYourDocuments = async (): Promise => { +const reloadYourDocumentsAct = async (): Promise => { try { const key: API4MarkdownContractKey = `getYourDocuments`; + docsStoreActions.idle(); docManagementStoreActions.idle(); docsStoreActions.busy(); @@ -22,4 +23,4 @@ const reloadYourDocuments = async (): Promise => { } }; -export { reloadYourDocuments }; +export { reloadYourDocumentsAct }; diff --git a/src/actions/reload-your-user-profile.action.ts b/src/acts/reload-your-user-profile.act.ts similarity index 86% rename from src/actions/reload-your-user-profile.action.ts rename to src/acts/reload-your-user-profile.act.ts index e92bc1683..980654e97 100644 --- a/src/actions/reload-your-user-profile.action.ts +++ b/src/acts/reload-your-user-profile.act.ts @@ -2,7 +2,7 @@ import { getAPI, parseError, setCache } from "api-4markdown"; import type { API4MarkdownContractKey } from "api-4markdown-contracts"; import { useYourUserProfileState } from "store/your-user-profile"; -const reloadYourUserProfile = async (): Promise => { +const reloadYourUserProfileAct = async (): Promise => { try { const key: API4MarkdownContractKey = `getYourUserProfile`; @@ -22,4 +22,4 @@ const reloadYourUserProfile = async (): Promise => { } }; -export { reloadYourUserProfile }; +export { reloadYourUserProfileAct }; diff --git a/src/acts/remap-mindmap-structure.act.ts b/src/acts/remap-mindmap-structure.act.ts new file mode 100644 index 000000000..ecc82468b --- /dev/null +++ b/src/acts/remap-mindmap-structure.act.ts @@ -0,0 +1,49 @@ +import type { API4MarkdownPayload } from "api-4markdown-contracts"; +import { type SUID, suid } from "development-kit/suid"; + +type CreateMindmapStructure = Pick< + API4MarkdownPayload<`createMindmap`>, + "nodes" | "edges" +>; + +const remapMindmapStructure = ({ + nodes, + edges, +}: CreateMindmapStructure): CreateMindmapStructure => { + const nodeIdMap = new Map(); + const newNodes = nodes.map((node) => { + const newId = suid(); + + // Keep a stable old->new reference for edge remapping. + // If duplicated old IDs exist, edges resolve to the first occurrence. + if (!nodeIdMap.has(node.id)) { + nodeIdMap.set(node.id, newId); + } + + return { + ...node, + id: newId, + }; + }); + + const newEdges = edges.flatMap((edge) => { + const source = nodeIdMap.get(edge.source); + const target = nodeIdMap.get(edge.target); + + // Keep payload consistent when stale edges point to removed nodes. + if (!source || !target) return []; + + return [ + { + ...edge, + id: suid(), + source, + target, + }, + ]; + }); + + return { nodes: newNodes, edges: newEdges }; +}; + +export { remapMindmapStructure }; diff --git a/src/acts/reset-password.act.ts b/src/acts/reset-password.act.ts new file mode 100644 index 000000000..28dfd62b6 --- /dev/null +++ b/src/acts/reset-password.act.ts @@ -0,0 +1,13 @@ +import { getAPI, parseError } from "api-4markdown"; +import type { AsyncResult } from "development-kit/utility-types"; + +const resetPasswordAct = async ({ email }: { email: string }): AsyncResult => { + try { + await getAPI().resetPassword(email); + return { is: `ok` }; + } catch (rawError: unknown) { + return { is: `fail`, error: parseError(rawError) }; + } +}; + +export { resetPasswordAct }; diff --git a/src/actions/update-document-code.action.ts b/src/acts/update-document-code.act.ts similarity index 92% rename from src/actions/update-document-code.action.ts rename to src/acts/update-document-code.act.ts index a907294dc..b83c06b58 100644 --- a/src/actions/update-document-code.action.ts +++ b/src/acts/update-document-code.act.ts @@ -5,7 +5,7 @@ import { docsStoreActions, docsStoreSelectors } from "store/docs/docs.store"; import { useDocumentCreatorState } from "store/document-creator"; import { markAsUnchangedAction } from "store/document-creator/actions"; -const updateDocumentCode = async () => { +const updateDocumentCodeAct = async (): Promise => { const doc = docStoreSelectors.active(); const { code } = useDocumentCreatorState.get(); @@ -37,4 +37,4 @@ const updateDocumentCode = async () => { } }; -export { updateDocumentCode }; +export { updateDocumentCodeAct }; diff --git a/src/actions/update-document-name.action.ts b/src/acts/update-document-name.act.ts similarity index 95% rename from src/actions/update-document-name.action.ts rename to src/acts/update-document-name.act.ts index 750d211ac..c556c5164 100644 --- a/src/actions/update-document-name.action.ts +++ b/src/acts/update-document-name.act.ts @@ -6,7 +6,7 @@ import { docsStoreActions, docsStoreSelectors } from "store/docs/docs.store"; import { useDocumentCreatorState } from "store/document-creator"; import { changeWithoutMarkAsUnchangedAction } from "store/document-creator/actions"; -const updateDocumentName = async ( +const updateDocumentNameAct = async ( name: API4MarkdownPayload<"updateDocumentName">["name"], ): Promise => { try { @@ -37,4 +37,4 @@ const updateDocumentName = async ( } }; -export { updateDocumentName }; +export { updateDocumentNameAct }; diff --git a/src/acts/update-mindmap-name.act.ts b/src/acts/update-mindmap-name.act.ts index 5c9a9a9f5..bc42e99ca 100644 --- a/src/acts/update-mindmap-name.act.ts +++ b/src/acts/update-mindmap-name.act.ts @@ -1,11 +1,13 @@ import { getAPI, parseError, setCache } from "api-4markdown"; import type { API4MarkdownPayload } from "api-4markdown-contracts"; +import type { Atoms } from "api-4markdown-contracts"; import { type AsyncResult } from "development-kit/utility-types"; import { useMindmapCreatorState } from "store/mindmap-creator"; import { readyMindmapsSelector, safeActiveMindmapSelector, } from "store/mindmap-creator/selectors"; +import { addOrBumpEntryAction } from "modules/previous-work"; const updateMindmapNameAct = async ( payload: Pick, "name">, @@ -48,6 +50,13 @@ const updateMindmapNameAct = async ( mindmapsCount: newMindmaps.length, }); + addOrBumpEntryAction({ + type: `mindmap`, + resourceId: activeMindmap.id as Atoms["MindmapId"], + title: payload.name, + lastTouched: Date.now(), + }); + return { is: `ok` }; } catch (error: unknown) { const err = parseError(error); diff --git a/src/acts/update-mindmap-shape.act.ts b/src/acts/update-mindmap-shape.act.ts index 0f26278b3..8d01b9bf2 100644 --- a/src/acts/update-mindmap-shape.act.ts +++ b/src/acts/update-mindmap-shape.act.ts @@ -1,10 +1,12 @@ import { getAPI, parseError, setCache } from "api-4markdown"; +import type { Atoms } from "api-4markdown-contracts"; import { type AsyncResult } from "development-kit/utility-types"; import { useMindmapCreatorState } from "store/mindmap-creator"; import { readyMindmapsSelector, safeActiveMindmapSelector, } from "store/mindmap-creator/selectors"; +import { addOrBumpEntryAction } from "modules/previous-work"; const updateMindmapShapeAct = async (): AsyncResult => { try { @@ -46,6 +48,13 @@ const updateMindmapShapeAct = async (): AsyncResult => { mindmapsCount: newMindmaps.length, }); + addOrBumpEntryAction({ + type: `mindmap`, + resourceId: activeMindmap.id as Atoms["MindmapId"], + title: activeMindmap.name, + lastTouched: Date.now(), + }); + return { is: `ok` }; } catch (error: unknown) { const err = parseError(error); diff --git a/src/acts/update-mindmap-visibility.act.ts b/src/acts/update-mindmap-visibility.act.ts index 84437f073..f70877123 100644 --- a/src/acts/update-mindmap-visibility.act.ts +++ b/src/acts/update-mindmap-visibility.act.ts @@ -6,6 +6,7 @@ import { readyMindmapsSelector, safeActiveMindmapSelector, } from "store/mindmap-creator/selectors"; +import { addOrBumpEntryAction } from "modules/previous-work"; const updateMindmapVisibilityAct = async ( visibility: MindmapDto["visibility"], @@ -58,6 +59,13 @@ const updateMindmapVisibilityAct = async ( mindmapsCount: newMindmaps.length, }); + addOrBumpEntryAction({ + type: `mindmap`, + resourceId: activeMindmap.id as Atoms["MindmapId"], + title: activeMindmap.name, + lastTouched: Date.now(), + }); + return { is: "ok" }; } catch (error: unknown) { const parsed = parseError(error); diff --git a/src/acts/update-mindmap.act.ts b/src/acts/update-mindmap.act.ts index 92821fb6d..e1a58ab57 100644 --- a/src/acts/update-mindmap.act.ts +++ b/src/acts/update-mindmap.act.ts @@ -1,10 +1,12 @@ import { getAPI, parseError, setCache } from "api-4markdown"; import type { API4MarkdownPayload } from "api-4markdown-contracts"; +import type { Atoms } from "api-4markdown-contracts"; import { useMindmapCreatorState } from "store/mindmap-creator"; import { readyMindmapsSelector, safeActiveMindmapSelector, } from "store/mindmap-creator/selectors"; +import { addOrBumpEntryAction } from "modules/previous-work"; const updateMindmapAct = async ( payload: Pick< @@ -47,6 +49,13 @@ const updateMindmapAct = async ( mindmaps: newMindmaps, mindmapsCount: newMindmaps.length, }); + + addOrBumpEntryAction({ + type: `mindmap`, + resourceId: activeMindmap.id as Atoms["MindmapId"], + title: payload.name, + lastTouched: Date.now(), + }); } catch (error: unknown) { useMindmapCreatorState.set({ operation: { is: `fail`, error: parseError(error) }, diff --git a/src/acts/upload-image.act.ts b/src/acts/upload-image.act.ts index a66637422..0c84609c0 100644 --- a/src/acts/upload-image.act.ts +++ b/src/acts/upload-image.act.ts @@ -1,5 +1,6 @@ import { getAPI, parseError } from "api-4markdown"; import { useUploadImageState } from "store/upload-image"; +import { addAssetsAction } from "features/assets-management/store/actions"; const uploadImageAct = async (image: string): Promise => { try { @@ -10,6 +11,8 @@ const uploadImageAct = async (image: string): Promise => { }); useUploadImageState.swap({ is: `ok`, ...data }); + // Also add the uploaded image to the assets-management store at the top + addAssetsAction([data]); } catch (error: unknown) { useUploadImageState.swap({ is: `fail`, error: parseError(error) }); } diff --git a/src/api-4markdown-contracts/contracts.ts b/src/api-4markdown-contracts/contracts.ts index db560d88b..259aeefc4 100644 --- a/src/api-4markdown-contracts/contracts.ts +++ b/src/api-4markdown-contracts/contracts.ts @@ -6,15 +6,22 @@ import type { PublicDocumentDto, ManualDocumentDto, DocumentCommentDto, + MindmapNodeCommentDto, } from "./dtos"; import { AccessGroupDto, + AccessibleMindmapDto, Atoms, UserProfileCommentDto, FullMindmapDto, ImageDto, MindmapDto, ResourceCompletionDto, + ResourceLikeDto, + SetUserResourceCompletionPayload, + SetUserResourceCompletionResult, + SetUserResourceLikeRequestItem, + SetUserResourceLikeResultItem, UserProfileDto, YourAccountDto, } from "./dtos"; @@ -133,6 +140,66 @@ type DocumentCommentsContracts = } >; +type MindmapNodeCommentsContracts = + | Contract< + `addMindmapNodeComment`, + MindmapNodeCommentDto, + { + mindmapId: Atoms["MindmapId"]; + nodeId: Atoms["MindmapNodeId"]; + comment: string; + } + > + | Contract< + `editMindmapNodeComment`, + MindmapNodeCommentDto, + { + mindmapId: Atoms["MindmapId"]; + nodeId: Atoms["MindmapNodeId"]; + commentId: Atoms["MindmapNodeCommentId"]; + content: string; + } + > + | Contract< + `getMindmapNodeComments`, + { + comments: MindmapNodeCommentDto[]; + hasMore: boolean; + nextCursor: { + cdate: Atoms["UTCDate"]; + id: Atoms["MindmapNodeCommentId"]; + } | null; + }, + { + mindmapId: Atoms["MindmapId"]; + nodeId: Atoms["MindmapNodeId"]; + nextCursor: { + cdate: Atoms["UTCDate"]; + id: Atoms["MindmapNodeCommentId"]; + } | null; + limit: number | null; + } + > + | Contract< + `deleteMindmapNodeComment`, + null, + { + mindmapId: Atoms["MindmapId"]; + nodeId: Atoms["MindmapNodeId"]; + commentId: Atoms["MindmapNodeCommentId"]; + } + > + | Contract< + `rateMindmapNodeComment`, + null, + { + mindmapId: Atoms["MindmapId"]; + nodeId: Atoms["MindmapNodeId"]; + commentId: Atoms["MindmapNodeCommentId"]; + category: Atoms["RatingCategory"]; + } + >; + type ResourceCompletionsContracts = | Contract< "getUserResourceCompletions", @@ -140,11 +207,95 @@ type ResourceCompletionsContracts = > | Contract< "setUserResourceCompletion", - ResourceCompletionDto | null, + SetUserResourceCompletionResult, + SetUserResourceCompletionPayload + >; + +type ResourceLikesContracts = + | Contract< + "getUserResourceLikes", + Record + > + | Contract< + "setUserResourceLike", + SetUserResourceLikeResultItem[], + SetUserResourceLikeRequestItem[] + >; + +type ResourceActivityContracts = + | Contract< + "getDocumentActivity", + { + hasMore: boolean; + nextCursor: { + cdate: Atoms["UTCDate"]; + id: Atoms["DocumentActivityId"]; + } | null; + activities: Array<{ + id: Atoms["DocumentActivityId"]; + cdate: Atoms["UTCDate"]; + type: "content-changed"; + documentId: Atoms["DocumentId"]; + previousContent: string; + newContent: string; + appliedByProfile: UserProfileDto | null; + }>; + }, + { + documentId: Atoms["DocumentId"]; + nextCursor: { + cdate: Atoms["UTCDate"]; + id: Atoms["DocumentActivityId"]; + } | null; + limit: number | null; + } + > + | Contract< + "getMindmapNodeActivity", + { + hasMore: boolean; + nextCursor: { + cdate: Atoms["UTCDate"]; + id: Atoms["MindmapNodeActivityId"]; + } | null; + activities: Array<{ + id: Atoms["MindmapNodeActivityId"]; + cdate: Atoms["UTCDate"]; + type: "content-changed"; + mindmapId: Atoms["MindmapId"]; + nodeId: Atoms["MindmapNodeId"]; + previousContent: string; + newContent: string; + appliedByProfile: UserProfileDto | null; + }>; + }, + { + mindmapId: Atoms["MindmapId"]; + nodeId: Atoms["MindmapNodeId"]; + nextCursor: { + cdate: Atoms["UTCDate"]; + id: Atoms["MindmapNodeActivityId"]; + } | null; + limit: number | null; + } + >; + +type ResourceContributionContracts = + | Contract< + "submitDocumentContribution", + { id: Atoms["DocumentContributionId"] }, { - type: Atoms["ResourceType"]; - resourceId: Atoms["ResourceId"]; - parentId?: Atoms["MindmapId"]; + documentId: Atoms["DocumentId"]; + proposedContent: string; + } + > + | Contract< + "submitMindmapNodeContribution", + { id: Atoms["MindmapNodeContributionId"] }, + { + mindmapId: Atoms["MindmapId"]; + nodeId: Atoms["MindmapNodeId"]; + proposedContent: string; } >; @@ -307,6 +458,30 @@ type DocumentsContracts = Pick >; +type MindmapNodeEngagementContracts = + | Contract< + `addMindmapNodeScore`, + { + average: number; + count: number; + values: Atoms["ScoreValue"][]; + }, + { + mindmapId: Atoms["MindmapId"]; + nodeId: Atoms["MindmapNodeId"]; + score: Atoms["ScoreValue"]; + } + > + | Contract< + `rateMindmapNode`, + Atoms["Rating"], + { + mindmapId: Atoms["MindmapId"]; + nodeId: Atoms["MindmapNodeId"]; + category: Atoms["RatingCategory"]; + } + >; + type MindmapsContracts = | Contract< `createMindmap`, @@ -346,8 +521,8 @@ type MindmapsContracts = > | Contract< `getAccessibleMindmap`, - FullMindmapDto, - { authorId: Atoms["UserProfileId"]; mindmapId: Atoms["MindmapId"] } + AccessibleMindmapDto, + { mindmapId: Atoms["MindmapId"] } > | Contract<`getPermanentMindmaps`, FullMindmapDto[], { limit?: number }>; @@ -374,11 +549,10 @@ type AIContracts = } >; -type AssetsContracts = Contract< - `uploadImage`, - ImageDto, - { image: FileReader["result"] } ->; +type AssetsContracts = + | Contract<`uploadImage`, ImageDto, { image: FileReader["result"] }> + | Contract<`getYourImages`, ImageDto[]> + | Contract<`deleteImage`, null, Array<{ id: Atoms["ImageId"] }>>; type AnalyticsContracts = Contract< `reportBug`, @@ -392,13 +566,18 @@ type AnalyticsContracts = Contract< type API4MarkdownContracts = | DocumentCommentsContracts + | MindmapNodeCommentsContracts | AssetsContracts | AnalyticsContracts | MindmapsContracts | AIContracts | DocumentsContracts + | MindmapNodeEngagementContracts | AccountsContracts | ResourceCompletionsContracts + | ResourceLikesContracts + | ResourceActivityContracts + | ResourceContributionContracts | AccessGroupsContracts | UserProfilesContracts | UserProfileCommentsContracts; diff --git a/src/api-4markdown-contracts/dtos.ts b/src/api-4markdown-contracts/dtos.ts index 2f4fa5b54..14de45fce 100644 --- a/src/api-4markdown-contracts/dtos.ts +++ b/src/api-4markdown-contracts/dtos.ts @@ -28,6 +28,12 @@ export type Atoms = { src: Atoms["Path"]; }; DocumentCommentId: Brand; + DocumentActivityId: Brand; + MindmapNodeActivityId: Brand; + DocumentContributionId: Brand; + MindmapNodeCommentId: Brand; + MindmapNodeContributionId: Brand; + ResourceActivityId: Brand; Rating: Record; Score: { scoreAverage: number; @@ -51,8 +57,181 @@ export type ResourceCompletionDto = { type: Atoms["ResourceType"]; resourceId: Atoms["ResourceId"]; parentId?: Atoms["MindmapId"]; + title?: string; + description?: string; + /** When type is "mindmap-node" and the node is external, URL of the pasted resource. */ + externalUrl?: Atoms["Url"]; }; +/** + * One item in the setUserResourceCompletion request payload. + * Matches backend resourceCompletionItemSchema (type, resourceId, title?, description?, completed, parentId? for mindmap-node). + */ +export type SetUserResourceCompletionPayloadItem = + | { + type: "document"; + resourceId: Atoms["DocumentId"]; + title?: string; + description?: string; + completed: boolean; + } + | { + type: "mindmap"; + resourceId: Atoms["MindmapId"]; + title?: string; + description?: string; + completed: boolean; + } + | { + type: "mindmap-node"; + resourceId: Atoms["MindmapNodeId"]; + parentId: Atoms["MindmapId"]; + title?: string; + description?: string; + completed: boolean; + }; + +/** Payload for setUserResourceCompletion API (array, min 1 item). */ +export type SetUserResourceCompletionPayload = [ + SetUserResourceCompletionPayloadItem, + ...SetUserResourceCompletionPayloadItem[], +]; + +/** + * One item in the setUserResourceCompletion response (batch result). + * Matches backend DtoItem (removed, resourceId, type, title?, description?, cdate, parentId? for mindmap-node). + */ +export type SetUserResourceCompletionResultItem = { + removed: boolean; + resourceId: Atoms["ResourceId"]; + type: Atoms["ResourceType"]; + title?: string; + description?: string; + cdate: Atoms["UTCDate"]; + parentId?: Atoms["MindmapId"]; +}; + +/** setUserResourceCompletion response: array of result items (one per request item). */ +export type SetUserResourceCompletionResult = + SetUserResourceCompletionResultItem[]; + +/** Single item with completed (e.g. for toggle act). Alias for one payload item. */ +export type SetUserResourceCompletionItem = + SetUserResourceCompletionPayloadItem; + +/** Single item payload without `completed` (e.g. for toggle hook). */ +export type SetUserResourceCompletionPayloadWithoutCompleted = + | { + type: "document"; + resourceId: Atoms["DocumentId"]; + title?: string; + description?: string; + } + | { + type: "mindmap"; + resourceId: Atoms["MindmapId"]; + title?: string; + description?: string; + } + | { + type: "mindmap-node"; + resourceId: Atoms["MindmapNodeId"]; + parentId: Atoms["MindmapId"]; + title?: string; + description?: string; + }; + +export type ResourceLikeDto = { + cdate: Atoms["UTCDate"]; + type: Atoms["ResourceType"]; + resourceId: Atoms["ResourceId"]; + parentId?: Atoms["MindmapId"]; + title: string; + description?: string; + /** When type is "mindmap-node" and the node is external, URL of the pasted resource. */ + externalUrl?: Atoms["Url"]; +}; + +/** One item in the setUserResourceLike request payload. */ +export type SetUserResourceLikeItem = + | { + type: "document"; + resourceId: Atoms["DocumentId"]; + title: string; + description?: string | null; + liked: boolean; + } + | { + type: "mindmap"; + resourceId: Atoms["MindmapId"]; + title: string; + description?: string | null; + liked: boolean; + } + | { + type: "mindmap-node"; + resourceId: Atoms["MindmapNodeId"]; + parentId: Atoms["MindmapId"]; + title: string; + description?: string | null; + liked: boolean; + }; + +/** + * Payload actually sent to setUserResourceLike API. + * description is omitted when null/undefined (backend must not receive null). + */ +export type SetUserResourceLikeRequestItem = + | { + type: "document"; + resourceId: Atoms["DocumentId"]; + title: string; + liked: boolean; + description?: string; + } + | { + type: "mindmap"; + resourceId: Atoms["MindmapId"]; + title: string; + liked: boolean; + description?: string; + } + | { + type: "mindmap-node"; + resourceId: Atoms["MindmapNodeId"]; + parentId: Atoms["MindmapId"]; + title: string; + liked: boolean; + description?: string; + }; + +/** One item in the setUserResourceLike response (batch result). */ +export type SetUserResourceLikeResultItem = ResourceLikeDto & { + removed: boolean; +}; + +/** Single item payload without `liked` (e.g. for toggle hook). */ +export type SetUserResourceLikePayloadWithoutLiked = + | { + type: "document"; + resourceId: Atoms["DocumentId"]; + title: string; + description?: string | null; + } + | { + type: "mindmap"; + resourceId: Atoms["MindmapId"]; + title: string; + description?: string | null; + } + | { + type: "mindmap-node"; + resourceId: Atoms["MindmapNodeId"]; + parentId: Atoms["MindmapId"]; + title: string; + description?: string | null; + }; + export type ImageDto = { extension: `png` | `jpeg` | `jpg` | `gif` | `webp`; contentType: @@ -63,6 +242,8 @@ export type ImageDto = { | `image/webp`; url: Atoms["Path"]; id: Atoms["ImageId"]; + /** Creation date of the image (if available). */ + cdate?: Atoms["UTCDate"]; }; export type UserProfileDto = Prettify< @@ -137,7 +318,7 @@ export type EmbeddedNode = MakeNode< `embedded`, NodeBaseData & { content: string | null } >; -type MindmapNode = ExternalNode | EmbeddedNode; +export type MindmapNode = ExternalNode | EmbeddedNode; export type SolidEdge = MakeEdge<`solid`>; type MindmapEdge = SolidEdge; @@ -157,12 +338,26 @@ export type MindmapDto = { tags: string[] | null; }; +/** Node shape returned only by getAccessibleMindmap (includes rating counts and score). */ +export type MindmapNodeWithEngagement = MindmapNode & + Atoms["Rating"] & + Atoms["Score"]; + export type FullMindmapDto = MindmapDto & { - authorId: Atoms["UserProfileId"]; authorProfile: UserProfileDto | null; isAuthorTrusted: boolean; }; +/** Node from getAccessibleMindmap: engagement data plus commentsCount in data. */ +export type AccessibleMindmapNode = MindmapNodeWithEngagement & { + data: MindmapNode["data"] & { commentsCount?: number }; +}; + +/** getAccessibleMindmap result: full mindmap with nodes enhanced by engagement/score/commentsCount. */ +export type AccessibleMindmapDto = Omit & { + nodes: AccessibleMindmapNode[]; +}; + type Base = Prettify<{ id: Atoms["DocumentId"]; name: string; @@ -228,3 +423,92 @@ export type DocumentCommentDto = Prettify< resourceId: Atoms["DocumentId"]; } >; + +export type MindmapNodeCommentDto = Prettify< + Atoms["Rating"] & { + id: Atoms["MindmapNodeCommentId"]; + ownerProfile: UserProfileDto; + cdate: Atoms["UTCDate"]; + mdate: Atoms["UTCDate"]; + content: string; + etag: Atoms["Etag"]; + mindmapId: Atoms["MindmapId"]; + nodeId: Atoms["MindmapNodeId"]; + type: Atoms["ResourceType"]; + } +>; + +export type ResourceActivityDto = + | { + id: Atoms["ResourceActivityId"]; + type: "created"; + resourceId: Atoms["ResourceId"]; + resourceType: Atoms["ResourceType"]; + cdate: Atoms["UTCDate"]; + authorProfile: UserProfileDto | null; + } + | { + id: Atoms["ResourceActivityId"]; + type: "content-changed"; + resourceId: Atoms["ResourceId"]; + resourceType: Atoms["ResourceType"]; + previousContent: string; + newContent: string; + cdate: Atoms["UTCDate"]; + authorProfile: UserProfileDto | null; + } + | { + id: Atoms["ResourceActivityId"]; + type: "visibility-changed"; + resourceId: Atoms["ResourceId"]; + resourceType: Atoms["ResourceType"]; + cdate: Atoms["UTCDate"]; + authorProfile: UserProfileDto | null; + previousVisibility: Atoms["ResourceVisibility"]; + newVisibility: Atoms["ResourceVisibility"]; + } + | { + id: Atoms["ResourceActivityId"]; + type: "metadata-updated"; + resourceId: Atoms["ResourceId"]; + resourceType: Atoms["ResourceType"]; + cdate: Atoms["UTCDate"]; + authorProfile: UserProfileDto | null; + previousMeta: { + tags: string[]; + description: string | null; + }; + newMeta: { + tags: string[]; + description: string | null; + }; + } + | { + id: Atoms["ResourceActivityId"]; + type: "comment-added"; + resourceId: Atoms["ResourceId"]; + resourceType: Atoms["ResourceType"]; + cdate: Atoms["UTCDate"]; + authorProfile: UserProfileDto | null; + comment: UserProfileCommentDto; + } + | { + id: Atoms["ResourceActivityId"]; + type: "rating-changed"; + resourceId: Atoms["ResourceId"]; + resourceType: Atoms["ResourceType"]; + cdate: Atoms["UTCDate"]; + authorProfile: UserProfileDto | null; + previousRating: Atoms["Rating"]; + newRating: Atoms["Rating"]; + } + | { + id: Atoms["ResourceActivityId"]; + type: "score-changed"; + resourceId: Atoms["ResourceId"]; + resourceType: Atoms["ResourceType"]; + cdate: Atoms["UTCDate"]; + authorProfile: UserProfileDto | null; + previousScore: Atoms["Score"]; + newScore: Atoms["Score"]; + }; diff --git a/src/api-4markdown/use-api.ts b/src/api-4markdown/use-api.ts index 2ecfe7471..c015ab338 100644 --- a/src/api-4markdown/use-api.ts +++ b/src/api-4markdown/use-api.ts @@ -8,6 +8,7 @@ import { type FirebaseOptions, initializeApp } from "firebase/app"; import type { Functions } from "firebase/functions"; import { type CompleteFn, + createUserWithEmailAndPassword, type ErrorFn, GoogleAuthProvider, type NextOrObserver, @@ -16,13 +17,16 @@ import { browserLocalPersistence, getAuth, onAuthStateChanged, + sendEmailVerification, setPersistence, signInWithEmailAndPassword, signInWithPopup, signOut, + sendPasswordResetEmail, } from "firebase/auth"; import { emit } from "./observer"; import { CacheVersion } from "./cache"; +import { customError } from "./errors"; // @TODO[PRIO=2]: [Decouple from Firebase interfaces, and lazy load what can be lazy loaded]. // @TODO[PRIO=2]: [Make this API less "object" oriented, maybe there is a possibility to three-shake it]. @@ -35,6 +39,9 @@ type Call = ( type Api = { call: Call; logIn(): Promise; + logInWithCredentials(email: string, password: string): Promise; + registerWithCredentials(email: string, password: string): Promise; + resetPassword(email: string): Promise; logOut(): Promise; onAuthChange( nextOrObserver: NextOrObserver, @@ -52,6 +59,11 @@ const isOffline = (): boolean => class NoInternetException extends Error {} +const maskEmail = (email: string | null): string => { + if (!email) return "your inbox"; + return email.replace(/^[^@]+/, "***"); +}; + const initializeAPI = (version: CacheVersion): Api => { cacheVersion = version; @@ -129,6 +141,38 @@ const initializeAPI = (version: CacheVersion): Api => { await signInWithPopup(auth, provider); }, + logInWithCredentials: async (email: string, password: string) => { + await setPersistence(auth, browserLocalPersistence); + const { user } = await signInWithEmailAndPassword( + auth, + email, + password, + ); + + if (!user.emailVerified) { + await sendEmailVerification(user); + await signOut(auth); + throw customError( + `We sent a verification email to ${maskEmail(user.email)}. Please verify your email before signing in.`, + ); + } + }, + registerWithCredentials: async (email: string, password: string) => { + await setPersistence(auth, browserLocalPersistence); + const { user } = await createUserWithEmailAndPassword( + auth, + email, + password, + ); + await sendEmailVerification(user); + await signOut(auth); + throw customError( + `We sent a verification email to ${maskEmail(user.email)}. Please verify your email to finish creating your account.`, + ); + }, + resetPassword: async (email: string) => { + await sendPasswordResetEmail(auth, email); + }, logOut: async () => { await signOut(auth); }, diff --git a/src/components/add-doc-popover.tsx b/src/components/add-doc-popover.tsx index c56e81aa1..fed5e1db4 100644 --- a/src/components/add-doc-popover.tsx +++ b/src/components/add-doc-popover.tsx @@ -7,7 +7,7 @@ import { Button } from "design-system/button"; import React from "react"; import { BiPlus } from "react-icons/bi"; import { useAuthStore } from "store/auth/auth.store"; -import { logIn } from "actions/log-in.action"; +import { logInAct } from "acts/log-in.act"; import { useDocManagementStore } from "store/doc-management/doc-management.store"; import { useSimpleFeature } from "@greenonsoftware/react-kit"; @@ -31,7 +31,7 @@ const AddDocPopover = () => { } triggerDocumentCreation(); - logIn(); + logInAct(); }; React.useEffect(() => { diff --git a/src/components/auth-credentials-modal.tsx b/src/components/auth-credentials-modal.tsx new file mode 100644 index 000000000..bbb17fc4a --- /dev/null +++ b/src/components/auth-credentials-modal.tsx @@ -0,0 +1,151 @@ +import React from "react"; +import { Modal2 } from "design-system/modal2"; +import { Field } from "design-system/field"; +import { Input } from "design-system/input"; +import { Button } from "design-system/button"; +import { useAuthCredentialsForm } from "core/use-auth-credentials-form"; + +type AuthCredentialsModalProps = { + onClose(): void; +}; + +const AuthCredentialsModal = ({ onClose }: AuthCredentialsModalProps) => { + const { + mode, + status, + result, + inject, + resetStatus, + canSubmit, + passwordMismatch, + submit, + switchMode, + } = useAuthCredentialsForm({ onSuccess: onClose }); + + const withReset = + (onChange: (event: React.ChangeEvent) => void) => + (event: React.ChangeEvent) => { + resetStatus(); + onChange(event); + }; + + const emailInput = inject("email"); + const passwordInput = inject("password"); + const confirmPasswordInput = inject("confirmPassword"); + + return ( + + + +
{ + event.preventDefault(); + submit(); + }} + > + {status.is === "fail" && ( + + {status.error.message} + + )} + + + {result.email && {result.email}} + + + + {result.password && {result.password}} + + {mode === "register" && ( + + + {passwordMismatch && ( + Passwords do not match. + )} + + )} + +
+
+ + + + +
+ ); +}; + +export { AuthCredentialsModal }; diff --git a/src/components/bullshit-meter.tsx b/src/components/bullshit-meter.tsx new file mode 100644 index 000000000..1fc11aa13 --- /dev/null +++ b/src/components/bullshit-meter.tsx @@ -0,0 +1,388 @@ +import React from "react"; +import { useSimpleFeature } from "@greenonsoftware/react-kit"; +import { Atoms } from "api-4markdown-contracts"; +import { Markdown } from "components/markdown"; +import { Button } from "design-system/button"; +import { c } from "design-system/c"; +import { Modal2 } from "design-system/modal2"; +import { BiInfoCircle } from "react-icons/bi"; + +type BullshitMeterProps = { + rating: Atoms["Rating"]; + score: Atoms["Score"]; + commentsCount: number; + className?: string; +}; + +const BULLSHIT_EXPLANATION_MARKDOWN = ` +## How Bullshit score is calculated + +This meter combines **score** and **rating sentiment** into one number in range **0..10**. + +### 1) Score part + +The incoming score (already converted to "bullshit direction" by the consumer) is clamped: + +$$ +S = \\text{clamp}(\\text{scoreAverage}, 0, 10) +$$ + +### 2) Rating part + +Rating buckets are weighted by sentiment: + +- **Negative**: \`ugly = 10\`, \`bad = 8\` +- **Neutral**: \`decent = 5\` +- **Positive**: \`good = 2\`, \`perfect = 0\` + +Weighted rating score: + +$$ +R = \\frac{10 \\cdot ugly + 8 \\cdot bad + 5 \\cdot decent + 2 \\cdot good + 0 \\cdot perfect}{ugly + bad + decent + good + perfect} +$$ + +If there are no ratings, then: + +$$ +R = 0 +$$ + +### 3) Dynamic blend + +More ratings increase rating impact: + +$$ +w = \\min(0.45, 0.2 + \\frac{ratingsCount}{60}) +$$ + +Final meter score: + +$$ +B = \\text{clamp}(S \\cdot (1 - w) + R \\cdot w, 0, 10) +$$ + +Where **B** is the displayed **Bullshit score**. + +## Quality labels + +Labels are picked by the final normalized index: + +- **Looks solid**: very low bullshit +- **Mostly fine**: low bullshit +- **Mixed quality**: moderate uncertainty +- **Questionable**: medium-high bullshit +- **Very sketchy**: high bullshit +- **BULLSHIT!!**: extreme bullshit +`; + +type BullshitCalculationModalProps = { + onClose(): void; +}; + +const BullshitCalculationModal = ({ + onClose, +}: BullshitCalculationModalProps) => { + return ( + + + + {BULLSHIT_EXPLANATION_MARKDOWN} + + + ); +}; + +const clampScore = (score: number): number => { + return Math.max(0, Math.min(10, score)); +}; + +const getBullshitStatus = ({ + scoreAverage, + commentsCount, + scoreCount, +}: { + scoreAverage: number; + commentsCount: number; + scoreCount: number; +}): { emoji: string; text: string; index: number } => { + const scoreBadness = clampScore(scoreAverage) / 10; + const commentsImpact = Math.min(1, commentsCount / 20); + const participationImpact = Math.min(1, scoreCount / 20); + const confidence = Math.max(commentsImpact, participationImpact) * 0.15; + const index = Math.max(0, Math.min(1, scoreBadness * (0.85 + confidence))); + + if (index >= 0.85) { + return { emoji: "🤡", text: "BULLSHIT!!", index }; + } + if (index >= 0.7) { + return { emoji: "😵", text: "Very sketchy", index }; + } + if (index >= 0.55) { + return { emoji: "😬", text: "Questionable", index }; + } + if (index >= 0.4) { + return { emoji: "😐", text: "Mixed quality", index }; + } + if (index >= 0.25) { + return { emoji: "🙂", text: "Mostly fine", index }; + } + return { emoji: "😎", text: "Looks solid", index }; +}; + +const getFillPaletteClass = (score: number): string => { + if (score === 0) { + return "from-emerald-600 via-green-500 to-emerald-600 dark:from-emerald-900 dark:via-green-800 dark:to-emerald-900"; + } + + const normalizedScore = clampScore(score); + + if (normalizedScore <= 2) { + return "from-green-600 via-green-500 to-emerald-600 dark:from-green-800 dark:via-green-700 dark:to-emerald-800"; + } + if (normalizedScore <= 4) { + return "from-yellow-500 via-lime-500 to-green-600 dark:from-yellow-700 dark:via-lime-700 dark:to-green-800"; + } + if (normalizedScore <= 6) { + return "from-orange-500 via-amber-500 to-yellow-500 dark:from-orange-800 dark:via-amber-800 dark:to-yellow-700"; + } + if (normalizedScore <= 8) { + return "from-red-500 via-orange-600 to-orange-500 dark:from-red-800 dark:via-orange-900 dark:to-orange-800"; + } + + return "from-red-600 via-red-500 to-orange-600 dark:from-red-900 dark:via-red-800 dark:to-orange-900"; +}; + +type BubbleSpec = { + left: number; + top: number; + size: number; + duration: number; + delay: number; + opacity: number; +}; + +const getRatingBullshitScore = (rating: Atoms["Rating"]): number => { + const total = + rating.ugly + rating.bad + rating.decent + rating.good + rating.perfect; + + if (total === 0) { + return 0; + } + + // 2 negative keys (ugly/bad), 1 neutral (decent), 2 positive (good/perfect). + // Higher result means more bullshit in 0..10 scale. + const weightedSum = + rating.ugly * 10 + + rating.bad * 8 + + rating.decent * 5 + + rating.good * 2 + + rating.perfect * 0; + + return clampScore((weightedSum / total / 10) * 10); +}; + +const createBubbleSpecs = (bullshitLevel: number): BubbleSpec[] => { + const count = Math.max(2, Math.round(2 + bullshitLevel * 2.2)); + + return Array.from({ length: count }, (_, index) => { + const seed = (index + 1) * 73; + const duration = + 1.4 + (((seed * 13) % 17) / 10 + (10 - bullshitLevel) * 0.04); + + return { + left: (seed * 37) % 100, + top: 18 + ((seed * 23) % 62), + size: 3 + ((seed * 19) % 7), + duration, + // Negative delay phases each bubble so they don't stack at animation start. + delay: -(((seed * 29) % 14) * 0.12 + duration * ((seed % 7) / 10)), + opacity: 0.3 + ((seed * 41) % 5) * 0.12, + }; + }); +}; + +const BullshitMeter = ({ + rating, + score, + commentsCount, + className, +}: BullshitMeterProps) => { + const explanationModal = useSimpleFeature(); + const rawBullshitScore = clampScore(score.scoreAverage); + const ratingBullshitScore = getRatingBullshitScore(rating); + const totalRatings = + rating.ugly + rating.bad + rating.decent + rating.good + rating.perfect; + const ratingWeight = + totalRatings === 0 ? 0 : Math.min(0.45, 0.2 + totalRatings / 60); + const normalizedScore = clampScore( + rawBullshitScore * (1 - ratingWeight) + ratingBullshitScore * ratingWeight, + ); + const bullshitLevel = normalizedScore; + const isMaxBullshitLevel = bullshitLevel >= 10; + const scoreCount = Math.max(score.scoreCount, score.scoreValues.length); + const meterFillPercent = normalizedScore * 10; + const fillPaletteClass = getFillPaletteClass(normalizedScore); + const bubbleSpecs = React.useMemo( + () => createBubbleSpecs(bullshitLevel), + [bullshitLevel], + ); + const status = getBullshitStatus({ + scoreAverage: normalizedScore, + commentsCount, + scoreCount, + }); + + return ( +
+ +
+

+ Bullshit Meter +

+
+ + + {status.emoji} + {isMaxBullshitLevel && ( + <> + + + + )} + +
+
+ +
+ + {normalizedScore.toFixed(1)} out of 10 + +
+
+
+ {bubbleSpecs.map((bubble, index) => ( + + ))} +
+
+
+
+

+ {status.text} +

+
+

+ Bullshit score +

+

+ {normalizedScore.toFixed(1)}/10 +

+
+
+
+ {explanationModal.isOn && ( + + )} +
+ ); +}; + +export type { BullshitMeterProps }; +export { BullshitMeter }; diff --git a/src/components/company-logo.tsx b/src/components/company-logo.tsx deleted file mode 100644 index a61987966..000000000 --- a/src/components/company-logo.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import React from "react"; - -interface CompanyLogoProps { - className?: string; - size: number; -} - -const CompanyLogo = ({ className, size = 32 }: CompanyLogoProps) => { - return ( - - - - - - - - - - - - - - - - - - - - ); -}; - -export { CompanyLogo }; diff --git a/src/components/cookies-modal.tsx b/src/components/cookies-modal.tsx index d8f9873da..c9a140474 100644 --- a/src/components/cookies-modal.tsx +++ b/src/components/cookies-modal.tsx @@ -1,5 +1,6 @@ import { Button } from "design-system/button"; import { Modal2 } from "design-system/modal2"; +import { Switch } from "design-system/switch"; import React from "react"; import c from "classnames"; import { getCookie, setCookie } from "development-kit/cookies"; @@ -120,29 +121,15 @@ const CookiesModal = () => { >

{key} Cookies

-
, + ); +}; + +export { FullscreenImageModal }; diff --git a/src/design-system/switch.tsx b/src/design-system/switch.tsx new file mode 100644 index 000000000..e26e2e41f --- /dev/null +++ b/src/design-system/switch.tsx @@ -0,0 +1,70 @@ +import React from "react"; +import { c } from "./c"; + +type SwitchProps = { + checked: boolean; + onChange: (checked: boolean) => void; + id?: string; + name?: string; + disabled?: boolean; + "aria-label"?: string; + "aria-labelledby"?: string; + className?: string; +}; + +const Switch: React.FC = ({ + checked, + onChange, + id, + name, + disabled = false, + "aria-label": ariaLabel, + "aria-labelledby": ariaLabelledBy, + className, +}) => { + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + if (!disabled) { + onChange(!checked); + } + } + }; + + return ( +
+ + ) : ( + - - - + + + + )} - + {pending && ( { )} {docsStore.is === `fail` && (

- Something went wrong... Try again with above button refresh button + Something went wrong… Try again with above button refresh button

)} {docsStore.is === `ok` && ( @@ -87,58 +283,41 @@ const DocsListModalContainer = ({ onClose }: { onClose(): void }) => { {docs.length > 0 ? (
    {docs.map((doc) => ( -
  • selectDoc(doc)} - > -
    - - Edited{` `} - {formatDistance(new Date(), doc.mdate, { - addSuffix: true, - })} - {` `} - ago - - -
    - {doc.name} -
  • + doc={doc} + isActive={isDocActive(doc.id)} + onSelect={selectDoc} + /> ))}
) : (

- No documents for selected filters + {searchQuery.trim().length > 0 + ? `No documents found matching "${searchQuery}"` + : `No documents for selected filters`}

)} )}
- - - {rangeFilters.map((range) => ( - setActiveRange(range)} - > - {range} - - ))} - - + {!isSearchActive && ( + + + {rangeFilters.map((range) => ( + handleRangeChange(range)} + > + {range} + + ))} + + + )} ); }; diff --git a/src/features/creator/containers/document-details.container.tsx b/src/features/creator/containers/document-details.container.tsx index b27c67cb0..f5a36ffff 100644 --- a/src/features/creator/containers/document-details.container.tsx +++ b/src/features/creator/containers/document-details.container.tsx @@ -1,6 +1,6 @@ import { Button } from "design-system/button"; import React from "react"; -import { BiPencil, BiTrash } from "react-icons/bi"; +import { BiHistory, BiPencil, BiTrash } from "react-icons/bi"; import { useDocManagementStore } from "store/doc-management/doc-management.store"; import { docStoreSelectors } from "store/doc/doc.store"; import { PublicConfirmationContainer } from "features/creator/containers/public-confirmation.container"; @@ -13,6 +13,7 @@ import { useSimpleFeature } from "@greenonsoftware/react-kit"; import { ResourceVisibilityTabs } from "components/resource-visibility-tabs"; import { updateDocumentVisibilityAct } from "../acts/update-document-visibility.act"; import { ResourceDetails } from "components/resource-details"; +import { ResourceActivityContainer } from "modules/resource-activity"; const AccessGroupsAssignModule = React.lazy(() => import("modules/access-groups-assign").then((m) => ({ @@ -33,6 +34,7 @@ const DocumentDetailsContainer = ({ const permanentConfirmation = useSimpleFeature(); const publicConfirmation = useSimpleFeature(); const manualConfirmation = useSimpleFeature(); + const historyModal = useSimpleFeature(); const docStore = docStoreSelectors.useActive(); const docManagementStore = useDocManagementStore(); const permamentDocumentEdition = useSimpleFeature(); @@ -91,11 +93,23 @@ const DocumentDetailsContainer = ({ )} + {historyModal.isOn && ( + + + + )} + {permanentConfirmation.isOff && publicConfirmation.isOff && privateConfirmation.isOff && permamentDocumentEdition.isOff && - manualConfirmation.isOff && ( + manualConfirmation.isOff && + historyModal.isOff && ( <> + {docStore.visibility === `permanent` && ( { + e.stopPropagation(); + }} > - + - + ); }; diff --git a/src/features/mindmap-creator/components/node-tile.tsx b/src/features/mindmap-creator/components/node-tile.tsx index f8e32818c..54f06a4ca 100644 --- a/src/features/mindmap-creator/components/node-tile.tsx +++ b/src/features/mindmap-creator/components/node-tile.tsx @@ -9,36 +9,41 @@ const NodeTile = ({ selected: boolean; }) => { return ( -
{children} -
+ ); }; // eslint-disable-next-line react/display-name NodeTile.Name = ({ children }: { children: ReactNode }) => { - return
{children}
; + return

{children}

; }; // eslint-disable-next-line react/display-name NodeTile.Label = ({ children }: { children: ReactNode }) => { - return

{children}

; + return ( +

+ {children} +

+ ); }; // eslint-disable-next-line react/display-name NodeTile.Description = ({ children }: { children: ReactNode }) => { - return

{children}

; + return

{children}

; }; + // eslint-disable-next-line react/display-name -NodeTile.Toolbox = ({ children }: { children: ReactNode }) => { +NodeTile.Actions = ({ children }: { children: ReactNode }) => { return ( -
+
{children}
); diff --git a/src/features/mindmap-creator/containers/embedded-node-tile.container.tsx b/src/features/mindmap-creator/containers/embedded-node-tile.container.tsx index 26315b833..047c37d5a 100644 --- a/src/features/mindmap-creator/containers/embedded-node-tile.container.tsx +++ b/src/features/mindmap-creator/containers/embedded-node-tile.container.tsx @@ -18,54 +18,88 @@ const EmbeddedNodeTileContainer = ({ positionAbsoluteX, positionAbsoluteY, data, -}: EmbeddedNodeTileContainerProps) => ( - - Embedded Resource - {data.name} - {data.description && ( - {data.description} - )} - - - - - -); +}: EmbeddedNodeTileContainerProps) => { + const handleEdit = React.useCallback( + (e: React.MouseEvent | React.KeyboardEvent) => { + e.stopPropagation(); + openNodeEditionAction({ + type: `embedded`, + id, + position: { + x: positionAbsoluteX, + y: positionAbsoluteY, + }, + data, + }); + }, + [id, positionAbsoluteX, positionAbsoluteY, data], + ); + + const handlePreview = React.useCallback( + (e: React.MouseEvent | React.KeyboardEvent) => { + e.stopPropagation(); + openNodePreviewAction({ + type: `embedded`, + id, + data, + position: { + x: positionAbsoluteX, + y: positionAbsoluteY, + }, + }); + }, + [id, positionAbsoluteX, positionAbsoluteY, data], + ); + + const handleEditKeyDown = React.useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleEdit(e); + } + }, + [handleEdit], + ); + + const handlePreviewKeyDown = React.useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handlePreview(e); + } + }, + [handlePreview], + ); + + return ( + + {data.name} + {data.description && ( + {data.description} + )} + + + + + + ); +}; const EmbeddedNodeTileContainerX = (props: EmbeddedNodeTileContainerProps) => ( diff --git a/src/features/mindmap-creator/containers/mindmap-creator.container.tsx b/src/features/mindmap-creator/containers/mindmap-creator.container.tsx index 9361f955d..823f10f98 100644 --- a/src/features/mindmap-creator/containers/mindmap-creator.container.tsx +++ b/src/features/mindmap-creator/containers/mindmap-creator.container.tsx @@ -166,7 +166,7 @@ const MindmapCreatorContainer = () => { onViewportChange={updateLatestViewport} fitView > - + + + + )} + + {mindmapForm.is === `active` && creationMode === `manual` && (

- Mindmap will be created in private mode. Visible - only to you, but data inside is{` `} - not encrypted -{` `} - avoid sensitive data + Mindmap will be created in private mode, based on + your current editor content. Visible only to you, but data inside is + {` `} + not encrypted—avoid sensitive data.

)} -
- - {validationLimits.name.min} - {validationLimits.name.max} - {` `} - characters - - } + + {mindmapForm.is === `edition` && ( +

+ Update metadata of your existing mindmap. Structure and content stay + unchanged. +

+ )} + + {(mindmapForm.is === `edition` || + (mindmapForm.is === `active` && creationMode === `manual`)) && ( +
+ + {validationLimits.name.min} - {validationLimits.name.max} + {` `} + characters + + } + /> + } + > + - } - > - - - - {validationLimits.description.min} -{` `} - {validationLimits.description.max} - {` `} - characters - - } + + + {validationLimits.description.min} -{` `} + {validationLimits.description.max} + {` `} + characters + + } + /> + } + > +