diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5dc3a85 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +**/bin/ +**/obj/ +**/node_modules/ +**/dist/ +**/wwwroot/ +.git/ +inbox/ +data/ +deploy.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..68d5233 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +**/bin/ +**/obj/ +**/node_modules/ +**/dist/ +backend/Wiki.Api/wwwroot/ +backend/Wiki.Api/data/ +data/ +inbox/ +deploy.json +*.user diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..a4ebe9e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,30 @@ +# crm-wiki + +Вики (база знаний) для CRM Tvorymo, встраивается в CRM через iframe в боковой панели. +**Полная спецификация — `SPEC.md` в этой папке. Начинай с неё.** + +## Окружение + +- Код: Gitea `gitea.tes.gd`, репозиторий `admin/crm-wiki`, env `GIT_URL`, `GIT_USER`, `GIT_KEY`. Gitea создала репозиторий с начальным коммитом в `main`, поэтому сначала клонируй его, а не делай `git init`. +- Деплой: Coolify storage, env `COOLIFY_URL`, `COOLIFY_KEY` (профиль «Storage coolify»). + Деплоить через Coolify v4 REST API (скилл `deploying-to-coolify-via-api`), fire-and-forget. + Упаковка .NET-приложения для Coolify: скилл `preparing-dotnet-react-app-for-coolify`. +- **Постоянный том обязателен:** файл SQLite и картинки должны лежать на named volume в Coolify, иначе пропадут при пересборке. +- .NET SDK есть локально (`dotnet --list-sdks`: 8, 9, 10). Сборка для продакшена выполняется в Dockerfile. +- Домен: `https://wiki-crm.tes.gd` (HTTPS, домены *.tes.gd на этом Coolify работают). +- CRM: `https://test.units.tvorymo.com`. Supabase: `https://yjxbkcqsquwbsyoonfkn.supabase.co`. + +## Образец встраивания + +Тестовая страница `https://test-crm.tes.gd` уже работает: авторизация через postMessage, проверка токена в Supabase, стили CRM. +- Репозиторий: `https://gitea.tes.gd/admin/crm-embed-test.git` (те же `GIT_USER`/`GIT_KEY` дают доступ на чтение). +- Рабочая копия на этом сервере: `/app/data/home/crm_embed_test` (только читать, не менять). + +## Supabase publishable key + +`sb_publishable_TQaZAtnBpImL49A-o87bKg_q5sYvhPR` (получен от Ланы 2026-10-13; публичный по дизайну) + +## Правила + +- Токены CRM/Supabase не логировать (ни браузер, ни сервер). +- Никогда не печатать `GIT_KEY` / `COOLIFY_KEY`. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5066051 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,35 @@ +# syntax=docker/dockerfile:1.7 + +# 1. Фронтенд +FROM node:22-alpine AS web +WORKDIR /src +COPY frontend/package.json frontend/package-lock.json ./ +RUN npm ci --no-audit --no-fund +COPY frontend/ ./ +RUN npm run build + +# 2. Бэкенд +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS api +WORKDIR /src +COPY backend/Wiki.Api/Wiki.Api.csproj backend/Wiki.Api/ +RUN dotnet restore backend/Wiki.Api/Wiki.Api.csproj +COPY backend/Wiki.Api/ backend/Wiki.Api/ +RUN dotnet publish backend/Wiki.Api/Wiki.Api.csproj -c Release -o /out --no-restore /p:UseAppHost=false + +# 3. Runtime +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime +WORKDIR /app +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* +COPY --from=api /out ./ +COPY --from=web /src/dist ./wwwroot +# /data — постоянный том Coolify (SQLite + картинки) +RUN mkdir -p /data +ENV ASPNETCORE_ENVIRONMENT=Production \ + ASPNETCORE_URLS=http://+:8080 \ + Storage__DataDir=/data +EXPOSE 8080 +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD curl -fsS http://127.0.0.1:8080/health || exit 1 +ENTRYPOINT ["dotnet", "Wiki.Api.dll"] diff --git a/README.md b/README.md index 23a18b7..01e255f 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,73 @@ # crm-wiki +Вики (база знаний) для CRM Tvorymo. Открывается в iframe из боковой панели CRM, пользователя узнаёт по токену CRM (postMessage `crm:ready` → `crm:auth`). + +- Сайт: https://wiki-crm.tes.gd +- Спецификация: [SPEC.md](SPEC.md) +- Стек: ASP.NET Core 10 (minimal API) + SQLite (FTS5), фронтенд Vite + React + TipTap. +- Данные: `/data/wiki.db` и `/data/images/` на постоянном томе `wiki-data` (Coolify). + +## Авторизация + +Каждый запрос к `/api/*` (кроме `/api/verify` и `/api/config`) требует: + +| Заголовок | Значение | +|---|---| +| `Authorization` | `Bearer ` | +| `X-Wiki-Workspace` | `shared` (по умолчанию), `personal:` или `` своего workspace | + +Сервер проверяет токен: `exp`, `aud = "authenticated"` и `GET /auth/v1/user` с publishable-ключом (результат кэшируется на 5 минут по SHA-256 токена). Workspace из запроса не доверяется: `shared` доступен всем, `personal:` только владельцу `uid`, произвольный `` проверяется запросом `GET /rest/v1/workspaces?id=eq.` с токеном пользователя (RLS Supabase). Ответы: `401` — токен не принят или истёк, `403` — нет доступа к workspace. + +Пока CRM не передаёт workspace (задача Олега `mv6p5b92slw1an`), фронтенд работает в `shared`. Когда в `crm:auth` появится `workspace: { id, kind, title }`, он подхватится автоматически: `kind: "shared"` → `shared`, `kind: "personal"` → `personal:`, иначе `id`. Сообщение о смене workspace тоже обрабатывается (`{ type: "crm:workspace", workspace: {…} }`), если Олег выберет другое имя — поправить в `frontend/src/crm.ts`. + +## REST API + +Все ответы JSON (кроме `/markdown` и картинок). Ошибки: `{ "error": "…" }`. + +| Метод | Путь | Что делает | +|---|---|---| +| `GET` | `/health` | `{ status, dbCreatedAt }` — без авторизации; `dbCreatedAt` не меняется, пока жив том с базой | +| `GET` | `/api/config` | `{ crmOrigin }` — без авторизации | +| `POST` | `/api/verify` | `{ access_token }` → `{ ok, id, email, name, exp }` — без авторизации | +| `GET` | `/api/me` | текущий пользователь и workspace | +| `GET` | `/api/tree` | `{ workspace, nodes: [Node] }` — все папки и страницы workspace без текста | +| `GET` | `/api/nodes/{id}` | папка или страница (Page) | +| `GET` | `/api/pages/{id}` | страница (Page), `404` для папки | +| `GET` | `/api/pages/{id}/markdown` | текст страницы, `text/markdown` | +| `POST` | `/api/pages` | `{ title, parentId?, markdown? }` → `201` Page | +| `POST` | `/api/folders` | `{ title, parentId? }` → `201` Page (kind = folder) | +| `PUT` | `/api/pages/{id}` | `{ title?, markdown?, version }` → Page; если `version` устарела — `409 { error, current }` | +| `PATCH` | `/api/nodes/{id}` | переименовать/переместить: `{ title?, parentId?, version? }`; `parentId: null` — в корень. Перенос в себя/потомка или в страницу — `400` | +| `DELETE` | `/api/nodes/{id}` | удалить узел со всем содержимым → `{ deleted: N }` | +| `GET` | `/api/search?q=…&limit=30` | `{ results: [{ id, kind, title, snippet, path }] }` — FTS5 по названию и тексту, префиксный поиск по словам; совпадения в `snippet` обрамлены `\u0002…\u0003` | +| `POST` | `/api/images` | `multipart/form-data`: `file` (PNG/JPEG/GIF/WebP, до 10 МБ), `pageId?` → `201 { id, url }` | +| `GET` | `/api/images/{id}` | картинка; нужен токен и доступ к workspace картинки | + +```ts +type Node = { id, parentId: string|null, kind: "folder"|"page", title, version: number, + createdAt, updatedAt, updatedBy /* uid */, updatedByName }; +type Page = Node & { markdown: string, path: { id, kind, title }[] /* папки от корня */ }; +``` + +Формат текста — Markdown (GFM: таблицы, списки задач). Ссылка на другую страницу: `[текст](/p/)`, картинка: `![alt](/api/images/)`. Ссылки по id не ломаются при переименовании и переносе. + +Пример (агент или скрипт): + +```sh +curl -H "Authorization: Bearer $TOKEN" https://wiki-crm.tes.gd/api/tree +curl -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ + -d '{"title":"Регламент","markdown":"# Привет"}' https://wiki-crm.tes.gd/api/pages +``` + +## Заголовки + +`Content-Security-Policy: frame-ancestors https://test.units.tvorymo.com; …` (без `X-Frame-Options`). Origin CRM задаётся `Crm__Origin`, домен стилей CRM — `Crm__AssetsOrigin`. + +## Разработка + +```sh +cd backend/Wiki.Api && ASPNETCORE_URLS=http://127.0.0.1:5080 dotnet run # база в ./data +cd frontend && npm i && npm run dev # Vite, /api проксируется на 127.0.0.1:5080 +``` + +Настройки (env): `Storage__DataDir`, `Crm__Origin`, `Crm__AssetsOrigin`, `Supabase__Url`, `Supabase__PublishableKey`. diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..b5e24ed --- /dev/null +++ b/SPEC.md @@ -0,0 +1,46 @@ +--- +добавлено: 2026-10-13 +--- +# Спецификация. Вики для CRM + +База знаний для людей и агентов, отдельный сайт, встраивается в CRM через боковую панель. Задача владельца в CRM: «Додати в CRM вікі-сторінки зі структурою папок» (`mv2pby2qbv9vdj`, проект Tvorymo shotam). Как встраивать: [[Встраивание страниц в CRM]]. + +## Стек + +- Бэкенд на C# (ASP.NET Core): REST API, чтобы к вики было легко обращаться из скиллов и других сервисов. +- Данные в SQLite, нагрузка небольшая. Файл базы на постоянном томе Coolify, иначе пропадёт при пересборке. +- Хостинг: наш сервер, Coolify storage, код в `gitea.tes.gd`. Агента (`crm-wiki`) создаёт админ. +- Домен: `https://wiki-crm.tes.gd`. +- Картинки хранятся на диске, на том же постоянном томе, что и SQLite. Отдаются только после проверки токена и доступа к workspace страницы. + +## Авторизация + +- Токен пользователя CRM приходит через `postMessage` (`crm:ready` → `crm:auth`), как на тестовой странице `https://test-crm.tes.gd`. +- Токен нужен только для входа: сервер вики проверяет его через Supabase (`GET /auth/v1/user` с publishable-ключом, `exp`, `aud = "authenticated"`). Данные вики в Supabase не хранятся. +- Агенты получают такой же доступ, как люди. Отдельный скилл для работы с вики сделаем позже, сейчас на агентах не фокусируемся. + +## Workspace + +- Сейчас CRM workspace во встроенную страницу не передаёт: в `crm:auth` только `access_token`, `expires_at`, `user { id, email, name }` (проверено по `js/pagebridge.js` 2026-10-13). Выбранный workspace CRM держит в `localStorage`. +- Нужна доработка протокола от Олега (задача в CRM): поле `workspace: { id, kind, title }` в `crm:auth` и новое сообщение при переключении workspace. +- Сервер вики id из сообщения не доверяет, а сверяет доступ: `personal:` только для владельца, `shared` для всех, `custom` проверяется запросом к таблице `workspaces` Supabase с токеном пользователя (RLS). +- У каждой папки и страницы поле `workspace_id`, дерево показывает только текущий workspace. + +## Первая версия + +1. Страницы: название и текст в визуальном редакторе как в Notion (заголовки, списки, жирный, ссылки, таблицы, картинки). Хранить текст в Markdown или в JSON редактора с экспортом в Markdown — решает агент, но API должен отдавать Markdown. +2. Папки с любой вложенностью, дерево сбоку (раскрыть, свернуть), хлебные крошки сверху. +3. Действия: создать, переименовать, переместить, удалить с подтверждением. +4. Ссылки между страницами по id: не ломаются при переименовании и переносе. +5. Поиск по названиям и тексту (SQLite FTS5). +6. Стили CRM: `tvorymo-ui.css` и `crm.css` ссылками, `body.side-off`. +7. Защита от одновременной правки: проверка версии страницы при сохранении. + +## Потом + +- История изменений и откат версий. +- Скилл для агентов. + +## Зависимости + +- Фильтр по workspace зависит от задачи Олега `mv6p5b92slw1an` (она блокирует задачу вики в CRM). До её выкатки делать всё остальное; workspace брать из сообщения, если поле есть, иначе временно работать в общем workspace (`shared`). diff --git a/backend/Wiki.Api/Auth.cs b/backend/Wiki.Api/Auth.cs new file mode 100644 index 0000000..83deb16 --- /dev/null +++ b/backend/Wiki.Api/Auth.cs @@ -0,0 +1,133 @@ +using System.Collections.Concurrent; +using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace Wiki.Api; + +public sealed record WikiUser(string Id, string? Email, string? Name, long Exp); + +/// +/// Проверка токена CRM (Supabase access_token) и доступа к workspace. +/// Токены не логируются и не хранятся: в кэше ключ — SHA-256 токена. +/// +public sealed partial class SupabaseAuth(IHttpClientFactory http, IConfiguration cfg) +{ + private readonly string _url = (cfg["Supabase:Url"] ?? "").TrimEnd('/'); + private readonly string _key = cfg["Supabase:PublishableKey"] ?? ""; + private readonly ConcurrentDictionary _users = new(); + private readonly ConcurrentDictionary _ws = new(); + private static readonly TimeSpan CacheFor = TimeSpan.FromMinutes(5); + + public const string Shared = "shared"; + + [GeneratedRegex("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")] + private static partial Regex UuidRx(); + + public enum Fail { None, BadToken, Expired, Rejected, Unavailable } + + public async Task<(WikiUser? User, Fail Fail)> VerifyAsync(string token, CancellationToken ct) + { + var now = DateTimeOffset.UtcNow; + var key = Hash(token); + if (_users.TryGetValue(key, out var hit) && hit.Until > now && hit.User.Exp > now.ToUnixTimeSeconds()) + return (hit.User, Fail.None); + + // 1. Разбор JWT: exp и aud. + JsonElement claims; + try + { + var parts = token.Split('.'); + if (parts.Length != 3) return (null, Fail.BadToken); + claims = JsonDocument.Parse(Base64Url(parts[1])).RootElement; + } + catch { return (null, Fail.BadToken); } + if (!claims.TryGetProperty("exp", out var expEl) || !expEl.TryGetInt64(out var exp)) return (null, Fail.BadToken); + if (exp <= now.ToUnixTimeSeconds()) return (null, Fail.Expired); + if (!AudIsAuthenticated(claims)) return (null, Fail.Rejected); + + // 2. Supabase: GET /auth/v1/user. + HttpResponseMessage r; + try + { + using var req = new HttpRequestMessage(HttpMethod.Get, $"{_url}/auth/v1/user"); + req.Headers.Add("apikey", _key); + req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + r = await http.CreateClient("supabase").SendAsync(req, ct); + } + catch (Exception e) when (e is HttpRequestException or TaskCanceledException) { return (null, Fail.Unavailable); } + using (r) + { + if (!r.IsSuccessStatusCode) return (null, (int)r.StatusCode >= 500 ? Fail.Unavailable : Fail.Rejected); + using var doc = JsonDocument.Parse(await r.Content.ReadAsStringAsync(ct)); + var u = doc.RootElement; + if (Str(u, "aud") != "authenticated" || Str(u, "id") is not { } id) return (null, Fail.Rejected); + string? name = null; + if (u.TryGetProperty("user_metadata", out var md) && md.ValueKind == JsonValueKind.Object) + name = Str(md, "name") ?? Str(md, "full_name") ?? Str(md, "display_name"); + var user = new WikiUser(id, Str(u, "email"), string.IsNullOrWhiteSpace(name) ? null : name, exp); + _users[key] = (user, now + CacheFor); + if (_users.Count > 5000) Sweep(now); + return (user, Fail.None); + } + } + + /// + /// Нормализует и проверяет workspace. id из клиента не доверяем: + /// shared — для всех, personal:<uid> — только владельцу, <uuid> (custom) — по таблице workspaces с RLS. + /// + public async Task CanAccessAsync(WikiUser user, string token, string workspaceId, CancellationToken ct) + { + if (workspaceId == Shared) return true; + if (workspaceId.StartsWith("personal:", StringComparison.Ordinal)) + return workspaceId == $"personal:{user.Id}"; + if (!UuidRx().IsMatch(workspaceId)) return false; + + var now = DateTimeOffset.UtcNow; + var key = user.Id + "|" + workspaceId.ToLowerInvariant(); + if (_ws.TryGetValue(key, out var hit) && hit.Until > now) return hit.Ok; + bool ok; + try + { + using var req = new HttpRequestMessage(HttpMethod.Get, + $"{_url}/rest/v1/workspaces?id=eq.{workspaceId.ToLowerInvariant()}&select=id"); + req.Headers.Add("apikey", _key); + req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + using var r = await http.CreateClient("supabase").SendAsync(req, ct); + if (!r.IsSuccessStatusCode) return false; // ошибку не кэшируем + using var doc = JsonDocument.Parse(await r.Content.ReadAsStringAsync(ct)); + ok = doc.RootElement.ValueKind == JsonValueKind.Array && doc.RootElement.GetArrayLength() > 0; + } + catch (Exception e) when (e is HttpRequestException or TaskCanceledException or JsonException) { return false; } + _ws[key] = (ok, now + CacheFor); + return ok; + } + + private void Sweep(DateTimeOffset now) + { + foreach (var (k, v) in _users) if (v.Until <= now) _users.TryRemove(k, out _); + foreach (var (k, v) in _ws) if (v.Until <= now) _ws.TryRemove(k, out _); + } + + private static bool AudIsAuthenticated(JsonElement claims) + { + if (!claims.TryGetProperty("aud", out var aud)) return false; + if (aud.ValueKind == JsonValueKind.String) return aud.GetString() == "authenticated"; + if (aud.ValueKind == JsonValueKind.Array) + foreach (var a in aud.EnumerateArray()) if (a.ValueKind == JsonValueKind.String && a.GetString() == "authenticated") return true; + return false; + } + + private static string? Str(JsonElement e, string p) => + e.TryGetProperty(p, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null; + + private static string Hash(string s) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(s))); + + private static byte[] Base64Url(string s) + { + s = s.Replace('-', '+').Replace('_', '/'); + return Convert.FromBase64String(s.PadRight(s.Length + (4 - s.Length % 4) % 4, '=')); + } +} diff --git a/backend/Wiki.Api/Db.cs b/backend/Wiki.Api/Db.cs new file mode 100644 index 0000000..42581b9 --- /dev/null +++ b/backend/Wiki.Api/Db.cs @@ -0,0 +1,110 @@ +using Microsoft.Data.Sqlite; + +namespace Wiki.Api; + +/// Подключения к SQLite и схема. Файл базы лежит на постоянном томе (Storage:DataDir). +public sealed class Db +{ + public string DataDir { get; } + public string ImagesDir { get; } + private readonly string _cs; + + public Db(IConfiguration cfg) + { + DataDir = Path.GetFullPath(cfg["Storage:DataDir"] ?? "data"); + ImagesDir = Path.Combine(DataDir, "images"); + Directory.CreateDirectory(ImagesDir); + _cs = new SqliteConnectionStringBuilder + { + DataSource = Path.Combine(DataDir, "wiki.db"), + Cache = SqliteCacheMode.Shared, + ForeignKeys = true, + }.ToString(); + } + + public SqliteConnection Open() + { + var c = new SqliteConnection(_cs); + c.Open(); + using var cmd = c.CreateCommand(); + cmd.CommandText = "PRAGMA busy_timeout=5000;"; + cmd.ExecuteNonQuery(); + return c; + } + + public void Init() + { + using var c = Open(); + Exec(c, """ + PRAGMA journal_mode=WAL; + + CREATE TABLE IF NOT EXISTS meta( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + INSERT OR IGNORE INTO meta(key, value) VALUES ('created_at', strftime('%Y-%m-%dT%H:%M:%fZ','now')); + INSERT OR IGNORE INTO meta(key, value) VALUES ('schema', '1'); + + -- Папки и страницы одной таблицей: kind = 'folder' | 'page'. + CREATE TABLE IF NOT EXISTS nodes( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + parent_id TEXT NULL REFERENCES nodes(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK (kind IN ('folder','page')), + title TEXT NOT NULL, + content TEXT NOT NULL DEFAULT '', + version INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + created_by TEXT NOT NULL, + updated_at TEXT NOT NULL, + updated_by TEXT NOT NULL, + updated_by_name TEXT NULL + ); + CREATE INDEX IF NOT EXISTS ix_nodes_ws_parent ON nodes(workspace_id, parent_id); + + CREATE TABLE IF NOT EXISTS images( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + page_id TEXT NULL, + mime TEXT NOT NULL, + size INTEGER NOT NULL, + file_name TEXT NOT NULL, + created_at TEXT NOT NULL, + created_by TEXT NOT NULL + ); + + -- Полнотекстовый поиск по названию и тексту (external content на nodes). + CREATE VIRTUAL TABLE IF NOT EXISTS nodes_fts USING fts5( + title, content, + content='nodes', content_rowid='rowid', + tokenize='unicode61 remove_diacritics 2' + ); + CREATE TRIGGER IF NOT EXISTS nodes_ai AFTER INSERT ON nodes BEGIN + INSERT INTO nodes_fts(rowid, title, content) VALUES (new.rowid, new.title, new.content); + END; + CREATE TRIGGER IF NOT EXISTS nodes_ad AFTER DELETE ON nodes BEGIN + INSERT INTO nodes_fts(nodes_fts, rowid, title, content) VALUES ('delete', old.rowid, old.title, old.content); + END; + CREATE TRIGGER IF NOT EXISTS nodes_au AFTER UPDATE OF title, content ON nodes BEGIN + INSERT INTO nodes_fts(nodes_fts, rowid, title, content) VALUES ('delete', old.rowid, old.title, old.content); + INSERT INTO nodes_fts(rowid, title, content) VALUES (new.rowid, new.title, new.content); + END; + """); + } + + public string? Meta(string key) + { + using var c = Open(); + using var cmd = c.CreateCommand(); + cmd.CommandText = "SELECT value FROM meta WHERE key=$k"; + cmd.Parameters.AddWithValue("$k", key); + return cmd.ExecuteScalar() as string; + } + + public static void Exec(SqliteConnection c, string sql) + { + using var cmd = c.CreateCommand(); + cmd.CommandText = sql; + cmd.ExecuteNonQuery(); + } +} diff --git a/backend/Wiki.Api/Program.cs b/backend/Wiki.Api/Program.cs new file mode 100644 index 0000000..c818f70 --- /dev/null +++ b/backend/Wiki.Api/Program.cs @@ -0,0 +1,252 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.StaticFiles; +using Wiki.Api; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddHttpClient("supabase", c => c.Timeout = TimeSpan.FromSeconds(10)); +builder.Services.Configure(o => o.MultipartBodyLengthLimit = Images.MaxBytes + 64 * 1024); + +var app = builder.Build(); +var cfg = app.Configuration; +var crmOrigin = (cfg["Crm:Origin"] ?? "https://test.units.tvorymo.com").TrimEnd('/'); +// Откуда грузятся стили CRM (ссылки в index.html). +var crmAssets = (cfg["Crm:AssetsOrigin"] ?? "https://test.units.tvorymo.com").TrimEnd('/'); +app.Services.GetRequiredService().Init(); + +// ---------------------------------------------------------------- заголовки +// Встраивание только в CRM; X-Frame-Options не ставим (иначе iframe пустой). +var csp = string.Join("; ", + $"frame-ancestors {crmOrigin}", + "default-src 'self'", + "script-src 'self'", + $"style-src 'self' 'unsafe-inline' {crmAssets} https://fonts.googleapis.com", + $"font-src 'self' data: {crmAssets} https://fonts.gstatic.com", + "img-src 'self' data: blob: https:", + "connect-src 'self'", + "object-src 'none'", + "base-uri 'self'", + "form-action 'self'"); +app.Use(async (ctx, next) => +{ + var h = ctx.Response.Headers; + h["Content-Security-Policy"] = csp; + h["X-Content-Type-Options"] = "nosniff"; + h["Referrer-Policy"] = "strict-origin"; + await next(); +}); + +// ---------------------------------------------------------------- ошибки +app.Use(async (ctx, next) => +{ + try { await next(); } + catch (WikiError e) when (!ctx.Response.HasStarted) + { + ctx.Response.StatusCode = e.Status; + await ctx.Response.WriteAsJsonAsync(new { error = e.Message }); + } + catch (JsonException) when (!ctx.Response.HasStarted) + { + ctx.Response.StatusCode = 400; + await ctx.Response.WriteAsJsonAsync(new { error = "некорректный JSON" }); + } + catch (BadHttpRequestException e) when (!ctx.Response.HasStarted) + { + ctx.Response.StatusCode = e.StatusCode; + await ctx.Response.WriteAsJsonAsync(new { error = "некорректный запрос" }); + } +}); + +// ---------------------------------------------------------------- авторизация /api/* +// Bearer = access_token Supabase из CRM. Workspace — заголовок X-Wiki-Workspace (по умолчанию shared). +var open = new HashSet(StringComparer.OrdinalIgnoreCase) { "/api/verify", "/api/config" }; +app.Use(async (ctx, next) => +{ + var path = ctx.Request.Path.Value ?? ""; + if (!path.StartsWith("/api/", StringComparison.OrdinalIgnoreCase) || open.Contains(path)) { await next(); return; } + ctx.Response.Headers.CacheControl = "no-store"; + + var auth = ctx.Request.Headers.Authorization.ToString(); + var token = auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) ? auth[7..].Trim() : ""; + if (token.Length == 0) { await Deny(ctx, 401, "нужен заголовок Authorization: Bearer <токен CRM>"); return; } + var sa = ctx.RequestServices.GetRequiredService(); + var (user, fail) = await sa.VerifyAsync(token, ctx.RequestAborted); + if (user is null) + { + await Deny(ctx, fail == SupabaseAuth.Fail.Unavailable ? 502 : 401, fail switch + { + SupabaseAuth.Fail.Expired => "токен истёк", + SupabaseAuth.Fail.Unavailable => "Supabase недоступен", + _ => "токен не принят", + }); + return; + } + var ws = ctx.Request.Headers["X-Wiki-Workspace"].ToString().Trim(); + if (ws.Length == 0) ws = SupabaseAuth.Shared; + if (!await sa.CanAccessAsync(user, token, ws, ctx.RequestAborted)) { await Deny(ctx, 403, "нет доступа к workspace"); return; } + ctx.Items["user"] = user; + ctx.Items["token"] = token; + ctx.Items["ws"] = ws; + await next(); +}); + +static Task Deny(HttpContext ctx, int status, string error) +{ + ctx.Response.StatusCode = status; + return ctx.Response.WriteAsJsonAsync(new { error }); +} +static WikiUser U(HttpContext c) => (WikiUser)c.Items["user"]!; +static string Ws(HttpContext c) => (string)c.Items["ws"]!; + +// ---------------------------------------------------------------- статика SPA +var types = new FileExtensionContentTypeProvider(); +app.UseDefaultFiles(); +app.UseStaticFiles(new StaticFileOptions +{ + ContentTypeProvider = types, + OnPrepareResponse = s => s.Context.Response.Headers.CacheControl = + s.File.Name.EndsWith(".html") ? "no-cache" : "public, max-age=31536000, immutable", +}); + +// ---------------------------------------------------------------- открытые +app.MapGet("/health", (Db db) => Results.Ok(new { status = "ok", dbCreatedAt = db.Meta("created_at") })); +app.MapGet("/api/config", () => Results.Ok(new { crmOrigin })); + +app.MapPost("/api/verify", async (HttpContext ctx, SupabaseAuth sa) => +{ + ctx.Response.Headers.CacheControl = "no-store"; + string? token; + try + { + using var doc = await JsonDocument.ParseAsync(ctx.Request.Body, cancellationToken: ctx.RequestAborted); + token = doc.RootElement.TryGetProperty("access_token", out var t) && t.ValueKind == JsonValueKind.String ? t.GetString() : null; + } + catch (JsonException) { return Results.Json(new { ok = false, error = "некорректный запрос" }, statusCode: 400); } + if (string.IsNullOrEmpty(token)) return Results.Json(new { ok = false, error = "нет access_token" }, statusCode: 400); + var (u, fail) = await sa.VerifyAsync(token, ctx.RequestAborted); + if (u is null) + return Results.Json(new { ok = false, error = fail == SupabaseAuth.Fail.Expired ? "токен истёк" : "токен не принят" }, + statusCode: fail == SupabaseAuth.Fail.Unavailable ? 502 : 401); + return Results.Ok(new { ok = true, id = u.Id, email = u.Email, name = u.Name, exp = u.Exp }); +}); + +// ---------------------------------------------------------------- вики +var api = app.MapGroup("/api"); + +api.MapGet("/me", (HttpContext c) => { var u = U(c); return Results.Ok(new { id = u.Id, email = u.Email, name = u.Name, exp = u.Exp, workspace = Ws(c) }); }); + +api.MapGet("/tree", (HttpContext c, Store s) => Results.Ok(new { workspace = Ws(c), nodes = s.Tree(Ws(c)) })); + +api.MapGet("/nodes/{id}", (HttpContext c, Store s, string id) => + s.Get(Ws(c), id) is { } p ? Results.Ok(p) : Results.NotFound(new { error = "не найдено" })); + +api.MapGet("/pages/{id}", (HttpContext c, Store s, string id) => + s.Get(Ws(c), id) is { Kind: "page" } p ? Results.Ok(p) : Results.NotFound(new { error = "страница не найдена" })); + +api.MapGet("/pages/{id}/markdown", (HttpContext c, Store s, string id) => + s.Get(Ws(c), id) is { Kind: "page" } p + ? Results.Text(p.Markdown, "text/markdown; charset=utf-8") + : Results.NotFound(new { error = "страница не найдена" })); + +api.MapPost("/folders", (HttpContext c, Store s, CreateReq r) => +{ + var p = s.Create(Ws(c), "folder", r.Title, r.ParentId, null, U(c)); + return Results.Created($"/api/nodes/{p.Id}", p); +}); + +api.MapPost("/pages", (HttpContext c, Store s, CreateReq r) => +{ + var p = s.Create(Ws(c), "page", r.Title, r.ParentId, r.Markdown, U(c)); + return Results.Created($"/api/pages/{p.Id}", p); +}); + +api.MapPut("/pages/{id}", (HttpContext c, Store s, string id, SaveReq r) => +{ + if (r.Version is not { } v) return Results.BadRequest(new { error = "нужно поле version" }); + var (saved, current) = s.Save(Ws(c), id, r.Title, r.Markdown, v, U(c)); + return saved is not null + ? Results.Ok(saved) + : Results.Json(new { error = "страницу уже изменили, версия устарела", current }, statusCode: 409); +}); + +// Переименовать и/или переместить: { title?, parentId? (null — в корень), version? }. +api.MapMethods("/nodes/{id}", ["PATCH"], async (HttpContext c, Store s, string id) => +{ + using var doc = await JsonDocument.ParseAsync(c.Request.Body, cancellationToken: c.RequestAborted); + var b = doc.RootElement; + if (b.ValueKind != JsonValueKind.Object) return Results.BadRequest(new { error = "ожидается объект" }); + string? title = b.TryGetProperty("title", out var t) && t.ValueKind == JsonValueKind.String ? t.GetString() : null; + var moveSet = b.TryGetProperty("parentId", out var pEl); + string? parent = moveSet && pEl.ValueKind == JsonValueKind.String ? pEl.GetString() : null; + if (moveSet && pEl.ValueKind is not (JsonValueKind.String or JsonValueKind.Null)) + return Results.BadRequest(new { error = "parentId — строка или null" }); + int? version = b.TryGetProperty("version", out var vEl) && vEl.TryGetInt32(out var vv) ? vv : null; + return Results.Ok(s.Patch(Ws(c), id, title, moveSet, parent, version, U(c))); +}); + +api.MapDelete("/nodes/{id}", (HttpContext c, Store s, string id) => Results.Ok(new { deleted = s.Delete(Ws(c), id) })); + +api.MapGet("/search", (HttpContext c, Store s, string? q, int? limit) => + Results.Ok(new { results = s.Search(Ws(c), q ?? "", limit ?? 30) })); + +api.MapPost("/images", async (HttpContext c, Store s, Db db) => +{ + if (!c.Request.HasFormContentType) return Results.BadRequest(new { error = "нужен multipart/form-data с полем file" }); + var form = await c.Request.ReadFormAsync(c.RequestAborted); + var file = form.Files["file"]; + if (file is null || file.Length == 0) return Results.BadRequest(new { error = "нет файла" }); + if (file.Length > Images.MaxBytes) return Results.Json(new { error = "картинка больше 10 МБ" }, statusCode: 413); + var head = new byte[16]; + await using (var st = file.OpenReadStream()) { _ = await st.ReadAtLeastAsync(head, head.Length, false, c.RequestAborted); } + if (Images.Sniff(head) is not { } kind) return Results.BadRequest(new { error = "поддерживаются PNG, JPEG, GIF, WebP" }); + var id = Store.NewId(); + var fileName = id + kind.Ext; + var path = Path.Combine(db.ImagesDir, fileName); + await using (var outS = File.Create(path)) await file.CopyToAsync(outS, c.RequestAborted); + var pageId = form["pageId"].ToString(); + s.AddImage(id, Ws(c), string.IsNullOrEmpty(pageId) ? null : pageId, kind.Mime, file.Length, fileName, U(c)); + return Results.Created($"/api/images/{id}", new { id, url = $"/api/images/{id}" }); +}).DisableAntiforgery(); + +// Картинку отдаём только после проверки токена и доступа к workspace самой картинки. +api.MapGet("/images/{id}", async (HttpContext c, Store s, Db db, SupabaseAuth sa, string id) => +{ + var img = s.GetImage(id); + if (img is null) return Results.NotFound(new { error = "картинка не найдена" }); + if (img.WorkspaceId != Ws(c) && !await sa.CanAccessAsync(U(c), (string)c.Items["token"]!, img.WorkspaceId, c.RequestAborted)) + return Results.NotFound(new { error = "картинка не найдена" }); + var path = Path.Combine(db.ImagesDir, img.FileName); + if (!File.Exists(path)) return Results.NotFound(new { error = "файл картинки потерян" }); + c.Response.Headers.CacheControl = "private, max-age=86400"; + return Results.File(path, img.Mime); +}); + +app.MapFallback("/api/{**rest}", () => Results.NotFound(new { error = "нет такого метода API" })); +app.MapFallbackToFile("index.html", new StaticFileOptions +{ + OnPrepareResponse = s => s.Context.Response.Headers.CacheControl = "no-cache", +}); + +app.Run(); + +record CreateReq(string? Title, string? ParentId, string? Markdown); +record SaveReq(string? Title, string? Markdown, int? Version); + +static class Images +{ + public const long MaxBytes = 10 * 1024 * 1024; + + public static (string Mime, string Ext)? Sniff(byte[] b) + { + if (b[0] == 0x89 && b[1] == 0x50 && b[2] == 0x4E && b[3] == 0x47) return ("image/png", ".png"); + if (b[0] == 0xFF && b[1] == 0xD8 && b[2] == 0xFF) return ("image/jpeg", ".jpg"); + if (b[0] == 'G' && b[1] == 'I' && b[2] == 'F' && b[3] == '8') return ("image/gif", ".gif"); + if (b[0] == 'R' && b[1] == 'I' && b[2] == 'F' && b[3] == 'F' && b[8] == 'W' && b[9] == 'E' && b[10] == 'B' && b[11] == 'P') + return ("image/webp", ".webp"); + return null; + } +} diff --git a/backend/Wiki.Api/Store.cs b/backend/Wiki.Api/Store.cs new file mode 100644 index 0000000..c2f19ff --- /dev/null +++ b/backend/Wiki.Api/Store.cs @@ -0,0 +1,308 @@ +using System.Security.Cryptography; +using System.Text.RegularExpressions; +using Microsoft.Data.Sqlite; + +namespace Wiki.Api; + +public sealed record NodeDto( + string Id, string? ParentId, string Kind, string Title, int Version, + string CreatedAt, string UpdatedAt, string UpdatedBy, string? UpdatedByName); + +public sealed record Crumb(string Id, string Kind, string Title); + +public sealed record PageDto( + string Id, string? ParentId, string Kind, string Title, int Version, + string CreatedAt, string UpdatedAt, string UpdatedBy, string? UpdatedByName, + string Markdown, IReadOnlyList Path); + +public sealed record SearchHit(string Id, string Kind, string Title, string Snippet, IReadOnlyList Path); + +public sealed record ImageRow(string Id, string WorkspaceId, string Mime, string FileName); + +public sealed class WikiError(int status, string message) : Exception(message) +{ + public int Status { get; } = status; +} + +/// Доступ к данным вики. Каждый запрос ограничен workspace. +public sealed partial class Store(Db db) +{ + public const int MaxTitle = 200; + public const int MaxContent = 2_000_000; + + private const string Cols = "id, parent_id, kind, title, version, created_at, updated_at, updated_by, updated_by_name"; + + public static string NewId() => Convert.ToHexString(RandomNumberGenerator.GetBytes(8)).ToLowerInvariant(); + private static string Now() => DateTime.UtcNow.ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'"); + + public List Tree(string ws) + { + using var c = db.Open(); + using var cmd = c.CreateCommand(); + cmd.CommandText = $"SELECT {Cols} FROM nodes WHERE workspace_id=$ws"; + cmd.Parameters.AddWithValue("$ws", ws); + using var r = cmd.ExecuteReader(); + var list = new List(); + while (r.Read()) list.Add(ReadNode(r)); + return list; + } + + public PageDto? Get(string ws, string id) + { + using var c = db.Open(); + return Get(c, ws, id); + } + + private static PageDto? Get(SqliteConnection c, string ws, string id) + { + using var cmd = c.CreateCommand(); + cmd.CommandText = $"SELECT {Cols}, content FROM nodes WHERE workspace_id=$ws AND id=$id"; + cmd.Parameters.AddWithValue("$ws", ws); + cmd.Parameters.AddWithValue("$id", id); + using var r = cmd.ExecuteReader(); + if (!r.Read()) return null; + var n = ReadNode(r); + var content = r.GetString(9); + return new PageDto(n.Id, n.ParentId, n.Kind, n.Title, n.Version, n.CreatedAt, n.UpdatedAt, n.UpdatedBy, + n.UpdatedByName, content, Path(c, ws, n.ParentId)); + } + + /// Цепочка папок от корня до parentId включительно (хлебные крошки). + private static List Path(SqliteConnection c, string ws, string? parentId) + { + var list = new List(); + if (parentId is null) return list; + using var cmd = c.CreateCommand(); + cmd.CommandText = """ + WITH RECURSIVE up(id, parent_id, kind, title, depth) AS ( + SELECT id, parent_id, kind, title, 0 FROM nodes WHERE id=$id AND workspace_id=$ws + UNION ALL + SELECT n.id, n.parent_id, n.kind, n.title, up.depth+1 FROM nodes n JOIN up ON n.id = up.parent_id + WHERE n.workspace_id=$ws AND up.depth < 100 + ) + SELECT id, kind, title FROM up ORDER BY depth DESC + """; + cmd.Parameters.AddWithValue("$ws", ws); + cmd.Parameters.AddWithValue("$id", parentId); + using var r = cmd.ExecuteReader(); + while (r.Read()) list.Add(new Crumb(r.GetString(0), r.GetString(1), r.GetString(2))); + return list; + } + + public PageDto Create(string ws, string kind, string? title, string? parentId, string? markdown, WikiUser u) + { + var t = CleanTitle(title); + var content = kind == "page" ? CleanContent(markdown) : ""; + using var c = db.Open(); + using var tx = c.BeginTransaction(); + parentId = NullIfEmpty(parentId); + if (parentId is not null) EnsureFolder(c, ws, parentId); + var id = NewId(); + var now = Now(); + using (var cmd = c.CreateCommand()) + { + cmd.CommandText = """ + INSERT INTO nodes(id, workspace_id, parent_id, kind, title, content, version, created_at, created_by, updated_at, updated_by, updated_by_name) + VALUES ($id, $ws, $p, $k, $t, $c, 1, $now, $u, $now, $u, $un) + """; + cmd.Parameters.AddWithValue("$id", id); + cmd.Parameters.AddWithValue("$ws", ws); + cmd.Parameters.AddWithValue("$p", (object?)parentId ?? DBNull.Value); + cmd.Parameters.AddWithValue("$k", kind); + cmd.Parameters.AddWithValue("$t", t); + cmd.Parameters.AddWithValue("$c", content); + cmd.Parameters.AddWithValue("$now", now); + cmd.Parameters.AddWithValue("$u", u.Id); + cmd.Parameters.AddWithValue("$un", (object?)(u.Name ?? u.Email) ?? DBNull.Value); + cmd.ExecuteNonQuery(); + } + tx.Commit(); + return Get(c, ws, id)!; + } + + /// Сохранение страницы с проверкой версии. Конфликт — WikiError 409, current — актуальная версия. + public (PageDto? Saved, PageDto? Current) Save(string ws, string id, string? title, string? markdown, int version, WikiUser u) + { + using var c = db.Open(); + var cur = Get(c, ws, id) ?? throw new WikiError(404, "страница не найдена"); + if (cur.Kind != "page") throw new WikiError(400, "это папка, у неё нет текста"); + var t = title is null ? cur.Title : CleanTitle(title); + var content = markdown is null ? cur.Markdown : CleanContent(markdown); + using var cmd = c.CreateCommand(); + cmd.CommandText = """ + UPDATE nodes SET title=$t, content=$c, version=version+1, updated_at=$now, updated_by=$u, updated_by_name=$un + WHERE id=$id AND workspace_id=$ws AND version=$v + """; + cmd.Parameters.AddWithValue("$t", t); + cmd.Parameters.AddWithValue("$c", content); + cmd.Parameters.AddWithValue("$now", Now()); + cmd.Parameters.AddWithValue("$u", u.Id); + cmd.Parameters.AddWithValue("$un", (object?)(u.Name ?? u.Email) ?? DBNull.Value); + cmd.Parameters.AddWithValue("$id", id); + cmd.Parameters.AddWithValue("$ws", ws); + cmd.Parameters.AddWithValue("$v", version); + if (cmd.ExecuteNonQuery() == 0) return (null, Get(c, ws, id)); + return (Get(c, ws, id), null); + } + + /// Переименование и/или перенос. Переименование страницы увеличивает версию. + public PageDto Patch(string ws, string id, string? title, bool moveSet, string? parentId, int? version, WikiUser u) + { + using var c = db.Open(); + using var tx = c.BeginTransaction(); + var cur = Get(c, ws, id) ?? throw new WikiError(404, "не найдено"); + if (version is { } v && v != cur.Version) throw new WikiError(409, "страницу уже изменили"); + var newTitle = title is null ? cur.Title : CleanTitle(title); + var newParent = moveSet ? NullIfEmpty(parentId) : cur.ParentId; + if (moveSet && newParent is not null) + { + EnsureFolder(c, ws, newParent); + if (newParent == id || Path(c, ws, newParent).Any(p => p.Id == id)) + throw new WikiError(400, "нельзя перенести папку внутрь неё самой"); + } + var renamed = newTitle != cur.Title; + using (var cmd = c.CreateCommand()) + { + cmd.CommandText = """ + UPDATE nodes SET title=$t, parent_id=$p, + version = version + $bump, updated_at=$now, updated_by=$u, updated_by_name=$un + WHERE id=$id AND workspace_id=$ws + """; + cmd.Parameters.AddWithValue("$t", newTitle); + cmd.Parameters.AddWithValue("$p", (object?)newParent ?? DBNull.Value); + cmd.Parameters.AddWithValue("$bump", renamed ? 1 : 0); + cmd.Parameters.AddWithValue("$now", Now()); + cmd.Parameters.AddWithValue("$u", u.Id); + cmd.Parameters.AddWithValue("$un", (object?)(u.Name ?? u.Email) ?? DBNull.Value); + cmd.Parameters.AddWithValue("$id", id); + cmd.Parameters.AddWithValue("$ws", ws); + cmd.ExecuteNonQuery(); + } + tx.Commit(); + return Get(c, ws, id)!; + } + + /// Удаляет узел со всем содержимым. Возвращает число удалённых узлов. + public int Delete(string ws, string id) + { + using var c = db.Open(); + using var tx = c.BeginTransaction(); + int count; + using (var cmd = c.CreateCommand()) + { + cmd.CommandText = """ + WITH RECURSIVE sub(id) AS ( + SELECT id FROM nodes WHERE id=$id AND workspace_id=$ws + UNION ALL + SELECT n.id FROM nodes n JOIN sub ON n.parent_id = sub.id + ) + SELECT count(*) FROM sub + """; + cmd.Parameters.AddWithValue("$id", id); + cmd.Parameters.AddWithValue("$ws", ws); + count = Convert.ToInt32(cmd.ExecuteScalar()); + } + if (count == 0) throw new WikiError(404, "не найдено"); + using (var cmd = c.CreateCommand()) + { + cmd.CommandText = "DELETE FROM nodes WHERE id=$id AND workspace_id=$ws"; + cmd.Parameters.AddWithValue("$id", id); + cmd.Parameters.AddWithValue("$ws", ws); + cmd.ExecuteNonQuery(); + } + tx.Commit(); + return count; + } + + [GeneratedRegex(@"[\p{L}\p{N}_]+")] + private static partial Regex WordRx(); + + public List Search(string ws, string q, int limit) + { + var words = WordRx().Matches(q).Select(m => m.Value).Take(8).ToList(); + var hits = new List(); + if (words.Count == 0) return hits; + var match = string.Join(" ", words.Select(w => $"\"{w}\"*")); + using var c = db.Open(); + using var cmd = c.CreateCommand(); + // \u0002 … \u0003 — границы совпадения в сниппете, клиент сам их подсвечивает. + cmd.CommandText = """ + SELECT n.id, n.kind, n.title, n.parent_id, + snippet(nodes_fts, 1, char(2), char(3), '…', 14) + FROM nodes_fts JOIN nodes n ON n.rowid = nodes_fts.rowid + WHERE nodes_fts MATCH $q AND n.workspace_id = $ws + ORDER BY bm25(nodes_fts, 8.0, 1.0) + LIMIT $lim + """; + cmd.Parameters.AddWithValue("$q", match); + cmd.Parameters.AddWithValue("$ws", ws); + cmd.Parameters.AddWithValue("$lim", Math.Clamp(limit, 1, 100)); + var rows = new List<(string Id, string Kind, string Title, string? Parent, string Snip)>(); + using (var r = cmd.ExecuteReader()) + while (r.Read()) + rows.Add((r.GetString(0), r.GetString(1), r.GetString(2), r.IsDBNull(3) ? null : r.GetString(3), + r.IsDBNull(4) ? "" : r.GetString(4))); + foreach (var x in rows) hits.Add(new SearchHit(x.Id, x.Kind, x.Title, x.Snip, Path(c, ws, x.Parent))); + return hits; + } + + public void AddImage(string id, string ws, string? pageId, string mime, long size, string fileName, WikiUser u) + { + using var c = db.Open(); + using var cmd = c.CreateCommand(); + cmd.CommandText = """ + INSERT INTO images(id, workspace_id, page_id, mime, size, file_name, created_at, created_by) + VALUES ($id, $ws, $p, $m, $s, $f, $now, $u) + """; + cmd.Parameters.AddWithValue("$id", id); + cmd.Parameters.AddWithValue("$ws", ws); + cmd.Parameters.AddWithValue("$p", (object?)pageId ?? DBNull.Value); + cmd.Parameters.AddWithValue("$m", mime); + cmd.Parameters.AddWithValue("$s", size); + cmd.Parameters.AddWithValue("$f", fileName); + cmd.Parameters.AddWithValue("$now", Now()); + cmd.Parameters.AddWithValue("$u", u.Id); + cmd.ExecuteNonQuery(); + } + + public ImageRow? GetImage(string id) + { + using var c = db.Open(); + using var cmd = c.CreateCommand(); + cmd.CommandText = "SELECT id, workspace_id, mime, file_name FROM images WHERE id=$id"; + cmd.Parameters.AddWithValue("$id", id); + using var r = cmd.ExecuteReader(); + return r.Read() ? new ImageRow(r.GetString(0), r.GetString(1), r.GetString(2), r.GetString(3)) : null; + } + + private static void EnsureFolder(SqliteConnection c, string ws, string id) + { + using var cmd = c.CreateCommand(); + cmd.CommandText = "SELECT kind FROM nodes WHERE id=$id AND workspace_id=$ws"; + cmd.Parameters.AddWithValue("$id", id); + cmd.Parameters.AddWithValue("$ws", ws); + var kind = cmd.ExecuteScalar() as string ?? throw new WikiError(400, "папка назначения не найдена"); + if (kind != "folder") throw new WikiError(400, "класть можно только в папку"); + } + + private static NodeDto ReadNode(SqliteDataReader r) => new( + r.GetString(0), r.IsDBNull(1) ? null : r.GetString(1), r.GetString(2), r.GetString(3), r.GetInt32(4), + r.GetString(5), r.GetString(6), r.GetString(7), r.IsDBNull(8) ? null : r.GetString(8)); + + private static string CleanTitle(string? t) + { + t = (t ?? "").Trim(); + if (t.Length == 0) throw new WikiError(400, "название не может быть пустым"); + if (t.Length > MaxTitle) throw new WikiError(400, $"название длиннее {MaxTitle} символов"); + return t; + } + + private static string CleanContent(string? md) + { + md ??= ""; + if (md.Length > MaxContent) throw new WikiError(413, "текст страницы слишком большой"); + return md; + } + + private static string? NullIfEmpty(string? s) => string.IsNullOrWhiteSpace(s) ? null : s; +} diff --git a/backend/Wiki.Api/Wiki.Api.csproj b/backend/Wiki.Api/Wiki.Api.csproj new file mode 100644 index 0000000..c26b4a8 --- /dev/null +++ b/backend/Wiki.Api/Wiki.Api.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/backend/Wiki.Api/appsettings.json b/backend/Wiki.Api/appsettings.json new file mode 100644 index 0000000..6571ebc --- /dev/null +++ b/backend/Wiki.Api/appsettings.json @@ -0,0 +1,16 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "System.Net.Http.HttpClient": "Warning" + } + }, + "AllowedHosts": "*", + "Storage": { "DataDir": "data" }, + "Crm": { "Origin": "https://test.units.tvorymo.com" }, + "Supabase": { + "Url": "https://yjxbkcqsquwbsyoonfkn.supabase.co", + "PublishableKey": "sb_publishable_TQaZAtnBpImL49A-o87bKg_q5sYvhPR" + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..523ee8e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,28 @@ +services: + wiki: + build: + context: . + dockerfile: Dockerfile + expose: + - "8080" + environment: + SERVICE_FQDN_WIKI_8080: / + ASPNETCORE_ENVIRONMENT: Production + ASPNETCORE_URLS: "http://+:8080" + Storage__DataDir: /data + Crm__Origin: "https://test.units.tvorymo.com" + Crm__AssetsOrigin: "https://test.units.tvorymo.com" + Supabase__Url: "https://yjxbkcqsquwbsyoonfkn.supabase.co" + # publishable-ключ публичный по дизайну + Supabase__PublishableKey: "sb_publishable_TQaZAtnBpImL49A-o87bKg_q5sYvhPR" + volumes: + - wiki-data:/data + healthcheck: + test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 20s + +volumes: + wiki-data: diff --git a/frontend/.oxlintrc.json b/frontend/.oxlintrc.json new file mode 100644 index 0000000..6fa991d --- /dev/null +++ b/frontend/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..ec109c6 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,17 @@ + + + + + +Вики · CRM Tvorymo + + + + + + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..cc10513 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2001 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "@tiptap/extension-image": "^3.32.0", + "@tiptap/extension-list": "^3.32.0", + "@tiptap/extension-table": "^3.32.0", + "@tiptap/extensions": "^3.32.0", + "@tiptap/markdown": "^3.32.0", + "@tiptap/pm": "^3.32.0", + "@tiptap/react": "^3.32.0", + "@tiptap/starter-kit": "^3.32.0", + "react": "^19.2.8", + "react-dom": "^19.2.8" + }, + "devDependencies": { + "@types/node": "^24.13.3", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.7", + "@vitejs/plugin-react": "^6.1.1", + "oxlint": "^1.81.0", + "typescript": "~6.0.2", + "vite": "^8.3.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT", + "optional": true + }, + "node_modules/@oxc-project/types": { + "version": "0.153.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.153.0.tgz", + "integrity": "sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.88.0.tgz", + "integrity": "sha512-k6Fgkf86ubfeH4GvqYsRolfuVAp0pfYVZvifSlXNHPdMaWUOfxUrmLEeDD7hF6U2oJuhsVxhSWq86/xCJ+9MmA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.88.0.tgz", + "integrity": "sha512-AsIyhl9o1ejwtac7NMbhKpMdSml6NWR0ljKjZERxC8OHYDAW8o/Om9n1QHVLi4VCEUo42Zte2/Op9kyoGpJZMg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.88.0.tgz", + "integrity": "sha512-qgBjB7eEKcCB4RqjXGuoSZq74g0wD09fWFJ+HWcCJcKaga8TeUHtb+BY0SsxdleSLBgx4RZ7qmgo0EzqEq7JoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.88.0.tgz", + "integrity": "sha512-sCz3X5Mw/xdzEzn4fsCt1Iq8NKJnGNfhR+b9B5k+4DysYAHsmIhYoocnBPiQ9kvY3+pUun2A0Z8Vtun/Nv6YoQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.88.0.tgz", + "integrity": "sha512-xTOdYOIqCufgIj0knvxu86FBfovP6QBiD7ngNva01gb8xPQCD5gXkE5APGczH35zStim1O3zqvFbLQmY85xXRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.88.0.tgz", + "integrity": "sha512-BLadtw88NdYJBrAtC1dYzrPfSP2d5zgx6iuZycdD0YmZBJ2Nqbit9Z4PSaIb6gxK25dweArGMPbOmq/0ugwUPg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.88.0.tgz", + "integrity": "sha512-wILSczl/DaZsCcf7j7tvNbssp8hxEHIJxrv4j7+JjI3TAL1U/JTWbeUtoJJC/vo8+2hkTmHLYoKJjmiNbijdXw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.88.0.tgz", + "integrity": "sha512-jutT2Hy8JCuZuMwIFHo2HtyxfOfBJHyQxlBHBK1SL9W2f8n/WhjAP0FnDMz9d/mjhv9+QA0mx5AdQ9fDjsrFFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.88.0.tgz", + "integrity": "sha512-8N8iGV9hh4Lkeuq7QsEkjoXNRRflqR1G2qyoGYXcA0k5qllrdqmfylKEXyHIVERxqNDyMO4StqDQ0uLxXcBQAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.88.0.tgz", + "integrity": "sha512-5ydcQt3znNQ/rGEWYRvpS2cK9+T18P2VYgnyJ+eloEAwi9YUmsXy5VGrRqNWIK6qzWwHGnEE3ojVqqQNo+f0hA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.88.0.tgz", + "integrity": "sha512-iGx0uvFT54b/5DPJdEBCxYIrVB3v0uTy+JhRh3CyEp8/22BXDkG8rffkFwREdLsdj9mOrPbJYVOZw8ipcBuzjw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.88.0.tgz", + "integrity": "sha512-Wt47mKMWelVjRjuR7BCFxXkB0m79K1sTEbqMHRWciKUgo5iv7sWsF4RXSOyFYHD9zerNrkRZblnN273rhwOA1Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.88.0.tgz", + "integrity": "sha512-g3L12KCS4+1gtUt3RbJ4F+Yxx4LNmmZLEPfhd8rAHEJjJk2lAgNfnjGoP/phJyhG5WS6VbNYbJtadJelNfvTMA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.88.0.tgz", + "integrity": "sha512-og/XHVDOD9ZJgRiZkT9DgzQd4sr+JZW7e4l17Q/jVMZiIbeEd4uIu9iAoUbTF1gIvVZoRc4QDOxyVUvQGp4WHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.88.0.tgz", + "integrity": "sha512-EmhBsp+xuKb6YbZNEMLBo80Xw71AZOIm18do8ni/GLX/Lw1XzNhVYpbxO1KasYtoAvrGtCOdCPULRlIE0bPbTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.88.0.tgz", + "integrity": "sha512-DBUcx/KwYdg1DAz+G01P/GFv512J04EsZadbHptx2FTpWT/MWRg21ZToNHt9pnsfQRBQ3et/g+vqzbvrdAVbuA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.88.0.tgz", + "integrity": "sha512-xeVd0n4XG5HvynxfYvXi6uB+JvZbVZataowPS8cYpMbYg9mwE4sSyElFQd4LVJFifq8bRLDSR18XgEildmcXAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.88.0.tgz", + "integrity": "sha512-MA6iOw//amleaz2mVMsZrA7NHlEexjTrhIxSMFCXQi6IUuQO+3Od00GZhr2iCYw6RFzMW36jkKK/WHBXMsoQAw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.88.0.tgz", + "integrity": "sha512-3Sz6rVWe8Dy+ufsXurFN1GX2dX43F/lQMSLelK9gFjiba7nHvLlqlhR5cHlWVnCpSqUY1hN9pyCX9XToe6ve6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.13.tgz", + "integrity": "sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.13.tgz", + "integrity": "sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.13.tgz", + "integrity": "sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.13.tgz", + "integrity": "sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.13.tgz", + "integrity": "sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.13.tgz", + "integrity": "sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.13.tgz", + "integrity": "sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.13.tgz", + "integrity": "sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.13.tgz", + "integrity": "sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.13.tgz", + "integrity": "sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.13.tgz", + "integrity": "sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.13.tgz", + "integrity": "sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.13.tgz", + "integrity": "sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.13.tgz", + "integrity": "sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.13.tgz", + "integrity": "sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tiptap/core": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.32.0.tgz", + "integrity": "sha512-Ghh2h02GKSqzytQGcLV4espYOG4EHxjj0MsrUB2Vnr47xmlZYRf6r7k2J09H+JpBdaGrWwrTkyBEi5i3c3yy2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/pm": "3.32.0" + } + }, + "node_modules/@tiptap/extension-blockquote": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.32.0.tgz", + "integrity": "sha512-wmuthEdlYVArNoUbqFPH35giutBABFu7fqEB0lTD1k0UXJUs4dFvcbRyGWWgnHFZgP3TT4u8e+xiayYw0eQ0NQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.32.0", + "@tiptap/pm": "3.32.0" + } + }, + "node_modules/@tiptap/extension-bold": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.32.0.tgz", + "integrity": "sha512-SQYewnYDoIBN+8oH53IDcD8p+Pz+Jnyq3hrxjy/FAo5ctc+96d3ZLdqc9gzLVSqFFo/SUINv2FVGoq9PvU7yqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.32.0" + } + }, + "node_modules/@tiptap/extension-bubble-menu": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.32.0.tgz", + "integrity": "sha512-X3ll8i9Z8QNvt7+tcqlwopJG+68ugO13Av0Ox4Ico+jXcTnFdDuKQJBSI9sLuMR5vTQOeTHhKYXhjF88aahMSA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@floating-ui/dom": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.32.0", + "@tiptap/pm": "3.32.0" + } + }, + "node_modules/@tiptap/extension-bullet-list": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.32.0.tgz", + "integrity": "sha512-Y5P/l06pVCUG46vXpBl3z0fW2b6JpSMX4Cvr+5sfQh8zB3TxwSiLQaaPwI3R5MFD1KfRk3AjiPRieEyXdWcNHw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.32.0" + } + }, + "node_modules/@tiptap/extension-code": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.32.0.tgz", + "integrity": "sha512-I+aEVl0dTmkfnU6HzBGEefb9U7ja2MkucwBBwrmoMwU+uIKpC4xw8NcHC9X6sjAcom3Pxs2HX+8WEo4Vp13R/Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.32.0" + } + }, + "node_modules/@tiptap/extension-code-block": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.32.0.tgz", + "integrity": "sha512-9NZW/yXP9j9XKr+hRS5m58xiyZI+tQo1PJ7eLHXjGNFWaF601NG/h6741mbKh5uCY51XsbyqG+7que+RavngPA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.32.0", + "@tiptap/pm": "3.32.0" + } + }, + "node_modules/@tiptap/extension-document": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.32.0.tgz", + "integrity": "sha512-GugeKE6AnZDluRXJUNBuX43TPcaJxy5wck6vT2J4a0/EnKStJ43suSZZf1IPfb1ij9EL9bYvAwg9DOcrvdEzTQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.32.0" + } + }, + "node_modules/@tiptap/extension-dropcursor": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.32.0.tgz", + "integrity": "sha512-t4Ug8hFLt30dwwYvrh63BVUt6N1Yg5PVI3tMllhCmN3Q68oD47v4MB/i5nGfzIsQqMyXUOetwHJWcZ4MoLTx4Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "3.32.0" + } + }, + "node_modules/@tiptap/extension-floating-menu": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.32.0.tgz", + "integrity": "sha512-AzKCWceL+4Q1pTc8Whw1kPqN/aYGPQ70/jXPsblWl501t/pXi6Kz0Pi/NggKkuu73VGjykh68YYf/dcOJ/mPZQ==", + "license": "MIT", + "optional": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@floating-ui/dom": "^1.0.0", + "@tiptap/core": "3.32.0", + "@tiptap/pm": "3.32.0" + } + }, + "node_modules/@tiptap/extension-gapcursor": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.32.0.tgz", + "integrity": "sha512-HUpKKWJLtthnpQY5LIjHlJJOmh562RusmOMIe/4iP/H7y+WUR9ZFiZjEs1NBkRjig9fwj8F3BfsrVPYBnM4nLA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "3.32.0" + } + }, + "node_modules/@tiptap/extension-hard-break": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.32.0.tgz", + "integrity": "sha512-IKUYvK+bO2hGrDWhhTVg0CSh9C2belWg9GyLzvpyd3SZyQ9EgdccLfoytNjOBKbAE/TQ4Oo3kSP+XF6RsUIKPA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.32.0" + } + }, + "node_modules/@tiptap/extension-heading": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.32.0.tgz", + "integrity": "sha512-XRiZxZuhfjvbgwCvoBHj04gOsPANEXxxRwrFCQVuFi5yeTHYIzMEioc81j6iAH9pV7aQeJCwLxuh6VHNz3dXEQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.32.0" + } + }, + "node_modules/@tiptap/extension-horizontal-rule": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.32.0.tgz", + "integrity": "sha512-Iry7ynsG4xgcxnH1cm+uXJnEtUzoop4sGA10NUN9I+08TOjhj/GrDiRKMQHKAiVI/R7t2MVav1MJlxFf2FdzGw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.32.0", + "@tiptap/pm": "3.32.0" + } + }, + "node_modules/@tiptap/extension-image": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-3.32.0.tgz", + "integrity": "sha512-nv/YlOMwFUp2ItffQbHb14yAJjrn9sdxKl0WK5rH8PPiNoXVkvasieoBLNTHan9gjhrSp7LjVG2ZsMoSe/I7gg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.32.0" + } + }, + "node_modules/@tiptap/extension-italic": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.32.0.tgz", + "integrity": "sha512-KB/i1xoHilbBGcQ+UotDWhi60KpdCde3RzRIpl2na0Yol4z4Fe2IRWg8ljIrlsssDRwel/fsHJbG7a13EP8oKA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.32.0" + } + }, + "node_modules/@tiptap/extension-link": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.32.0.tgz", + "integrity": "sha512-DRdQSgWPSEjrDg5EDBB5yxRygHtUVLhceVGzAsTz8RziiFyvcwAvzBHCO9hd9jutjCXQ2DLIuZB8P+aHhFWnpg==", + "license": "MIT", + "dependencies": { + "linkifyjs": "^4.3.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.32.0", + "@tiptap/pm": "3.32.0" + } + }, + "node_modules/@tiptap/extension-list": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.32.0.tgz", + "integrity": "sha512-QdzS5nFz9agAQhBgIuQluobWib39W2cMKx1XB6tYSue7bHMJdgPFP0EnGfFajaeVX42CG6RXtQWrNfyQGKhBvg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.32.0", + "@tiptap/pm": "3.32.0" + } + }, + "node_modules/@tiptap/extension-list-item": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.32.0.tgz", + "integrity": "sha512-FdJTCAeALixzyyGSSEmroXmkIEclbo/4t2Pl03aRERqpPOSPJcC3evDtIhLazTuP6v+FjCmJ+pXNql6ov7qitA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.32.0" + } + }, + "node_modules/@tiptap/extension-list-keymap": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.32.0.tgz", + "integrity": "sha512-vXFre18Dk2m5N/gjo408C02GSrolZ8ZIf0wz6/U8lNC4FcYOISgZFj3BND6uabVntGh2GCSPrVFuNpVPhFnn/A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.32.0" + } + }, + "node_modules/@tiptap/extension-ordered-list": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.32.0.tgz", + "integrity": "sha512-htZ5hoMLBkGiDP6M5mGemjOoaBmkrj911wiGoTmMMKzO7qJq9QxUBkLY7ILPaKlUWL8U3ccLdDV2TB/nysp4Jg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.32.0" + } + }, + "node_modules/@tiptap/extension-paragraph": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.32.0.tgz", + "integrity": "sha512-afKLMc7fWMkkar9H5FilizStSbhFzUoCzdwwdZf6DDsn3LUt8tJlFHACK78cAyx/TzMJBo0N8/8T1u9bL+N67Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.32.0" + } + }, + "node_modules/@tiptap/extension-strike": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.32.0.tgz", + "integrity": "sha512-J9Vw6wmh/wV6SxSsN9RhurvAGcXSY5J3p4EQrROD5PARBl0VOnDFi7/UVoz1RkSdqOKPgAiqDZpaLRgtdBO9Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.32.0" + } + }, + "node_modules/@tiptap/extension-table": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-table/-/extension-table-3.32.0.tgz", + "integrity": "sha512-uOqCzWzxXvMccapZo+1CDxp3JGegxqNl11Lj8XiiWLxwmg81B/bKwMJW7+YlIdffgGNYJETk5YwL8T0IVBD2QQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.32.0", + "@tiptap/pm": "3.32.0" + } + }, + "node_modules/@tiptap/extension-text": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.32.0.tgz", + "integrity": "sha512-sF0jIK67IHdZMnzv2mz5vPTyK7R0ui6/YZgFg+/bLnFCNmweVAoYcHZLlC7ISRnwu0mqJr+epNtzJepY1ZDCjg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.32.0" + } + }, + "node_modules/@tiptap/extension-underline": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.32.0.tgz", + "integrity": "sha512-rMLdd4vg6sed/JnaXnaupLbCtYiWikTnQ8qir9ItKOXwf1Ui9P1Y4G0bYU4+gcnMsFzaSGtrOhMqG4/c66AogQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.32.0" + } + }, + "node_modules/@tiptap/extensions": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.32.0.tgz", + "integrity": "sha512-qO+ndNvlOjzU2MWCtQHoRg1tIortCXLvqPjDx58F1pUYFNCOVZegXi+jqLGDODUA31er+TVz66umzRbbN1bO1Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.32.0", + "@tiptap/pm": "3.32.0" + } + }, + "node_modules/@tiptap/markdown": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@tiptap/markdown/-/markdown-3.32.0.tgz", + "integrity": "sha512-y+pGAu4QMkXBMxhBoLvc6FWucQ1EWIoPi+Ajf4rAh5OlZtSV3CjBwyK6/xTwO7mvQxtoUf7DD23CzSaj5nCTBA==", + "license": "MIT", + "dependencies": { + "marked": "^17.0.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.32.0", + "@tiptap/pm": "3.32.0" + } + }, + "node_modules/@tiptap/pm": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.32.0.tgz", + "integrity": "sha512-H9eN+xHFboJybE7KPGVsu24S8aBlCIgYz38vCHRSnNv17nwmqm06owq/IQ5scza0n3Y7IfUfpgPOtm9SgeHmNQ==", + "license": "MIT", + "dependencies": { + "prosemirror-changeset": "^2.4.4", + "prosemirror-commands": "^1.7.2", + "prosemirror-dropcursor": "^1.8.4", + "prosemirror-gapcursor": "^1.4.1", + "prosemirror-history": "^1.5.1", + "prosemirror-inputrules": "^1.5.1", + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.12", + "prosemirror-schema-list": "^1.5.1", + "prosemirror-state": "^1.4.4", + "prosemirror-tables": "^1.8.5", + "prosemirror-transform": "^1.12.2", + "prosemirror-view": "^1.42.6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@tiptap/react": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.32.0.tgz", + "integrity": "sha512-+iS9RKj6Ve8SXqJ49xptDiifF7PQXmUOh4cjMu+BCXXqKphQkiUccvbWYAeOJCTceM01wWgFhrpfy8qG+CHJYw==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "fast-equals": "^5.3.3", + "use-sync-external-store": "^1.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "optionalDependencies": { + "@tiptap/extension-bubble-menu": "3.32.0", + "@tiptap/extension-floating-menu": "3.32.0" + }, + "peerDependencies": { + "@tiptap/core": "3.32.0", + "@tiptap/pm": "3.32.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tiptap/starter-kit": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.32.0.tgz", + "integrity": "sha512-aPYxaevci4PgMrTLsg+baiqI7M1ICd4V9H+zTTdrqs8OUZIbAvj/pNDuHU8uWZyKMu3Dh72t2D6ZdqRu/6aSWw==", + "license": "MIT", + "dependencies": { + "@tiptap/core": "3.32.0", + "@tiptap/extension-blockquote": "3.32.0", + "@tiptap/extension-bold": "3.32.0", + "@tiptap/extension-bullet-list": "3.32.0", + "@tiptap/extension-code": "3.32.0", + "@tiptap/extension-code-block": "3.32.0", + "@tiptap/extension-document": "3.32.0", + "@tiptap/extension-dropcursor": "3.32.0", + "@tiptap/extension-gapcursor": "3.32.0", + "@tiptap/extension-hard-break": "3.32.0", + "@tiptap/extension-heading": "3.32.0", + "@tiptap/extension-horizontal-rule": "3.32.0", + "@tiptap/extension-italic": "3.32.0", + "@tiptap/extension-link": "3.32.0", + "@tiptap/extension-list": "3.32.0", + "@tiptap/extension-list-item": "3.32.0", + "@tiptap/extension-list-keymap": "3.32.0", + "@tiptap/extension-ordered-list": "3.32.0", + "@tiptap/extension-paragraph": "3.32.0", + "@tiptap/extension-strike": "3.32.0", + "@tiptap/extension-text": "3.32.0", + "@tiptap/extension-underline": "3.32.0", + "@tiptap/extensions": "3.32.0", + "@tiptap/pm": "3.32.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@types/node": { + "version": "24.19.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz", + "integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.2.tgz", + "integrity": "sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.152.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/fast-equals": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.4.tgz", + "integrity": "sha512-d9/I4rfMfUlO9HOkQqLJgfyRM4siqk1SF3Gjxm4OAMZzV+kCKHe1t/bHxn3xtCPHs3HCQFcvqzkTuanzqECQxw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/linkifyjs": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz", + "integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==", + "license": "MIT" + }, + "node_modules/marked": { + "version": "17.0.6", + "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.6.tgz", + "integrity": "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/orderedmap": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", + "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", + "license": "MIT" + }, + "node_modules/oxlint": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.88.0.tgz", + "integrity": "sha512-uIcFgw8BmqvFRGzH5+r7d2zJMEexwNSrEmM6WQNnGh3VkO0XYTEFoyxyNE6tjKMUib9Cue4XJA2DoWruO5D6aw==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.88.0", + "@oxlint/binding-android-arm64": "1.88.0", + "@oxlint/binding-darwin-arm64": "1.88.0", + "@oxlint/binding-darwin-x64": "1.88.0", + "@oxlint/binding-freebsd-x64": "1.88.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.88.0", + "@oxlint/binding-linux-arm-musleabihf": "1.88.0", + "@oxlint/binding-linux-arm64-gnu": "1.88.0", + "@oxlint/binding-linux-arm64-musl": "1.88.0", + "@oxlint/binding-linux-ppc64-gnu": "1.88.0", + "@oxlint/binding-linux-riscv64-gnu": "1.88.0", + "@oxlint/binding-linux-riscv64-musl": "1.88.0", + "@oxlint/binding-linux-s390x-gnu": "1.88.0", + "@oxlint/binding-linux-x64-gnu": "1.88.0", + "@oxlint/binding-linux-x64-musl": "1.88.0", + "@oxlint/binding-openharmony-arm64": "1.88.0", + "@oxlint/binding-win32-arm64-msvc": "1.88.0", + "@oxlint/binding-win32-ia32-msvc": "1.88.0", + "@oxlint/binding-win32-x64-msvc": "1.88.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=7.0.2003", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.30", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.30.tgz", + "integrity": "sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prosemirror-changeset": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.4.tgz", + "integrity": "sha512-Gyuz9jYdrAeA9I7AholyOes9KUmv/lEwsC5O9s5xF5VB3EgTPsXMGUxz4+H1B+wKVPdZbvRcg1Cp3x8qX+8ofA==", + "license": "MIT", + "dependencies": { + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-commands": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.2.tgz", + "integrity": "sha512-q6Q6szxqdu9Xd6EcdKsqXghu5nQdZTpB4Q9yd04WRc7/jt763e/rT60Owh0L1GYY+T46o5rD+9lEN36dZS43tw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.10.2" + } + }, + "node_modules/prosemirror-dropcursor": { + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.4.tgz", + "integrity": "sha512-VmKxhcA6a+h5oPSYmfF1ZNeQqPbyYaIxqZsKqeYCyTlPtrjUgNaHL3P8vVaO19oqAV0zuaM/axkowZHrB1P3Tg==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0", + "prosemirror-view": "^1.1.0" + } + }, + "node_modules/prosemirror-gapcursor": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz", + "integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.0.0", + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-view": "^1.0.0" + } + }, + "node_modules/prosemirror-history": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.1.tgz", + "integrity": "sha512-onlwnqKvgtFg6srZOmDOAhlAy1FIHQpOExORybX0S+DvJepE5tPALrB9iGet/2sqqqrelSnB10OoiOJqAvu2DA==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.2.2", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.31.0", + "rope-sequence": "^1.3.0" + } + }, + "node_modules/prosemirror-inputrules": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz", + "integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-keymap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz", + "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "w3c-keyname": "^2.2.0" + } + }, + "node_modules/prosemirror-model": { + "version": "1.25.12", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.12.tgz", + "integrity": "sha512-Ue2gTmXMa7EhpLNhC7J+h4+ykD8ha12K6rrZFFKKJHBForfIStw5gJ6Zrf1mqqAa9NmpmEB4wp9bKmA4eUVjWg==", + "license": "MIT", + "dependencies": { + "orderedmap": "^2.0.0" + } + }, + "node_modules/prosemirror-schema-list": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz", + "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.7.3" + } + }, + "node_modules/prosemirror-state": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", + "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.27.0" + } + }, + "node_modules/prosemirror-tables": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz", + "integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.4", + "prosemirror-state": "^1.4.4", + "prosemirror-transform": "^1.10.5", + "prosemirror-view": "^1.41.4" + } + }, + "node_modules/prosemirror-transform": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.2.tgz", + "integrity": "sha512-PE/aY0HEY4zczvmqrilgkUK/WautF0chvMqkmM/iN5/aPHvwrCWaQZjS6CIZp+K84YrvPFqrL+YArrfJX8xX7g==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.21.0" + } + }, + "node_modules/prosemirror-view": { + "version": "1.42.6", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.6.tgz", + "integrity": "sha512-3g25f6hKV2bBxo3puH3EcYWmmVNSH0iWmNsMVT7sugDSpA025jgpOLarcDWr39SdzPjYMaiYtRdNtXialPSjEQ==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.25.12", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0" + } + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/rolldown": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.13.tgz", + "integrity": "sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.153.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.13", + "@rolldown/binding-android-arm64": "1.2.13", + "@rolldown/binding-darwin-arm64": "1.2.13", + "@rolldown/binding-darwin-x64": "1.2.13", + "@rolldown/binding-freebsd-x64": "1.2.13", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.13", + "@rolldown/binding-linux-arm64-gnu": "1.2.13", + "@rolldown/binding-linux-arm64-musl": "1.2.13", + "@rolldown/binding-linux-ppc64-gnu": "1.2.13", + "@rolldown/binding-linux-s390x-gnu": "1.2.13", + "@rolldown/binding-linux-x64-gnu": "1.2.13", + "@rolldown/binding-linux-x64-musl": "1.2.13", + "@rolldown/binding-openharmony-arm64": "1.2.13", + "@rolldown/binding-win32-arm64-msvc": "1.2.13", + "@rolldown/binding-win32-x64-msvc": "1.2.13" + } + }, + "node_modules/rope-sequence": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", + "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/use-sync-external-store": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz", + "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/vite": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz", + "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.29", + "rolldown": "~1.2.12", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..96854ba --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,33 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "oxlint", + "preview": "vite preview" + }, + "dependencies": { + "@tiptap/extension-image": "^3.32.0", + "@tiptap/extension-list": "^3.32.0", + "@tiptap/extension-table": "^3.32.0", + "@tiptap/extensions": "^3.32.0", + "@tiptap/markdown": "^3.32.0", + "@tiptap/pm": "^3.32.0", + "@tiptap/react": "^3.32.0", + "@tiptap/starter-kit": "^3.32.0", + "react": "^19.2.8", + "react-dom": "^19.2.8" + }, + "devDependencies": { + "@types/node": "^24.13.3", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.7", + "@vitejs/plugin-react": "^6.1.1", + "oxlint": "^1.81.0", + "typescript": "~6.0.2", + "vite": "^8.3.0" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..90a5bda --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,280 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Api, ApiError, type Crumb, type Node, type Page, type SearchHit } from "./api"; +import { useAuth } from "./crm"; +import { ConfirmDialog, MoveDialog, PromptDialog } from "./Dialogs"; +import { Icon } from "./Icon"; +import { PageView } from "./PageView"; +import { hrefOf, navigate, useRoute } from "./router"; +import { childrenMap, Tree } from "./Tree"; + +type Dlg = + | { kind: "new"; what: "page" | "folder"; parentId: string | null } + | { kind: "rename"; node: Node } + | { kind: "move"; node: Node } + | { kind: "delete"; node: Node; count: number } + | null; + +export default function App() { + const auth = useAuth(); + if (auth.status === "ok") return ; + + const text = { + standalone: <>Эта страница работает внутри CRM Tvorymo. Откройте её из боковой панели CRM: она пришлёт авторизацию (crm:auth)., + waiting: <>Жду авторизацию от CRM…, + checking: <>Проверяю пользователя CRM…, + error: <>Не удалось войти: {auth.error}. Обновите страницу CRM., + logout: <>Вы вышли из CRM. Войдите снова, чтобы открыть вики., + }[auth.status]; + return ( +
+

