Вики для CRM: ASP.NET Core + SQLite/FTS5 + React/TipTap, авторизация через CRM
This commit is contained in:
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
||||
**/bin/
|
||||
**/obj/
|
||||
**/node_modules/
|
||||
**/dist/
|
||||
**/wwwroot/
|
||||
.git/
|
||||
inbox/
|
||||
data/
|
||||
deploy.json
|
||||
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
**/bin/
|
||||
**/obj/
|
||||
**/node_modules/
|
||||
**/dist/
|
||||
backend/Wiki.Api/wwwroot/
|
||||
backend/Wiki.Api/data/
|
||||
data/
|
||||
inbox/
|
||||
deploy.json
|
||||
*.user
|
||||
30
CLAUDE.md
Normal file
30
CLAUDE.md
Normal file
@@ -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`.
|
||||
35
Dockerfile
Normal file
35
Dockerfile
Normal file
@@ -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"]
|
||||
71
README.md
71
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 <access_token Supabase из CRM>` |
|
||||
| `X-Wiki-Workspace` | `shared` (по умолчанию), `personal:<uid>` или `<uuid>` своего workspace |
|
||||
|
||||
Сервер проверяет токен: `exp`, `aud = "authenticated"` и `GET <SUPABASE_URL>/auth/v1/user` с publishable-ключом (результат кэшируется на 5 минут по SHA-256 токена). Workspace из запроса не доверяется: `shared` доступен всем, `personal:<uid>` только владельцу `uid`, произвольный `<uuid>` проверяется запросом `GET /rest/v1/workspaces?id=eq.<uuid>` с токеном пользователя (RLS Supabase). Ответы: `401` — токен не принят или истёк, `403` — нет доступа к workspace.
|
||||
|
||||
Пока CRM не передаёт workspace (задача Олега `mv6p5b92slw1an`), фронтенд работает в `shared`. Когда в `crm:auth` появится `workspace: { id, kind, title }`, он подхватится автоматически: `kind: "shared"` → `shared`, `kind: "personal"` → `personal:<uid>`, иначе `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/<id>)`, картинка: ``. Ссылки по 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`.
|
||||
|
||||
46
SPEC.md
Normal file
46
SPEC.md
Normal file
@@ -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:<uid>` только для владельца, `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`).
|
||||
133
backend/Wiki.Api/Auth.cs
Normal file
133
backend/Wiki.Api/Auth.cs
Normal file
@@ -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);
|
||||
|
||||
/// <summary>
|
||||
/// Проверка токена CRM (Supabase access_token) и доступа к workspace.
|
||||
/// Токены не логируются и не хранятся: в кэше ключ — SHA-256 токена.
|
||||
/// </summary>
|
||||
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<string, (WikiUser User, DateTimeOffset Until)> _users = new();
|
||||
private readonly ConcurrentDictionary<string, (bool Ok, DateTimeOffset Until)> _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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Нормализует и проверяет workspace. id из клиента не доверяем:
|
||||
/// shared — для всех, personal:<uid> — только владельцу, <uuid> (custom) — по таблице workspaces с RLS.
|
||||
/// </summary>
|
||||
public async Task<bool> 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, '='));
|
||||
}
|
||||
}
|
||||
110
backend/Wiki.Api/Db.cs
Normal file
110
backend/Wiki.Api/Db.cs
Normal file
@@ -0,0 +1,110 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace Wiki.Api;
|
||||
|
||||
/// <summary>Подключения к SQLite и схема. Файл базы лежит на постоянном томе (Storage:DataDir).</summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
252
backend/Wiki.Api/Program.cs
Normal file
252
backend/Wiki.Api/Program.cs
Normal file
@@ -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<Db>();
|
||||
builder.Services.AddSingleton<Store>();
|
||||
builder.Services.AddSingleton<SupabaseAuth>();
|
||||
builder.Services.AddHttpClient("supabase", c => c.Timeout = TimeSpan.FromSeconds(10));
|
||||
builder.Services.Configure<FormOptions>(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<Db>().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<string>(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<SupabaseAuth>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
308
backend/Wiki.Api/Store.cs
Normal file
308
backend/Wiki.Api/Store.cs
Normal file
@@ -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<Crumb> Path);
|
||||
|
||||
public sealed record SearchHit(string Id, string Kind, string Title, string Snippet, IReadOnlyList<Crumb> 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;
|
||||
}
|
||||
|
||||
/// <summary>Доступ к данным вики. Каждый запрос ограничен workspace.</summary>
|
||||
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<NodeDto> 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<NodeDto>();
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>Цепочка папок от корня до parentId включительно (хлебные крошки).</summary>
|
||||
private static List<Crumb> Path(SqliteConnection c, string ws, string? parentId)
|
||||
{
|
||||
var list = new List<Crumb>();
|
||||
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)!;
|
||||
}
|
||||
|
||||
/// <summary>Сохранение страницы с проверкой версии. Конфликт — WikiError 409, current — актуальная версия.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Переименование и/или перенос. Переименование страницы увеличивает версию.</summary>
|
||||
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)!;
|
||||
}
|
||||
|
||||
/// <summary>Удаляет узел со всем содержимым. Возвращает число удалённых узлов.</summary>
|
||||
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<SearchHit> Search(string ws, string q, int limit)
|
||||
{
|
||||
var words = WordRx().Matches(q).Select(m => m.Value).Take(8).ToList();
|
||||
var hits = new List<SearchHit>();
|
||||
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;
|
||||
}
|
||||
13
backend/Wiki.Api/Wiki.Api.csproj
Normal file
13
backend/Wiki.Api/Wiki.Api.csproj
Normal file
@@ -0,0 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.12" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
16
backend/Wiki.Api/appsettings.json
Normal file
16
backend/Wiki.Api/appsettings.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
28
docker-compose.yml
Normal file
28
docker-compose.yml
Normal file
@@ -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:
|
||||
8
frontend/.oxlintrc.json
Normal file
8
frontend/.oxlintrc.json
Normal file
@@ -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 }]
|
||||
}
|
||||
}
|
||||
17
frontend/index.html
Normal file
17
frontend/index.html
Normal file
@@ -0,0 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Вики · CRM Tvorymo</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<!-- Стили CRM ссылками: сначала дизайн-система, затем crm.css (порядок важен). -->
|
||||
<link rel="stylesheet" href="https://test.units.tvorymo.com/tvorymo-crm/css/tvorymo-ui.css">
|
||||
<link rel="stylesheet" href="https://test.units.tvorymo.com/tvorymo-crm/css/crm.css">
|
||||
</head>
|
||||
<body class="side-off">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
2001
frontend/package-lock.json
generated
Normal file
2001
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
frontend/package.json
Normal file
33
frontend/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
280
frontend/src/App.tsx
Normal file
280
frontend/src/App.tsx
Normal file
@@ -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 <Wiki ws={auth.workspace} wsTitle={auth.workspaceTitle} userName={auth.user?.name || auth.user?.email || ""} />;
|
||||
|
||||
const text = {
|
||||
standalone: <>Эта страница работает внутри CRM Tvorymo. Откройте её из боковой панели CRM: она пришлёт авторизацию (<span className="code">crm:auth</span>).</>,
|
||||
waiting: <>Жду авторизацию от CRM…</>,
|
||||
checking: <>Проверяю пользователя CRM…</>,
|
||||
error: <>Не удалось войти: {auth.error}. Обновите страницу CRM.</>,
|
||||
logout: <>Вы вышли из CRM. Войдите снова, чтобы открыть вики.</>,
|
||||
}[auth.status];
|
||||
return (
|
||||
<main id="main"><div className="page">
|
||||
<div className="head"><div><h1>Вики</h1><div className="sub">База знаний <b>CRM Tvorymo</b></div></div></div>
|
||||
<div className="wsinfo">
|
||||
<Icon name="page" />
|
||||
<div className="grow">
|
||||
<span className={"chip " + (auth.status === "error" ? "dng" : "gold")}>
|
||||
{auth.status === "error" ? "ошибка авторизации" : auth.status === "logout" ? "нет сессии" : "жду авторизацию от CRM"}
|
||||
</span>
|
||||
<div className="small mt-6">{text}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div></main>
|
||||
);
|
||||
}
|
||||
|
||||
function Wiki(props: { ws: string; wsTitle?: string; userName: string }) {
|
||||
const route = useRoute();
|
||||
const [nodes, setNodes] = useState<Node[] | null>(null);
|
||||
const [treeErr, setTreeErr] = useState("");
|
||||
const [page, setPage] = useState<Page | null>(null);
|
||||
const [pageErr, setPageErr] = useState("");
|
||||
const [dlg, setDlg] = useState<Dlg>(null);
|
||||
const [q, setQ] = useState("");
|
||||
const [hits, setHits] = useState<SearchHit[] | null>(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 <div className="empty">Здесь пока пусто.</div>;
|
||||
return list.map((n) => (
|
||||
<a key={n.id} className="mrow" href={hrefOf(n.kind, n.id)} onClick={(e) => { e.preventDefault(); void navigate(hrefOf(n.kind, n.id)); }}>
|
||||
<Icon name={n.kind === "folder" ? "folder" : "page"} />
|
||||
<div className="t">{n.title}<small>{n.kind === "folder" ? `${kids.get(n.id)?.length ?? 0} внутри` : `изменено ${new Date(n.updatedAt).toLocaleDateString("uk-UA")}${n.updatedByName ? " · " + n.updatedByName : ""}`}</small></div>
|
||||
</a>
|
||||
));
|
||||
};
|
||||
|
||||
const actions = (n: Node) => (
|
||||
<div className="row wrap">
|
||||
{n.kind === "folder" && <>
|
||||
<button className="btn sm gold" onClick={() => setDlg({ kind: "new", what: "page", parentId: n.id })}><Icon name="plus" />Страница</button>
|
||||
<button className="btn sm" onClick={() => setDlg({ kind: "new", what: "folder", parentId: n.id })}><Icon name="folder" />Папка</button>
|
||||
<button className="btn sm ghost" onClick={() => setDlg({ kind: "rename", node: n })}><Icon name="edit" />Переименовать</button>
|
||||
</>}
|
||||
<button className="btn sm ghost" onClick={() => setDlg({ kind: "move", node: n })}><Icon name="move" />Переместить</button>
|
||||
{n.kind === "page" && <button className="btn sm ghost" title="Ссылка вида /p/<id> не ломается при переименовании и переносе"
|
||||
onClick={() => void navigator.clipboard?.writeText(`[${n.title}](/p/${n.id})`)}><Icon name="copy" />Markdown-ссылка</button>}
|
||||
<button className="btn sm ghost danger" onClick={() => askDelete(n)}><Icon name="trash" />Удалить</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
let content: React.ReactNode;
|
||||
if (route.kind === "root") {
|
||||
content = <>
|
||||
<div className="head">
|
||||
<div><h1>Вики</h1><div className="sub">Workspace: <b>{wsLabel}</b></div></div>
|
||||
</div>
|
||||
<div className="row wrap">
|
||||
<button className="btn sm gold" onClick={() => setDlg({ kind: "new", what: "page", parentId: null })}><Icon name="plus" />Страница</button>
|
||||
<button className="btn sm" onClick={() => setDlg({ kind: "new", what: "folder", parentId: null })}><Icon name="folder" />Папка</button>
|
||||
</div>
|
||||
<div className="sec"><h2>Содержимое <b>{kids.get(null)?.length ?? 0}</b></h2></div>
|
||||
{nodes ? listing(null) : <div className="empty">Загрузка…</div>}
|
||||
</>;
|
||||
} else if (pageErr) {
|
||||
content = <div className="empty">{pageErr} <a href="/" onClick={(e) => { e.preventDefault(); void navigate("/"); }}>На главную</a></div>;
|
||||
} else if (!page || page.id !== currentId) {
|
||||
content = <div className="empty">Загрузка…</div>;
|
||||
} else if (page.kind === "folder") {
|
||||
const n = current ?? page;
|
||||
content = <>
|
||||
<div className="head"><div className="min-w-0"><h1 className="trunc">{n.title}</h1><div className="sub">Папка</div></div></div>
|
||||
{actions(n)}
|
||||
<div className="sec"><h2>Содержимое <b>{kids.get(n.id)?.length ?? 0}</b></h2></div>
|
||||
{listing(n.id)}
|
||||
</>;
|
||||
} else {
|
||||
content = <>
|
||||
<PageView key={page.id} page={page} nodes={nodes ?? []}
|
||||
onSaved={(p, titleChanged) => {
|
||||
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();
|
||||
}} />
|
||||
<div className="sep-top mt-24 pt-14">{actions(current ?? page)}</div>
|
||||
</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<main id="main" className="wide">
|
||||
<div className={"wk" + (sideOpen ? " side-open" : "")}>
|
||||
<aside className="wk-side" aria-label="Страницы вики">
|
||||
<div className="qa">
|
||||
<input placeholder="Поиск по вики…" value={q} onChange={(e) => setQ(e.target.value)} aria-label="Поиск" />
|
||||
{q && <button className="go" onClick={() => setQ("")} title="Очистить"><Icon name="x" /></button>}
|
||||
</div>
|
||||
{hits ? (
|
||||
<div className="mt-10">
|
||||
<div className="sec"><h2>Найдено <b>{hits.length}</b></h2></div>
|
||||
{hits.length === 0 && <div className="empty">Ничего не нашлось.</div>}
|
||||
{hits.map((h) => (
|
||||
<a key={h.id} className="mrow" href={hrefOf(h.kind, h.id)} onClick={(e) => { e.preventDefault(); void navigate(hrefOf(h.kind, h.id)); }}>
|
||||
<Icon name={h.kind === "folder" ? "folder" : "page"} />
|
||||
<div className="t">{h.title}
|
||||
{h.path.length > 0 && <small>{h.path.map((c) => c.title).join(" / ")}</small>}
|
||||
{h.snippet && <small><Snippet s={h.snippet} /></small>}
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="sec">
|
||||
<h2>{wsLabel}</h2>
|
||||
<div className="row">
|
||||
<button className="icon-btn sm" title="Новая папка" aria-label="Новая папка" onClick={() => setDlg({ kind: "new", what: "folder", parentId: null })}><Icon name="folder" /></button>
|
||||
<button className="icon-btn sm" title="Новая страница" aria-label="Новая страница" onClick={() => setDlg({ kind: "new", what: "page", parentId: null })}><Icon name="plus" /></button>
|
||||
</div>
|
||||
</div>
|
||||
<a className={"wk-row wk-home" + (route.kind === "root" ? " on" : "")} href="/" onClick={(e) => { e.preventDefault(); void navigate("/"); }}>
|
||||
<span className="wk-link"><Icon name="home" /><span>Главная</span></span>
|
||||
</a>
|
||||
{treeErr && <div className="err">{treeErr}</div>}
|
||||
{nodes ? <Tree nodes={nodes} ws={props.ws} currentId={currentId} onAddPage={(pid) => setDlg({ kind: "new", what: "page", parentId: pid })} />
|
||||
: !treeErr && <div className="empty">Загрузка…</div>}
|
||||
</>
|
||||
)}
|
||||
<div className="small mute2 mt-20">{props.userName}</div>
|
||||
</aside>
|
||||
|
||||
<section className="wk-main min-w-0">
|
||||
<nav className="wk-crumbs" aria-label="Путь">
|
||||
<button className="icon-btn sm wk-menu" onClick={() => setSideOpen((v) => !v)} title="Страницы" aria-label="Страницы"><Icon name="menu" /></button>
|
||||
<a href="/" onClick={(e) => { e.preventDefault(); void navigate("/"); }}>Вики</a>
|
||||
{crumbs.map((c) => (
|
||||
<span key={c.id} className="row">
|
||||
<span className="mute2">/</span>
|
||||
<a href={hrefOf(c.kind, c.id)} onClick={(e) => { e.preventDefault(); void navigate(hrefOf(c.kind, c.id)); }}>{c.title}</a>
|
||||
</span>
|
||||
))}
|
||||
{current && route.kind !== "root" && <span className="row"><span className="mute2">/</span><span className="trunc">{current.title}</span></span>}
|
||||
</nav>
|
||||
{content}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{dlg?.kind === "new" && (
|
||||
<PromptDialog title={dlg.what === "page" ? "Новая страница" : "Новая папка"} label="Название" ok="Создать"
|
||||
onClose={() => setDlg(null)} onOk={(t) => create(dlg.what, t, dlg.parentId)} />
|
||||
)}
|
||||
{dlg?.kind === "rename" && (
|
||||
<PromptDialog title="Переименовать" label="Название" initial={dlg.node.title} ok="Сохранить"
|
||||
onClose={() => 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 && (
|
||||
<MoveDialog node={dlg.node} nodes={nodes} onClose={() => 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" && (
|
||||
<ConfirmDialog title="Удалить" danger ok="Удалить"
|
||||
text={dlg.node.kind === "folder"
|
||||
? <>Удалить папку <b>«{dlg.node.title}»</b> и всё, что в ней ({dlg.count - 1} {plural(dlg.count - 1)})? Отменить нельзя.</>
|
||||
: <>Удалить страницу <b>«{dlg.node.title}»</b>? Отменить нельзя.</>}
|
||||
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);
|
||||
}} />
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function isInside(id: string, ancestor: string, byId: Map<string, Node>) {
|
||||
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") ? <b key={i} className="gold">{p.slice(1, -1)}</b> : <span key={i}>{p}</span>))}</>;
|
||||
}
|
||||
158
frontend/src/Dialogs.tsx
Normal file
158
frontend/src/Dialogs.tsx
Normal file
@@ -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 (
|
||||
<div id="sheet-root" className="open">
|
||||
<div className="backdrop" onClick={props.onClose} />
|
||||
<div className="sheet" role="dialog" aria-modal="true" aria-label={props.title}>
|
||||
<div className="grab" />
|
||||
<div className="top">
|
||||
<span className="tt">{props.title}</span>
|
||||
<button className="icon-btn x" onClick={props.onClose} title="Закрыть"><Icon name="x" /></button>
|
||||
</div>
|
||||
<div className="sc">{props.children}</div>
|
||||
{props.footer && <div className="bot">{props.footer}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PromptDialog(props: {
|
||||
title: string; label: string; initial?: string; ok: string;
|
||||
onOk: (v: string) => Promise<void> | void; onClose: () => void;
|
||||
}) {
|
||||
const [v, setV] = useState(props.initial ?? "");
|
||||
const [err, setErr] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const ref = useRef<HTMLInputElement>(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 (
|
||||
<Sheet title={props.title} onClose={props.onClose} footer={<>
|
||||
<span className="sp" />
|
||||
<button className="btn ghost" onClick={props.onClose}>Отмена</button>
|
||||
<button className="btn gold" disabled={busy} onClick={submit}>{props.ok}</button>
|
||||
</>}>
|
||||
<div className="f">
|
||||
<label>{props.label}</label>
|
||||
<div className="in big">
|
||||
<input ref={ref} value={v} maxLength={200} onChange={(e) => setV(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") void submit(); }} />
|
||||
</div>
|
||||
{err && <div className="err">{err}</div>}
|
||||
</div>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConfirmDialog(props: {
|
||||
title: string; text: ReactNode; ok: string; danger?: boolean;
|
||||
onOk: () => Promise<void> | 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 (
|
||||
<Sheet title={props.title} onClose={props.onClose} footer={<>
|
||||
<span className="sp" />
|
||||
<button className="btn ghost" onClick={props.onClose}>Отмена</button>
|
||||
<button className={props.danger ? "btn dng" : "btn gold"} disabled={busy} onClick={go}>{props.ok}</button>
|
||||
</>}>
|
||||
<div className="block mt-10">{props.text}</div>
|
||||
{err && <div className="err">{err}</div>}
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
function pathTitle(n: Node, byId: Map<string, Node>) {
|
||||
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<void>; 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<string>([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 (
|
||||
<Sheet title={`Переместить «${props.node.title}»`} onClose={props.onClose}>
|
||||
<div className="f"><div className="in"><input placeholder="Найти папку…" value={q} onChange={(e) => setQ(e.target.value)} autoFocus /></div></div>
|
||||
{err && <div className="err">{err}</div>}
|
||||
<div className="mt-10">
|
||||
<button className="mrow" disabled={props.node.parentId === null} onClick={() => pick(null)}>
|
||||
<Icon name="home" /><div className="t">В корень{props.node.parentId === null && <small>уже здесь</small>}</div>
|
||||
</button>
|
||||
{shown.map((o) => (
|
||||
<button key={o.id} className="mrow" disabled={props.node.parentId === o.id} onClick={() => pick(o.id)}>
|
||||
<Icon name="folder" /><div className="t">{o.label}{props.node.parentId === o.id && <small>уже здесь</small>}</div>
|
||||
</button>
|
||||
))}
|
||||
{shown.length === 0 && <div className="empty">Папок не найдено</div>}
|
||||
</div>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
/** Выбор страницы для ссылки. */
|
||||
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 (
|
||||
<Sheet title="Ссылка на страницу" onClose={props.onClose}>
|
||||
<div className="f"><div className="in"><input placeholder="Название страницы…" value={q} onChange={(e) => setQ(e.target.value)} autoFocus /></div></div>
|
||||
<div className="mt-10">
|
||||
{list.map(({ n, label }) => (
|
||||
<button key={n.id} className="mrow" onClick={() => { props.onPick(n); props.onClose(); }}>
|
||||
<Icon name="page" /><div className="t">{n.title}<small>{label}</small></div>
|
||||
</button>
|
||||
))}
|
||||
{list.length === 0 && <div className="empty">Страниц не найдено</div>}
|
||||
</div>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
194
frontend/src/Editor.tsx
Normal file
194
frontend/src/Editor.tsx
Normal file
@@ -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/<id>, а показываем 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 | "link" | "page">(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 }) => (
|
||||
<button type="button" className={"wk-tb" + (p.on ? " on" : "")} title={p.title} aria-label={p.title} aria-pressed={p.on}
|
||||
disabled={p.disabled} onMouseDown={(e) => e.preventDefault()} onClick={p.run}>
|
||||
{p.icon ? <Icon name={p.icon} /> : <b>{p.text}</b>}
|
||||
</button>
|
||||
);
|
||||
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 (
|
||||
<div className="wk-editor">
|
||||
<div className="wk-toolbar" role="toolbar" aria-label="Форматирование">
|
||||
<B title="Заголовок 1" text="H1" on={s?.h1} run={() => c().toggleHeading({ level: 1 }).run()} />
|
||||
<B title="Заголовок 2" text="H2" on={s?.h2} run={() => c().toggleHeading({ level: 2 }).run()} />
|
||||
<B title="Заголовок 3" text="H3" on={s?.h3} run={() => c().toggleHeading({ level: 3 }).run()} />
|
||||
<span className="wk-sep" />
|
||||
<B title="Жирный (Ctrl+B)" icon="bold" on={s?.bold} run={() => c().toggleBold().run()} />
|
||||
<B title="Курсив (Ctrl+I)" icon="italic" on={s?.italic} run={() => c().toggleItalic().run()} />
|
||||
<B title="Зачёркнутый" icon="strike" on={s?.strike} run={() => c().toggleStrike().run()} />
|
||||
<B title="Код" icon="code" on={s?.code} run={() => c().toggleCode().run()} />
|
||||
<span className="wk-sep" />
|
||||
<B title="Маркированный список" icon="ul" on={s?.ul} run={() => c().toggleBulletList().run()} />
|
||||
<B title="Нумерованный список" icon="ol" on={s?.ol} run={() => c().toggleOrderedList().run()} />
|
||||
<B title="Список задач" icon="task" on={s?.task} run={() => c().toggleTaskList().run()} />
|
||||
<B title="Цитата" icon="quote" on={s?.quote} run={() => c().toggleBlockquote().run()} />
|
||||
<B title="Блок кода" text="{ }" on={s?.codeBlock} run={() => c().toggleCodeBlock().run()} />
|
||||
<B title="Разделитель" icon="hr" run={() => c().setHorizontalRule().run()} />
|
||||
<span className="wk-sep" />
|
||||
<B title={s?.link ? "Убрать ссылку" : "Ссылка"} icon="link" on={s?.link}
|
||||
run={() => (s?.link ? c().unsetLink().run() : setDialog("link"))} />
|
||||
<B title="Ссылка на страницу вики" icon="pagelink" run={() => setDialog("page")} />
|
||||
<B title="Картинка (можно вставить из буфера или перетащить)" icon="image" run={pickImage} />
|
||||
<B title="Таблица" icon="table" on={s?.table} run={() => c().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()} disabled={s?.table} />
|
||||
{s?.table && <>
|
||||
<B title="Добавить строку" icon="rowadd" run={() => c().addRowAfter().run()} />
|
||||
<B title="Добавить столбец" icon="coladd" run={() => c().addColumnAfter().run()} />
|
||||
<B title="Удалить строку" icon="rowdel" run={() => c().deleteRow().run()} />
|
||||
<B title="Удалить столбец" icon="coldel" run={() => c().deleteColumn().run()} />
|
||||
<B title="Удалить таблицу" icon="trash" run={() => c().deleteTable().run()} />
|
||||
</>}
|
||||
<span className="wk-sep" />
|
||||
<B title="Отменить (Ctrl+Z)" icon="undo" disabled={!s?.undo} run={() => c().undo().run()} />
|
||||
<B title="Повторить (Ctrl+Shift+Z)" icon="redo" disabled={!s?.redo} run={() => c().redo().run()} />
|
||||
</div>
|
||||
{upErr && <div className="err">{upErr}</div>}
|
||||
<EditorContent editor={editor} />
|
||||
|
||||
{dialog === "link" && (
|
||||
<PromptDialog title="Ссылка" label="Адрес (https://…)" initial="https://" ok="Вставить"
|
||||
onClose={() => 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" && (
|
||||
<PagePickDialog nodes={props.nodes} exclude={props.pageId} onClose={() => 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();
|
||||
}} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
frontend/src/Icon.tsx
Normal file
43
frontend/src/Icon.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
// Иконки по правилам tvorymo-ui.css: <svg class="ic" viewBox="0 0 24 24">, обводка currentColor.
|
||||
const P: Record<string, string> = {
|
||||
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 (
|
||||
<svg className="ic" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d={P[name] ?? ""} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
140
frontend/src/PageView.tsx
Normal file
140
frontend/src/PageView.tsx
Normal file
@@ -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<SaveState>("saved");
|
||||
const [err, setErr] = useState("");
|
||||
const [meta, setMeta] = useState({ updatedAt: props.page.updatedAt, by: props.page.updatedByName });
|
||||
const [conflict, setConflict] = useState<Page | null>(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<number | undefined>(undefined);
|
||||
const inflight = useRef<Promise<void> | null>(null);
|
||||
|
||||
const save = useCallback(async (): Promise<void> => {
|
||||
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: <span className="chip"><span className="syncdot" />сохранено</span>,
|
||||
dirty: <span className="chip"><span className="syncdot off" />есть изменения</span>,
|
||||
saving: <span className="chip"><span className="syncdot off" />сохраняю…</span>,
|
||||
error: <button className="chip dng" onClick={() => { dirty.current = true; void save(); }} title={err}>не сохранено · повторить</button>,
|
||||
conflict: <span className="chip dng">конфликт версий</span>,
|
||||
}[state];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="wk-titlebar">
|
||||
<input className="wk-title" value={title} maxLength={200} placeholder="Без названия" aria-label="Название страницы"
|
||||
onChange={(e) => { 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(); } }} />
|
||||
</div>
|
||||
<div className="row wrap small mute mt-6">
|
||||
{status}
|
||||
<span>изменено {fmt(meta.updatedAt)}{meta.by ? ` · ${meta.by}` : ""}</span>
|
||||
</div>
|
||||
{state === "error" && err && <div className="err">{err}</div>}
|
||||
{conflict && (
|
||||
<div className="blockbar">
|
||||
<div className="grow">
|
||||
<b>Страницу уже изменили</b>{conflict.updatedByName ? ` (${conflict.updatedByName}, ${fmt(conflict.updatedAt)})` : ""}.
|
||||
Ваши правки ещё не сохранены. Что оставить?
|
||||
<div className="row wrap mt-10">
|
||||
<button className="btn sm" onClick={takeTheirs}>Загрузить их версию</button>
|
||||
<button className="btn sm dng" onClick={keepMine}>Перезаписать своей</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<WikiEditor key={editorKey} markdown={markdown} pageId={props.page.id} nodes={props.nodes}
|
||||
onChange={(g) => { getMd.current = g; schedule(); }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
87
frontend/src/Tree.tsx
Normal file
87
frontend/src/Tree.tsx
Normal file
@@ -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<string | null, Node[]>();
|
||||
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<Set<string>>(() => {
|
||||
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 (
|
||||
<div key={n.id} role="treeitem" aria-expanded={n.kind === "folder" ? isOpen : undefined}>
|
||||
<div className={"wk-row" + (n.id === props.currentId ? " on" : "")} style={{ paddingLeft: `calc(${depth} * var(--sp-14) + var(--sp-4))` }}>
|
||||
{n.kind === "folder" ? (
|
||||
<button className={"wk-chev" + (isOpen ? " open" : "") + (hasKids ? "" : " op-40")} onClick={() => toggle(n.id)}
|
||||
title={isOpen ? "Свернуть" : "Развернуть"} aria-label={isOpen ? "Свернуть" : "Развернуть"}>
|
||||
<Icon name="chev" />
|
||||
</button>
|
||||
) : <span className="wk-chev" />}
|
||||
<a className="wk-link" href={hrefOf(n.kind, n.id)}
|
||||
onClick={(e) => {
|
||||
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));
|
||||
}}>
|
||||
<Icon name={n.kind === "folder" ? "folder" : "page"} />
|
||||
<span className="trunc">{n.title}</span>
|
||||
</a>
|
||||
{n.kind === "folder" && (
|
||||
<button className="wk-add" title="Новая страница в папке" aria-label="Новая страница в папке" onClick={() => props.onAddPage(n.id)}>
|
||||
<Icon name="plus" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{n.kind === "folder" && isOpen && <div role="group">{render(n.id, depth + 1)}</div>}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
if (props.nodes.length === 0) return <div className="empty">Пока пусто. Создайте первую страницу или папку.</div>;
|
||||
return <div role="tree" className="wk-tree">{render(null, 0)}</div>;
|
||||
}
|
||||
71
frontend/src/api.ts
Normal file
71
frontend/src/api.ts
Normal file
@@ -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<T = any>(path: string, init: RequestInit = {}, ws = currentWorkspace()): Promise<T> {
|
||||
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<Page>(`/api/nodes/${encodeURIComponent(id)}`),
|
||||
createPage: (title: string, parentId: string | null, markdown = "") =>
|
||||
api<Page>("/api/pages", json("POST", { title, parentId, markdown })),
|
||||
createFolder: (title: string, parentId: string | null) => api<Page>("/api/folders", json("POST", { title, parentId })),
|
||||
save: (id: string, title: string, markdown: string, version: number) =>
|
||||
api<Page>(`/api/pages/${encodeURIComponent(id)}`, json("PUT", { title, markdown, version })),
|
||||
patch: (id: string, body: { title?: string; parentId?: string | null }) =>
|
||||
api<Page>(`/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<string, Promise<string>>();
|
||||
export function imageUrl(src: string): Promise<string> {
|
||||
if (!src.startsWith("/api/images/")) return Promise.resolve(src);
|
||||
let p = imgCache.get(src);
|
||||
if (!p) {
|
||||
p = api<Blob>(src).then((b) => URL.createObjectURL(b));
|
||||
p.catch(() => imgCache.delete(src));
|
||||
imgCache.set(src, p);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
145
frontend/src/crm.ts
Normal file
145
frontend/src/crm.ts
Normal file
@@ -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:<uid> | <uuid>
|
||||
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<AuthState>) {
|
||||
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<string, unknown>) {
|
||||
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<void> {
|
||||
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<string> {
|
||||
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;
|
||||
}
|
||||
8
frontend/src/main.tsx
Normal file
8
frontend/src/main.tsx
Normal file
@@ -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(<StrictMode><App /></StrictMode>);
|
||||
36
frontend/src/router.ts
Normal file
36
frontend/src/router.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
// Адреса: / — корень workspace
|
||||
// /f/<id> — папка
|
||||
// /p/<id> — страница (так же выглядят ссылки между страницами в 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<void>) | null = null;
|
||||
/** Перед уходом со страницы (например, дождаться сохранения). */
|
||||
export function setLeaveGuard(g: (() => Promise<void>) | 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}`);
|
||||
104
frontend/src/wiki.css
Normal file
104
frontend/src/wiki.css
Normal file
@@ -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>) — без подчёркивания и золотого цвета ссылок */
|
||||
a.mrow,a.wk-row{color:inherit;text-decoration:none}
|
||||
a.mrow:hover .t{color:var(--gold-text)}
|
||||
26
frontend/tsconfig.app.json
Normal file
26
frontend/tsconfig.app.json
Normal file
@@ -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"]
|
||||
}
|
||||
7
frontend/tsconfig.json
Normal file
7
frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
23
frontend/tsconfig.node.json
Normal file
23
frontend/tsconfig.node.json
Normal file
@@ -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"]
|
||||
}
|
||||
7
frontend/vite.config.ts
Normal file
7
frontend/vite.config.ts
Normal file
@@ -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' } },
|
||||
})
|
||||
Reference in New Issue
Block a user