Несколько origin CRM (Crm__Origins), диагностика авторизации, интерфейс на украинском
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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-ключ публичный по дизайну
|
||||
|
||||
@@ -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 (порядок важен). -->
|
||||
|
||||
@@ -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 — совпадение. */
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user