Автор и последний редактор: профили из довідника CRM, аватары как в CRM
This commit is contained in:
12
README.md
12
README.md
@@ -46,9 +46,19 @@
|
||||
```ts
|
||||
type Node = { id, parentId: string|null, kind: "folder"|"page", title, version: number,
|
||||
createdAt, updatedAt, updatedBy /* uid */, updatedByName };
|
||||
type Page = Node & { markdown: string, path: { id, kind, title }[] /* папки от корня */ };
|
||||
type Page = Node & { markdown: string, path: { id, kind, title }[] /* папки от корня */,
|
||||
author: Person | null /* кто создал (createdAt) */,
|
||||
lastEditor: Person | null /* кто последний правил (updatedAt) */ };
|
||||
type Person = { id /* uid Supabase */, name: string|null, email: string|null,
|
||||
avatarUrl: string|null /* публичный https-URL или data:image */, color: string|null /* кружок с инициалами */ };
|
||||
```
|
||||
|
||||
### Автор
|
||||
|
||||
У каждой страницы и папки хранятся автор (`created_by` = uid из проверенного токена, `createdAt`) и последний редактор (`updated_by`, `updatedAt`); перенос и переименование тоже считаются правкой. Агенты (бот «Лана» и т. п.) — такие же авторы, как люди. Поля `author` и `lastEditor` есть в ответах `GET /api/pages/{id}`, `GET /api/nodes/{id}` и в ответах на создание и сохранение; в `/api/tree` их нет. Если автор неизвестен, поле `null`; если uid известен, а профиля нет, `name`/`avatarUrl` будут `null`.
|
||||
|
||||
Профиль берётся там же, где его берёт CRM: довідник виконавців в `app_config` (`id = 'main'`, `data.assignees[]`: `{ id, name, email, uid, avatar, color }`), запрос с токеном пользователя (RLS), сопоставление по `uid`, затем по email. Обновляется не чаще раза в 10 минут на пользователя и хранится в таблице `users` вики. Аватар — URL из `avatar`: CRM кладёт фото в публичный бакет Storage `logos` (`avatars/<id записи>`) и сохраняет `getPublicUrl`, поэтому картинка грузится без токена. Принимаются только `https://` и `data:image/`. Нет фото — кружок с инициалами на цвете `color` (свой цвет записи или `colorOf(id)`, как в CRM). Нет записи в довіднику (например, бот) — имя из токена (`user_metadata.name` → email), инициалы.
|
||||
|
||||
Формат текста — Markdown (GFM: таблицы, списки задач). Ссылка на другую страницу: `[текст](/p/<id>)`, картинка: ``. Ссылки по id не ломаются при переименовании и переносе.
|
||||
|
||||
Пример (агент или скрипт):
|
||||
|
||||
129
backend/Wiki.Api.Tests/ProfilesTests.cs
Normal file
129
backend/Wiki.Api.Tests/ProfilesTests.cs
Normal file
@@ -0,0 +1,129 @@
|
||||
using System.Net;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Wiki.Api;
|
||||
|
||||
namespace Wiki.Api.Tests;
|
||||
|
||||
/// <summary>Профили авторов: довідник CRM (app_config.data.assignees) → имя, аватар, цвет.</summary>
|
||||
public class ProfilesTests : IDisposable
|
||||
{
|
||||
private readonly string _dir = Path.Combine(Path.GetTempPath(), "wiki-tests-" + Guid.NewGuid().ToString("N"));
|
||||
|
||||
private const string Config = """
|
||||
[{"data":{"assignees":[
|
||||
{"id":"a2","name":"Лана","email":"LANA@test","uid":"u-lana","avatar":"https://x.supabase.co/storage/v1/object/public/logos/avatars/a2.webp?v=1"},
|
||||
{"id":"a3","name":"Олег","email":"oleg@test","uid":"u-oleg","color":"#4ADE80","avatar":"javascript:alert(1)"},
|
||||
{"id":"a4","name":"Без входу","email":"none@test"}
|
||||
]}}]
|
||||
""";
|
||||
|
||||
private sealed class Fake(string body) : HttpMessageHandler
|
||||
{
|
||||
public int Calls;
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage r, CancellationToken ct)
|
||||
{
|
||||
Calls++;
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(body) });
|
||||
}
|
||||
}
|
||||
private sealed class Factory(HttpMessageHandler h) : IHttpClientFactory
|
||||
{
|
||||
public HttpClient CreateClient(string name) => new(h, disposeHandler: false);
|
||||
}
|
||||
|
||||
private (Profiles P, Db Db, Fake Fake) Make(string body = Config)
|
||||
{
|
||||
var cfg = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Storage:DataDir"] = _dir, ["Supabase:Url"] = "https://supabase.test", ["Supabase:PublishableKey"] = "k",
|
||||
}).Build();
|
||||
var db = new Db(cfg); db.Init();
|
||||
var fake = new Fake(body);
|
||||
return (new Profiles(db, new Factory(fake), cfg), db, fake);
|
||||
}
|
||||
|
||||
private static WikiUser U(string id, string? email, string? name = null) => new(id, email, name, 0);
|
||||
|
||||
private static Person Load(Db db, string uid) { using var c = db.Open(); return Profiles.Load(c, uid)!; }
|
||||
|
||||
[Theory]
|
||||
[InlineData("a2", "#5EEAD4")]
|
||||
[InlineData("mv5abc123", "#C4B5FD")]
|
||||
[InlineData("Лана", "#A78BFA")]
|
||||
[InlineData("x😀y", "#FFCA40")]
|
||||
public void ColorOf_MatchesCrm(string key, string expected) => Assert.Equal(expected, Profiles.ColorOf(key));
|
||||
|
||||
[Fact]
|
||||
public async Task MatchByUid_TakesCrmNameAvatarAndColor()
|
||||
{
|
||||
var (p, db, _) = Make();
|
||||
await p.EnsureAsync(U("u-lana", "lana@test", "токен-ім'я"), "t", default);
|
||||
var me = Load(db, "u-lana");
|
||||
Assert.Equal("Лана", me.Name);
|
||||
Assert.StartsWith("https://x.supabase.co/storage/v1/object/public/logos/avatars/", me.AvatarUrl);
|
||||
Assert.Equal("#5EEAD4", me.Color); // своего цвета нет — colorOf(id записи), как в CRM
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MatchByEmail_CaseInsensitive()
|
||||
{
|
||||
var (p, db, _) = Make();
|
||||
await p.EnsureAsync(U("other-uid", "lana@TEST"), "t", default);
|
||||
Assert.Equal("Лана", Load(db, "other-uid").Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnsafeAvatar_Dropped_OwnColorKept()
|
||||
{
|
||||
var (p, db, _) = Make();
|
||||
await p.EnsureAsync(U("u-oleg", "oleg@test"), "t", default);
|
||||
var o = Load(db, "u-oleg");
|
||||
Assert.Null(o.AvatarUrl);
|
||||
Assert.Equal("#4ADE80", o.Color);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bot_NotInDirectory_NameFromToken()
|
||||
{
|
||||
var (p, db, _) = Make();
|
||||
await p.EnsureAsync(U("u-bot", "bot@test", "Лана (бот)"), "t", default);
|
||||
var b = Load(db, "u-bot");
|
||||
Assert.Equal("Лана (бот)", b.Name);
|
||||
Assert.Null(b.AvatarUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DirectoryUnavailable_FallsBackToToken_AndKeepsAvatar()
|
||||
{
|
||||
var (p, db, _) = Make();
|
||||
await p.EnsureAsync(U("u-lana", "lana@test"), "t", default);
|
||||
var (p2, _, _) = (new Profiles(db, new Factory(new Fake("not json")),
|
||||
new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?> { ["Supabase:Url"] = "https://s" }).Build()), db, 0);
|
||||
await p2.EnsureAsync(U("u-lana", "lana@test", "Інше"), "t", default);
|
||||
Assert.NotNull(Load(db, "u-lana").AvatarUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CachedForTenMinutes()
|
||||
{
|
||||
var (p, _, fake) = Make();
|
||||
await p.EnsureAsync(U("u-lana", "lana@test"), "t", default);
|
||||
await p.EnsureAsync(U("u-lana", "lana@test"), "t", default);
|
||||
Assert.Equal(1, fake.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoAuthor_IsNull()
|
||||
{
|
||||
var (_, db, _) = Make();
|
||||
using var c = db.Open();
|
||||
Assert.Null(Profiles.Load(c, null));
|
||||
Assert.Null(Profiles.Load(c, ""));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
try { Directory.Delete(_dir, true); } catch { /* временная папка */ }
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,21 @@ public sealed class Db
|
||||
created_by TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Профили людей и агентов (автор, последний редактор): имя, почта, аватар и цвет кружка из CRM.
|
||||
CREATE TABLE IF NOT EXISTS users(
|
||||
uid TEXT PRIMARY KEY,
|
||||
name TEXT NULL,
|
||||
email TEXT NULL,
|
||||
avatar_url TEXT NULL,
|
||||
color TEXT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
-- Для уже существующих записей — хотя бы имя, сохранённое при правке.
|
||||
INSERT OR IGNORE INTO users(uid, name, updated_at)
|
||||
SELECT updated_by, updated_by_name, max(updated_at) FROM nodes
|
||||
WHERE updated_by IS NOT NULL AND updated_by <> '' AND updated_by_name IS NOT NULL
|
||||
GROUP BY updated_by;
|
||||
|
||||
-- Полнотекстовый поиск по названию и тексту (external content на nodes).
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS nodes_fts USING fts5(
|
||||
title, content,
|
||||
|
||||
144
backend/Wiki.Api/Profiles.cs
Normal file
144
backend/Wiki.Api/Profiles.cs
Normal file
@@ -0,0 +1,144 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace Wiki.Api;
|
||||
|
||||
/// <summary>Человек или агент в ответах API: автор и последний редактор.</summary>
|
||||
public sealed record Person(string Id, string? Name, string? Email, string? AvatarUrl, string? Color);
|
||||
|
||||
/// <summary>
|
||||
/// Профили берём там же, где CRM: довідник виконавців — JSON в app_config (id = 'main'), поле data.assignees[]
|
||||
/// ({ id, name, email, uid, avatar, color }). avatar — публичный URL бакета Storage `logos` (avatars/<id>).
|
||||
/// Запрос идёт с токеном пользователя (RLS). Обновляем не чаще раза в 10 минут на пользователя.
|
||||
/// </summary>
|
||||
public sealed class Profiles(Db db, IHttpClientFactory http, IConfiguration cfg)
|
||||
{
|
||||
private readonly string _url = (cfg["Supabase:Url"] ?? "").TrimEnd('/');
|
||||
private readonly string _key = cfg["Supabase:PublishableKey"] ?? "";
|
||||
private readonly ConcurrentDictionary<string, DateTimeOffset> _fresh = new();
|
||||
private static readonly TimeSpan RefreshEvery = TimeSpan.FromMinutes(10);
|
||||
|
||||
private sealed record Assignee(string? Id, string? Uid, string? Name, string? Email, string? Avatar, string? Color)
|
||||
{
|
||||
/// <summary>Цвет кружка как в CRM: свой или подобранный по id записи (avatarHtml → colorOf).</summary>
|
||||
public string? Circle => SafeColor(Color) ?? (string.IsNullOrEmpty(Id) ? null : ColorOf(Id));
|
||||
}
|
||||
|
||||
/// <summary>Палитра и алгоритм — копия colorOf из CRM (js/util.js), чтобы цвета совпадали.</summary>
|
||||
private static readonly string[] AvatarColors =
|
||||
["#FFCA40", "#7DD3FC", "#A78BFA", "#4ADE80", "#FB923C", "#F472B6", "#F97066", "#5EEAD4", "#C4B5FD", "#E5E5E5"];
|
||||
public static string ColorOf(string s)
|
||||
{
|
||||
long h = 0;
|
||||
for (var i = 0; i < s.Length; i += char.IsSurrogatePair(s, i) ? 2 : 1)
|
||||
h = (h * 31 + char.ConvertToUtf32(s, i)) % 9973;
|
||||
return AvatarColors[h % AvatarColors.Length];
|
||||
}
|
||||
|
||||
public async Task EnsureAsync(WikiUser u, string token, CancellationToken ct)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
if (_fresh.TryGetValue(u.Id, out var until) && until > now) return;
|
||||
_fresh[u.Id] = now + RefreshEvery;
|
||||
|
||||
var team = await FetchTeamAsync(token, ct);
|
||||
var email = u.Email?.Trim().ToLowerInvariant();
|
||||
var mine = team?.FirstOrDefault(a => a.Uid == u.Id)
|
||||
?? team?.FirstOrDefault(a => email is not null && a.Email?.Trim().ToLowerInvariant() == email);
|
||||
var name = Clean(mine?.Name) ?? Clean(u.Name) ?? Clean(u.Email?.Split('@')[0]);
|
||||
|
||||
using var c = db.Open();
|
||||
using var tx = c.BeginTransaction();
|
||||
// Свой профиль: из CRM, если нашёлся; иначе из токена (бот без записи в довіднику — тоже автор).
|
||||
Upsert(c, u.Id, name, u.Email ?? mine?.Email, mine is null ? null : Avatar(mine.Avatar), mine?.Circle, keepVisual: team is null);
|
||||
// Заодно обновляем остальных из довідника (кто уже входил в CRM — у них есть uid).
|
||||
if (team is not null)
|
||||
foreach (var a in team.Where(a => !string.IsNullOrEmpty(a.Uid) && a.Uid != u.Id))
|
||||
Upsert(c, a.Uid!, Clean(a.Name), a.Email, Avatar(a.Avatar), a.Circle, keepVisual: false, onlyExisting: true);
|
||||
tx.Commit();
|
||||
}
|
||||
|
||||
private async Task<List<Assignee>?> FetchTeamAsync(string token, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var req = new HttpRequestMessage(HttpMethod.Get, $"{_url}/rest/v1/app_config?id=eq.main&select=data");
|
||||
req.Headers.Add("apikey", _key);
|
||||
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
using var r = await http.CreateClient("supabase").SendAsync(req, ct);
|
||||
if (!r.IsSuccessStatusCode) return null;
|
||||
using var doc = JsonDocument.Parse(await r.Content.ReadAsStringAsync(ct));
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Array || doc.RootElement.GetArrayLength() == 0) return null;
|
||||
var row = doc.RootElement[0];
|
||||
if (!row.TryGetProperty("data", out var data) || data.ValueKind != JsonValueKind.Object) return null;
|
||||
if (!data.TryGetProperty("assignees", out var list) || list.ValueKind != JsonValueKind.Array) return null;
|
||||
return list.EnumerateArray().Where(a => a.ValueKind == JsonValueKind.Object)
|
||||
.Select(a => new Assignee(Str(a, "id"), Str(a, "uid"), Str(a, "name"), Str(a, "email"), Str(a, "avatar"), Str(a, "color")))
|
||||
.ToList();
|
||||
}
|
||||
catch (Exception e) when (e is HttpRequestException or TaskCanceledException or JsonException) { return null; }
|
||||
}
|
||||
|
||||
/// <param name="keepVisual">не трогать аватар и цвет (довідник не прочитали)</param>
|
||||
/// <param name="onlyExisting">только обновить уже известных вики (не заводить всех из довідника)</param>
|
||||
private static void Upsert(SqliteConnection c, string uid, string? name, string? email, string? avatar, string? color,
|
||||
bool keepVisual, bool onlyExisting = false)
|
||||
{
|
||||
using var cmd = c.CreateCommand();
|
||||
cmd.CommandText = onlyExisting
|
||||
? """
|
||||
UPDATE users SET name = coalesce($n, name), email = coalesce($e, email), avatar_url = $a, color = $c, updated_at = $now
|
||||
WHERE uid = $u
|
||||
"""
|
||||
: keepVisual
|
||||
? """
|
||||
INSERT INTO users(uid, name, email, updated_at) VALUES ($u, $n, $e, $now)
|
||||
ON CONFLICT(uid) DO UPDATE SET name = coalesce(excluded.name, name), email = coalesce(excluded.email, email),
|
||||
updated_at = excluded.updated_at
|
||||
"""
|
||||
: """
|
||||
INSERT INTO users(uid, name, email, avatar_url, color, updated_at) VALUES ($u, $n, $e, $a, $c, $now)
|
||||
ON CONFLICT(uid) DO UPDATE SET name = coalesce(excluded.name, name), email = coalesce(excluded.email, email),
|
||||
avatar_url = excluded.avatar_url, color = excluded.color, updated_at = excluded.updated_at
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("$u", uid);
|
||||
cmd.Parameters.AddWithValue("$n", (object?)name ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("$e", (object?)email ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("$a", (object?)avatar ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("$c", (object?)SafeColor(color) ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("$now", DateTime.UtcNow.ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'"));
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
public static Person? Load(SqliteConnection c, string? uid)
|
||||
{
|
||||
if (string.IsNullOrEmpty(uid)) return null; // автора нет — поле пустое
|
||||
using var cmd = c.CreateCommand();
|
||||
cmd.CommandText = "SELECT name, email, avatar_url, color FROM users WHERE uid = $u";
|
||||
cmd.Parameters.AddWithValue("$u", uid);
|
||||
using var r = cmd.ExecuteReader();
|
||||
if (!r.Read()) return new Person(uid, null, null, null, null);
|
||||
string? S(int i) => r.IsDBNull(i) ? null : r.GetString(i);
|
||||
return new Person(uid, S(0), S(1), S(2), S(3));
|
||||
}
|
||||
|
||||
/// <summary>Только https-URL или небольшая data:image (CRM без облака) — то, что можно отдать в <img src>.</summary>
|
||||
private static string? Avatar(string? v)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(v)) return null;
|
||||
v = v.Trim();
|
||||
if (v.StartsWith("https://", StringComparison.OrdinalIgnoreCase) && v.Length <= 2048) return v;
|
||||
if (v.StartsWith("data:image/", StringComparison.OrdinalIgnoreCase) && v.Length <= 400_000) return v;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? SafeColor(string? v) =>
|
||||
v is not null && System.Text.RegularExpressions.Regex.IsMatch(v, "^#[0-9A-Fa-f]{3,8}$") ? v : null;
|
||||
|
||||
private static string? Clean(string? s) => string.IsNullOrWhiteSpace(s) ? null : s.Trim();
|
||||
|
||||
private static string? Str(JsonElement e, string p) =>
|
||||
e.TryGetProperty(p, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddSingleton<Db>();
|
||||
builder.Services.AddSingleton<Store>();
|
||||
builder.Services.AddSingleton<SupabaseAuth>();
|
||||
builder.Services.AddSingleton<Profiles>();
|
||||
builder.Services.AddHttpClient("supabase", c => c.Timeout = TimeSpan.FromSeconds(10));
|
||||
builder.Services.Configure<FormOptions>(o => o.MultipartBodyLengthLimit = Images.MaxBytes + 64 * 1024);
|
||||
|
||||
@@ -91,6 +92,9 @@ app.Use(async (ctx, next) =>
|
||||
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, "немає доступу до воркспейсу"); return; }
|
||||
// Профиль (имя, аватар из довідника CRM) — для поля «автор»; сбой не мешает запросу.
|
||||
try { await ctx.RequestServices.GetRequiredService<Profiles>().EnsureAsync(user, token, ctx.RequestAborted); }
|
||||
catch (Exception e) when (e is not OperationCanceledException) { /* профиль обновится в следующий раз */ }
|
||||
ctx.Items["user"] = user;
|
||||
ctx.Items["token"] = token;
|
||||
ctx.Items["ws"] = ws;
|
||||
|
||||
@@ -13,7 +13,8 @@ public sealed record Crumb(string Id, string Kind, string Title);
|
||||
public sealed record PageDto(
|
||||
string Id, string? ParentId, string Kind, string Title, int Version,
|
||||
string CreatedAt, string UpdatedAt, string UpdatedBy, string? UpdatedByName,
|
||||
string Markdown, IReadOnlyList<Crumb> Path);
|
||||
string Markdown, IReadOnlyList<Crumb> Path,
|
||||
Person? Author, Person? LastEditor);
|
||||
|
||||
public sealed record SearchHit(string Id, string Kind, string Title, string Snippet, IReadOnlyList<Crumb> Path);
|
||||
|
||||
@@ -56,15 +57,17 @@ public sealed partial class Store(Db db)
|
||||
private static PageDto? Get(SqliteConnection c, string ws, string id)
|
||||
{
|
||||
using var cmd = c.CreateCommand();
|
||||
cmd.CommandText = $"SELECT {Cols}, content FROM nodes WHERE workspace_id=$ws AND id=$id";
|
||||
cmd.CommandText = $"SELECT {Cols}, content, created_by FROM nodes WHERE workspace_id=$ws AND id=$id";
|
||||
cmd.Parameters.AddWithValue("$ws", ws);
|
||||
cmd.Parameters.AddWithValue("$id", id);
|
||||
using var r = cmd.ExecuteReader();
|
||||
if (!r.Read()) return null;
|
||||
var n = ReadNode(r);
|
||||
var content = r.GetString(9);
|
||||
var createdBy = r.IsDBNull(10) ? null : r.GetString(10);
|
||||
r.Close();
|
||||
return new PageDto(n.Id, n.ParentId, n.Kind, n.Title, n.Version, n.CreatedAt, n.UpdatedAt, n.UpdatedBy,
|
||||
n.UpdatedByName, content, Path(c, ws, n.ParentId));
|
||||
n.UpdatedByName, content, Path(c, ws, n.ParentId), Profiles.Load(c, createdBy), Profiles.Load(c, n.UpdatedBy));
|
||||
}
|
||||
|
||||
/// <summary>Цепочка папок от корня до parentId включительно (хлебные крошки).</summary>
|
||||
|
||||
39
frontend/src/Avatar.tsx
Normal file
39
frontend/src/Avatar.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { Person } from "./api";
|
||||
|
||||
// Кружок профиля как в CRM (ui.js → avatarHtml): фото или инициалы на цвете.
|
||||
// Палитра и colorOf — копия js/util.js CRM; цвет обычно уже приходит с сервера.
|
||||
const AVATAR_COLORS = ["#FFCA40", "#7DD3FC", "#A78BFA", "#4ADE80", "#FB923C", "#F472B6", "#F97066", "#5EEAD4", "#C4B5FD", "#E5E5E5"];
|
||||
const colorOf = (s: string) => {
|
||||
let h = 0;
|
||||
for (const ch of s) h = (h * 31 + ch.codePointAt(0)!) % 9973;
|
||||
return AVATAR_COLORS[h % AVATAR_COLORS.length];
|
||||
};
|
||||
const monogram = (name: string) => {
|
||||
const w = name.trim().split(/\s+/).filter(Boolean);
|
||||
return w.length ? w.slice(0, 2).map((s) => [...s][0]).join("").toUpperCase() : "—";
|
||||
};
|
||||
|
||||
export const personName = (p: Person | null) => p?.name || p?.email || "Невідомий користувач";
|
||||
|
||||
export function Avatar({ p, size = 18 }: { p: Person; size?: number }) {
|
||||
const name = personName(p);
|
||||
// Размер и цвет зависят от данных — inline, как в CRM.
|
||||
if (p.avatarUrl)
|
||||
return <img className="av" src={p.avatarUrl} alt={name} referrerPolicy="no-referrer" style={{ width: size, height: size }} />;
|
||||
return (
|
||||
<i className="av" aria-hidden="true"
|
||||
style={{ background: p.color || colorOf(name), width: size, height: size, fontSize: Math.round(size * 0.44) }}>
|
||||
{monogram(name)}
|
||||
</i>
|
||||
);
|
||||
}
|
||||
|
||||
/** «аватар + имя»; автор — в золотом кольце (.by), как автор задачи в CRM. */
|
||||
export function Who({ p, author = false }: { p: Person; author?: boolean }) {
|
||||
return (
|
||||
<span className="who">
|
||||
{author ? <span className="by"><Avatar p={p} /></span> : <Avatar p={p} />}
|
||||
<span>{personName(p)}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Api, ApiError, type Node, type Page } from "./api";
|
||||
import { Who } from "./Avatar";
|
||||
import { WikiEditor } from "./Editor";
|
||||
import { Icon } from "./Icon";
|
||||
import { setLeaveGuard } from "./router";
|
||||
@@ -25,7 +26,7 @@ export function PageView(props: {
|
||||
const [title, setTitle] = useState(props.page.title);
|
||||
const [state, setState] = useState<SaveState>("saved");
|
||||
const [err, setErr] = useState("");
|
||||
const [meta, setMeta] = useState({ updatedAt: props.page.updatedAt, by: props.page.updatedByName });
|
||||
const [meta, setMeta] = useState({ updatedAt: props.page.updatedAt, by: props.page.updatedByName, editor: props.page.lastEditor });
|
||||
const [conflict, setConflict] = useState<Page | null>(null);
|
||||
const [editorKey, setEditorKey] = useState(0);
|
||||
const [markdown, setMarkdown] = useState(props.page.markdown);
|
||||
@@ -52,7 +53,7 @@ export function PageView(props: {
|
||||
version.current = p.version;
|
||||
const titleChanged = p.title !== savedTitle.current;
|
||||
savedTitle.current = p.title;
|
||||
setMeta({ updatedAt: p.updatedAt, by: p.updatedByName });
|
||||
setMeta({ updatedAt: p.updatedAt, by: p.updatedByName, editor: p.lastEditor });
|
||||
setState(dirty.current ? "dirty" : "saved");
|
||||
setErr("");
|
||||
props.onSaved(p, titleChanged);
|
||||
@@ -105,7 +106,7 @@ export function PageView(props: {
|
||||
setTitle(conflict.title);
|
||||
setMarkdown(conflict.markdown);
|
||||
getMd.current = () => conflict.markdown;
|
||||
setMeta({ updatedAt: conflict.updatedAt, by: conflict.updatedByName });
|
||||
setMeta({ updatedAt: conflict.updatedAt, by: conflict.updatedByName, editor: conflict.lastEditor });
|
||||
dirty.current = false;
|
||||
setConflict(null);
|
||||
setState("saved");
|
||||
@@ -152,10 +153,19 @@ export function PageView(props: {
|
||||
<button key="edit" className="btn sm gold" onClick={() => setEditing(true)}><Icon name="edit" />Редагувати</button>
|
||||
)}
|
||||
</div>
|
||||
{editing ? (
|
||||
<div className="row wrap small mute mt-6">
|
||||
{editing && status}
|
||||
{status}
|
||||
<span>змінено {fmt(meta.updatedAt)}{meta.by ? ` · ${meta.by}` : ""}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="row wrap small mute mt-6 gap-14">
|
||||
<span className="row gap-6">Автор: {props.page.author ? <Who p={props.page.author} author /> : "—"}
|
||||
<span>· {fmt(props.page.createdAt)}</span></span>
|
||||
<span className="row gap-6">Змінено: {meta.editor ? <Who p={meta.editor} /> : (meta.by ?? "—")}
|
||||
<span>· {fmt(meta.updatedAt)}</span></span>
|
||||
</div>
|
||||
)}
|
||||
{leaveMsg && <div className="err">{leaveMsg}</div>}
|
||||
{state === "error" && err && <div className="err">{err}</div>}
|
||||
{conflict && (
|
||||
|
||||
@@ -5,7 +5,9 @@ export type Node = {
|
||||
createdAt: string; updatedAt: string; updatedBy: string; updatedByName: string | null;
|
||||
};
|
||||
export type Crumb = { id: string; kind: "folder" | "page"; title: string };
|
||||
export type Page = Node & { markdown: string; path: Crumb[] };
|
||||
/** Автор или последний редактор: профиль из довідника CRM (у агентов — из токена). */
|
||||
export type Person = { id: string; name: string | null; email: string | null; avatarUrl: string | null; color: string | null };
|
||||
export type Page = Node & { markdown: string; path: Crumb[]; author: Person | null; lastEditor: Person | null };
|
||||
export type SearchHit = { id: string; kind: "folder" | "page"; title: string; snippet: string; path: Crumb[] };
|
||||
|
||||
export class ApiError extends Error {
|
||||
|
||||
Reference in New Issue
Block a user