Несколько origin CRM (Crm__Origins), диагностика авторизации, интерфейс на украинском

This commit is contained in:
crm-wiki agent
2026-10-13 13:42:07 +00:00
parent 11c5c8ec7e
commit 426ed6f248
12 changed files with 279 additions and 166 deletions

View File

@@ -27,7 +27,7 @@
| Метод | Путь | Что делает |
|---|---|---|
| `GET` | `/health` | `{ status, dbCreatedAt }` — без авторизации; `dbCreatedAt` не меняется, пока жив том с базой |
| `GET` | `/api/config` | `{ crmOrigin }` — без авторизации |
| `GET` | `/api/config` | `{ crmOrigins: [...] }` — без авторизации |
| `POST` | `/api/verify` | `{ access_token }` → `{ ok, id, email, name, exp }` — без авторизации |
| `GET` | `/api/me` | текущий пользователь и workspace |
| `GET` | `/api/tree` | `{ workspace, nodes: [Node] }` — все папки и страницы workspace без текста |
@@ -61,7 +61,9 @@ curl -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
## Заголовки
`Content-Security-Policy: frame-ancestors https://test.units.tvorymo.com; …` (без `X-Frame-Options`). Origin CRM задаётся `Crm__Origin`, домен стилей CRM — `Crm__AssetsOrigin`.
`Content-Security-Policy: frame-ancestors https://test.units.tvorymo.com https://units.tvorymo.com; …` (без `X-Frame-Options`). Список origin CRM задаётся `Crm__Origins` (через пробел или запятую): из них принимается `crm:auth` (плюс `ev.source === window.parent`) и они же попадают в `frame-ancestors`. Origin родителя фронтенд определяет по `location.ancestorOrigins` или `document.referrer` и шлёт `crm:ready` только ему; если родитель не определён — каждому origin из списка (браузер доставит лишь совпавшему). Домен стилей CRM — `Crm__AssetsOrigin`.
На экране ожидания есть диагностика: origin родителя, куда ушёл `crm:ready`, какие сообщения пришли (без токенов); через 10 секунд без `crm:auth` — подсказка. Интерфейс на украинском.
## Разработка
@@ -70,4 +72,4 @@ cd backend/Wiki.Api && ASPNETCORE_URLS=http://127.0.0.1:5080 dotnet run # ба
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`.
Настройки (env): `Storage__DataDir`, `Crm__Origins`, `Crm__AssetsOrigin`, `Supabase__Url`, `Supabase__PublishableKey`.

View File

@@ -13,7 +13,10 @@ builder.Services.Configure<FormOptions>(o => o.MultipartBodyLengthLimit = Images
var app = builder.Build();
var cfg = app.Configuration;
var crmOrigin = (cfg["Crm:Origin"] ?? "https://test.units.tvorymo.com").TrimEnd('/');
// Origin CRM, из которых принимаем crm:auth и в которые разрешено встраивание (через пробел или запятую).
var crmOrigins = (cfg["Crm:Origins"] ?? cfg["Crm:Origin"] ?? "https://test.units.tvorymo.com https://units.tvorymo.com")
.Split([' ', ',', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(o => o.TrimEnd('/')).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
// Откуда грузятся стили CRM (ссылки в index.html).
var crmAssets = (cfg["Crm:AssetsOrigin"] ?? "https://test.units.tvorymo.com").TrimEnd('/');
app.Services.GetRequiredService<Db>().Init();
@@ -21,7 +24,7 @@ app.Services.GetRequiredService<Db>().Init();
// ---------------------------------------------------------------- заголовки
// Встраивание только в CRM; X-Frame-Options не ставим (иначе iframe пустой).
var csp = string.Join("; ",
$"frame-ancestors {crmOrigin}",
$"frame-ancestors {string.Join(' ', crmOrigins)}",
"default-src 'self'",
"script-src 'self'",
$"style-src 'self' 'unsafe-inline' {crmAssets} https://fonts.googleapis.com",
@@ -52,12 +55,12 @@ app.Use(async (ctx, next) =>
catch (JsonException) when (!ctx.Response.HasStarted)
{
ctx.Response.StatusCode = 400;
await ctx.Response.WriteAsJsonAsync(new { error = "некорректный JSON" });
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 = "некорректный запрос" });
await ctx.Response.WriteAsJsonAsync(new { error = "некоректний запит" });
}
});
@@ -72,22 +75,22 @@ app.Use(async (ctx, next) =>
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; }
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 недоступен",
_ => "токен не принят",
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; }
if (!await sa.CanAccessAsync(user, token, ws, ctx.RequestAborted)) { await Deny(ctx, 403, "немає доступу до воркспейсу"); return; }
ctx.Items["user"] = user;
ctx.Items["token"] = token;
ctx.Items["ws"] = ws;
@@ -114,7 +117,7 @@ app.UseStaticFiles(new StaticFileOptions
// ---------------------------------------------------------------- открытые
app.MapGet("/health", (Db db) => Results.Ok(new { status = "ok", dbCreatedAt = db.Meta("created_at") }));
app.MapGet("/api/config", () => Results.Ok(new { crmOrigin }));
app.MapGet("/api/config", () => Results.Ok(new { crmOrigins }));
app.MapPost("/api/verify", async (HttpContext ctx, SupabaseAuth sa) =>
{
@@ -125,11 +128,11 @@ app.MapPost("/api/verify", async (HttpContext ctx, SupabaseAuth sa) =>
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);
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 ? "токен истёк" : "токен не принят" },
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 });
});
@@ -142,15 +145,15 @@ api.MapGet("/me", (HttpContext c) => { var u = U(c); return Results.Ok(new { id
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 = "не найдено" }));
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 = "страница не найдена" }));
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 = "страница не найдена" }));
: Results.NotFound(new { error = "сторінку не знайдено" }));
api.MapPost("/folders", (HttpContext c, Store s, CreateReq r) =>
{
@@ -166,11 +169,11 @@ api.MapPost("/pages", (HttpContext c, Store s, CreateReq r) =>
api.MapPut("/pages/{id}", (HttpContext c, Store s, string id, SaveReq r) =>
{
if (r.Version is not { } v) return Results.BadRequest(new { error = "нужно поле version" });
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);
: Results.Json(new { error = "сторінку вже змінили, версія застаріла", current }, statusCode: 409);
});
// Переименовать и/или переместить: { title?, parentId? (null — в корень), version? }.
@@ -178,12 +181,12 @@ api.MapMethods("/nodes/{id}", ["PATCH"], async (HttpContext c, Store s, string i
{
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 = "ожидается объект" });
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" });
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)));
});
@@ -195,14 +198,14 @@ api.MapGet("/search", (HttpContext c, Store s, string? q, int? limit) =>
api.MapPost("/images", async (HttpContext c, Store s, Db db) =>
{
if (!c.Request.HasFormContentType) return Results.BadRequest(new { error = "нужен multipart/form-data с полем file" });
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);
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" });
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);
@@ -216,16 +219,16 @@ api.MapPost("/images", async (HttpContext c, Store s, Db db) =>
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 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 = "картинка не найдена" });
return Results.NotFound(new { error = "зображення не знайдено" });
var path = Path.Combine(db.ImagesDir, img.FileName);
if (!File.Exists(path)) return Results.NotFound(new { error = "файл картинки потерян" });
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.MapFallback("/api/{**rest}", () => Results.NotFound(new { error = "немає такого методу API" }));
app.MapFallbackToFile("index.html", new StaticFileOptions
{
OnPrepareResponse = s => s.Context.Response.Headers.CacheControl = "no-cache",

View File

@@ -124,8 +124,8 @@ public sealed partial class Store(Db db)
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 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();
@@ -150,15 +150,15 @@ public sealed partial class Store(Db db)
{
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 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, "нельзя перенести папку внутрь неё самой");
throw new WikiError(400, "не можна перенести папку всередину неї самої");
}
var renamed = newTitle != cur.Title;
using (var cmd = c.CreateCommand())
@@ -202,7 +202,7 @@ public sealed partial class Store(Db db)
cmd.Parameters.AddWithValue("$ws", ws);
count = Convert.ToInt32(cmd.ExecuteScalar());
}
if (count == 0) throw new WikiError(404, "не найдено");
if (count == 0) throw new WikiError(404, "не знайдено");
using (var cmd = c.CreateCommand())
{
cmd.CommandText = "DELETE FROM nodes WHERE id=$id AND workspace_id=$ws";
@@ -281,8 +281,8 @@ public sealed partial class Store(Db db)
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, "класть можно только в папку");
var kind = cmd.ExecuteScalar() as string ?? throw new WikiError(400, "папку призначення не знайдено");
if (kind != "folder") throw new WikiError(400, "класти можна лише в папку");
}
private static NodeDto ReadNode(SqliteDataReader r) => new(
@@ -292,15 +292,15 @@ public sealed partial class Store(Db db)
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} символов");
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, "текст страницы слишком большой");
if (md.Length > MaxContent) throw new WikiError(413, "текст сторінки завеликий");
return md;
}

View File

@@ -7,10 +7,15 @@
}
},
"AllowedHosts": "*",
"Storage": { "DataDir": "data" },
"Crm": { "Origin": "https://test.units.tvorymo.com" },
"Storage": {
"DataDir": "data"
},
"Crm": {
"Origins": "https://test.units.tvorymo.com https://units.tvorymo.com",
"AssetsOrigin": "https://test.units.tvorymo.com"
},
"Supabase": {
"Url": "https://yjxbkcqsquwbsyoonfkn.supabase.co",
"PublishableKey": "sb_publishable_TQaZAtnBpImL49A-o87bKg_q5sYvhPR"
}
}
}

View File

@@ -10,7 +10,8 @@ services:
ASPNETCORE_ENVIRONMENT: Production
ASPNETCORE_URLS: "http://+:8080"
Storage__DataDir: /data
Crm__Origin: "https://test.units.tvorymo.com"
# origin CRM через пробел: из них принимается crm:auth, они же в frame-ancestors
Crm__Origins: "https://test.units.tvorymo.com https://units.tvorymo.com"
Crm__AssetsOrigin: "https://test.units.tvorymo.com"
Supabase__Url: "https://yjxbkcqsquwbsyoonfkn.supabase.co"
# publishable-ключ публичный по дизайну

View File

@@ -1,9 +1,9 @@
<!doctype html>
<html lang="ru">
<html lang="uk">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Вики · CRM Tvorymo</title>
<title>Вікі · CRM Tvorymo</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<!-- Стили CRM ссылками: сначала дизайн-система, затем crm.css (порядок важен). -->

View File

@@ -1,6 +1,6 @@
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 { retryReady, useAuth } from "./crm";
import { ConfirmDialog, MoveDialog, PromptDialog } from "./Dialogs";
import { Icon } from "./Icon";
import { PageView } from "./PageView";
@@ -17,26 +17,66 @@ type Dlg =
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 || ""} />;
return <Waiting />;
}
/** Экран до входа: статус + диагностика postMessage (без токенов). */
function Waiting() {
const auth = useAuth();
const [now, setNow] = useState(Date.now());
useEffect(() => { const t = window.setInterval(() => setNow(Date.now()), 1000); return () => window.clearInterval(t); }, []);
const d = auth.diag;
const slow = auth.status === "waiting" && now - d.startedAt > 10_000;
const parentOk = d.parentOrigin != null && d.allowed.includes(d.parentOrigin);
const text = {
standalone: <>Эта страница работает внутри CRM Tvorymo. Откройте её из боковой панели CRM: она пришлёт авторизацию (<span className="code">crm:auth</span>).</>,
waiting: <>Жду авторизацию от CRM…</>,
checking: <>Проверяю пользователя CRM…</>,
error: <>Не удалось войти: {auth.error}. Обновите страницу CRM.</>,
logout: <>Вы вышли из CRM. Войдите снова, чтобы открыть вики.</>,
standalone: <>Ця сторінка працює всередині CRM Tvorymo. Відкрийте її з бічної панелі CRM: вона надішле авторизацію (<span className="code">crm:auth</span>).</>,
waiting: <>Чекаю на авторизацію від CRM…</>,
checking: <>Перевіряю користувача CRM…</>,
error: <>Не вдалося увійти: {auth.error}. Оновіть сторінку CRM.</>,
logout: <>Ви вийшли з CRM. Увійдіть знову, щоб відкрити вікі.</>,
ok: null,
}[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="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"}
{auth.status === "error" ? "помилка авторизації" : auth.status === "logout" ? "немає сесії" : auth.status === "checking" ? "перевірка" : "чекаю на авторизацію від CRM"}
</span>
<div className="small mt-6">{text}</div>
</div>
</div>
{slow && (
<div className="blockbar">
<Icon name="x" />
<div className="grow">
<b>CRM не надіслала авторизацію за 10 секунд.</b> Перевірте:
<div className="mt-6">1. У налаштуваннях бічної панелі CRM для цього посилання ввімкнено галочку «Передавати авторизацію».</div>
<div>2. CRM відкрита на одному з дозволених доменів: {d.allowed.length ? d.allowed.join(", ") : "—"}.
{d.parentOrigin && !parentOk && <> Зараз батьківська сторінка — <b>{d.parentOrigin}</b>, її немає в списку (змінна <span className="code">Crm__Origins</span> на сервері вікі).</>}</div>
<div>3. Адреса посилання в налаштуваннях CRM починається саме з <b>{location.origin}</b> (https, без www і редиректів): CRM відповідає лише сторінці з тим самим origin, що збережений у посиланні.</div>
{d.readySentTo.length > 0 && d.received.length === 0 && <div className="mt-6">CRM не відповіла жодним повідомленням. Так буває, коли галочку вимкнено або адреса посилання не збігається: тоді CRM мовчки ігнорує <span className="code">crm:ready</span>.</div>}
<div className="row wrap mt-10"><button className="btn sm" onClick={retryReady}>Надіслати crm:ready ще раз</button></div>
</div>
</div>
)}
{auth.status !== "standalone" && auth.status !== "ok" && (
<>
<div className="sec"><h2>Діагностика</h2></div>
<div className="mrow"><div className="t">Батьківська сторінка (origin)<small>{d.parentOrigin ?? "невідомо"} · джерело: {d.parentSource === "unknown" ? "не визначено" : d.parentSource}{d.parentOrigin ? (parentOk ? " · є в списку CRM" : " · НЕМАЄ в списку CRM") : ""}</small></div></div>
<div className="mrow"><div className="t">Дозволені origin CRM<small>{d.allowed.length ? d.allowed.join(", ") : "ще не завантажено"}</small></div></div>
<div className="mrow"><div className="t">crm:ready надіслано<small>{d.readySentTo.length ? d.readySentTo.join(", ") : "ні"}</small></div></div>
<div className="mrow"><div className="t">Отримані повідомлення від батьківської сторінки<small>{d.received.length === 0 ? "жодного" : ""}</small></div></div>
{d.received.map((m, i) => (
<div key={i} className="arcitem"><span className="s">{i + 1}</span><span className="t">{m.type} · {m.origin} · {m.accepted ? "прийнято" : `відхилено: ${m.why}`}</span></div>
))}
</>
)}
</div></main>
);
}
@@ -71,7 +111,7 @@ function Wiki(props: { ws: string; wsTitle?: string; userName: string }) {
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); } });
.catch((e) => { if (live) { setPage(null); setPageErr(e instanceof ApiError && e.status === 404 ? "Не знайдено: сторінку видалили або вона в іншому воркспейсі." : (e as Error).message); } });
return () => { live = false; };
}, [currentId, props.ws]);
@@ -103,15 +143,15 @@ function Wiki(props: { ws: string; wsTitle?: string; userName: string }) {
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 wsLabel = props.ws === "shared" ? "Загальний" : props.ws.startsWith("personal:") ? "Мій" : props.wsTitle || "Воркспейс";
const listing = (parentId: string | null) => {
const list = kids.get(parentId) ?? [];
if (!list.length) return <div className="empty">Здесь пока пусто.</div>;
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>
<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>
));
};
@@ -119,14 +159,14 @@ function Wiki(props: { ws: string; wsTitle?: string; userName: string }) {
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 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: "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>
<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>
);
@@ -134,25 +174,25 @@ function Wiki(props: { ws: string; wsTitle?: string; userName: string }) {
if (route.kind === "root") {
content = <>
<div className="head">
<div><h1>Вики</h1><div className="sub">Workspace: <b>{wsLabel}</b></div></div>
<div><h1>Вікі</h1><div className="sub">Воркспейс: <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 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>}
<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>;
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>;
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>
<div className="sec"><h2>Вміст <b>{kids.get(n.id)?.length ?? 0}</b></h2></div>
{listing(n.id)}
</>;
} else {
@@ -169,15 +209,15 @@ function Wiki(props: { ws: string; wsTitle?: string; userName: string }) {
return (
<main id="main" className="wide">
<div className={"wk" + (sideOpen ? " side-open" : "")}>
<aside className="wk-side" aria-label="Страницы вики">
<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>}
<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>}
<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"} />
@@ -193,25 +233,25 @@ function Wiki(props: { ws: string; wsTitle?: string; userName: string }) {
<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>
<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>
<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>}
: !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>
<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>
@@ -225,11 +265,11 @@ function Wiki(props: { ws: string; wsTitle?: string; userName: string }) {
</div>
{dlg?.kind === "new" && (
<PromptDialog title={dlg.what === "page" ? "Новая страница" : "Новая папка"} label="Название" ok="Создать"
<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="Сохранить"
<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); }} />
)}
@@ -242,10 +282,10 @@ function Wiki(props: { ws: string; wsTitle?: string; userName: string }) {
}} />
)}
{dlg?.kind === "delete" && (
<ConfirmDialog title="Удалить" danger ok="Удалить"
<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>? Отменить нельзя.</>}
? <>Видалити папку <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);
@@ -268,9 +308,9 @@ function isInside(id: string, ancestor: string, byId: Map<string, Node>) {
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 "элементов";
if (m10 === 1 && m100 !== 11) return "елемент";
if (m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14)) return "елементи";
return "елементів";
}
/** Сниппет поиска: \u0002…\u0003 — совпадение. */

View File

@@ -17,7 +17,7 @@ export function Sheet(props: { title: string; onClose: () => void; children: Rea
<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>
<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>}
@@ -36,7 +36,7 @@ export function PromptDialog(props: {
const ref = useRef<HTMLInputElement>(null);
useEffect(() => { ref.current?.focus(); ref.current?.select(); }, []);
const submit = async () => {
if (!v.trim()) { setErr("Введите название"); return; }
if (!v.trim()) { setErr("Введіть назву"); return; }
setBusy(true);
try { await props.onOk(v.trim()); props.onClose(); }
catch (e) { setErr((e as Error).message); setBusy(false); }
@@ -44,7 +44,7 @@ export function PromptDialog(props: {
return (
<Sheet title={props.title} onClose={props.onClose} footer={<>
<span className="sp" />
<button className="btn ghost" onClick={props.onClose}>Отмена</button>
<button className="btn ghost" onClick={props.onClose}>Скасувати</button>
<button className="btn gold" disabled={busy} onClick={submit}>{props.ok}</button>
</>}>
<div className="f">
@@ -73,7 +73,7 @@ export function ConfirmDialog(props: {
return (
<Sheet title={props.title} onClose={props.onClose} footer={<>
<span className="sp" />
<button className="btn ghost" onClick={props.onClose}>Отмена</button>
<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>
@@ -115,19 +115,19 @@ export function MoveDialog(props: {
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>
<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>
<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>
<Icon name="folder" /><div className="t">{o.label}{props.node.parentId === o.id && <small>вже тут</small>}</div>
</button>
))}
{shown.length === 0 && <div className="empty">Папок не найдено</div>}
{shown.length === 0 && <div className="empty">Папок не знайдено</div>}
</div>
</Sheet>
);
@@ -143,15 +143,15 @@ export function PagePickDialog(props: { nodes: Node[]; exclude?: string; onPick:
.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>
<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>}
{list.length === 0 && <div className="empty">Сторінок не знайдено</div>}
</div>
</Sheet>
);

View File

@@ -23,7 +23,7 @@ const AuthImage = Image.extend({
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 = "картинка недоступна"; });
.catch(() => { img.alt = "зображення недоступне"; });
};
load(node.attrs.src, node.attrs.alt ?? "");
return {
@@ -56,7 +56,7 @@ export function WikiEditor(props: {
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}`);
setUpErr(`Не вдалося завантажити «${f.name}»: ${(e as Error).message}`);
}
}
};
@@ -71,7 +71,7 @@ export function WikiEditor(props: {
TableKit.configure({ table: { resizable: false } }),
TaskList,
TaskItem.configure({ nested: true }),
Placeholder.configure({ placeholder: "Начните писать… Markdown работает: # заголовок, - список, **жирный**" }),
Placeholder.configure({ placeholder: "Почніть писати… Markdown працює: # заголовок, - список, **жирний**" }),
Markdown.configure({ markedOptions: { gfm: true } }),
],
content: props.markdown,
@@ -135,44 +135,44 @@ export function WikiEditor(props: {
return (
<div className="wk-editor">
<div className="wk-toolbar" role="toolbar" aria-label="Форматирование">
<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+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="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="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()} />
<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}
<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} />
<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()} />
<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()} />
<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="Вставить"
<PromptDialog title="Посилання" label="Адреса (https://…)" initial="https://" ok="Вставити"
onClose={() => setDialog(null)}
onOk={(url) => {
let href = url.trim();

View File

@@ -32,7 +32,7 @@ export function PageView(props: { page: Page; nodes: Node[]; onSaved: (p: Page,
window.clearTimeout(timer.current);
if (inflight.current) { await inflight.current; }
if (!dirty.current) return;
const t = titleRef.current.trim() || "Без названия";
const t = titleRef.current.trim() || "Без назви";
dirty.current = false;
setState("saving");
const run = (async () => {
@@ -102,33 +102,33 @@ export function PageView(props: { page: Page; nodes: Node[]; onSaved: (p: Page,
};
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>,
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="Название страницы"
<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>
<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)})` : ""}.
Ваши правки ещё не сохранены. Что оставить?
<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>
<button className="btn sm" onClick={takeTheirs}>Завантажити їхню версію</button>
<button className="btn sm dng" onClick={keepMine}>Перезаписати своєю</button>
</div>
</div>
</div>

View File

@@ -57,7 +57,7 @@ export function Tree(props: { nodes: Node[]; ws: string; currentId: string | nul
<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 ? "Свернуть" : "Развернуть"}>
title={isOpen ? "Згорнути" : "Розгорнути"} aria-label={isOpen ? "Згорнути" : "Розгорнути"}>
<Icon name="chev" />
</button>
) : <span className="wk-chev" />}
@@ -72,7 +72,7 @@ export function Tree(props: { nodes: Node[]; ws: string; currentId: string | nul
<span className="trunc">{n.title}</span>
</a>
{n.kind === "folder" && (
<button className="wk-add" title="Новая страница в папке" aria-label="Новая страница в папке" onClick={() => props.onAddPage(n.id)}>
<button className="wk-add" title="Нова сторінка в папці" aria-label="Нова сторінка в папці" onClick={() => props.onAddPage(n.id)}>
<Icon name="plus" />
</button>
)}
@@ -82,6 +82,6 @@ export function Tree(props: { nodes: Node[]; ws: string; currentId: string | nul
);
});
if (props.nodes.length === 0) return <div className="empty">Пока пусто. Создайте первую страницу или папку.</div>;
if (props.nodes.length === 0) return <div className="empty">Поки порожньо. Створіть першу сторінку або папку.</div>;
return <div role="tree" className="wk-tree">{render(null, 0)}</div>;
}

View File

@@ -5,18 +5,33 @@ 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 Diag = {
allowed: string[]; // origin CRM из /api/config
parentOrigin: string | null; // origin родителя: ancestorOrigins или referrer
parentSource: "ancestorOrigins" | "referrer" | "unknown";
readySentTo: string[]; // куда отправлен crm:ready
received: { origin: string; type: string; accepted: boolean; why?: string }[];
startedAt: number;
};
export type AuthState = {
status: "standalone" | "waiting" | "checking" | "ok" | "error" | "logout";
error?: string;
user?: User;
workspace: string; // shared | personal:<uid> | <uuid>
workspaceTitle?: string;
diag: Diag;
};
let crmOrigin = "";
let allowed: string[] = [];
let crmOrigin = ""; // origin, от которого пришёл принятый crm:auth (туда же шлём crm:refresh)
let token: string | null = null;
let expiresAt: number | null = null; // мс
let state: AuthState = { status: "waiting", workspace: "shared" };
let state: AuthState = {
status: "waiting", workspace: "shared",
diag: { allowed: [], parentOrigin: null, parentSource: "unknown", readySentTo: [], received: [], startedAt: Date.now() },
};
const subs = new Set<() => void>();
let waiters: Array<() => void> = [];
let lastWs: CrmWorkspace | null = null;
@@ -25,6 +40,9 @@ function set(p: Partial<AuthState>) {
state = { ...state, ...p };
subs.forEach((f) => f());
}
function setDiag(p: Partial<Diag>) {
set({ diag: { ...state.diag, ...p } });
}
export function useAuth() {
return useSyncExternalStore(
@@ -81,7 +99,7 @@ async function onAuth(d: Record<string, unknown>) {
set({ status: "ok", user, error: undefined, workspace: ws.id, workspaceTitle: ws.title });
flushWaiters();
} catch {
set({ status: "error", error: "не удалось проверить токен" });
set({ status: "error", error: "не вдалося перевірити токен" });
}
}
@@ -91,38 +109,82 @@ function flushWaiters() {
w.forEach((f) => f());
}
/** Origin родителя: location.ancestorOrigins (Chrome/Safari) или document.referrer (iframe с referrerpolicy=strict-origin). */
function detectParent(): { origin: string | null; source: Diag["parentSource"] } {
try {
const ao = (location as Location & { ancestorOrigins?: DOMStringList }).ancestorOrigins;
if (ao && ao.length > 0 && ao[0] && ao[0] !== "null") return { origin: ao[0], source: "ancestorOrigins" };
} catch { /* недоступно */ }
try {
if (document.referrer) return { origin: new URL(document.referrer).origin, source: "referrer" };
} catch { /* битый referrer */ }
return { origin: null, source: "unknown" };
}
function sendReady() {
const { origin } = detectParent();
// Известен родитель из списка — шлём только ему. Иначе каждому разрешённому origin:
// браузер доставит сообщение только тому, чей origin совпадёт с родителем.
const targets = origin && allowed.includes(origin) ? [origin] : allowed;
for (const o of targets) window.parent.postMessage({ type: "crm:ready", version: 1 }, o);
setDiag({ readySentTo: targets });
}
export async function initCrm() {
if (window.parent === window) { set({ status: "standalone" }); return; }
const parent = detectParent();
setDiag({ parentOrigin: parent.origin, parentSource: parent.source, startedAt: Date.now() });
// Слушаем сразу; сообщения, пришедшие до загрузки списка origin, обработаем после неё.
let pending: MessageEvent[] | null = [];
window.addEventListener("message", (ev) => {
if (ev.source !== window.parent) return; // сообщения не от родителя (сами себе, расширения) не считаем
if (pending) pending.push(ev); else handle(ev);
});
try {
const cfg = await (await fetch("/api/config")).json();
crmOrigin = cfg.crmOrigin;
allowed = Array.isArray(cfg.crmOrigins) ? cfg.crmOrigins : [];
} catch {
set({ status: "error", error: "нет связи с сервером вики" });
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);
setDiag({ allowed });
const early = pending ?? [];
pending = null;
early.forEach(handle);
sendReady();
}
function handle(ev: MessageEvent) {
const d = ev.data;
const type = d && typeof d === "object" && typeof d.type === "string" ? d.type : typeof d;
const ok = allowed.includes(ev.origin);
const rec = state.diag.received;
if (rec.length < 20) setDiag({ received: [...rec, { origin: ev.origin, type, accepted: ok, why: ok ? undefined : "origin не в списку CRM" }] });
if (!ok || !d || typeof d !== "object") return;
if (d.type === "crm:auth") { crmOrigin = ev.origin; 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 });
}
}
/** Повторно отправить crm:ready (кнопка на экране ожидания). */
export function retryReady() {
if (window.parent !== window && allowed.length) sendReady();
}
function requestRefresh(): Promise<void> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
waiters = waiters.filter((w) => w !== done);
reject(new Error("CRM не прислала новый токен"));
reject(new Error("CRM не надіслала новий токен"));
}, 15000);
const done = () => { clearTimeout(timer); resolve(); };
waiters.push(done);
@@ -136,7 +198,7 @@ export async function getToken(force = false): Promise<string> {
if (stale) {
try { await requestRefresh(); } catch (e) { if (!token) throw e; }
}
if (!token) throw new Error("нет авторизации CRM");
if (!token) throw new Error("немає авторизації CRM");
return token;
}