Вики

База знаний CRM Tvorymo
+
+ +
+ + {auth.status === "error" ? "ошибка авторизации" : auth.status === "logout" ? "нет сессии" : "жду авторизацию от CRM"} + +
{text}
+
+
+
+ ); +} + +function Wiki(props: { ws: string; wsTitle?: string; userName: string }) { + const route = useRoute(); + const [nodes, setNodes] = useState(null); + const [treeErr, setTreeErr] = useState(""); + const [page, setPage] = useState(null); + const [pageErr, setPageErr] = useState(""); + const [dlg, setDlg] = useState(null); + const [q, setQ] = useState(""); + const [hits, setHits] = useState(null); + const [sideOpen, setSideOpen] = useState(false); + + const reloadTree = useCallback(async () => { + try { setNodes((await Api.tree()).nodes); setTreeErr(""); } + catch (e) { setTreeErr((e as Error).message); } + }, []); + + useEffect(() => { setNodes(null); void reloadTree(); }, [props.ws, reloadTree]); + + // Сменился workspace в CRM — открытая страница из другого workspace, уходим в корень. + const [wsSeen, setWsSeen] = useState(props.ws); + if (wsSeen !== props.ws) { setWsSeen(props.ws); void navigate("/", true); } + + const currentId = route.kind === "root" ? null : route.id; + useEffect(() => { + setSideOpen(false); + if (!currentId) { setPage(null); setPageErr(""); return; } + let live = true; + setPageErr(""); + Api.page(currentId) + .then((p) => { if (live) setPage(p); }) + .catch((e) => { if (live) { setPage(null); setPageErr(e instanceof ApiError && e.status === 404 ? "Не найдено: страницу удалили или она в другом workspace." : (e as Error).message); } }); + return () => { live = false; }; + }, [currentId, props.ws]); + + // Поиск с задержкой. + useEffect(() => { + const s = q.trim(); + if (!s) { setHits(null); return; } + const t = window.setTimeout(() => { + Api.search(s).then((r) => setHits(r.results)).catch(() => setHits([])); + }, 250); + return () => window.clearTimeout(t); + }, [q, props.ws]); + + const kids = useMemo(() => childrenMap(nodes ?? []), [nodes]); + const byId = useMemo(() => new Map((nodes ?? []).map((n) => [n.id, n])), [nodes]); + + const create = async (what: "page" | "folder", title: string, parentId: string | null) => { + const p = what === "page" ? await Api.createPage(title, parentId) : await Api.createFolder(title, parentId); + await reloadTree(); + void navigate(hrefOf(p.kind, p.id)); + }; + + const askDelete = (n: Node) => { + let count = 0; + const walk = (id: string) => { count++; (kids.get(id) ?? []).forEach((c) => walk(c.id)); }; + walk(n.id); + setDlg({ kind: "delete", node: n, count }); + }; + + const current: Node | undefined = currentId ? byId.get(currentId) : undefined; + const crumbs: Crumb[] = page && page.id === currentId ? page.path : []; + const wsLabel = props.ws === "shared" ? "Общий" : props.ws.startsWith("personal:") ? "Мой" : props.wsTitle || "Workspace"; + + const listing = (parentId: string | null) => { + const list = kids.get(parentId) ?? []; + if (!list.length) return
Здесь пока пусто.
; + return list.map((n) => ( + { e.preventDefault(); void navigate(hrefOf(n.kind, n.id)); }}> + +
{n.title}{n.kind === "folder" ? `${kids.get(n.id)?.length ?? 0} внутри` : `изменено ${new Date(n.updatedAt).toLocaleDateString("uk-UA")}${n.updatedByName ? " · " + n.updatedByName : ""}`}
+
+ )); + }; + + const actions = (n: Node) => ( +
+ {n.kind === "folder" && <> + + + + } + + {n.kind === "page" && } + +
+ ); + + let content: React.ReactNode; + if (route.kind === "root") { + content = <> +
+

Вики

Workspace: {wsLabel}
+
+
+ + +
+

Содержимое {kids.get(null)?.length ?? 0}

+ {nodes ? listing(null) :
Загрузка…
} + ; + } else if (pageErr) { + content = ; + } else if (!page || page.id !== currentId) { + content =
Загрузка…
; + } else if (page.kind === "folder") { + const n = current ?? page; + content = <> +

{n.title}

Папка
+ {actions(n)} +

Содержимое {kids.get(n.id)?.length ?? 0}

+ {listing(n.id)} + ; + } else { + content = <> + { + setNodes((list) => list?.map((x) => (x.id === p.id ? { ...x, title: p.title, version: p.version, updatedAt: p.updatedAt, updatedByName: p.updatedByName } : x)) ?? list); + if (titleChanged) void reloadTree(); + }} /> +
{actions(current ?? page)}
+ ; + } + + return ( +
+
+ + +
+ + {content} +
+
+ + {dlg?.kind === "new" && ( + setDlg(null)} onOk={(t) => create(dlg.what, t, dlg.parentId)} /> + )} + {dlg?.kind === "rename" && ( + setDlg(null)} + onOk={async (t) => { const p = await Api.patch(dlg.node.id, { title: t }); await reloadTree(); if (page?.id === p.id) setPage(p); }} /> + )} + {dlg?.kind === "move" && nodes && ( + setDlg(null)} + onOk={async (pid) => { + const p = await Api.patch(dlg.node.id, { parentId: pid }); + await reloadTree(); + if (page?.id === p.id) setPage((old) => (old ? { ...old, parentId: p.parentId, path: p.path } : old)); + }} /> + )} + {dlg?.kind === "delete" && ( + Удалить папку «{dlg.node.title}» и всё, что в ней ({dlg.count - 1} {plural(dlg.count - 1)})? Отменить нельзя. + : <>Удалить страницу «{dlg.node.title}»? Отменить нельзя.} + onClose={() => setDlg(null)} + onOk={async () => { + await Api.remove(dlg.node.id); + const parent = dlg.node.parentId; + await reloadTree(); + if (currentId === dlg.node.id || (currentId && isInside(currentId, dlg.node.id, byId))) + void navigate(parent ? hrefOf("folder", parent) : "/", true); + }} /> + )} +
+ ); +} + +function isInside(id: string, ancestor: string, byId: Map) { + let cur = byId.get(id); + let guard = 0; + while (cur?.parentId && guard++ < 100) { if (cur.parentId === ancestor) return true; cur = byId.get(cur.parentId); } + return false; +} + +function plural(n: number) { + const m10 = n % 10, m100 = n % 100; + if (m10 === 1 && m100 !== 11) return "элемент"; + if (m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14)) return "элемента"; + return "элементов"; +} + +/** Сниппет поиска: \u0002…\u0003 — совпадение. */ +function Snippet({ s }: { s: string }) { + const parts = s.split(/(\u0002[^\u0003]*\u0003)/); + return <>{parts.map((p, i) => (p.startsWith("\u0002") ? {p.slice(1, -1)} : {p}))}; +} diff --git a/frontend/src/Dialogs.tsx b/frontend/src/Dialogs.tsx new file mode 100644 index 0000000..0f8812b --- /dev/null +++ b/frontend/src/Dialogs.tsx @@ -0,0 +1,158 @@ +import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import type { Node } from "./api"; +import { Icon } from "./Icon"; + +/** Аркуш CRM (#sheet-root > .backdrop + .sheet). */ +export function Sheet(props: { title: string; onClose: () => void; children: ReactNode; footer?: ReactNode }) { + useEffect(() => { + const k = (e: KeyboardEvent) => { if (e.key === "Escape") props.onClose(); }; + window.addEventListener("keydown", k); + document.body.classList.add("lock"); + return () => { window.removeEventListener("keydown", k); document.body.classList.remove("lock"); }; + }, [props.onClose]); + return ( +
+
+
+
+
+ {props.title} + +
+
{props.children}
+ {props.footer &&
{props.footer}
} +
+
+ ); +} + +export function PromptDialog(props: { + title: string; label: string; initial?: string; ok: string; + onOk: (v: string) => Promise | void; onClose: () => void; +}) { + const [v, setV] = useState(props.initial ?? ""); + const [err, setErr] = useState(""); + const [busy, setBusy] = useState(false); + const ref = useRef(null); + useEffect(() => { ref.current?.focus(); ref.current?.select(); }, []); + const submit = async () => { + if (!v.trim()) { setErr("Введите название"); return; } + setBusy(true); + try { await props.onOk(v.trim()); props.onClose(); } + catch (e) { setErr((e as Error).message); setBusy(false); } + }; + return ( + + + + + }> +
+ +
+ setV(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") void submit(); }} /> +
+ {err &&
{err}
} +
+
+ ); +} + +export function ConfirmDialog(props: { + title: string; text: ReactNode; ok: string; danger?: boolean; + onOk: () => Promise | void; onClose: () => void; +}) { + const [err, setErr] = useState(""); + const [busy, setBusy] = useState(false); + const go = async () => { + setBusy(true); + try { await props.onOk(); props.onClose(); } + catch (e) { setErr((e as Error).message); setBusy(false); } + }; + return ( + + + + + }> +
{props.text}
+ {err &&
{err}
} +
+ ); +} + +function pathTitle(n: Node, byId: Map) { + const parts: string[] = []; + let cur: Node | undefined = n; + let guard = 0; + while (cur && guard++ < 100) { parts.unshift(cur.title); cur = cur.parentId ? byId.get(cur.parentId) : undefined; } + return parts.join(" / "); +} + +/** Выбор папки назначения для переноса. */ +export function MoveDialog(props: { + node: Node; nodes: Node[]; onOk: (parentId: string | null) => Promise; onClose: () => void; +}) { + const [err, setErr] = useState(""); + const [q, setQ] = useState(""); + const byId = useMemo(() => new Map(props.nodes.map((n) => [n.id, n])), [props.nodes]); + const options = useMemo(() => { + // Нельзя внутрь себя и своих потомков. + const banned = new Set([props.node.id]); + let grew = true; + while (grew) { + grew = false; + for (const n of props.nodes) if (n.parentId && banned.has(n.parentId) && !banned.has(n.id)) { banned.add(n.id); grew = true; } + } + return props.nodes + .filter((n) => n.kind === "folder" && !banned.has(n.id)) + .map((n) => ({ id: n.id, label: pathTitle(n, byId) })) + .sort((a, b) => a.label.localeCompare(b.label, "uk")); + }, [props.nodes, props.node.id, byId]); + const shown = options.filter((o) => o.label.toLowerCase().includes(q.toLowerCase())); + const pick = async (id: string | null) => { + try { await props.onOk(id); props.onClose(); } catch (e) { setErr((e as Error).message); } + }; + return ( + +
setQ(e.target.value)} autoFocus />
+ {err &&
{err}
} +
+ + {shown.map((o) => ( + + ))} + {shown.length === 0 &&
Папок не найдено
} +
+
+ ); +} + +/** Выбор страницы для ссылки. */ +export function PagePickDialog(props: { nodes: Node[]; exclude?: string; onPick: (n: Node) => void; onClose: () => void }) { + const [q, setQ] = useState(""); + const byId = useMemo(() => new Map(props.nodes.map((n) => [n.id, n])), [props.nodes]); + const list = props.nodes + .filter((n) => n.kind === "page" && n.id !== props.exclude && n.title.toLowerCase().includes(q.toLowerCase())) + .map((n) => ({ n, label: pathTitle(n, byId) })) + .sort((a, b) => a.label.localeCompare(b.label, "uk")) + .slice(0, 100); + return ( + +
setQ(e.target.value)} autoFocus />
+
+ {list.map(({ n, label }) => ( + + ))} + {list.length === 0 &&
Страниц не найдено
} +
+
+ ); +} diff --git a/frontend/src/Editor.tsx b/frontend/src/Editor.tsx new file mode 100644 index 0000000..a9db862 --- /dev/null +++ b/frontend/src/Editor.tsx @@ -0,0 +1,194 @@ +import { useState } from "react"; +import { EditorContent, useEditor, useEditorState, type Editor } from "@tiptap/react"; +import StarterKit from "@tiptap/starter-kit"; +import Image from "@tiptap/extension-image"; +import { TableKit } from "@tiptap/extension-table"; +import { TaskItem, TaskList } from "@tiptap/extension-list"; +import { Placeholder } from "@tiptap/extensions"; +import { Markdown } from "@tiptap/markdown"; +import { Api, imageUrl, type Node } from "./api"; +import { Icon } from "./Icon"; +import { PagePickDialog, PromptDialog } from "./Dialogs"; +import { navigate } from "./router"; + +// Картинка: src в Markdown остаётся /api/images/, а показываем blob-URL, загруженный с токеном. +const AuthImage = Image.extend({ + addNodeView() { + return ({ node }) => { + const img = document.createElement("img"); + let src = ""; + const load = (s: string, alt: string) => { + img.alt = alt; + if (s === src) return; + src = s; + img.classList.add("op-40"); + imageUrl(s).then((u) => { if (src === s) { img.src = u; img.classList.remove("op-40"); } }) + .catch(() => { img.alt = "картинка недоступна"; }); + }; + load(node.attrs.src, node.attrs.alt ?? ""); + return { + dom: img, + update: (n) => { + if (n.type.name !== "image") return false; + load(n.attrs.src, n.attrs.alt ?? ""); + return true; + }, + }; + }; + }, +}); + +const isInternal = (href: string) => /^\/p\/[A-Za-z0-9_-]+$/.test(href); + +export function WikiEditor(props: { + markdown: string; pageId: string; nodes: Node[]; + onChange: (getMarkdown: () => string) => void; +}) { + const [dialog, setDialog] = useState(null); + const [upErr, setUpErr] = useState(""); + + const upload = async (ed: Editor, files: File[], pos?: number) => { + setUpErr(""); + for (const f of files) { + try { + const r = await Api.upload(f, props.pageId); + const content = { type: "image", attrs: { src: r.url, alt: f.name.replace(/\.[^.]+$/, "") } }; + if (pos != null) ed.chain().focus().insertContentAt(pos, content).run(); + else ed.chain().focus().insertContent(content).run(); + } catch (e) { + setUpErr(`Не удалось загрузить «${f.name}»: ${(e as Error).message}`); + } + } + }; + + const editor = useEditor({ + extensions: [ + StarterKit.configure({ + heading: { levels: [1, 2, 3] }, + link: { openOnClick: false, autolink: true, defaultProtocol: "https" }, + }), + AuthImage, + TableKit.configure({ table: { resizable: false } }), + TaskList, + TaskItem.configure({ nested: true }), + Placeholder.configure({ placeholder: "Начните писать… Markdown работает: # заголовок, - список, **жирный**" }), + Markdown.configure({ markedOptions: { gfm: true } }), + ], + content: props.markdown, + contentType: "markdown", + onUpdate: ({ editor }) => props.onChange(() => editor.getMarkdown()), + editorProps: { + attributes: { class: "wk-prose", spellcheck: "true" }, + handleClick: (_view, _pos, event) => { + const a = (event.target as HTMLElement).closest("a"); + const href = a?.getAttribute("href"); + if (!href) return false; + event.preventDefault(); + if (isInternal(href)) void navigate(href); + else window.open(href, "_blank", "noopener,noreferrer"); + return true; + }, + handlePaste: (_view, event) => { + const files = [...(event.clipboardData?.files ?? [])].filter((f) => f.type.startsWith("image/")); + if (!files.length || !editor) return false; + void upload(editor, files); + return true; + }, + handleDrop: (view, event) => { + const files = [...(event.dataTransfer?.files ?? [])].filter((f) => f.type.startsWith("image/")); + if (!files.length || !editor) return false; + event.preventDefault(); + const pos = view.posAtCoords({ left: event.clientX, top: event.clientY })?.pos; + void upload(editor, files, pos); + return true; + }, + }, + }); + + const s = useEditorState({ + editor, + selector: ({ editor: e }) => ({ + h1: e.isActive("heading", { level: 1 }), h2: e.isActive("heading", { level: 2 }), h3: e.isActive("heading", { level: 3 }), + bold: e.isActive("bold"), italic: e.isActive("italic"), strike: e.isActive("strike"), code: e.isActive("code"), + ul: e.isActive("bulletList"), ol: e.isActive("orderedList"), task: e.isActive("taskList"), + quote: e.isActive("blockquote"), codeBlock: e.isActive("codeBlock"), link: e.isActive("link"), + table: e.isActive("table"), undo: e.can().undo(), redo: e.can().redo(), + }), + }); + + if (!editor) return null; + const c = () => editor.chain().focus(); + const B = (p: { on?: boolean; title: string; icon?: string; text?: string; run: () => void; disabled?: boolean }) => ( + + ); + const pickImage = () => { + const inp = document.createElement("input"); + inp.type = "file"; + inp.accept = "image/png,image/jpeg,image/gif,image/webp"; + inp.multiple = true; + inp.onchange = () => void upload(editor, [...(inp.files ?? [])]); + inp.click(); + }; + + return ( +
+
+ c().toggleHeading({ level: 1 }).run()} /> + c().toggleHeading({ level: 2 }).run()} /> + c().toggleHeading({ level: 3 }).run()} /> + + c().toggleBold().run()} /> + c().toggleItalic().run()} /> + c().toggleStrike().run()} /> + c().toggleCode().run()} /> + + c().toggleBulletList().run()} /> + c().toggleOrderedList().run()} /> + c().toggleTaskList().run()} /> + c().toggleBlockquote().run()} /> + c().toggleCodeBlock().run()} /> + c().setHorizontalRule().run()} /> + + (s?.link ? c().unsetLink().run() : setDialog("link"))} /> + setDialog("page")} /> + + c().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()} disabled={s?.table} /> + {s?.table && <> + c().addRowAfter().run()} /> + c().addColumnAfter().run()} /> + c().deleteRow().run()} /> + c().deleteColumn().run()} /> + c().deleteTable().run()} /> + } + + c().undo().run()} /> + c().redo().run()} /> +
+ {upErr &&
{upErr}
} + + + {dialog === "link" && ( + setDialog(null)} + onOk={(url) => { + let href = url.trim(); + if (!/^(https?:|mailto:|\/p\/)/i.test(href)) href = "https://" + href.replace(/^\/+/, ""); + if (editor.state.selection.empty) c().insertContent({ type: "text", text: href, marks: [{ type: "link", attrs: { href } }] }).run(); + else c().extendMarkRange("link").setLink({ href }).run(); + }} /> + )} + {dialog === "page" && ( + setDialog(null)} + onPick={(n) => { + const href = `/p/${n.id}`; + if (editor.state.selection.empty) c().insertContent({ type: "text", text: n.title, marks: [{ type: "link", attrs: { href } }] }).insertContent(" ").run(); + else c().extendMarkRange("link").setLink({ href }).run(); + }} /> + )} +
+ ); +} diff --git a/frontend/src/Icon.tsx b/frontend/src/Icon.tsx new file mode 100644 index 0000000..5b1875e --- /dev/null +++ b/frontend/src/Icon.tsx @@ -0,0 +1,43 @@ +// Иконки по правилам tvorymo-ui.css: , обводка currentColor. +const P: Record = { + x: "M6 6l12 12M18 6 6 18", + folder: "M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z", + page: "M7 3h7l5 5v11a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2zM14 3v5h5M9 13h6M9 17h6", + home: "M4 11l8-7 8 7M6 10v10h12V10", + chev: "M9 6l6 6-6 6", + plus: "M12 5v14M5 12h14", + search: "M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM20 20l-4-4", + edit: "M4 20h4L19 9l-4-4L4 16zM14 6l4 4", + move: "M5 12h14M13 6l6 6-6 6", + trash: "M4 7h16M10 11v6M14 11v6M6 7l1 13h10l1-13M9 7V4h6v3", + menu: "M4 6h16M4 12h16M4 18h16", + link: "M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1", + pagelink: "M7 3h7l5 5v11a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2zM10 15l4-4M11 11h3v3", + image: "M4 5h16v14H4zM4 16l5-5 4 4 3-3 4 4M15 9h.01", + table: "M4 5h16v14H4zM4 10h16M4 15h16M10 5v14", + bold: "M7 5h6a3.5 3.5 0 0 1 0 7H7zM7 12h7a3.5 3.5 0 0 1 0 7H7z", + italic: "M11 5h6M7 19h6M14 5l-4 14", + strike: "M5 12h14M16 7a4 3 0 0 0-8 0c0 4 8 2 8 6a4 3 0 0 1-8 0", + code: "M9 8l-4 4 4 4M15 8l4 4-4 4", + ul: "M9 6h11M9 12h11M9 18h11M4.5 6h.01M4.5 12h.01M4.5 18h.01", + ol: "M10 6h10M10 12h10M10 18h10M4 5l1-1v4M4 14h2l-2 3h2", + task: "M4 6l2 2 3-3M4 15h5v5H4zM12 7h8M12 17h8", + quote: "M6 17c-1-1-2-3-2-5 0-3 2-5 4-6M14 17c-1-1-2-3-2-5 0-3 2-5 4-6", + hr: "M4 12h16", + undo: "M9 14 4 9l5-5M4 9h10a6 6 0 0 1 0 12h-3", + redo: "M15 14l5-5-5-5M20 9H10a6 6 0 0 0 0 12h3", + rowadd: "M4 5h16v6H4zM12 15v6M9 18h6", + coladd: "M4 4h6v16H4zM15 12h6M18 9v6", + rowdel: "M4 5h16v6H4zM9 18h6", + coldel: "M4 4h6v16H4zM15 12h6", + copy: "M9 9h11v11H9zM5 15V4h11", + check: "M5 12l5 5 9-10", +}; + +export function Icon({ name }: { name: string }) { + return ( + + ); +} diff --git a/frontend/src/PageView.tsx b/frontend/src/PageView.tsx new file mode 100644 index 0000000..a9d9c5f --- /dev/null +++ b/frontend/src/PageView.tsx @@ -0,0 +1,140 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { Api, ApiError, type Node, type Page } from "./api"; +import { WikiEditor } from "./Editor"; +import { setLeaveGuard } from "./router"; + +type SaveState = "saved" | "dirty" | "saving" | "error" | "conflict"; + +const fmt = (iso: string) => new Date(iso).toLocaleString("uk-UA", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" }); + +/** + * Страница: заголовок + редактор, автосохранение через 1 с после правки. + * Сохраняем с версией: если страницу уже изменил кто-то другой, сервер отвечает 409 и мы показываем выбор. + */ +export function PageView(props: { page: Page; nodes: Node[]; onSaved: (p: Page, titleChanged: boolean) => void }) { + const [title, setTitle] = useState(props.page.title); + const [state, setState] = useState("saved"); + const [err, setErr] = useState(""); + const [meta, setMeta] = useState({ updatedAt: props.page.updatedAt, by: props.page.updatedByName }); + const [conflict, setConflict] = useState(null); + const [editorKey, setEditorKey] = useState(0); + const [markdown, setMarkdown] = useState(props.page.markdown); + + const version = useRef(props.page.version); + const savedTitle = useRef(props.page.title); + const getMd = useRef<() => string>(() => props.page.markdown); + const titleRef = useRef(title); + const dirty = useRef(false); + const timer = useRef(undefined); + const inflight = useRef | null>(null); + + const save = useCallback(async (): Promise => { + window.clearTimeout(timer.current); + if (inflight.current) { await inflight.current; } + if (!dirty.current) return; + const t = titleRef.current.trim() || "Без названия"; + dirty.current = false; + setState("saving"); + const run = (async () => { + try { + const p = await Api.save(props.page.id, t, getMd.current(), version.current); + version.current = p.version; + const titleChanged = p.title !== savedTitle.current; + savedTitle.current = p.title; + setMeta({ updatedAt: p.updatedAt, by: p.updatedByName }); + setState(dirty.current ? "dirty" : "saved"); + setErr(""); + props.onSaved(p, titleChanged); + } catch (e) { + dirty.current = true; + if (e instanceof ApiError && e.status === 409) { + setConflict(e.body?.current ?? null); + setState("conflict"); + } else { + setErr((e as Error).message); + setState("error"); + } + } + })(); + inflight.current = run; + await run; + inflight.current = null; + }, [props.page.id]); + + const schedule = useCallback(() => { + dirty.current = true; + setState((s) => (s === "conflict" ? s : "dirty")); + window.clearTimeout(timer.current); + timer.current = window.setTimeout(() => { if (!conflictRef.current) void save(); }, 1000); + }, [save]); + + const conflictRef = useRef(conflict); + conflictRef.current = conflict; + + // Перед переходом на другую страницу — дописать изменения. При закрытии вкладки — предупредить. + useEffect(() => { + setLeaveGuard(async () => { if (dirty.current && !conflictRef.current) await save(); }); + const before = (e: BeforeUnloadEvent) => { if (dirty.current) { e.preventDefault(); } }; + window.addEventListener("beforeunload", before); + return () => { setLeaveGuard(null); window.removeEventListener("beforeunload", before); window.clearTimeout(timer.current); }; + }, [save]); + + const takeTheirs = () => { + if (!conflict) return; + version.current = conflict.version; + savedTitle.current = conflict.title; + titleRef.current = conflict.title; + setTitle(conflict.title); + setMarkdown(conflict.markdown); + getMd.current = () => conflict.markdown; + setMeta({ updatedAt: conflict.updatedAt, by: conflict.updatedByName }); + dirty.current = false; + setConflict(null); + setState("saved"); + setEditorKey((k) => k + 1); + }; + const keepMine = () => { + if (!conflict) return; + version.current = conflict.version; + setConflict(null); + dirty.current = true; + void save(); + }; + + const status = { + saved: сохранено, + dirty: есть изменения, + saving: сохраняю…, + error: , + conflict: конфликт версий, + }[state]; + + return ( +
+
+ { setTitle(e.target.value); titleRef.current = e.target.value; schedule(); }} + onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); (document.querySelector(".wk-prose") as HTMLElement | null)?.focus(); } }} /> +
+
+ {status} + изменено {fmt(meta.updatedAt)}{meta.by ? ` · ${meta.by}` : ""} +
+ {state === "error" && err &&
{err}
} + {conflict && ( +
+
+ Страницу уже изменили{conflict.updatedByName ? ` (${conflict.updatedByName}, ${fmt(conflict.updatedAt)})` : ""}. + Ваши правки ещё не сохранены. Что оставить? +
+ + +
+
+
+ )} + { getMd.current = g; schedule(); }} /> +
+ ); +} diff --git a/frontend/src/Tree.tsx b/frontend/src/Tree.tsx new file mode 100644 index 0000000..beeaedf --- /dev/null +++ b/frontend/src/Tree.tsx @@ -0,0 +1,87 @@ +import { useEffect, useMemo, useState } from "react"; +import type { Node } from "./api"; +import { Icon } from "./Icon"; +import { hrefOf, navigate } from "./router"; + +export function sortNodes(a: Node, b: Node) { + if (a.kind !== b.kind) return a.kind === "folder" ? -1 : 1; + return a.title.localeCompare(b.title, "uk", { sensitivity: "base", numeric: true }); +} + +export function childrenMap(nodes: Node[]) { + const m = new Map(); + for (const n of nodes) { + const k = n.parentId ?? null; + if (!m.has(k)) m.set(k, []); + m.get(k)!.push(n); + } + for (const list of m.values()) list.sort(sortNodes); + return m; +} + +const storeKey = (ws: string) => `wiki.open.${ws}`; + +/** Дерево папок и страниц: раскрыть/свернуть, текущий узел подсвечен, предки раскрыты. */ +export function Tree(props: { nodes: Node[]; ws: string; currentId: string | null; onAddPage: (parentId: string) => void }) { + const kids = useMemo(() => childrenMap(props.nodes), [props.nodes]); + const byId = useMemo(() => new Map(props.nodes.map((n) => [n.id, n])), [props.nodes]); + const [open, setOpen] = useState>(() => { + try { return new Set(JSON.parse(localStorage.getItem(storeKey(props.ws)) || "[]")); } catch { return new Set(); } + }); + + useEffect(() => { + try { localStorage.setItem(storeKey(props.ws), JSON.stringify([...open])); } catch { /* хранилище недоступно */ } + }, [open, props.ws]); + + // Раскрыть предков текущего узла. + useEffect(() => { + if (!props.currentId) return; + const need: string[] = []; + let cur = byId.get(props.currentId); + const self = cur; + let guard = 0; + while (cur?.parentId && guard++ < 100) { need.push(cur.parentId); cur = byId.get(cur.parentId); } + if (self?.kind === "folder") need.push(self.id); + if (need.some((id) => !open.has(id))) setOpen((o) => new Set([...o, ...need])); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [props.currentId, byId]); + + const toggle = (id: string) => setOpen((o) => { const n = new Set(o); if (n.has(id)) n.delete(id); else n.add(id); return n; }); + + const render = (parent: string | null, depth: number): React.ReactNode => + (kids.get(parent) ?? []).map((n) => { + const isOpen = open.has(n.id); + const hasKids = (kids.get(n.id)?.length ?? 0) > 0; + return ( +
+
+ {n.kind === "folder" ? ( + + ) : } + { + if (e.ctrlKey || e.metaKey) return; + e.preventDefault(); + if (n.kind === "folder" && n.id === props.currentId) toggle(n.id); + void navigate(hrefOf(n.kind, n.id)); + }}> + + {n.title} + + {n.kind === "folder" && ( + + )} +
+ {n.kind === "folder" && isOpen &&
{render(n.id, depth + 1)}
} +
+ ); + }); + + if (props.nodes.length === 0) return
Пока пусто. Создайте первую страницу или папку.
; + return
{render(null, 0)}
; +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts new file mode 100644 index 0000000..07e4433 --- /dev/null +++ b/frontend/src/api.ts @@ -0,0 +1,71 @@ +import { currentWorkspace, getToken } from "./crm"; + +export type Node = { + id: string; parentId: string | null; kind: "folder" | "page"; title: string; version: number; + createdAt: string; updatedAt: string; updatedBy: string; updatedByName: string | null; +}; +export type Crumb = { id: string; kind: "folder" | "page"; title: string }; +export type Page = Node & { markdown: string; path: Crumb[] }; +export type SearchHit = { id: string; kind: "folder" | "page"; title: string; snippet: string; path: Crumb[] }; + +export class ApiError extends Error { + status: number; + body: any; + constructor(status: number, body: any) { + super(body?.error || `HTTP ${status}`); + this.status = status; + this.body = body; + } +} + +/** Запрос к API вики: Bearer-токен CRM + текущий workspace. На 401 — свежий токен и один повтор. */ +export async function api(path: string, init: RequestInit = {}, ws = currentWorkspace()): Promise { + const go = async (force: boolean) => { + const headers = new Headers(init.headers); + headers.set("Authorization", `Bearer ${await getToken(force)}`); + headers.set("X-Wiki-Workspace", ws); + if (init.body && typeof init.body === "string") headers.set("Content-Type", "application/json"); + return fetch(path, { ...init, headers }); + }; + let r = await go(false); + if (r.status === 401) r = await go(true); + const type = r.headers.get("Content-Type") || ""; + const body = type.includes("json") ? await r.json() : type.startsWith("image/") ? await r.blob() : await r.text(); + if (!r.ok) throw new ApiError(r.status, body); + return body as T; +} + +const json = (method: string, body: unknown): RequestInit => ({ method, body: JSON.stringify(body) }); + +export const Api = { + tree: () => api<{ workspace: string; nodes: Node[] }>("/api/tree"), + page: (id: string) => api(`/api/nodes/${encodeURIComponent(id)}`), + createPage: (title: string, parentId: string | null, markdown = "") => + api("/api/pages", json("POST", { title, parentId, markdown })), + createFolder: (title: string, parentId: string | null) => api("/api/folders", json("POST", { title, parentId })), + save: (id: string, title: string, markdown: string, version: number) => + api(`/api/pages/${encodeURIComponent(id)}`, json("PUT", { title, markdown, version })), + patch: (id: string, body: { title?: string; parentId?: string | null }) => + api(`/api/nodes/${encodeURIComponent(id)}`, json("PATCH", body)), + remove: (id: string) => api<{ deleted: number }>(`/api/nodes/${encodeURIComponent(id)}`, { method: "DELETE" }), + search: (q: string) => api<{ results: SearchHit[] }>(`/api/search?q=${encodeURIComponent(q)}&limit=30`), + upload: (file: File, pageId: string) => { + const fd = new FormData(); + fd.append("file", file); + fd.append("pageId", pageId); + return api<{ id: string; url: string }>("/api/images", { method: "POST", body: fd }); + }, +}; + +// Картинки отдаются только с токеном: грузим через fetch и подставляем blob-URL. +const imgCache = new Map>(); +export function imageUrl(src: string): Promise { + if (!src.startsWith("/api/images/")) return Promise.resolve(src); + let p = imgCache.get(src); + if (!p) { + p = api(src).then((b) => URL.createObjectURL(b)); + p.catch(() => imgCache.delete(src)); + imgCache.set(src, p); + } + return p; +} diff --git a/frontend/src/crm.ts b/frontend/src/crm.ts new file mode 100644 index 0000000..3283fd6 --- /dev/null +++ b/frontend/src/crm.ts @@ -0,0 +1,145 @@ +// Мост с CRM: postMessage (crm:ready → crm:auth, crm:refresh, crm:logout). +// Токен живёт только в памяти, никуда не логируется и не выводится. +import { useSyncExternalStore } from "react"; + +export type User = { id: string; email: string | null; name: string | null }; +export type CrmWorkspace = { id?: string; kind?: string; title?: string }; + +export type AuthState = { + status: "standalone" | "waiting" | "checking" | "ok" | "error" | "logout"; + error?: string; + user?: User; + workspace: string; // shared | personal: | + workspaceTitle?: string; +}; + +let crmOrigin = ""; +let token: string | null = null; +let expiresAt: number | null = null; // мс +let state: AuthState = { status: "waiting", workspace: "shared" }; +const subs = new Set<() => void>(); +let waiters: Array<() => void> = []; +let lastWs: CrmWorkspace | null = null; + +function set(p: Partial) { + state = { ...state, ...p }; + subs.forEach((f) => f()); +} + +export function useAuth() { + return useSyncExternalStore( + (f) => { subs.add(f); return () => subs.delete(f); }, + () => state, + ); +} + +function toMs(v: unknown): number | null { + if (v == null) return null; + const n = Number(v); + if (Number.isFinite(n)) return n < 1e12 ? n * 1000 : n; + const d = Date.parse(String(v)); + return Number.isNaN(d) ? null : d; +} + +/** Workspace из сообщения CRM → id вики. Сервер всё равно проверяет доступ сам. */ +function wsKey(w: CrmWorkspace | null | undefined, uid: string | undefined): { id: string; title?: string } { + if (!w || typeof w !== "object") return { id: "shared" }; + const id = String(w.id ?? ""); + const kind = String(w.kind ?? ""); + if (kind === "shared" || id === "shared" || (!id && !kind)) return { id: "shared", title: w.title }; + if (kind === "personal" || id === "personal" || id.startsWith("personal:")) + return uid ? { id: `personal:${uid}`, title: w.title } : { id: "shared" }; + return { id, title: w.title }; +} + +async function onAuth(d: Record) { + const t = d.access_token; + if (typeof t !== "string" || !t) { set({ status: "error", error: "crm:auth без access_token" }); return; } + const changedUser = !state.user || (d.user as { id?: string } | undefined)?.id !== state.user.id; + token = t; + expiresAt = toMs(d.expires_at); + if ("workspace" in d) lastWs = (d.workspace as CrmWorkspace) ?? null; + const ws0 = wsKey(lastWs, state.user?.id); + // Если пользователь тот же, повторно не проверяем: каждый запрос к API всё равно проверяется на сервере. + if (!changedUser && state.status === "ok") { + set({ workspace: ws0.id, workspaceTitle: ws0.title }); + flushWaiters(); + return; + } + if (state.status !== "ok") set({ status: "checking" }); + try { + const r = await fetch("/api/verify", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ access_token: t }), + }); + const res = await r.json(); + if (!r.ok || !res.ok) { set({ status: "error", error: res.error || `HTTP ${r.status}` }); return; } + if (!expiresAt && res.exp) expiresAt = res.exp * 1000; + const user: User = { id: res.id, email: res.email, name: res.name }; + const ws = wsKey(lastWs, user.id); + set({ status: "ok", user, error: undefined, workspace: ws.id, workspaceTitle: ws.title }); + flushWaiters(); + } catch { + set({ status: "error", error: "не удалось проверить токен" }); + } +} + +function flushWaiters() { + const w = waiters; + waiters = []; + w.forEach((f) => f()); +} + +export async function initCrm() { + if (window.parent === window) { set({ status: "standalone" }); return; } + try { + const cfg = await (await fetch("/api/config")).json(); + crmOrigin = cfg.crmOrigin; + } catch { + set({ status: "error", error: "нет связи с сервером вики" }); + return; + } + window.addEventListener("message", (ev) => { + if (ev.origin !== crmOrigin || ev.source !== window.parent) return; + const d = ev.data; + if (!d || typeof d !== "object") return; + if (d.type === "crm:auth") void onAuth(d); + else if (d.type === "crm:workspace") { + // Будущее сообщение о смене workspace (задача Олега); формат: { workspace: { id, kind, title } }. + lastWs = d.workspace ?? null; + const ws = wsKey(lastWs, state.user?.id); + set({ workspace: ws.id, workspaceTitle: ws.title }); + } else if (d.type === "crm:logout") { + token = null; expiresAt = null; + set({ status: "logout", user: undefined }); + } + }); + window.parent.postMessage({ type: "crm:ready", version: 1 }, crmOrigin); +} + +function requestRefresh(): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + waiters = waiters.filter((w) => w !== done); + reject(new Error("CRM не прислала новый токен")); + }, 15000); + const done = () => { clearTimeout(timer); resolve(); }; + waiters.push(done); + if (crmOrigin) window.parent.postMessage({ type: "crm:refresh" }, crmOrigin); + }); +} + +/** Токен для запроса: если истекает меньше чем через минуту — просим у CRM свежий. */ +export async function getToken(force = false): Promise { + const stale = !token || force || (expiresAt != null && expiresAt - Date.now() < 60_000); + if (stale) { + try { await requestRefresh(); } catch (e) { if (!token) throw e; } + } + if (!token) throw new Error("нет авторизации CRM"); + return token; +} + +export function currentWorkspace() { + return state.workspace; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..5819ea0 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,8 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import App from "./App"; +import { initCrm } from "./crm"; +import "./wiki.css"; + +void initCrm(); +createRoot(document.getElementById("root")!).render(); diff --git a/frontend/src/router.ts b/frontend/src/router.ts new file mode 100644 index 0000000..e3396d9 --- /dev/null +++ b/frontend/src/router.ts @@ -0,0 +1,36 @@ +import { useSyncExternalStore } from "react"; + +// Адреса: / — корень workspace +// /f/ — папка +// /p/ — страница (так же выглядят ссылки между страницами в Markdown) +export type Route = { kind: "root" } | { kind: "folder"; id: string } | { kind: "page"; id: string }; + +const subs = new Set<() => void>(); +let path = location.pathname; +window.addEventListener("popstate", () => { path = location.pathname; subs.forEach((f) => f()); }); + +let guard: (() => Promise) | null = null; +/** Перед уходом со страницы (например, дождаться сохранения). */ +export function setLeaveGuard(g: (() => Promise) | null) { guard = g; } + +export async function navigate(to: string, replace = false) { + if (to === path) return; + if (guard) { try { await guard(); } catch { /* ошибка сохранения видна на самой странице */ } } + if (replace) history.replaceState(null, "", to); + else history.pushState(null, "", to); + path = to; + subs.forEach((f) => f()); +} + +export function parse(p: string): Route { + const m = /^\/([pf])\/([A-Za-z0-9_-]+)\/?$/.exec(p); + if (!m) return { kind: "root" }; + return m[1] === "p" ? { kind: "page", id: m[2] } : { kind: "folder", id: m[2] }; +} + +export function useRoute(): Route { + const p = useSyncExternalStore((f) => { subs.add(f); return () => subs.delete(f); }, () => path); + return parse(p); +} + +export const hrefOf = (kind: "folder" | "page", id: string) => (kind === "page" ? `/p/${id}` : `/f/${id}`); diff --git a/frontend/src/wiki.css b/frontend/src/wiki.css new file mode 100644 index 0000000..ef629ea --- /dev/null +++ b/frontend/src/wiki.css @@ -0,0 +1,104 @@ +/* Вики: только то, чего нет в tvorymo-ui.css / crm.css — раскладка «дерево + страница», строки дерева, + панель редактора и оформление текста страницы. Все значения — токены CRM (--sp-*, --fs-*, --r-*, цвета). */ + +/* ---------------------------------------------------------------- раскладка */ +#main.wide{max-width:none} +.wk{display:grid;grid-template-columns:1fr;gap:var(--sp-24);align-items:start} +.wk-side{display:none} +.wk.side-open .wk-side{display:block} +.wk.side-open .wk-main{display:none} +@media (min-width:900px){ + .wk{grid-template-columns:280px minmax(0,1fr)} + .wk-side{display:block;position:sticky;top:var(--sp-16);max-height:calc(100dvh - var(--sp-32));overflow-y:auto; + padding-right:var(--sp-14);border-right:1px solid var(--line)} + .wk.side-open .wk-main{display:block} + .wk-menu{display:none!important} + .wk-main{max-width:860px} +} + +/* ---------------------------------------------------------------- дерево */ +.wk-row{display:flex;align-items:center;gap:var(--sp-2);border-radius:var(--r-8);color:var(--mute); + font-size:var(--fs-13);font-weight:500;min-height:32px;text-decoration:none} +.wk-row:hover{background:var(--hover);color:var(--fg)} +.wk-row.on{background:var(--gold-dim);color:var(--fg)} +.wk-link{display:flex;align-items:center;gap:var(--sp-8);flex:1;min-width:0;padding:var(--sp-6) var(--sp-4); + color:inherit;text-decoration:none} +.wk-link svg{flex:none;stroke-width:var(--ic-w-thin)} +.wk-home{margin-bottom:var(--sp-2)} +.wk-home .wk-link{padding-left:var(--sp-8)} +.wk-chev{width:20px;height:20px;display:inline-flex;align-items:center;justify-content:center;flex:none;color:var(--mute2)} +.wk-chev svg{width:14px;height:14px;transition:transform var(--dur-fast)} +.wk-chev.open svg{transform:rotate(90deg)} +.wk-add{width:24px;height:24px;display:inline-flex;align-items:center;justify-content:center;flex:none; + border-radius:var(--r-6);color:var(--mute2);opacity:0} +.wk-add svg{width:14px;height:14px} +.wk-row:hover .wk-add,.wk-add:focus-visible{opacity:1} +.wk-add:hover{color:var(--gold-text);background:var(--hover)} +@media (hover:none){.wk-add{opacity:1}} + +/* ---------------------------------------------------------------- шапка страницы */ +.wk-crumbs{display:flex;align-items:center;flex-wrap:wrap;gap:var(--sp-6);font-size:var(--fs-12);color:var(--mute); + margin-bottom:var(--sp-14);min-height:30px} +.wk-crumbs a{color:var(--mute);text-decoration:none} +.wk-crumbs a:hover{color:var(--fg)} +.wk-crumbs .row{gap:var(--sp-6);min-width:0} +.wk-title{font-family:var(--fh);font-weight:700;font-size:var(--fs-22);line-height:var(--lh-tight);letter-spacing:-.01em; + padding:var(--sp-4) 0;border-bottom:1px solid transparent} +.wk-title:focus{border-bottom-color:var(--gold)} +.wk-title::placeholder{color:var(--mute2)} +@media (min-width:900px){.wk-title{font-size:var(--fs-27)}} + +/* ---------------------------------------------------------------- панель редактора */ +.wk-editor{margin-top:var(--sp-14)} +.wk-toolbar{display:flex;flex-wrap:wrap;align-items:center;gap:var(--sp-2);position:sticky;top:0;z-index:var(--z-sticky); + background:var(--bg);padding:var(--sp-6) 0;border-bottom:1px solid var(--line)} +.wk-tb{min-width:30px;height:30px;padding:0 var(--sp-6);border-radius:var(--r-8);display:inline-flex;align-items:center; + justify-content:center;color:var(--mute);font-size:var(--fs-12)} +.wk-tb b{font-family:var(--fh);font-weight:700;font-size:var(--fs-11)} +.wk-tb:hover{background:var(--hover);color:var(--fg)} +.wk-tb.on{background:var(--gold-dim);color:var(--gold-text)} +.wk-tb[disabled]{opacity:.35;pointer-events:none} +.wk-sep{width:1px;height:18px;background:var(--line);margin:0 var(--sp-4)} + +/* ---------------------------------------------------------------- текст страницы */ +.wk-prose{outline:none;min-height:50vh;padding:var(--sp-16) 0 var(--sp-32);font-size:var(--fs-15);line-height:var(--lh-loose)} +.wk-prose>*+*{margin-top:var(--sp-10)} +.wk-prose h1{font-size:var(--fs-22);margin-top:var(--sp-28)} +.wk-prose h2{font-size:var(--fs-18);font-weight:700;letter-spacing:-.01em;margin-top:var(--sp-24)} +.wk-prose h3{font-size:var(--fs-16);margin-top:var(--sp-20)} +.wk-prose ul,.wk-prose ol{padding-left:var(--sp-24)} +.wk-prose ul{list-style:disc} +.wk-prose ol{list-style:decimal} +.wk-prose li>p{margin:0} +.wk-prose li+li{margin-top:var(--sp-2)} +.wk-prose ul[data-type="taskList"]{list-style:none;padding-left:var(--sp-2)} +.wk-prose ul[data-type="taskList"] li{display:flex;gap:var(--sp-8);align-items:flex-start} +.wk-prose ul[data-type="taskList"] li>label{flex:none;margin-top:var(--sp-4)} +.wk-prose ul[data-type="taskList"] li>label input{width:auto;accent-color:var(--gold)} +.wk-prose ul[data-type="taskList"] li[data-checked="true"]>div{color:var(--mute);text-decoration:line-through} +.wk-prose blockquote{border-left:3px solid var(--gold);padding:var(--sp-2) 0 var(--sp-2) var(--sp-14);color:var(--mute)} +.wk-prose code{font-family:var(--fm);font-size:.9em;background:var(--hover);border:1px solid var(--line); + border-radius:var(--r-4);padding:0 var(--sp-4)} +.wk-prose pre{font-family:var(--fm);font-size:var(--fs-13);background:var(--bg2);border:1px solid var(--line); + border-radius:var(--r-10);padding:var(--sp-12) var(--sp-14);overflow-x:auto;line-height:var(--lh-base)} +.wk-prose pre code{background:none;border:0;padding:0} +.wk-prose a{color:var(--gold-text);text-decoration:underline;text-underline-offset:2px;cursor:pointer} +.wk-prose a[href^="/p/"]{text-decoration-style:dotted} +.wk-prose hr{margin:var(--sp-20) 0} +.wk-prose img{max-width:100%;height:auto;border-radius:var(--r-8);border:1px solid var(--line);display:block} +.wk-prose img.ProseMirror-selectednode{outline:2px solid var(--gold)} +.wk-prose strong{font-weight:700} +.wk-prose em{font-style:italic} +.wk-prose s{text-decoration:line-through} +.wk-prose .tableWrapper{overflow-x:auto} +.wk-prose table{border-collapse:collapse;width:100%;font-size:var(--fs-14)} +.wk-prose th,.wk-prose td{border:1px solid var(--line2);padding:var(--sp-6) var(--sp-10);vertical-align:top;text-align:left; + position:relative;min-width:60px} +.wk-prose th{background:var(--hover);font-weight:700} +.wk-prose td>p,.wk-prose th>p{margin:0} +.wk-prose .selectedCell::after{content:'';position:absolute;inset:0;background:var(--gold-dim);pointer-events:none} +.wk-prose p.is-editor-empty:first-child::before{content:attr(data-placeholder);color:var(--mute2);float:left;height:0;pointer-events:none} + +/* строки-ссылки (.mrow как ) — без подчёркивания и золотого цвета ссылок */ +a.mrow,a.wk-row{color:inherit;text-decoration:none} +a.mrow:hover .t{color:var(--gold-text)} diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..6830b6f --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023", "DOM"], + "module": "esnext", + "types": ["vite/client"], + "allowArbitraryExtensions": true, + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..8455dcb --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "module": "nodenext", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..6c4f56c --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,7 @@ +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vite' + +export default defineConfig({ + plugins: [react()], + server: { proxy: { '/api': 'http://127.0.0.1:5080', '/health': 'http://127.0.0.1:5080' } }, +})