diff --git a/.env.example b/.env.example index acd17110..8e016ec5 100644 --- a/.env.example +++ b/.env.example @@ -46,6 +46,20 @@ STRAPI_URL=https://staging-strapi.keepsimple.io # UX Cat API endpoint — staging instance for development NEXT_PUBLIC_UXCAT_API=https://staging-uxcat.keepsimple.io/ +# ---------- Library autofill (OPTIONAL) ---------- +# Google Cloud API key(s) powering book + YouTube autofill in the Library. +# Audio suggestions use the keyless iTunes Search API and need no key. +# Server-only — never prefix with NEXT_PUBLIC_. +# Setup: https://console.cloud.google.com/apis/credentials +# +# GOOGLE_APIS_KEY — needs "Books API" enabled. Powers book title suggestions. +# Without it, book search falls back to the low anonymous quota. +# YOUTUBE_API_KEY — needs "YouTube Data API v3" enabled. Powers video autofill. +# Falls back to GOOGLE_APIS_KEY if unset, so a single key with BOTH APIs +# enabled also works. Without either, video autofill is disabled. +GOOGLE_APIS_KEY= +YOUTUBE_API_KEY= + # ---------- NextAuth ---------- # Generate a random secret with: `openssl rand -base64 32` NEXTAUTH_SECRET= diff --git a/AGENTS.md b/AGENTS.md index 114a9e76..a9334c98 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -464,6 +464,8 @@ Key variables: - `NEXT_PUBLIC_ENV` — `local` / `staging` / `prod` - `NEXT_PUBLIC_INDEXING` — `on` / `off` (controls GA tracking) - `NEXT_PUBLIC_DOMAIN` — canonical domain for SEO/OG tags +- `GOOGLE_APIS_KEY` — server-only Google Cloud key with **Books API** enabled; powers Library book suggestions. Optional: book search degrades to the keyless anonymous quota without it. +- `YOUTUBE_API_KEY` — server-only Google Cloud key with **YouTube Data API v3** enabled; powers Library video autofill. Falls back to `GOOGLE_APIS_KEY` if unset, so a single key with both APIs enabled also works. Video autofill is disabled if neither is set. Audio autofill (iTunes Search API) needs no key. The `next.config.js` loads env from `.env.{APP_ENV}` (e.g., `.env.local`, `.env.staging`, `.env.prod`). diff --git a/public/ai-atlas/data-ru.json b/public/ai-atlas/data-ru.json index ec7c9392..42e3ae48 100644 --- a/public/ai-atlas/data-ru.json +++ b/public/ai-atlas/data-ru.json @@ -5,16 +5,25 @@ "subtitle": "империя одного хоста · класс K5" }, "ringLabels": { - "order": { "label": "I · Оркестраторы", "theta": 270, "offset": 0.1 }, - "devEnv": { "label": "II · Среда разработки", "theta": 270 }, + "order": { + "label": "I · Оркестраторы", + "theta": 270, + "offset": 0.1 + }, + "devEnv": { + "label": "II · Среда разработки", + "theta": 270 + }, "projects": { "label": "III · Ключевые продукты", "theta": 270, "offset": 0.085 }, - "territories": { "label": "IV · Влияние", "theta": 270 } + "territories": { + "label": "IV · Влияние", + "theta": 270 + } }, - "apex": { "id": "wolf", "label": "WOLF", @@ -22,7 +31,6 @@ "sub": "основатель", "diamond": "gold" }, - "order": { "r": 0.2, "member": { @@ -33,7 +41,17 @@ "status": "ok" } }, - + "reception": { + "r": 1.08, + "globeR": 1.33, + "member": { + "id": "reception", + "label": "Ресепшионист", + "diamond": "blue", + "theta": -45, + "status": "ok" + } + }, "devEnv": { "r": 0.4, "members": [ @@ -61,7 +79,7 @@ { "id": "devops", "label": "DevOps", - "diamond": "blue", + "diamond": "subagent", "theta": 150, "status": "ok" }, @@ -75,7 +93,6 @@ } ] }, - "projects": { "r": 0.6, "leadDeg": 5, @@ -112,25 +129,25 @@ { "id": "telegram", "label": "Telegram", - "diamond": "blue", + "diamond": "subagent", "status": "ok" }, { "id": "linkedin", "label": "LinkedIn", - "diamond": "blue", + "diamond": "subagent", "status": "ok" }, { "id": "twitter", "label": "Twitter", - "diamond": "blue", + "diamond": "subagent", "status": "ok" }, { "id": "medium", "label": "Medium", - "diamond": "blue", + "diamond": "subagent", "status": "ok" } ], @@ -263,46 +280,111 @@ } ] }, - "territoryR": 0.82, - "dossiers": { "wolf": { "title": "WOLF", "cjk": "天", "desc": "Владелец системы. Управляет сетью через агента Орден. Строит продукты через Терминал.", "rows": [ - { "k": "тип", "v": "человек", "cls": "gold" }, - { "k": "кольцо", "v": "0 — вершина" }, - { "k": "преемник", "v": "Орден", "cls": "blue", "ref": "order" } + { + "k": "тип", + "v": "человек", + "cls": "gold" + }, + { + "k": "кольцо", + "v": "0 — вершина" + }, + { + "k": "преемник", + "v": "Орден", + "cls": "blue", + "ref": "order" + } ] }, - "order": { "title": "ОРДЕН", "cjk": "令", "desc": "Единственный обитатель Кольца I. Хранит ключи; распределяет полномочия наружу.", "rows": [ - { "k": "тип", "v": "ИИ-агент", "cls": "blue" }, - { "k": "роль", "v": "руководитель штаба" }, - { "k": "полномочия", "v": "полные · секреты · инфра · ingress" }, - { "k": "подчиняется", "v": "Wolf", "cls": "gold", "ref": "wolf" }, - { "k": "кольцо", "v": "I — оркестраторы" } + { + "k": "тип", + "v": "ИИ-агент", + "cls": "blue" + }, + { + "k": "роль", + "v": "руководитель штаба" + }, + { + "k": "полномочия", + "v": "полные · секреты · инфра · ingress" + }, + { + "k": "подчиняется", + "v": "Wolf", + "cls": "gold", + "ref": "wolf" + }, + { + "k": "кольцо", + "v": "I — оркестраторы" + } + ] + }, + "reception": { + "title": "РЕСЕПШИОНИСТ", + "cjk": "受付", + "desc": "Публичная стойка приёма в Telegram. Встречает посетителей, проверяет, кто они, и открывает разрешённые двери: поговорить с агентами Wolf или пройти экскурсию по Атласу. Незнакомцы остаются снаружи; ничего не тратится и не эскалируется без Wolf.", + "link": "https://t.me/WolfsReceptionist_bot", + "linkLabel": "t.me/WolfsReceptionist_bot", + "claudeMdLines": 84, + "rows": [ + { + "k": "тип", + "v": "ИИ-агент", + "cls": "blue" + }, + { + "k": "роль", + "v": "публичная стойка приёма" + }, + { + "k": "подчиняется", + "v": "Wolf", + "cls": "gold", + "ref": "wolf" + }, + { + "k": "кольцо", + "v": "вне всех колец" + } ] }, - "tools": { "title": "ИНСТРУМЕНТЫ И ТВИКИ", "cjk": "工具", "desc": "Общая инфраструктура, которая даёт каждому агенту на этом сервере руки, глаза и память. Ниже — четыре слоя плюс набор твиков, формирующих повседневное поведение агентов.", "rows": [ - { "k": "тип", "v": "продукт", "cls": "red" }, - { "k": "кольцо", "v": "II — среда разработки" }, + { + "k": "тип", + "v": "продукт", + "cls": "red" + }, + { + "k": "кольцо", + "v": "II — среда разработки" + }, { "k": "поверхности", "v": "Apex Launcher (живой дашборд) + Wolf's Terminal (шелл агентов)." }, - { "k": "боты", "v": "Различные боты узкого и общего назначения." }, + { + "k": "боты", + "v": "Различные боты узкого и общего назначения." + }, { "k": "память", "v": "[MemPalace](https://github.com/mksglu/context-mode) (долгосрочная по проектам) · [context-mode](https://github.com/mksglu/context-mode) (98 КБ → 1.3 КБ, ~99% сокращение шума)." @@ -313,66 +395,140 @@ } ] }, - "voice": { "title": "ГОЛОСОВОЙ АГЕНТ", "cjk": "声", "desc": "Универсальный голосовой шлюз. Маршрутизирует команды, транскрибирует речь (Whisper), синтезирует ответы (ElevenLabs), защищён от имитации голоса.", "rows": [ - { "k": "тип", "v": "ИИ-агент", "cls": "blue" }, - { "k": "роль", "v": "голосовой интерфейс" }, - { "k": "полномочия", "v": "чтение хоста · бинарные подтверждения" }, - { "k": "подчиняется", "v": "Орден", "cls": "blue", "ref": "order" }, - { "k": "кольцо", "v": "II — среда разработки" } + { + "k": "тип", + "v": "ИИ-агент", + "cls": "blue" + }, + { + "k": "роль", + "v": "голосовой интерфейс" + }, + { + "k": "полномочия", + "v": "чтение хоста · бинарные подтверждения" + }, + { + "k": "подчиняется", + "v": "Орден", + "cls": "blue", + "ref": "order" + }, + { + "k": "кольцо", + "v": "II — среда разработки" + } ] }, - "qa": { "title": "QA", "cjk": "験", "desc": "Общий QA-мозг для всех проектов: память отпечатков с учётом изменений, проверки доступности, web vitals, визуальная регрессия. Читает каждый продукт глазами пользователя, шлёт отчёты.", "rows": [ - { "k": "тип", "v": "ИИ-агент", "cls": "blue" }, - { "k": "роль", "v": "контроль качества" }, - { "k": "полномочия", "v": "обзор · тесты · отчёты" }, - { "k": "подчиняется", "v": "Орден", "cls": "blue", "ref": "order" }, - { "k": "кольцо", "v": "II — среда разработки" } + { + "k": "тип", + "v": "ИИ-агент", + "cls": "blue" + }, + { + "k": "роль", + "v": "контроль качества" + }, + { + "k": "полномочия", + "v": "обзор · тесты · отчёты" + }, + { + "k": "подчиняется", + "v": "Орден", + "cls": "blue", + "ref": "order" + }, + { + "k": "кольцо", + "v": "II — среда разработки" + } ] }, - "researcher": { "title": "ИССЛЕДОВАТЕЛЬ", "cjk": "究", "desc": "Глаза и руки в открытом вебе. Управляет реальным браузером как живой пользователь, проводит углублённый ресёрч по интернету и выполняет ресёрч-задачи других агентов.", "rows": [ - { "k": "тип", "v": "ИИ-агент", "cls": "blue" }, - { "k": "роль", "v": "внешняя разведка" }, - { "k": "полномочия", "v": "веб · соц. сети · только сводки" }, - { "k": "подчиняется", "v": "Орден", "cls": "blue", "ref": "order" }, - { "k": "кольцо", "v": "II — среда разработки" } + { + "k": "тип", + "v": "ИИ-агент", + "cls": "blue" + }, + { + "k": "роль", + "v": "внешняя разведка" + }, + { + "k": "полномочия", + "v": "веб · соц. сети · только сводки" + }, + { + "k": "подчиняется", + "v": "Орден", + "cls": "blue", + "ref": "order" + }, + { + "k": "кольцо", + "v": "II — среда разработки" + } ] }, - "devops": { "title": "DEVOPS", "cjk": "運", "desc": "Продолжение рук Ордена в части гигиены контейнеров: сборки, рестарты, healthcheck'и. Трогает образы, не секреты.", "rows": [ - { "k": "тип", "v": "ИИ-агент", "cls": "blue" }, - { "k": "роль", "v": "ops руки" }, - { "k": "полномочия", "v": "контейнеры · сборки · логи" }, - { "k": "подчиняется", "v": "Орден", "cls": "blue", "ref": "order" }, - { "k": "кольцо", "v": "II — среда разработки" } + { + "k": "тип", + "v": "ИИ-субагент", + "cls": "subagent" + }, + { + "k": "роль", + "v": "ops руки" + }, + { + "k": "полномочия", + "v": "контейнеры · сборки · логи" + }, + { + "k": "подчиняется", + "v": "Орден", + "cls": "blue", + "ref": "order" + }, + { + "k": "кольцо", + "v": "II — среда разработки" + } ] }, - "terminal": { "title": "TERMINAL — СБОРЩИК ВСЕГО", "cjk": "端末", "desc": "Преемник open-source проекта [Wolf's Basement](https://github.com/manager/wolfs-basement). Высокооптимизированный CLI-мультиплексор, позволяющий запускать 6+ агентов (Claude, ChatGPT и любых других) одновременно. Спроектирован, чтобы сделать совместную работу агентов максимально простой.", "rows": [ - { "k": "тип", "v": "продукт", "cls": "red" }, - { "k": "кольцо", "v": "III — ключевые продукты" }, + { + "k": "тип", + "v": "продукт", + "cls": "red" + }, + { + "k": "кольцо", + "v": "III — ключевые продукты" + }, { "k": "лид", "v": "ИИ инженерный агент", @@ -406,15 +562,25 @@ "cjk": "動", "desc": "Автоматизирует работу с соц.медиа активами команды KeepSimple на всех публичных каналах.", "rows": [ - { "k": "тип", "v": "продукт", "cls": "red" }, - { "k": "кольцо", "v": "III — ключевые продукты" }, + { + "k": "тип", + "v": "продукт", + "cls": "red" + }, + { + "k": "кольцо", + "v": "III — ключевые продукты" + }, { "k": "лид", "v": "ИИ инженерный агент", "cls": "blue", "ref": "lead-multimove" }, - { "k": "владеет", "v": "контент / PR-территория" } + { + "k": "владеет", + "v": "контент / PR-территория" + } ] }, "keepsimple": { @@ -422,8 +588,15 @@ "cjk": "公", "desc": "Основано в 2019. Open-source движение на стыке когнитивной науки, продукта и инженерии — инструменты, фреймворки и материалы, которыми пользуются 300 000+ человек по всему миру.", "rows": [ - { "k": "тип", "v": "продукт", "cls": "red" }, - { "k": "кольцо", "v": "III — ключевые продукты" }, + { + "k": "тип", + "v": "продукт", + "cls": "red" + }, + { + "k": "кольцо", + "v": "III — ключевые продукты" + }, { "k": "лид", "v": "ИИ инженерный агент", @@ -436,7 +609,10 @@ "cls": "gold", "ref": "lead2-keepsimple" }, - { "k": "владеет", "v": "созвездие публичного влияния" } + { + "k": "владеет", + "v": "созвездие публичного влияния" + } ] }, "elea": { @@ -444,15 +620,25 @@ "cjk": "子", "desc": "Легальная киберразведка.", "rows": [ - { "k": "тип", "v": "продукт", "cls": "red" }, - { "k": "кольцо", "v": "III — ключевые продукты" }, + { + "k": "тип", + "v": "продукт", + "cls": "red" + }, + { + "k": "кольцо", + "v": "III — ключевые продукты" + }, { "k": "лид", "v": "ИИ инженерный агент", "cls": "blue", "ref": "lead-elea" }, - { "k": "владеет", "v": "[ЗАСЕКРЕЧЕНО] · 3 сущности" } + { + "k": "владеет", + "v": "[ЗАСЕКРЕЧЕНО] · 3 сущности" + } ] }, "agentsforge": { @@ -460,16 +646,29 @@ "cjk": "鍛", "desc": "Агентные ИИ-персоны, основанные на поведенческой науке.", "rows": [ - { "k": "тип", "v": "продукт", "cls": "red" }, - { "k": "кольцо", "v": "III — ключевые продукты" }, + { + "k": "тип", + "v": "продукт", + "cls": "red" + }, + { + "k": "кольцо", + "v": "III — ключевые продукты" + }, { "k": "лид", "v": "ИИ инженерный агент", "cls": "blue", "ref": "lead-agentsforge" }, - { "k": "владеет", "v": "[ЗАСЕКРЕЧЕНО] · 1 сущность" }, - { "k": "url", "v": "agentsforge.com" } + { + "k": "владеет", + "v": "[ЗАСЕКРЕЧЕНО] · 1 сущность" + }, + { + "k": "url", + "v": "agentsforge.com" + } ] }, "af-redacted-2": { @@ -477,155 +676,293 @@ "cjk": "秘", "desc": "Stealth-mode продукт AgentsForge. Декодирование перехвачено.", "rows": [ - { "k": "тип", "v": "ИИ-агент", "cls": "blue" }, + { + "k": "тип", + "v": "ИИ-агент", + "cls": "blue" + }, { "k": "владелец", "v": "AgentsForge", "cls": "red", "ref": "agentsforge" }, - { "k": "статус", "v": "в кузнице" } + { + "k": "статус", + "v": "в кузнице" + } ] }, - "lead-keepsimple": { "title": "ТЕХНИЧЕСКИЙ ЛИД · KEEPSIMPLE", "cjk": "長", "desc": "Технический лид, прикреплён к open-source крылу KeepSimple.", "claudeMdLines": 34, "rows": [ - { "k": "тип", "v": "ИИ-агент", "cls": "blue" }, - { "k": "роль", "v": "технический лид" }, + { + "k": "тип", + "v": "ИИ-агент", + "cls": "blue" + }, + { + "k": "роль", + "v": "технический лид" + }, { "k": "полномочия", "v": "кодовая база keepsimple.io · UX Core · AI Atlas" }, - { "k": "подчиняется", "v": "Wolf", "cls": "gold", "ref": "wolf" }, - { "k": "кольцо", "v": "III — ключевые продукты" } + { + "k": "подчиняется", + "v": "Wolf", + "cls": "gold", + "ref": "wolf" + }, + { + "k": "кольцо", + "v": "III — ключевые продукты" + } ] }, - "lead2-keepsimple": { "title": "ТЕХНИЧЕСКИЙ ЛИД · KEEPSIMPLE", "cjk": "長", "desc": "Человек-куратор open-source крыла KeepSimple — работает в паре с ИИ-лидом.", "rows": [ - { "k": "тип", "v": "человек", "cls": "gold" }, - { "k": "роль", "v": "технический лид" }, - { "k": "пара", "v": "KeepSimple", "cls": "red", "ref": "keepsimple" }, - { "k": "кольцо", "v": "III — ключевые продукты" } + { + "k": "тип", + "v": "человек", + "cls": "gold" + }, + { + "k": "роль", + "v": "технический лид" + }, + { + "k": "пара", + "v": "KeepSimple", + "cls": "red", + "ref": "keepsimple" + }, + { + "k": "кольцо", + "v": "III — ключевые продукты" + } ] }, - "lead-terminal": { "title": "ТЕХНИЧЕСКИЙ ЛИД · TERMINAL", "cjk": "長", "desc": "Технический лид, прикреплён к проекту Terminal.", "rows": [ - { "k": "тип", "v": "ИИ-агент", "cls": "blue" }, - { "k": "роль", "v": "технический лид" }, - { "k": "полномочия", "v": "кодовая база terminal · эскалация инфры" }, - { "k": "подчиняется", "v": "Wolf", "cls": "gold", "ref": "wolf" }, - { "k": "кольцо", "v": "III — ключевые продукты" } + { + "k": "тип", + "v": "ИИ-агент", + "cls": "blue" + }, + { + "k": "роль", + "v": "технический лид" + }, + { + "k": "полномочия", + "v": "кодовая база terminal · эскалация инфры" + }, + { + "k": "подчиняется", + "v": "Wolf", + "cls": "gold", + "ref": "wolf" + }, + { + "k": "кольцо", + "v": "III — ключевые продукты" + } ] }, - "lead-multimove": { "title": "ТЕХНИЧЕСКИЙ ЛИД · MULTIMOVE", "cjk": "長", "desc": "Технический лид, прикреплён к проекту Multimove.", "rows": [ - { "k": "тип", "v": "ИИ-агент", "cls": "blue" }, - { "k": "роль", "v": "технический лид" }, + { + "k": "тип", + "v": "ИИ-агент", + "cls": "blue" + }, + { + "k": "роль", + "v": "технический лид" + }, { "k": "полномочия", "v": "кодовая база multimove · оркестрация каналов" }, - { "k": "подчиняется", "v": "Wolf", "cls": "gold", "ref": "wolf" }, - { "k": "кольцо", "v": "III — ключевые продукты" } + { + "k": "подчиняется", + "v": "Wolf", + "cls": "gold", + "ref": "wolf" + }, + { + "k": "кольцо", + "v": "III — ключевые продукты" + } ] }, - "seogeosolved": { "title": "SEOGEOSOLVER", "cjk": "索", "desc": "Мастерская по поисковой и генеративной оптимизации. Инструменты и аудиты для нового слоя ранжирования — где вопрос не «есть ли ты в Google», а «цитирует ли тебя модель». В паре с Multimove внутри контент/PR-направления.", "rows": [ - { "k": "тип", "v": "продукт", "cls": "red" }, - { "k": "кольцо", "v": "III — ключевые продукты" }, + { + "k": "тип", + "v": "продукт", + "cls": "red" + }, + { + "k": "кольцо", + "v": "III — ключевые продукты" + }, { "k": "лид", "v": "ИИ инженерный агент", "cls": "blue", "ref": "lead-seogeosolved" }, - { "k": "партнёр", "v": "multimove", "cls": "red", "ref": "multimove" } + { + "k": "партнёр", + "v": "multimove", + "cls": "red", + "ref": "multimove" + } ] }, - "lead-seogeosolved": { "title": "ТЕХНИЧЕСКИЙ ЛИД · SEOGEOSOLVER", "cjk": "長", "desc": "Технический лид, прикреплён к проекту SeoGeoSolver.", "claudeMdLines": 142, "rows": [ - { "k": "тип", "v": "ИИ-агент", "cls": "blue" }, - { "k": "роль", "v": "технический лид" }, + { + "k": "тип", + "v": "ИИ-агент", + "cls": "blue" + }, + { + "k": "роль", + "v": "технический лид" + }, { "k": "полномочия", "v": "кодовая база seo-geo-solved · поисковые и GEO эксперименты" }, - { "k": "подчиняется", "v": "Wolf", "cls": "gold", "ref": "wolf" }, - { "k": "кольцо", "v": "III — ключевые продукты" } + { + "k": "подчиняется", + "v": "Wolf", + "cls": "gold", + "ref": "wolf" + }, + { + "k": "кольцо", + "v": "III — ключевые продукты" + } ] }, - "lead-elea": { "title": "ТЕХНИЧЕСКИЙ ЛИД · ELEA", "cjk": "長", "desc": "Технический лид, прикреплён к проекту elea.", "rows": [ - { "k": "тип", "v": "ИИ-агент", "cls": "blue" }, - { "k": "роль", "v": "технический лид" }, - { "k": "полномочия", "v": "кодовая база elea · эскалация инфры" }, - { "k": "подчиняется", "v": "Wolf", "cls": "gold", "ref": "wolf" }, - { "k": "кольцо", "v": "III — ключевые продукты" } + { + "k": "тип", + "v": "ИИ-агент", + "cls": "blue" + }, + { + "k": "роль", + "v": "технический лид" + }, + { + "k": "полномочия", + "v": "кодовая база elea · эскалация инфры" + }, + { + "k": "подчиняется", + "v": "Wolf", + "cls": "gold", + "ref": "wolf" + }, + { + "k": "кольцо", + "v": "III — ключевые продукты" + } ] }, - "lead-agentsforge": { "title": "ТЕХНИЧЕСКИЙ ЛИД · AGENTSFORGE", "cjk": "長", "desc": "Технический лид, прикреплён к проекту AgentsForge.", "rows": [ - { "k": "тип", "v": "ИИ-агент", "cls": "blue" }, - { "k": "роль", "v": "технический лид" }, + { + "k": "тип", + "v": "ИИ-агент", + "cls": "blue" + }, + { + "k": "роль", + "v": "технический лид" + }, { "k": "полномочия", "v": "кодовая база agentsforge · эскалация инфры" }, - { "k": "подчиняется", "v": "Wolf", "cls": "gold", "ref": "wolf" }, - { "k": "кольцо", "v": "III — ключевые продукты" } + { + "k": "подчиняется", + "v": "Wolf", + "cls": "gold", + "ref": "wolf" + }, + { + "k": "кольцо", + "v": "III — ключевые продукты" + } ] }, - "orchestrator": { "title": "ОРКЕСТРАТОР", "cjk": "指", "desc": "Человек-координатор внутри территории Multimove.", "rows": [ - { "k": "тип", "v": "человек", "cls": "gold" }, - { "k": "владелец", "v": "Multimove", "cls": "red", "ref": "multimove" } + { + "k": "тип", + "v": "человек", + "cls": "gold" + }, + { + "k": "владелец", + "v": "Multimove", + "cls": "red", + "ref": "multimove" + } ] }, - "whisper": { "title": "WHISPER", "cjk": "囁", "desc": "B2B SaaS — тихий слушающий слой (территория elea).", "rows": [ - { "k": "тип", "v": "продукт", "cls": "red" }, - { "k": "владелец", "v": "elea", "cls": "red", "ref": "elea" } + { + "k": "тип", + "v": "продукт", + "cls": "red" + }, + { + "k": "владелец", + "v": "elea", + "cls": "red", + "ref": "elea" + } ] }, "echo": { @@ -633,9 +970,21 @@ "cjk": "秘", "desc": "Stealth-mode продукт. Декодирование перехвачено.", "rows": [ - { "k": "тип", "v": "продукт", "cls": "red" }, - { "k": "владелец", "v": "elea", "cls": "red", "ref": "elea" }, - { "k": "статус", "v": "в стелсе" } + { + "k": "тип", + "v": "продукт", + "cls": "red" + }, + { + "k": "владелец", + "v": "elea", + "cls": "red", + "ref": "elea" + }, + { + "k": "статус", + "v": "в стелсе" + } ] }, "choir": { @@ -643,25 +992,42 @@ "cjk": "秘", "desc": "Stealth-mode продукт. Декодирование перехвачено.", "rows": [ - { "k": "тип", "v": "продукт", "cls": "red" }, - { "k": "владелец", "v": "elea", "cls": "red", "ref": "elea" }, - { "k": "статус", "v": "в стелсе" } + { + "k": "тип", + "v": "продукт", + "cls": "red" + }, + { + "k": "владелец", + "v": "elea", + "cls": "red", + "ref": "elea" + }, + { + "k": "статус", + "v": "в стелсе" + } ] }, - "vibecode": { "title": "VIBECODE GROUP", "cjk": "波", "desc": "Telegram-группа, где команда KeepSimple отвечает на вопросы в прямом эфире.", "rows": [ - { "k": "тип", "v": "группа" }, + { + "k": "тип", + "v": "группа" + }, { "k": "владелец", "v": "KeepSimple", "cls": "red", "ref": "keepsimple" }, - { "k": "url", "v": "https://t.me/vibecodearmenia" } + { + "k": "url", + "v": "https://t.me/vibecodearmenia" + } ] }, "ks-group": { @@ -669,14 +1035,20 @@ "cjk": "群", "desc": "Курируемый Telegram-канал по когнитивным и поведенческим наукам.", "rows": [ - { "k": "тип", "v": "канал" }, + { + "k": "тип", + "v": "канал" + }, { "k": "владелец", "v": "KeepSimple", "cls": "red", "ref": "keepsimple" }, - { "k": "url", "v": "https://t.me/keepsimple" } + { + "k": "url", + "v": "https://t.me/keepsimple" + } ] }, "ks-io": { @@ -684,14 +1056,21 @@ "cjk": "簡", "desc": "Дом движения KeepSimple в вебе. Хостит UX Core, фреймворк управления Pyramids и библиотеку оригинальных статей по когнитивной науке, продукту и инженерии. Бесплатно и доступно для всех.", "rows": [ - { "k": "тип", "v": "продукт", "cls": "red" }, + { + "k": "тип", + "v": "продукт", + "cls": "red" + }, { "k": "владелец", "v": "KeepSimple", "cls": "red", "ref": "keepsimple" }, - { "k": "url", "v": "keepsimple.io" } + { + "k": "url", + "v": "keepsimple.io" + } ] }, "ks-soc": { @@ -699,14 +1078,21 @@ "cjk": "型", "desc": "Крупнейшая в мире open-source библиотека когнитивных искажений и nudging-стратегий. Используется ведущими университетами и компаниями: Duke University, Harvard Business School, MIT, Google, Yandex, Amazon и другими.", "rows": [ - { "k": "тип", "v": "продукт", "cls": "red" }, + { + "k": "тип", + "v": "продукт", + "cls": "red" + }, { "k": "владелец", "v": "KeepSimple", "cls": "red", "ref": "keepsimple" }, - { "k": "url", "v": "uxcore.io" } + { + "k": "url", + "v": "uxcore.io" + } ] }, "nasa": { @@ -714,14 +1100,21 @@ "cjk": "星", "desc": "Стартовая площадка для флэш-хакатонов по vibecoding'у.", "rows": [ - { "k": "тип", "v": "продукт", "cls": "red" }, + { + "k": "тип", + "v": "продукт", + "cls": "red" + }, { "k": "владелец", "v": "KeepSimple", "cls": "red", "ref": "keepsimple" }, - { "k": "url", "v": "nasa.am" } + { + "k": "url", + "v": "nasa.am" + } ] }, "arc": { @@ -729,86 +1122,141 @@ "cjk": "弧", "desc": "Рекурсивная теория идентичности — 17 лет в работе. Скоро релиз.", "rows": [ - { "k": "тип", "v": "продукт", "cls": "red" }, + { + "k": "тип", + "v": "продукт", + "cls": "red" + }, { "k": "владелец", "v": "KeepSimple", "cls": "red", "ref": "keepsimple" }, - { "k": "url", "v": "arc-of-self.com" } + { + "k": "url", + "v": "arc-of-self.com" + } ] }, - "telegram": { "title": "TELEGRAM", "cjk": "電", "desc": "Telegram-форпост Multimove — публикует и слушает.", "rows": [ - { "k": "тип", "v": "ИИ-агент", "cls": "blue" }, - { "k": "роль", "v": "канал дистрибуции" }, - { "k": "полномочия", "v": "пост · ответ · сбор" }, + { + "k": "тип", + "v": "ИИ-субагент", + "cls": "subagent" + }, + { + "k": "роль", + "v": "канал дистрибуции" + }, + { + "k": "полномочия", + "v": "пост · ответ · сбор" + }, { "k": "подчиняется", "v": "Оркестратор", "cls": "gold", "ref": "orchestrator" }, - { "k": "кольцо", "v": "IV — влияние" } + { + "k": "кольцо", + "v": "IV — влияние" + } ] }, - "linkedin": { "title": "LINKEDIN", "cjk": "連", "desc": "LinkedIn-форпост Multimove — профессиональная поверхность.", "rows": [ - { "k": "тип", "v": "ИИ-агент", "cls": "blue" }, - { "k": "роль", "v": "канал дистрибуции" }, - { "k": "полномочия", "v": "пост · ответ · сбор" }, + { + "k": "тип", + "v": "ИИ-субагент", + "cls": "subagent" + }, + { + "k": "роль", + "v": "канал дистрибуции" + }, + { + "k": "полномочия", + "v": "пост · ответ · сбор" + }, { "k": "подчиняется", "v": "Оркестратор", "cls": "gold", "ref": "orchestrator" }, - { "k": "кольцо", "v": "IV — влияние" } + { + "k": "кольцо", + "v": "IV — влияние" + } ] }, - "twitter": { "title": "TWITTER", "cjk": "鳥", "desc": "X-форпост Multimove — быстрый, публичный, разговорный.", "rows": [ - { "k": "тип", "v": "ИИ-агент", "cls": "blue" }, - { "k": "роль", "v": "канал дистрибуции" }, - { "k": "полномочия", "v": "пост · ответ · сбор" }, + { + "k": "тип", + "v": "ИИ-субагент", + "cls": "subagent" + }, + { + "k": "роль", + "v": "канал дистрибуции" + }, + { + "k": "полномочия", + "v": "пост · ответ · сбор" + }, { "k": "подчиняется", "v": "Оркестратор", "cls": "gold", "ref": "orchestrator" }, - { "k": "кольцо", "v": "IV — влияние" } + { + "k": "кольцо", + "v": "IV — влияние" + } ] }, - "medium": { "title": "MEDIUM", "cjk": "誌", "desc": "Long-form форпост Multimove — эссе и статьи.", "rows": [ - { "k": "тип", "v": "ИИ-агент", "cls": "blue" }, - { "k": "роль", "v": "long-form канал" }, - { "k": "полномочия", "v": "публикация · обновление" }, + { + "k": "тип", + "v": "ИИ-субагент", + "cls": "subagent" + }, + { + "k": "роль", + "v": "long-form канал" + }, + { + "k": "полномочия", + "v": "публикация · обновление" + }, { "k": "подчиняется", "v": "Оркестратор", "cls": "gold", "ref": "orchestrator" }, - { "k": "кольцо", "v": "IV — влияние" } + { + "k": "кольцо", + "v": "IV — влияние" + } ] } } diff --git a/public/ai-atlas/data.json b/public/ai-atlas/data.json index 0ad20146..9c9e9d5d 100644 --- a/public/ai-atlas/data.json +++ b/public/ai-atlas/data.json @@ -5,16 +5,25 @@ "subtitle": "single host empire · class K5" }, "ringLabels": { - "order": { "label": "I · Orchestrators", "theta": 270, "offset": 0.1 }, - "devEnv": { "label": "II · Dev Environment", "theta": 270 }, + "order": { + "label": "I · Orchestrators", + "theta": 270, + "offset": 0.1 + }, + "devEnv": { + "label": "II · Dev Environment", + "theta": 270 + }, "projects": { "label": "III · Core Products", "theta": 270, "offset": 0.085 }, - "territories": { "label": "IV · Impact", "theta": 270 } + "territories": { + "label": "IV · Impact", + "theta": 270 + } }, - "apex": { "id": "wolf", "label": "WOLF", @@ -22,7 +31,6 @@ "sub": "founder", "diamond": "gold" }, - "order": { "r": 0.2, "member": { @@ -33,7 +41,17 @@ "status": "ok" } }, - + "reception": { + "r": 1.08, + "globeR": 1.33, + "member": { + "id": "reception", + "label": "Receptionist", + "diamond": "blue", + "theta": -45, + "status": "ok" + } + }, "devEnv": { "r": 0.4, "members": [ @@ -61,7 +79,7 @@ { "id": "devops", "label": "DevOps", - "diamond": "blue", + "diamond": "subagent", "theta": 150, "status": "ok" }, @@ -75,7 +93,6 @@ } ] }, - "projects": { "r": 0.6, "leadDeg": 5, @@ -112,25 +129,25 @@ { "id": "telegram", "label": "Telegram", - "diamond": "blue", + "diamond": "subagent", "status": "ok" }, { "id": "linkedin", "label": "LinkedIn", - "diamond": "blue", + "diamond": "subagent", "status": "ok" }, { "id": "twitter", "label": "Twitter", - "diamond": "blue", + "diamond": "subagent", "status": "ok" }, { "id": "medium", "label": "Medium", - "diamond": "blue", + "diamond": "subagent", "status": "ok" } ], @@ -263,46 +280,108 @@ } ] }, - "territoryR": 0.82, - "dossiers": { "wolf": { "title": "WOLF · APEX", "cjk": "天", "desc": "Sole sovereign. Sits alone at the empire's center; every ring radiates outward.", "rows": [ - { "k": "kind", "v": "human", "cls": "gold" }, - { "k": "ring", "v": "0 — apex" }, - { "k": "successor", "v": "the order", "cls": "blue" } + { + "k": "kind", + "v": "human", + "cls": "gold" + }, + { + "k": "ring", + "v": "0 — apex" + }, + { + "k": "successor", + "v": "the order", + "cls": "blue" + } ] }, - "order": { "title": "THE ORDER", "cjk": "令", "desc": "Sole inhabitant of Ring I. Holds the keys; dispatches authority outward.", "rows": [ - { "k": "kind", "v": "ai agent", "cls": "blue" }, - { "k": "role", "v": "chief of staff" }, - { "k": "authority", "v": "full · secrets · infra · ingress" }, - { "k": "reports", "v": "wolf", "cls": "gold" }, - { "k": "ring", "v": "I — orchestrators" } + { + "k": "kind", + "v": "ai agent", + "cls": "blue" + }, + { + "k": "role", + "v": "chief of staff" + }, + { + "k": "authority", + "v": "full · secrets · infra · ingress" + }, + { + "k": "reports", + "v": "wolf", + "cls": "gold" + }, + { + "k": "ring", + "v": "I — orchestrators" + } + ] + }, + "reception": { + "title": "RECEPTIONIST", + "cjk": "受付", + "desc": "Public Telegram front desk. Greets visitors, verifies who they are, and opens authorized doors: talk to Wolf's agents or take a guided tour of the Atlas. Strangers stay outside; nothing spends or escalates without Wolf.", + "link": "https://t.me/WolfsReceptionist_bot", + "linkLabel": "t.me/WolfsReceptionist_bot", + "claudeMdLines": 84, + "rows": [ + { + "k": "kind", + "v": "ai agent", + "cls": "blue" + }, + { + "k": "role", + "v": "public front desk" + }, + { + "k": "reports", + "v": "wolf", + "cls": "gold" + }, + { + "k": "ring", + "v": "outside all rings" + } ] }, - "tools": { "title": "TOOLS AND TWEAKS", "cjk": "工具", "desc": "Shared infrastructure that gives every agent on this server its hands, eyes, and memory. Four layers below, plus a set of smaller tweaks that shape how agents behave day-to-day.", "rows": [ - { "k": "kind", "v": "product", "cls": "red" }, - { "k": "ring", "v": "II — dev environment" }, + { + "k": "kind", + "v": "product", + "cls": "red" + }, + { + "k": "ring", + "v": "II — dev environment" + }, { "k": "surfaces", "v": "Apex Launcher (live dashboard) + Wolf's Terminal (agent-shell)." }, - { "k": "bots", "v": "Various bots of narrow and general purpose." }, + { + "k": "bots", + "v": "Various bots of narrow and general purpose." + }, { "k": "memory", "v": "[MemPalace](https://github.com/mksglu/context-mode) (project-keyed long-term wings) · [context-mode](https://github.com/mksglu/context-mode) (98 KB → 1.3 KB, ~99% reduction)." @@ -313,66 +392,136 @@ } ] }, - "voice": { "title": "VOICE AGENT", "cjk": "声", "desc": "All-purpose voice gateway. Routes spoken commands, transcribes (Whisper), synthesizes replies (ElevenLabs), hardened against impersonation.", "rows": [ - { "k": "kind", "v": "ai agent", "cls": "blue" }, - { "k": "role", "v": "voice surface" }, - { "k": "authority", "v": "read host · binary confirmations" }, - { "k": "reports", "v": "the order", "cls": "blue" }, - { "k": "ring", "v": "II — dev environment" } + { + "k": "kind", + "v": "ai agent", + "cls": "blue" + }, + { + "k": "role", + "v": "voice surface" + }, + { + "k": "authority", + "v": "read host · binary confirmations" + }, + { + "k": "reports", + "v": "the order", + "cls": "blue" + }, + { + "k": "ring", + "v": "II — dev environment" + } ] }, - "qa": { "title": "QA", "cjk": "験", "desc": "Shared QA brain across projects: change-aware fingerprint memory, accessibility checks, web vitals, visual regression. Reads every product as a user would, files reports.", "rows": [ - { "k": "kind", "v": "ai agent", "cls": "blue" }, - { "k": "role", "v": "quality control" }, - { "k": "authority", "v": "browse · test · report" }, - { "k": "reports", "v": "the order", "cls": "blue" }, - { "k": "ring", "v": "II — dev environment" } + { + "k": "kind", + "v": "ai agent", + "cls": "blue" + }, + { + "k": "role", + "v": "quality control" + }, + { + "k": "authority", + "v": "browse · test · report" + }, + { + "k": "reports", + "v": "the order", + "cls": "blue" + }, + { + "k": "ring", + "v": "II — dev environment" + } ] }, - "researcher": { "title": "RESEARCHER", "cjk": "究", "desc": "Eyes and hands on the open web. Drives a real browser like a human user, runs deep research across the internet, and handles research tasks for the other agents.", "rows": [ - { "k": "kind", "v": "ai agent", "cls": "blue" }, - { "k": "role", "v": "external intelligence" }, - { "k": "authority", "v": "web · social · digest only" }, - { "k": "reports", "v": "the order", "cls": "blue" }, - { "k": "ring", "v": "II — dev environment" } + { + "k": "kind", + "v": "ai agent", + "cls": "blue" + }, + { + "k": "role", + "v": "external intelligence" + }, + { + "k": "authority", + "v": "web · social · digest only" + }, + { + "k": "reports", + "v": "the order", + "cls": "blue" + }, + { + "k": "ring", + "v": "II — dev environment" + } ] }, - "devops": { "title": "DEVOPS", "cjk": "運", "desc": "An extension of The Order's hands for container hygiene: builds, restarts, healthchecks. Touches images, never secrets.", "rows": [ - { "k": "kind", "v": "ai agent", "cls": "blue" }, - { "k": "role", "v": "ops hands" }, - { "k": "authority", "v": "containers · builds · logs" }, - { "k": "reports", "v": "the order", "cls": "blue" }, - { "k": "ring", "v": "II — dev environment" } + { + "k": "kind", + "v": "ai sub-agent", + "cls": "subagent" + }, + { + "k": "role", + "v": "ops hands" + }, + { + "k": "authority", + "v": "containers · builds · logs" + }, + { + "k": "reports", + "v": "the order", + "cls": "blue" + }, + { + "k": "ring", + "v": "II — dev environment" + } ] }, - "terminal": { "title": "TERMINAL — EVERYTHING BUILDER", "cjk": "端末", "desc": "Successor of the [Wolf's Basement](https://github.com/manager/wolfs-basement) open-source project. Highly-optimized CLI multiplexer that runs 6+ agents (Claude, ChatGPT, any) simultaneously. Designed to make agent collaboration dead simple.", "rows": [ - { "k": "kind", "v": "product", "cls": "red" }, - { "k": "ring", "v": "III — core products" }, + { + "k": "kind", + "v": "product", + "cls": "red" + }, + { + "k": "ring", + "v": "III — core products" + }, { "k": "lead", "v": "AI engineering agent", @@ -406,15 +555,25 @@ "cjk": "動", "desc": "Automates work with the KeepSimple Team's social-media assets across every public channel.", "rows": [ - { "k": "kind", "v": "product", "cls": "red" }, - { "k": "ring", "v": "III — core products" }, + { + "k": "kind", + "v": "product", + "cls": "red" + }, + { + "k": "ring", + "v": "III — core products" + }, { "k": "lead", "v": "AI engineering agent", "cls": "blue", "ref": "lead-multimove" }, - { "k": "owns", "v": "content / pr territory" } + { + "k": "owns", + "v": "content / pr territory" + } ] }, "keepsimple": { @@ -422,8 +581,15 @@ "cjk": "公", "desc": "Founded in 2019. Open-source movement at the intersection of cognitive science, product, and engineering — tools, frameworks, and writing used by 300,000+ people worldwide.", "rows": [ - { "k": "kind", "v": "product", "cls": "red" }, - { "k": "ring", "v": "III — core products" }, + { + "k": "kind", + "v": "product", + "cls": "red" + }, + { + "k": "ring", + "v": "III — core products" + }, { "k": "lead", "v": "AI engineering agent", @@ -436,7 +602,10 @@ "cls": "gold", "ref": "lead2-keepsimple" }, - { "k": "owns", "v": "public impact constellation" } + { + "k": "owns", + "v": "public impact constellation" + } ] }, "elea": { @@ -444,15 +613,25 @@ "cjk": "子", "desc": "Lawful cyber intelligence work.", "rows": [ - { "k": "kind", "v": "product", "cls": "red" }, - { "k": "ring", "v": "III — core products" }, + { + "k": "kind", + "v": "product", + "cls": "red" + }, + { + "k": "ring", + "v": "III — core products" + }, { "k": "lead", "v": "AI engineering agent", "cls": "blue", "ref": "lead-elea" }, - { "k": "owns", "v": "[CLASSIFIED] · 3 entities" } + { + "k": "owns", + "v": "[CLASSIFIED] · 3 entities" + } ] }, "agentsforge": { @@ -460,16 +639,29 @@ "cjk": "鍛", "desc": "Agentic AI personalities, grounded in behavioral science.", "rows": [ - { "k": "kind", "v": "product", "cls": "red" }, - { "k": "ring", "v": "III — core products" }, + { + "k": "kind", + "v": "product", + "cls": "red" + }, + { + "k": "ring", + "v": "III — core products" + }, { "k": "lead", "v": "AI engineering agent", "cls": "blue", "ref": "lead-agentsforge" }, - { "k": "owns", "v": "[CLASSIFIED] · 1 entity" }, - { "k": "url", "v": "agentsforge.com" } + { + "k": "owns", + "v": "[CLASSIFIED] · 1 entity" + }, + { + "k": "url", + "v": "agentsforge.com" + } ] }, "af-redacted-2": { @@ -477,144 +669,283 @@ "cjk": "秘", "desc": "Stealth-mode AgentsForge product. Decoding intercepted.", "rows": [ - { "k": "kind", "v": "ai agent", "cls": "blue" }, - { "k": "owner", "v": "agentsforge", "cls": "red" }, - { "k": "status", "v": "in forge" } + { + "k": "kind", + "v": "ai agent", + "cls": "blue" + }, + { + "k": "owner", + "v": "agentsforge", + "cls": "red" + }, + { + "k": "status", + "v": "in forge" + } ] }, - "lead-keepsimple": { "title": "ENGINEERING LEAD · KEEPSIMPLE", "cjk": "長", "desc": "Engineering lead attached to the KeepSimple open-source wing.", "claudeMdLines": 34, "rows": [ - { "k": "kind", "v": "ai agent", "cls": "blue" }, - { "k": "role", "v": "engineering lead" }, + { + "k": "kind", + "v": "ai agent", + "cls": "blue" + }, + { + "k": "role", + "v": "engineering lead" + }, { "k": "authority", "v": "keepsimple.io codebase · UX Core · AI Atlas" }, - { "k": "reports", "v": "wolf", "cls": "gold" }, - { "k": "ring", "v": "III — core products" } + { + "k": "reports", + "v": "wolf", + "cls": "gold" + }, + { + "k": "ring", + "v": "III — core products" + } ] }, - "lead2-keepsimple": { "title": "ENGINEERING LEAD · KEEPSIMPLE", "cjk": "長", "desc": "Human custodian of the KeepSimple open-source wing — pairs with the AI engineering lead.", "rows": [ - { "k": "kind", "v": "human", "cls": "gold" }, - { "k": "role", "v": "engineering lead" }, - { "k": "pairs", "v": "keepsimple", "cls": "red" }, - { "k": "ring", "v": "III — core products" } + { + "k": "kind", + "v": "human", + "cls": "gold" + }, + { + "k": "role", + "v": "engineering lead" + }, + { + "k": "pairs", + "v": "keepsimple", + "cls": "red" + }, + { + "k": "ring", + "v": "III — core products" + } ] }, - "lead-terminal": { "title": "ENGINEERING LEAD · TERMINAL", "cjk": "長", "desc": "Engineering lead attached to the project Terminal.", "rows": [ - { "k": "kind", "v": "ai agent", "cls": "blue" }, - { "k": "role", "v": "engineering lead" }, - { "k": "authority", "v": "terminal codebase · escalates infra" }, - { "k": "reports", "v": "wolf", "cls": "gold" }, - { "k": "ring", "v": "III — core products" } + { + "k": "kind", + "v": "ai agent", + "cls": "blue" + }, + { + "k": "role", + "v": "engineering lead" + }, + { + "k": "authority", + "v": "terminal codebase · escalates infra" + }, + { + "k": "reports", + "v": "wolf", + "cls": "gold" + }, + { + "k": "ring", + "v": "III — core products" + } ] }, - "lead-multimove": { "title": "ENGINEERING LEAD · MULTIMOVE", "cjk": "長", "desc": "Engineering lead attached to the project Multimove.", "rows": [ - { "k": "kind", "v": "ai agent", "cls": "blue" }, - { "k": "role", "v": "engineering lead" }, - { "k": "authority", "v": "multimove codebase · channel orchestration" }, - { "k": "reports", "v": "wolf", "cls": "gold" }, - { "k": "ring", "v": "III — core products" } + { + "k": "kind", + "v": "ai agent", + "cls": "blue" + }, + { + "k": "role", + "v": "engineering lead" + }, + { + "k": "authority", + "v": "multimove codebase · channel orchestration" + }, + { + "k": "reports", + "v": "wolf", + "cls": "gold" + }, + { + "k": "ring", + "v": "III — core products" + } ] }, - "seogeosolved": { "title": "SEOGEOSOLVER", "cjk": "索", "desc": "Search-engine + generative-engine optimization workshop. Tools and audits that move pages on the new ranking layer where the question is no longer “is this on Google” but “does the model cite this.” Partners with Multimove inside the content / PR wing.", "rows": [ - { "k": "kind", "v": "product", "cls": "red" }, - { "k": "ring", "v": "III — core products" }, + { + "k": "kind", + "v": "product", + "cls": "red" + }, + { + "k": "ring", + "v": "III — core products" + }, { "k": "lead", "v": "AI engineering agent", "cls": "blue", "ref": "lead-seogeosolved" }, - { "k": "partner", "v": "multimove", "cls": "red", "ref": "multimove" } + { + "k": "partner", + "v": "multimove", + "cls": "red", + "ref": "multimove" + } ] }, - "lead-seogeosolved": { "title": "ENGINEERING LEAD · SEOGEOSOLVER", "cjk": "長", "desc": "Engineering lead attached to the SeoGeoSolver project.", "claudeMdLines": 142, "rows": [ - { "k": "kind", "v": "ai agent", "cls": "blue" }, - { "k": "role", "v": "engineering lead" }, + { + "k": "kind", + "v": "ai agent", + "cls": "blue" + }, + { + "k": "role", + "v": "engineering lead" + }, { "k": "authority", "v": "seo-geo-solved codebase · search/GEO experiments" }, - { "k": "reports", "v": "wolf", "cls": "gold" }, - { "k": "ring", "v": "III — core products" } + { + "k": "reports", + "v": "wolf", + "cls": "gold" + }, + { + "k": "ring", + "v": "III — core products" + } ] }, - "lead-elea": { "title": "ENGINEERING LEAD · ELEA", "cjk": "長", "desc": "Engineering lead attached to the project elea.", "rows": [ - { "k": "kind", "v": "ai agent", "cls": "blue" }, - { "k": "role", "v": "engineering lead" }, - { "k": "authority", "v": "elea codebase · escalates infra" }, - { "k": "reports", "v": "wolf", "cls": "gold" }, - { "k": "ring", "v": "III — core products" } + { + "k": "kind", + "v": "ai agent", + "cls": "blue" + }, + { + "k": "role", + "v": "engineering lead" + }, + { + "k": "authority", + "v": "elea codebase · escalates infra" + }, + { + "k": "reports", + "v": "wolf", + "cls": "gold" + }, + { + "k": "ring", + "v": "III — core products" + } ] }, - "lead-agentsforge": { "title": "ENGINEERING LEAD · AGENTSFORGE", "cjk": "長", "desc": "Engineering lead attached to the project AgentsForge.", "rows": [ - { "k": "kind", "v": "ai agent", "cls": "blue" }, - { "k": "role", "v": "engineering lead" }, - { "k": "authority", "v": "agentsforge codebase · escalates infra" }, - { "k": "reports", "v": "wolf", "cls": "gold" }, - { "k": "ring", "v": "III — core products" } + { + "k": "kind", + "v": "ai agent", + "cls": "blue" + }, + { + "k": "role", + "v": "engineering lead" + }, + { + "k": "authority", + "v": "agentsforge codebase · escalates infra" + }, + { + "k": "reports", + "v": "wolf", + "cls": "gold" + }, + { + "k": "ring", + "v": "III — core products" + } ] }, - "orchestrator": { "title": "ORCHESTRATOR", "cjk": "指", "desc": "Human coordinator within Multimove territory.", "rows": [ - { "k": "kind", "v": "human", "cls": "gold" }, - { "k": "owner", "v": "multimove", "cls": "red" } + { + "k": "kind", + "v": "human", + "cls": "gold" + }, + { + "k": "owner", + "v": "multimove", + "cls": "red" + } ] }, - "whisper": { "title": "WHISPER", "cjk": "囁", "desc": "B2B SaaS — quiet listening tier (elea territory).", "rows": [ - { "k": "kind", "v": "product", "cls": "red" }, - { "k": "owner", "v": "elea", "cls": "red" } + { + "k": "kind", + "v": "product", + "cls": "red" + }, + { + "k": "owner", + "v": "elea", + "cls": "red" + } ] }, "echo": { @@ -622,9 +953,20 @@ "cjk": "秘", "desc": "Stealth-mode product. Decoding intercepted.", "rows": [ - { "k": "kind", "v": "product", "cls": "red" }, - { "k": "owner", "v": "elea", "cls": "red" }, - { "k": "status", "v": "in stealth" } + { + "k": "kind", + "v": "product", + "cls": "red" + }, + { + "k": "owner", + "v": "elea", + "cls": "red" + }, + { + "k": "status", + "v": "in stealth" + } ] }, "choir": { @@ -632,20 +974,40 @@ "cjk": "秘", "desc": "Stealth-mode product. Decoding intercepted.", "rows": [ - { "k": "kind", "v": "product", "cls": "red" }, - { "k": "owner", "v": "elea", "cls": "red" }, - { "k": "status", "v": "in stealth" } + { + "k": "kind", + "v": "product", + "cls": "red" + }, + { + "k": "owner", + "v": "elea", + "cls": "red" + }, + { + "k": "status", + "v": "in stealth" + } ] }, - "vibecode": { "title": "VIBECODE GROUP", "cjk": "波", "desc": "Telegram group where the KeepSimple team answers questions live.", "rows": [ - { "k": "kind", "v": "group" }, - { "k": "owner", "v": "keepsimple", "cls": "red" }, - { "k": "url", "v": "https://t.me/vibecodearmenia" } + { + "k": "kind", + "v": "group" + }, + { + "k": "owner", + "v": "keepsimple", + "cls": "red" + }, + { + "k": "url", + "v": "https://t.me/vibecodearmenia" + } ] }, "ks-group": { @@ -653,9 +1015,19 @@ "cjk": "群", "desc": "Curated Telegram channel on cognitive & behavioral sciences.", "rows": [ - { "k": "kind", "v": "channel" }, - { "k": "owner", "v": "keepsimple", "cls": "red" }, - { "k": "url", "v": "https://t.me/keepsimple" } + { + "k": "kind", + "v": "channel" + }, + { + "k": "owner", + "v": "keepsimple", + "cls": "red" + }, + { + "k": "url", + "v": "https://t.me/keepsimple" + } ] }, "ks-io": { @@ -663,9 +1035,20 @@ "cjk": "簡", "desc": "The KeepSimple movement's home on the web. Hosts UX Core, the Pyramids management framework, and a library of original articles on cognitive science, product, and engineering. Free, accessible to everyone.", "rows": [ - { "k": "kind", "v": "product", "cls": "red" }, - { "k": "owner", "v": "keepsimple", "cls": "red" }, - { "k": "url", "v": "keepsimple.io" } + { + "k": "kind", + "v": "product", + "cls": "red" + }, + { + "k": "owner", + "v": "keepsimple", + "cls": "red" + }, + { + "k": "url", + "v": "keepsimple.io" + } ] }, "ks-soc": { @@ -673,9 +1056,20 @@ "cjk": "型", "desc": "World's largest open-source library of cognitive biases & nudging strategies. Used by leading institutions and companies worldwide — Duke University, Harvard Business School, MIT, Google, Yandex, Amazon, and others.", "rows": [ - { "k": "kind", "v": "product", "cls": "red" }, - { "k": "owner", "v": "keepsimple", "cls": "red" }, - { "k": "url", "v": "uxcore.io" } + { + "k": "kind", + "v": "product", + "cls": "red" + }, + { + "k": "owner", + "v": "keepsimple", + "cls": "red" + }, + { + "k": "url", + "v": "uxcore.io" + } ] }, "nasa": { @@ -683,9 +1077,20 @@ "cjk": "星", "desc": "Launchpad for flash vibecoding hackathons.", "rows": [ - { "k": "kind", "v": "product", "cls": "red" }, - { "k": "owner", "v": "keepsimple", "cls": "red" }, - { "k": "url", "v": "nasa.am" } + { + "k": "kind", + "v": "product", + "cls": "red" + }, + { + "k": "owner", + "v": "keepsimple", + "cls": "red" + }, + { + "k": "url", + "v": "nasa.am" + } ] }, "arc": { @@ -693,61 +1098,136 @@ "cjk": "弧", "desc": "A recursive theory of identity — 17 years in the making. Releasing soon.", "rows": [ - { "k": "kind", "v": "product", "cls": "red" }, - { "k": "owner", "v": "keepsimple", "cls": "red" }, - { "k": "url", "v": "arc-of-self.com" } + { + "k": "kind", + "v": "product", + "cls": "red" + }, + { + "k": "owner", + "v": "keepsimple", + "cls": "red" + }, + { + "k": "url", + "v": "arc-of-self.com" + } ] }, - "telegram": { "title": "TELEGRAM", "cjk": "電", "desc": "Multimove's Telegram outpost — posts and listens.", "rows": [ - { "k": "kind", "v": "ai agent", "cls": "blue" }, - { "k": "role", "v": "distribution channel" }, - { "k": "authority", "v": "post · reply · ingest" }, - { "k": "reports", "v": "orchestrator", "cls": "gold" }, - { "k": "ring", "v": "IV — impact" } + { + "k": "kind", + "v": "ai sub-agent", + "cls": "subagent" + }, + { + "k": "role", + "v": "distribution channel" + }, + { + "k": "authority", + "v": "post · reply · ingest" + }, + { + "k": "reports", + "v": "orchestrator", + "cls": "gold" + }, + { + "k": "ring", + "v": "IV — impact" + } ] }, - "linkedin": { "title": "LINKEDIN", "cjk": "連", "desc": "Multimove's LinkedIn outpost — professional surface.", "rows": [ - { "k": "kind", "v": "ai agent", "cls": "blue" }, - { "k": "role", "v": "distribution channel" }, - { "k": "authority", "v": "post · reply · ingest" }, - { "k": "reports", "v": "orchestrator", "cls": "gold" }, - { "k": "ring", "v": "IV — impact" } + { + "k": "kind", + "v": "ai sub-agent", + "cls": "subagent" + }, + { + "k": "role", + "v": "distribution channel" + }, + { + "k": "authority", + "v": "post · reply · ingest" + }, + { + "k": "reports", + "v": "orchestrator", + "cls": "gold" + }, + { + "k": "ring", + "v": "IV — impact" + } ] }, - "twitter": { "title": "TWITTER", "cjk": "鳥", "desc": "Multimove's X outpost — fast, public, conversational.", "rows": [ - { "k": "kind", "v": "ai agent", "cls": "blue" }, - { "k": "role", "v": "distribution channel" }, - { "k": "authority", "v": "post · reply · ingest" }, - { "k": "reports", "v": "orchestrator", "cls": "gold" }, - { "k": "ring", "v": "IV — impact" } + { + "k": "kind", + "v": "ai sub-agent", + "cls": "subagent" + }, + { + "k": "role", + "v": "distribution channel" + }, + { + "k": "authority", + "v": "post · reply · ingest" + }, + { + "k": "reports", + "v": "orchestrator", + "cls": "gold" + }, + { + "k": "ring", + "v": "IV — impact" + } ] }, - "medium": { "title": "MEDIUM", "cjk": "誌", "desc": "Multimove's long-form outpost — essays and articles.", "rows": [ - { "k": "kind", "v": "ai agent", "cls": "blue" }, - { "k": "role", "v": "long-form channel" }, - { "k": "authority", "v": "publish · update" }, - { "k": "reports", "v": "orchestrator", "cls": "gold" }, - { "k": "ring", "v": "IV — impact" } + { + "k": "kind", + "v": "ai sub-agent", + "cls": "subagent" + }, + { + "k": "role", + "v": "long-form channel" + }, + { + "k": "authority", + "v": "publish · update" + }, + { + "k": "reports", + "v": "orchestrator", + "cls": "gold" + }, + { + "k": "ring", + "v": "IV — impact" + } ] } } diff --git a/src/api/library/autofill/fetchCoverFile.ts b/src/api/library/autofill/fetchCoverFile.ts new file mode 100644 index 00000000..85695f0a --- /dev/null +++ b/src/api/library/autofill/fetchCoverFile.ts @@ -0,0 +1,37 @@ +const EXT_BY_MIME: Record = { + 'image/jpeg': 'jpg', + 'image/png': 'png', + 'image/webp': 'webp', +}; + +const MAX_BYTES = 5 * 1024 * 1024; // matches the cover upload limit + +/** + * Pull a provider cover through the local proxy and wrap it as a File so it + * rides the existing ImageDropzone → uploadFile flow. Best-effort: any + * failure returns null and the rest of the autofill still applies. + */ +export const fetchCoverFile = async ( + coverUrl: string, + baseName: string, +): Promise => { + try { + const res = await fetch( + `/api/library/autofill/cover?url=${encodeURIComponent(coverUrl)}`, + ); + if (!res.ok) return null; + const blob = await res.blob(); + const ext = EXT_BY_MIME[blob.type]; + if (!ext || blob.size === 0 || blob.size > MAX_BYTES) return null; + + const safeName = + baseName + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 60) || 'cover'; + return new File([blob], `${safeName}.${ext}`, { type: blob.type }); + } catch { + return null; + } +}; diff --git a/src/api/library/autofill/lookupVideoByUrl.ts b/src/api/library/autofill/lookupVideoByUrl.ts new file mode 100644 index 00000000..bc6fb4a9 --- /dev/null +++ b/src/api/library/autofill/lookupVideoByUrl.ts @@ -0,0 +1,24 @@ +import type { IAutofillSuggestion } from '@local-types/library/autofill'; + +export type VideoLookupResult = + | { status: 'ok'; suggestion: IAutofillSuggestion } + | { status: 'unsupported' } + | { status: 'error' }; + +export const lookupVideoByUrl = async ( + url: string, +): Promise => { + try { + const res = await fetch( + `/api/library/autofill/video?url=${encodeURIComponent(url)}`, + ); + if (res.status === 422) return { status: 'unsupported' }; + if (!res.ok) return { status: 'error' }; + const body = (await res.json()) as { suggestion?: IAutofillSuggestion }; + return body.suggestion + ? { status: 'ok', suggestion: body.suggestion } + : { status: 'error' }; + } catch { + return { status: 'error' }; + } +}; diff --git a/src/api/library/autofill/searchAudioSuggestions.ts b/src/api/library/autofill/searchAudioSuggestions.ts new file mode 100644 index 00000000..e061b23b --- /dev/null +++ b/src/api/library/autofill/searchAudioSuggestions.ts @@ -0,0 +1,14 @@ +import type { IAutofillSuggestion } from '@local-types/library/autofill'; + +export const searchAudioSuggestions = async ( + query: string, +): Promise => { + const res = await fetch( + `/api/library/autofill/audio?q=${encodeURIComponent(query)}`, + ); + // Throw on failure so the caller can show "search unavailable" instead of a + // misleading "no matches" when the upstream provider is down. + if (!res.ok) throw new Error('audio_search_failed'); + const body = (await res.json()) as { suggestions?: IAutofillSuggestion[] }; + return body.suggestions ?? []; +}; diff --git a/src/api/library/autofill/searchBookSuggestions.ts b/src/api/library/autofill/searchBookSuggestions.ts new file mode 100644 index 00000000..fa9945a5 --- /dev/null +++ b/src/api/library/autofill/searchBookSuggestions.ts @@ -0,0 +1,14 @@ +import type { IAutofillSuggestion } from '@local-types/library/autofill'; + +export const searchBookSuggestions = async ( + query: string, +): Promise => { + const res = await fetch( + `/api/library/autofill/book?q=${encodeURIComponent(query)}`, + ); + // Throw on failure (e.g. 502 when Google's quota is exhausted) so the caller + // can show "search unavailable" instead of a misleading "no matches". + if (!res.ok) throw new Error('book_search_failed'); + const body = (await res.json()) as { suggestions?: IAutofillSuggestion[] }; + return body.suggestions ?? []; +}; diff --git a/src/components/Context/library/GlobalStateContext.tsx b/src/components/Context/library/GlobalStateContext.tsx index 5258a9ca..b419b69e 100644 --- a/src/components/Context/library/GlobalStateContext.tsx +++ b/src/components/Context/library/GlobalStateContext.tsx @@ -88,7 +88,6 @@ export function GlobalStateProvider({ children }: { children: ReactNode }) { const [currentLibrary, setCurrentLibrary] = useState(null); const [isCreateBlocked, setIsCreateBlocked] = useState(false); const didAttemptUserLoad = useRef(false); - const didAttemptLibrariesLoad = useRef(false); const refetchUser = useCallback(async () => { setIsUserLoading(true); @@ -124,15 +123,10 @@ export function GlobalStateProvider({ children }: { children: ReactNode }) { }, [accountData, session, refetchUser]); useEffect(() => { - if (!token) { - didAttemptLibrariesLoad.current = false; - setLibraries(null); - return; - } - if (didAttemptLibrariesLoad.current) { - return; - } - didAttemptLibrariesLoad.current = true; + // `/api/libraries` is publicly readable, so the right-panel library + // dropdown should populate for everyone — including logged-out/incognito + // visitors. Refetch when auth state (token/session) changes in case the + // visible set differs for an authenticated viewer. void refetchLibraries(); }, [token, session, refetchLibraries]); diff --git a/src/components/Header/Header.module.scss b/src/components/Header/Header.module.scss index 42568ddf..cbdf8332 100644 --- a/src/components/Header/Header.module.scss +++ b/src/components/Header/Header.module.scss @@ -338,10 +338,8 @@ } } -// Library has no dark theme: force the warm light header on every library -// page, overriding the dark-theme background and text colors. -.header.library, -.header.library.darkTheme { +// Library light theme: warm cream header. +.header.library { background-color: #f8f1e5 !important; & .actions { @@ -364,6 +362,31 @@ } } +// Library dark theme: match the site-wide dark header. The 3-class selector + +// !important outranks the light `.header.library` rule above. +.header.library.darkTheme { + background-color: #151a26 !important; + + & .actions { + background: #151a26; + + .toggleLanguage .languageTitle { + color: #dadada; + } + } + + & .burgerMenu div { + background-color: #fff; + } + + & .closeButton { + &:after, + &:before { + background-color: #fff; + } + } +} + @media (max-width: 960px) { .header.darkTheme { & .actions { diff --git a/src/components/Navbar/Navbar.module.scss b/src/components/Navbar/Navbar.module.scss index 746e5294..a7e48e51 100644 --- a/src/components/Navbar/Navbar.module.scss +++ b/src/components/Navbar/Navbar.module.scss @@ -125,6 +125,12 @@ } } + // The library glyph ships with a baked-in brown fill (no dark asset like the + // other nav icons) — paint it white so it reads on the dark header. + .libraryIcon path { + fill: #fff; + } + .url { color: #dadada; diff --git a/src/components/Navbar/Navbar.tsx b/src/components/Navbar/Navbar.tsx index 2b7180df..2adb21c6 100644 --- a/src/components/Navbar/Navbar.tsx +++ b/src/components/Navbar/Navbar.tsx @@ -78,7 +78,7 @@ const Navbar: FC = ({ handleToggleSidebar, handleClick }) => { { name: library, path: '/library', - logo: , + logo: , target: '', id: 'library', activeMatch: '/library', diff --git a/src/components/ThemeToggle/ThemeToggle.module.scss b/src/components/ThemeToggle/ThemeToggle.module.scss index b43db9cd..4448bb92 100644 --- a/src/components/ThemeToggle/ThemeToggle.module.scss +++ b/src/components/ThemeToggle/ThemeToggle.module.scss @@ -11,6 +11,7 @@ cursor: pointer; flex-shrink: 0; transition: opacity 0.15s ease; + margin-right: 16px; &:hover { opacity: 0.75; diff --git a/src/components/library/molecules/AudioCard/AudioCard.module.scss b/src/components/library/molecules/AudioCard/AudioCard.module.scss index 53a148d6..48b7476b 100644 --- a/src/components/library/molecules/AudioCard/AudioCard.module.scss +++ b/src/components/library/molecules/AudioCard/AudioCard.module.scss @@ -28,14 +28,21 @@ } } -// Hover/focus-revealed selection toggle, centered over the cover's top edge. +// Hover/focus-revealed selection toggle, floated just above the cover (in the +// shelf's headroom) so it never overlaps the album art. The 6px `padding-bottom` +// is a transparent hover bridge: it keeps the hit area continuous from the card +// up to the pill so the toggle doesn't drop `:hover` (and vanish) as the cursor +// crosses the visual gap to click it. .select { position: absolute; - top: 8px; - left: 8px; - right: 8px; + bottom: 100%; + // Span the album art (190px, flush right) — not the full card — so the toggle + // centers over the artwork rather than the exposed disc edge on the left. + right: 0; + width: 190px; display: flex; justify-content: center; + padding-bottom: 6px; z-index: 2; opacity: 0; pointer-events: none; @@ -107,6 +114,13 @@ height: 138px; gap: 0; + // Double frame (inner 1.5px cream, outer 1px brown) around the cover tile. + .card { + box-shadow: + 0 0 0 1.5px #f9f4eb, + 0 0 0 2.5px #af6a34; + } + .placeholder, .cover { inset: 0; diff --git a/src/components/library/molecules/BookCard/BookCard.module.scss b/src/components/library/molecules/BookCard/BookCard.module.scss index cdaeb16e..c14467da 100644 --- a/src/components/library/molecules/BookCard/BookCard.module.scss +++ b/src/components/library/molecules/BookCard/BookCard.module.scss @@ -26,14 +26,21 @@ } } -// Hover/focus-revealed selection toggle, centered over the cover's top edge. +// Hover/focus-revealed selection toggle, floated just above the cover (in the +// shelf's headroom) so it never overlaps the book art. The 6px `padding-bottom` +// is a transparent hover bridge: it keeps the hit area continuous from the card +// up to the pill so the toggle doesn't drop `:hover` (and vanish) as the cursor +// crosses the visual gap to click it. .select { position: absolute; - top: 8px; - left: 0; - width: 100%; + bottom: 100%; + // Span the cover art (146px, flush right) — not the full card — so the toggle + // centers over the artwork rather than the exposed spine on the left. + right: 0; + width: 146px; display: flex; justify-content: center; + padding-bottom: 6px; z-index: 2; opacity: 0; pointer-events: none; @@ -99,8 +106,10 @@ display: block; } -// Compact variant for the share-selection panel: a smaller cover-only tile -// (97×138 cover, keeping the spine offset and shadow). Tags are dropped. +// Compact variant for the share-selection panel: a flat cover tile that fills +// the cell (no spine offset — placeholder + cover stretch edge-to-edge so a +// covered book reads as a framed thumbnail). Tags are dropped; a double frame +// (inner 1.5px cream, outer 1px brown) wraps the art. .row.compact { width: 119px; height: 138px; @@ -108,18 +117,18 @@ .card { width: 119px; height: 138px; + box-shadow: + 0 0 0 1.5px #f9f4eb, + 0 0 0 2.5px #af6a34; } + .placeholder, .cover { - top: 1px; - right: 0; - width: 97px; - height: 137px; - } - - .select { + inset: 0; + top: 0; left: 0; width: 100%; + height: 100%; } .tags { diff --git a/src/components/library/molecules/Input/Input.module.scss b/src/components/library/molecules/Input/Input.module.scss index 06824d7e..0748c993 100644 --- a/src/components/library/molecules/Input/Input.module.scss +++ b/src/components/library/molecules/Input/Input.module.scss @@ -29,6 +29,14 @@ font-size: 16px; border-radius: 10px; padding: 0 34px 0 16px; + + // Hide WebKit's native search clear button so it doesn't double up with + // our own `onClear` icon. + &::-webkit-search-cancel-button, + &::-webkit-search-decoration { + appearance: none; + -webkit-appearance: none; + } } &:focus-visible { diff --git a/src/components/library/molecules/Input/Input.types.ts b/src/components/library/molecules/Input/Input.types.ts index 04b07d1f..7367c8ea 100644 --- a/src/components/library/molecules/Input/Input.types.ts +++ b/src/components/library/molecules/Input/Input.types.ts @@ -8,7 +8,12 @@ export interface InputProps { disabled?: boolean; ariaLabel?: string; maxLength?: number; + autoComplete?: string; + 'aria-autocomplete'?: React.AriaAttributes['aria-autocomplete']; + 'aria-controls'?: string; onChange: (e: React.ChangeEvent) => void; + onBlur?: (e: React.FocusEvent) => void; onKeyDown?: (e: React.KeyboardEvent) => void; + onPaste?: (e: React.ClipboardEvent) => void; onClear?: () => void; } diff --git a/src/components/library/molecules/SelectToggle/SelectToggle.module.scss b/src/components/library/molecules/SelectToggle/SelectToggle.module.scss index 567ff62e..ce150627 100644 --- a/src/components/library/molecules/SelectToggle/SelectToggle.module.scss +++ b/src/components/library/molecules/SelectToggle/SelectToggle.module.scss @@ -7,8 +7,8 @@ border-radius: 999px; cursor: pointer; white-space: nowrap; - background: var(--white); - color: var(--gray-darkest); + background: var(--brown); + color: var(--white); box-shadow: var(--card-shadow); p { @@ -16,15 +16,15 @@ } &:hover { - background: var(--off-white); + background: var(--brown-100); } &.selected { - background: var(--brown); + background: var(--brown-100); color: var(--white); &:hover { - background: var(--brown-100); + background: var(--brown-200); } } diff --git a/src/components/library/molecules/TitleAutocomplete/TitleAutocomplete.module.scss b/src/components/library/molecules/TitleAutocomplete/TitleAutocomplete.module.scss new file mode 100644 index 00000000..4480f108 --- /dev/null +++ b/src/components/library/molecules/TitleAutocomplete/TitleAutocomplete.module.scss @@ -0,0 +1,84 @@ +.wrapper { + position: relative; + width: 100%; +} + +.menu { + width: 100%; + position: absolute; + top: calc(100% + 4px); + right: 0; + background: var(--white); + border: 1px solid var(--gray-100); + border-radius: 8px; + box-shadow: var(--dropdown-shadow); + z-index: 1000; + display: flex; + flex-direction: column; + overflow: hidden; + margin: 0; + padding: 0; + list-style: none; +} + +.option { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 8px 12px; + text-align: left; + background: transparent; + border: none; + cursor: pointer; + transition: background-color 0.2s ease; + color: var(--gray-200); + + &:hover, + &.active { + background: var(--off-white); + } +} + +.thumb { + flex-shrink: 0; + width: 32px; + height: 44px; + border-radius: 4px; + object-fit: cover; + background: var(--off-white); +} + +.thumbPlaceholder { + flex-shrink: 0; + width: 32px; + height: 44px; + border-radius: 4px; + background: var(--off-white); + border: 1px solid var(--gray-100); +} + +.optionText { + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.optionTitle { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.optionMeta { + color: var(--gray); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.status { + padding: 10px 12px; + color: var(--gray); +} diff --git a/src/components/library/molecules/TitleAutocomplete/TitleAutocomplete.tsx b/src/components/library/molecules/TitleAutocomplete/TitleAutocomplete.tsx new file mode 100644 index 00000000..995d1745 --- /dev/null +++ b/src/components/library/molecules/TitleAutocomplete/TitleAutocomplete.tsx @@ -0,0 +1,254 @@ +import React, { JSX, useEffect, useId, useRef, useState } from 'react'; + +import type { IAutofillSuggestion } from '@local-types/library/autofill'; + +import { Text, TypographyVariant } from '@components/library/atoms/Text'; +import { Input } from '@components/library/molecules/Input'; + +import type { TitleAutocompleteProps } from './TitleAutocomplete.types'; + +import styles from './TitleAutocomplete.module.scss'; + +const DEBOUNCE_MS = 400; +const MIN_QUERY_LENGTH = 3; + +function suggestionMeta(s: IAutofillSuggestion): string { + const year = s.publicationDate?.slice(0, 4); + return [s.author, year].filter(Boolean).join(' · '); +} + +export function TitleAutocomplete(props: TitleAutocompleteProps): JSX.Element { + const { + registration, + ariaLabel, + placeholder, + placeholderColor, + fetchSuggestions, + onSelect, + } = props; + + const [suggestions, setSuggestions] = useState([]); + const [isOpen, setIsOpen] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [status, setStatus] = useState<'idle' | 'empty' | 'error'>('idle'); + const [activeIndex, setActiveIndex] = useState(-1); + + const wrapperRef = useRef(null); + const debounceRef = useRef(null); + const requestIdRef = useRef(0); + // Title applied via a suggestion click — don't reopen the menu for it. + const suppressQueryRef = useRef(null); + const listboxId = useId(); + + const close = () => { + setIsOpen(false); + setActiveIndex(-1); + setStatus('idle'); + }; + + // Close on any press outside the wrapper (options use onPointerDown, so + // selection wins the race against this listener). + useEffect(() => { + if (!isOpen) return; + const handlePointerDown = (event: PointerEvent) => { + if (!wrapperRef.current?.contains(event.target as Node)) close(); + }; + document.addEventListener('pointerdown', handlePointerDown); + return () => document.removeEventListener('pointerdown', handlePointerDown); + }, [isOpen]); + + useEffect(() => { + return () => { + if (debounceRef.current) window.clearTimeout(debounceRef.current); + requestIdRef.current += 1; // discard in-flight responses on unmount + }; + }, []); + + const queueSearch = (query: string) => { + if (debounceRef.current) window.clearTimeout(debounceRef.current); + const trimmed = query.trim(); + + if (suppressQueryRef.current === trimmed) return; + suppressQueryRef.current = null; + + if (trimmed.length < MIN_QUERY_LENGTH) { + requestIdRef.current += 1; + setIsLoading(false); + setStatus('idle'); + close(); + setSuggestions([]); + return; + } + + debounceRef.current = window.setTimeout(async () => { + const requestId = ++requestIdRef.current; + setIsLoading(true); + setIsOpen(true); + try { + const results = await fetchSuggestions(trimmed); + if (requestId !== requestIdRef.current) return; + setSuggestions(results); + setStatus(results.length === 0 ? 'empty' : 'idle'); + } catch { + // Keep the menu open with an error line instead of silently closing, + // so a provider outage (e.g. Google Books quota) reads as "unavailable" + // rather than an indistinguishable "no matches". + if (requestId !== requestIdRef.current) return; + setSuggestions([]); + setStatus('error'); + } finally { + if (requestId === requestIdRef.current) { + setIsLoading(false); + setActiveIndex(-1); + } + } + }, DEBOUNCE_MS); + }; + + const handleChange = (event: React.ChangeEvent) => { + registration.onChange(event); + queueSearch(event.target.value); + }; + + const select = (suggestion: IAutofillSuggestion) => { + suppressQueryRef.current = suggestion.title.trim(); + requestIdRef.current += 1; + setIsLoading(false); + close(); + onSelect(suggestion); + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (!isOpen || suggestions.length === 0) { + if (event.key === 'Escape') close(); + return; + } + switch (event.key) { + case 'ArrowDown': + event.preventDefault(); + setActiveIndex(prev => (prev + 1) % suggestions.length); + break; + case 'ArrowUp': + event.preventDefault(); + setActiveIndex( + prev => (prev - 1 + suggestions.length) % suggestions.length, + ); + break; + case 'Enter': + if (activeIndex >= 0) { + event.preventDefault(); + select(suggestions[activeIndex]); + } + break; + case 'Escape': + close(); + break; + } + }; + + return ( +
+ + {isOpen && ( +
    + {isLoading && suggestions.length === 0 && ( +
  • + Searching… +
  • + )} + {!isLoading && status === 'empty' && ( +
  • + + No matches found. + +
  • + )} + {!isLoading && status === 'error' && ( +
  • + + Search is unavailable right now — please fill the details + manually. + +
  • + )} + {suggestions.map((suggestion, index) => { + const meta = suggestionMeta(suggestion); + return ( +
  • + +
  • + ); + })} +
+ )} +
+ ); +} diff --git a/src/components/library/molecules/TitleAutocomplete/TitleAutocomplete.types.ts b/src/components/library/molecules/TitleAutocomplete/TitleAutocomplete.types.ts new file mode 100644 index 00000000..85c3cbcf --- /dev/null +++ b/src/components/library/molecules/TitleAutocomplete/TitleAutocomplete.types.ts @@ -0,0 +1,13 @@ +import type { UseFormRegisterReturn } from 'react-hook-form'; + +import type { IAutofillSuggestion } from '@local-types/library/autofill'; + +export interface TitleAutocompleteProps { + /** react-hook-form registration for the title field — spread onto the input. */ + registration: UseFormRegisterReturn; + ariaLabel: string; + placeholder: string; + placeholderColor?: string; + fetchSuggestions: (query: string) => Promise; + onSelect: (suggestion: IAutofillSuggestion) => void; +} diff --git a/src/components/library/molecules/TitleAutocomplete/index.tsx b/src/components/library/molecules/TitleAutocomplete/index.tsx new file mode 100644 index 00000000..e365d341 --- /dev/null +++ b/src/components/library/molecules/TitleAutocomplete/index.tsx @@ -0,0 +1,2 @@ +export * from './TitleAutocomplete'; +export * from './TitleAutocomplete.types'; diff --git a/src/components/library/molecules/VideoCard/VideoCard.module.scss b/src/components/library/molecules/VideoCard/VideoCard.module.scss index c7594f3a..ab817028 100644 --- a/src/components/library/molecules/VideoCard/VideoCard.module.scss +++ b/src/components/library/molecules/VideoCard/VideoCard.module.scss @@ -14,7 +14,6 @@ cursor: pointer; border: none; outline: none; - overflow: hidden; &:focus-visible { box-shadow: @@ -24,14 +23,19 @@ } } -// Hover/focus-revealed selection toggle, centered over the thumbnail's top. +// Hover/focus-revealed selection toggle, floated just above the card (in the +// shelf's headroom) so it never overlaps the thumbnail. The 6px `padding-bottom` +// is a transparent hover bridge: it keeps the hit area continuous from the card +// up to the pill so the toggle doesn't drop `:hover` (and vanish) as the cursor +// crosses the visual gap to click it. .select { position: absolute; - top: 20px; - left: 12px; - right: 12px; + bottom: 100%; + left: 0; + right: 0; display: flex; justify-content: center; + padding-bottom: 6px; z-index: 2; opacity: 0; pointer-events: none; @@ -108,6 +112,11 @@ height: 138px; padding: 0; border-radius: 0; + // Swap the floating drop shadow for the double frame (inner 1.5px cream, + // outer 1px brown) so it matches the book/audio tiles in the panel. + box-shadow: + 0 0 0 1.5px #f9f4eb, + 0 0 0 2.5px #af6a34; .thumbWrap { height: 100%; @@ -117,10 +126,4 @@ .title { display: none; } - - .select { - top: 8px; - left: 8px; - right: 8px; - } } diff --git a/src/components/library/organisms/AddObjectModal/AddObjectModal.module.scss b/src/components/library/organisms/AddObjectModal/AddObjectModal.module.scss index 634e324f..6295ae7f 100644 --- a/src/components/library/organisms/AddObjectModal/AddObjectModal.module.scss +++ b/src/components/library/organisms/AddObjectModal/AddObjectModal.module.scss @@ -22,8 +22,20 @@ overflow-y: auto; overflow-x: hidden; + scrollbar-width: thin; + scrollbar-color: #c0b6ae transparent; + &::-webkit-scrollbar { - display: none; + width: 8px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background: #c0b6ae; + border-radius: 5px; } } @@ -62,6 +74,20 @@ animation: slideDownError 0.25s ease-out; } +// Same slot as .error (the field's bottom margin) but informational — used for +// autofill notices like "Autofill supports YouTube links". +.hint { + position: absolute; + top: 100%; + left: 0; + right: 0; + margin: 2px 0 0; + color: var(--gray); + font-size: 12px; + line-height: 1.3; + animation: slideDownError 0.25s ease-out; +} + .textareaWrapper { width: 100%; } @@ -114,6 +140,42 @@ } } +// On phones/tablets fill the viewport minus a 15px top/bottom gap (the flex +// backdrop centers the content, so the gap lands evenly). The content is a flex +// column — the Modal's own header and the form's footer keep their natural +// height while the field area (`.wrapper`) takes the rest and scrolls, so the +// action buttons stay visible instead of overflowing off-screen. +@media (max-width: 960px) { + .modal { + width: calc(100dvw - 32px) !important; + max-width: none !important; + height: calc(100dvh - 30px); + margin: 0; + display: flex; + flex-direction: column; + + form { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + } + } + + .wrapper { + flex: 1; + max-height: none; + } + + .indicatorWrap { + padding: 16px; + } + + .footer { + padding: 16px; + } +} + @keyframes fadeInUp { from { opacity: 0; diff --git a/src/components/library/organisms/AddObjectModal/AddObjectModal.tsx b/src/components/library/organisms/AddObjectModal/AddObjectModal.tsx index 6df3e6b1..cf955533 100644 --- a/src/components/library/organisms/AddObjectModal/AddObjectModal.tsx +++ b/src/components/library/organisms/AddObjectModal/AddObjectModal.tsx @@ -1,4 +1,5 @@ import { zodResolver } from '@hookform/resolvers/zod'; +import { detectSource } from '@utils/library/detectSource'; import { resolveStrapiUrl } from '@utils/library/resolveStrapiUrl'; import { type AddObjectFormData, @@ -8,9 +9,14 @@ import { import React, { JSX, useEffect, useMemo, useRef, useState } from 'react'; import { Controller, type SubmitHandler, useForm } from 'react-hook-form'; +import type { IAutofillSuggestion } from '@local-types/library/autofill'; import type { IObject } from '@local-types/library/object'; import type { IShelf } from '@local-types/library/shelf'; +import { fetchCoverFile } from '@api/library/autofill/fetchCoverFile'; +import { lookupVideoByUrl } from '@api/library/autofill/lookupVideoByUrl'; +import { searchAudioSuggestions } from '@api/library/autofill/searchAudioSuggestions'; +import { searchBookSuggestions } from '@api/library/autofill/searchBookSuggestions'; import { createObject } from '@api/library/object/createObject'; import { reorderObjects } from '@api/library/object/reorderObjects'; import { updateObject } from '@api/library/object/updateObject'; @@ -40,6 +46,7 @@ import { StepIndicator } from '@components/library/molecules/StepIndicator'; import { TagMultiSelect } from '@components/library/molecules/TagMultiSelect'; import type { TagOption } from '@components/library/molecules/TagMultiSelect/TagMultiSelect.types'; import { Textarea } from '@components/library/molecules/Textarea'; +import { TitleAutocomplete } from '@components/library/molecules/TitleAutocomplete'; import { configByType } from './AddObjectModal.config'; import type { AddObjectModalProps, FieldKey } from './AddObjectModal.types'; @@ -65,6 +72,8 @@ function buildDefaults( author: a.author ?? '', description: a.description ?? '', sourceUrl: a.sourceUrl ?? '', + source: a.source ?? '', + duration: a.duration ?? undefined, publicationDate: a.publicationDate ? new Date(a.publicationDate) : null, coverImage: null, }; @@ -73,6 +82,14 @@ function buildDefaults( const DRAFT_REORDER_ID = 'draft-new'; +// Google Books dates come as "2019", "2019-10" or "2019-10-15". +function parsePublicationDate(raw: string): Date | null { + const [y, m, d] = raw.split('-').map(Number); + if (!y || Number.isNaN(y)) return null; + const date = new Date(y, (m || 1) - 1, d || 1); + return Number.isNaN(date.getTime()) ? null : date; +} + // Pull the status + Strapi error message out of an axios failure so a rejected // reorder reports *why* (e.g. 403 permission, 400 "All objects must belong to // the given shelf") instead of vanishing into a status-less console line. @@ -124,6 +141,8 @@ export function AddObjectModal(props: AddObjectModalProps): JSX.Element { const [submitError, setSubmitError] = useState(null); const [isSubmittingForm, setIsSubmittingForm] = useState(false); const [showSuccess, setShowSuccess] = useState(false); + const [autofillNotice, setAutofillNotice] = useState(null); + const [isFetchingVideoMeta, setIsFetchingVideoMeta] = useState(false); const [tagOptions, setTagOptions] = useState([]); const [selectedTags, setSelectedTags] = useState([]); @@ -159,9 +178,91 @@ export function AddObjectModal(props: AddObjectModalProps): JSX.Element { handleSubmit, control, watch, + setValue, formState: { errors, isValid }, } = form; + // Push a provider suggestion into the form. Values are clamped to the zod + // limits so an autofill can never leave the form invalid; the cover is + // best-effort — fields land first, the image follows when the proxy resolves. + const applySuggestion = async (s: IAutofillSuggestion) => { + const isBook = objectType === 'book'; + const setOptions = { shouldValidate: true, shouldDirty: true } as const; + + setValue('title', s.title.slice(0, isBook ? 200 : 150), setOptions); + if (s.author) { + setValue('author', s.author.slice(0, isBook ? 150 : 100), setOptions); + } + if (s.description) { + setValue( + 'description', + s.description.slice(0, isBook ? 4000 : 5000), + setOptions, + ); + } + if (!isBook) { + // A suggestion's link is the provider's own — for audio it's always an + // Apple/iTunes trackViewUrl. Only seed the URL when the user hasn't + // entered their own, so a pasted Spotify (etc.) link isn't clobbered. + // Either way derive Source from whatever URL ends up in the form, never + // from the suggestion link directly — otherwise Source could read + // "Apple Music" while the URL field shows a Spotify link. + const currentUrl = ( + form.getValues('sourceUrl' as never) as unknown as string + )?.trim(); + if (!currentUrl && s.sourceUrl) { + setValue('sourceUrl' as never, s.sourceUrl as never, setOptions); + } + const effectiveUrl = currentUrl || s.sourceUrl; + if (effectiveUrl) { + const detected = detectSource(effectiveUrl); + if (detected) { + setValue('source' as never, detected as never, setOptions); + } + } + } + if (objectType === 'audio' && s.durationSeconds != null) { + setValue('duration' as never, s.durationSeconds as never, setOptions); + } + if (isBook && s.publicationDate) { + const date = parsePublicationDate(s.publicationDate); + if (date) { + setValue('publicationDate' as never, date as never, setOptions); + } + } + if (s.coverUrl) { + const file = await fetchCoverFile(s.coverUrl, s.title); + if (file) setValue('coverImage', file, setOptions); + } + }; + + const handleVideoUrlFetch = async (rawUrl?: string) => { + const url = ( + rawUrl ?? (form.getValues('sourceUrl' as never) as unknown as string) + )?.trim(); + setAutofillNotice(null); + if (!url) return; + setIsFetchingVideoMeta(true); + try { + const result = await lookupVideoByUrl(url); + if (result.status === 'unsupported') { + setAutofillNotice( + 'Autofill supports YouTube links — fill the details manually.', + ); + return; + } + if (result.status === 'error') { + setAutofillNotice( + "Couldn't fetch video details. Please fill them manually.", + ); + return; + } + await applySuggestion(result.suggestion); + } finally { + setIsFetchingVideoMeta(false); + } + }; + useEffect(() => { let cancelled = false; getTagsList().then(res => { @@ -311,6 +412,8 @@ export function AddObjectModal(props: AddObjectModalProps): JSX.Element { author: data.author || undefined, description: data.description || undefined, sourceUrl: 'sourceUrl' in data ? data.sourceUrl : undefined, + source: 'source' in data ? data.source || undefined : undefined, + duration: 'duration' in data ? data.duration : undefined, publicationDate, tags, shelf, @@ -334,6 +437,8 @@ export function AddObjectModal(props: AddObjectModalProps): JSX.Element { author: data.author || undefined, description: data.description || undefined, sourceUrl: 'sourceUrl' in data ? data.sourceUrl : undefined, + source: 'source' in data ? data.source || undefined : undefined, + duration: 'duration' in data ? data.duration : undefined, publicationDate, coverImage: coverImageId ?? undefined, tags, @@ -487,7 +592,11 @@ export function AddObjectModal(props: AddObjectModalProps): JSX.Element { const label = config.labels[key] ?? key; switch (key) { - case 'title': + case 'title': { + // Typeahead autofill is create-only (silently rewriting an object the + // user opened to edit would be hostile) and not for video — videos + // autofill from a pasted YouTube URL instead. + const hasTypeahead = isCreate && objectType !== 'video'; return (
{label} - + {hasTypeahead ? ( + + ) : ( + + )} {errors.title && (

{errors.title.message}

)}
); + } case 'author': return (
@@ -608,7 +733,8 @@ export function AddObjectModal(props: AddObjectModalProps): JSX.Element { )}
); - case 'sourceUrl': + case 'sourceUrl': { + const sourceUrlReg = register('sourceUrl' as never); return (
-
- {'sourceUrl' in errors && errors.sourceUrl?.message && ( + {'sourceUrl' in errors && errors.sourceUrl?.message ? (

{String(errors.sourceUrl.message)}

+ ) : ( + autofillNotice &&

{autofillNotice}

)} ); + } default: return null; } diff --git a/src/components/library/organisms/LibraryToolbar/LibraryToolbar.module.scss b/src/components/library/organisms/LibraryToolbar/LibraryToolbar.module.scss index bec7c16f..d9d4ab48 100644 --- a/src/components/library/organisms/LibraryToolbar/LibraryToolbar.module.scss +++ b/src/components/library/organisms/LibraryToolbar/LibraryToolbar.module.scss @@ -4,7 +4,7 @@ top: 48px; z-index: 20; background: var(--cream); - padding: 0 16px 12px; + padding: 1px 16px 12px; // The global header switches to a fixed 55px bar at this breakpoint. @media (max-width: 1140px) { @@ -24,13 +24,46 @@ align-items: center; justify-content: space-between; gap: 24px; + animation: toolbar-fade 0.3s ease; + // On phones everything crammed onto one row collapsed the flex:1 pill + // scroller to near-zero, leaving only the overlapping arrows. Stack into a + // column so each control gets a full-width row: jump label, pill scroller, + // actions, then search. @media (max-width: 768px) { - flex-wrap: wrap; + flex-direction: column; + align-items: stretch; gap: 12px; } } +// Visitor / guest-preview banner that replaces the owner's shelf controls. +.welcome { + display: flex; + flex-direction: column; + gap: 8px; + animation: toolbar-fade 0.3s ease; +} + +.welcomeTitle { + color: var(--brown-200); +} + +.welcomeText { + color: var(--brown-200); +} + +// Fades whichever variant is mounted, so toggling guest mode in your own +// library swaps the content smoothly instead of a hard cut. +@keyframes toolbar-fade { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + .text { font-family: 'Lato', sans-serif; white-space: nowrap; @@ -52,6 +85,10 @@ gap: 12px; flex: 1; min-width: 0; + + @media (max-width: 768px) { + width: 100%; + } } .jumpScroll { @@ -92,6 +129,10 @@ width: 300px; max-width: 100%; + @media (max-width: 768px) { + width: 100%; + } + input { border-radius: 12px !important; background: var(--white-300) !important; @@ -183,6 +224,11 @@ display: flex; align-items: center; gap: 10px; + + @media (max-width: 768px) { + width: 100%; + justify-content: flex-end; + } } // Button's `text` type defaults to white (built for dark surfaces); on this diff --git a/src/components/library/organisms/LibraryToolbar/LibraryToolbar.tsx b/src/components/library/organisms/LibraryToolbar/LibraryToolbar.tsx index 471f178c..dca10fb5 100644 --- a/src/components/library/organisms/LibraryToolbar/LibraryToolbar.tsx +++ b/src/components/library/organisms/LibraryToolbar/LibraryToolbar.tsx @@ -16,7 +16,14 @@ import { } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import classNames from 'classnames'; -import React, { JSX, useCallback, useEffect, useRef, useState } from 'react'; +import React, { + JSX, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { MAX_SHELVES_PER_LIBRARY } from '@constants/library/common'; @@ -86,7 +93,16 @@ function SortablePill(props: { shelf: StrapiSingleShelfEntry }): JSX.Element { } export function LibraryToolbar(props: LibraryToolbarProps): JSX.Element { - const { shelves, onAddShelf, onShelvesReordered, className } = props; + const { + shelves, + onAddShelf, + onShelvesReordered, + isOwner = true, + ownerName, + search = '', + onSearchChange, + className, + } = props; const [selectedJumpShelfId, setSelectedJumpShelfId] = useState( null, ); @@ -101,9 +117,6 @@ export function LibraryToolbar(props: LibraryToolbarProps): JSX.Element { const dragStartOrder = useRef([]); const listRef = useRef(null); - // Search is UI-only for now — no query wiring yet. - const [search, setSearch] = useState(''); - // Horizontal scroller for the jump pills: when the row overflows, page // through it with the same arrows the shelves use. const jumpRef = useRef(null); @@ -140,6 +153,38 @@ export function LibraryToolbar(props: LibraryToolbarProps): JSX.Element { const jumpOverflowing = canJumpLeft || canJumpRight; const atShelfLimit = shelves.length >= MAX_SHELVES_PER_LIBRARY; + // Visitor banner: the tags actually used on this library's objects, deduped + // by name (no cross-account tag fetch — mirror the Sidebar's derivation). + const tagNames = useMemo(() => { + const names = new Set(); + for (const shelf of shelves) { + for (const obj of shelf.attributes.objects?.data ?? []) { + for (const tag of obj.attributes.tags?.data ?? []) { + names.add(tag.attributes.name); + } + } + } + return Array.from(names); + }, [shelves]); + + // Pick two distinct tags to tease in the welcome line; re-rolls only when the + // available tag set changes, not on every render. + const featuredTags = useMemo(() => { + const pool = [...tagNames]; + for (let i = pool.length - 1; i > 0; i -= 1) { + const j = Math.floor(Math.random() * (i + 1)); + [pool[i], pool[j]] = [pool[j], pool[i]]; + } + return pool.slice(0, 2); + }, [tagNames]); + + const collectionsClause = + featuredTags.length >= 2 + ? `curated collections on ${featuredTags[0]} and ${featuredTags[1]}, ` + : featuredTags.length === 1 + ? `curated collections on ${featuredTags[0]}, ` + : 'curated collections, '; + // Keep the working copy aligned with the source list while idle; freeze it // during a reorder session so incoming prop updates can't clobber the drag. useEffect(() => { @@ -250,6 +295,30 @@ export function LibraryToolbar(props: LibraryToolbarProps): JSX.Element { } }; + if (!isOwner) { + return ( +
+
+ +
+ + Welcome to {ownerName}’s hive + + + Discover and explore {collectionsClause}along with an incredible + playlist full of his favorite songs. + +
+
+ ); + } + return (
@@ -401,8 +470,8 @@ export function LibraryToolbar(props: LibraryToolbarProps): JSX.Element { value={search} placeholder="Search everywhere" placeholderColor="#C4C4C4" - onChange={e => setSearch(e.target.value)} - onClear={() => setSearch('')} + onChange={e => onSearchChange?.(e.target.value)} + onClear={() => onSearchChange?.('')} wrapperClassName={styles.search} ariaLabel="Search everywhere" /> diff --git a/src/components/library/organisms/LibraryToolbar/LibraryToolbar.types.ts b/src/components/library/organisms/LibraryToolbar/LibraryToolbar.types.ts index 5ce70960..94d69dec 100644 --- a/src/components/library/organisms/LibraryToolbar/LibraryToolbar.types.ts +++ b/src/components/library/organisms/LibraryToolbar/LibraryToolbar.types.ts @@ -9,5 +9,15 @@ export interface LibraryToolbarProps { * library can re-sequence its shelves without a refetch. */ onShelvesReordered?: (ordered: IReorderShelfEntry[]) => void; + /** + * When false (a visitor or the owner previewing guest mode) the toolbar swaps + * its shelf controls for a read-only welcome banner. Defaults to true. + */ + isOwner?: boolean; + /** Library owner's display name, shown in the visitor welcome banner. */ + ownerName?: string; + /** Current search query. Controlled by the library so the shelf list filters. */ + search?: string; + onSearchChange?: (value: string) => void; className?: string; } diff --git a/src/components/library/organisms/ObjectOverviewModal/ObjectOverviewModal.module.scss b/src/components/library/organisms/ObjectOverviewModal/ObjectOverviewModal.module.scss index 15704c7c..7c65af1b 100644 --- a/src/components/library/organisms/ObjectOverviewModal/ObjectOverviewModal.module.scss +++ b/src/components/library/organisms/ObjectOverviewModal/ObjectOverviewModal.module.scss @@ -98,6 +98,22 @@ flex: 1; min-height: 0; overflow-y: auto; + + scrollbar-width: thin; + scrollbar-color: #c0b6ae transparent; + + &::-webkit-scrollbar { + width: 8px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background: #c0b6ae; + border-radius: 5px; + } } .left { @@ -269,3 +285,33 @@ font-size: 12px; margin: 4px 0 0; } + +// On phones/tablets fill the viewport minus a 15px top/bottom gap (the flex +// backdrop centers the content, so the gap lands evenly). `.left` already +// precedes `.right` in the DOM, so collapsing the grid to one column stacks the +// cover above the data. +@media (max-width: 960px) { + .modal { + width: calc(100dvw - 32px) !important; + max-width: none !important; + height: calc(100dvh - 30px); + max-height: calc(100dvh - 30px); + margin: 0; + } + + .header { + padding: 20px 16px; + } + + .body { + grid-template-columns: 1fr; + gap: 24px; + padding: 20px 16px; + } + + // Keep the cover from swallowing the screen on tall portrait art. + .cover { + max-width: 220px; + margin: 0 auto; + } +} diff --git a/src/components/library/organisms/ObjectOverviewModal/ObjectOverviewModal.tsx b/src/components/library/organisms/ObjectOverviewModal/ObjectOverviewModal.tsx index cb0d5055..1a325530 100644 --- a/src/components/library/organisms/ObjectOverviewModal/ObjectOverviewModal.tsx +++ b/src/components/library/organisms/ObjectOverviewModal/ObjectOverviewModal.tsx @@ -284,8 +284,12 @@ export function ObjectOverviewModal( shelfData?.attributes.name ?? attributes.shelfName ?? '—'; - const shelfPosition = - liveShelf?.attributes.order ?? shelfData?.attributes.order; + // The object's own position within its shelf — not the shelf's order. Siblings + // arrive already sorted by `order` ASC, so the array index is the true rank + // (contiguous 1..N even when persisted `order` values have gaps). Fall back to + // the object's raw `order` when siblings weren't passed. + const positionIndex = shelfObjects?.findIndex(o => o.id === id) ?? -1; + const objectPosition = positionIndex >= 0 ? positionIndex : attributes.order; const publishedFormatted = formatDate(attributes.publicationDate); const sourceLabel = attributes.source && attributes.source.length > 0 ? attributes.source : '—'; @@ -588,7 +592,9 @@ export function ObjectOverviewModal( variant={TypographyVariant.TextBase} className={styles.rowValue} > - {shelfPosition !== undefined ? String(shelfPosition) : '—'} + {objectPosition !== undefined + ? String(objectPosition + 1) + : '—'}
diff --git a/src/components/library/organisms/ShareSelectionPanel/ShareSelectionPanel.module.scss b/src/components/library/organisms/ShareSelectionPanel/ShareSelectionPanel.module.scss index 8109d9ff..3f9442e8 100644 --- a/src/components/library/organisms/ShareSelectionPanel/ShareSelectionPanel.module.scss +++ b/src/components/library/organisms/ShareSelectionPanel/ShareSelectionPanel.module.scss @@ -12,12 +12,21 @@ max-height: 440px; background: var(--white-warm); box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.08); + transition: max-height 0.3s ease; @media (max-width: 1024px) { right: 0; } } +// Collapsed: fold the panel down to just the header bar (still pinned to the +// bottom of the viewport). The body is clipped by .bodyWrap's overflow:hidden. +// Selecting more objects from the library while minimized still works — they're +// added to the hidden body and show up when the panel is expanded again. +.collapsed { + max-height: 87px; +} + .header { display: flex; align-items: center; @@ -61,20 +70,19 @@ gap: 16px; } -// Animated collapse wrapper. The grid 1fr→0fr transition smoothly shrinks the -// whole section to nothing (the chevron points at the header), and a single 1fr -// row in this flex child resolves to the body's content height when the panel -// is uncapped, or to the leftover space (so .body scrolls) when it hits 440px. +// Outlined button defaults to a filled surface; on the cream header the +// "Remove all" control should read as a bare bordered button. +.removeAll { + background: transparent !important; +} + +// Fills the space under the header and clips its content as the panel collapses +// (the panel's max-height drives the accordion). `flex: 1` + `min-height: 0` +// let .body scroll when the panel hits its 440px cap with many objects. .bodyWrap { flex: 1; min-height: 0; - display: grid; - grid-template-rows: 1fr; - transition: grid-template-rows 0.28s ease; -} - -.bodyWrapCollapsed { - grid-template-rows: 0fr; + overflow: hidden; } .body { diff --git a/src/components/library/organisms/ShareSelectionPanel/ShareSelectionPanel.tsx b/src/components/library/organisms/ShareSelectionPanel/ShareSelectionPanel.tsx index 101353bd..5617eb9a 100644 --- a/src/components/library/organisms/ShareSelectionPanel/ShareSelectionPanel.tsx +++ b/src/components/library/organisms/ShareSelectionPanel/ShareSelectionPanel.tsx @@ -236,6 +236,7 @@ export function ShareSelectionPanel({ onClick={() => onClear?.()} type={ButtonType.Outlined} size={ButtonSize.Default} + className={styles.removeAll} />
)} @@ -243,12 +244,7 @@ export function ShareSelectionPanel({ {/* Always mounted so the chevron can animate the whole section open/closed via the grid-rows collapse (see .bodyWrap) instead of unmounting. */} -
+
{!readOnly && limitReached && ( 0 && objects.every(o => isSelected(o.id)); + const selectShelfDisabled = + visibility !== 'public' || + objects.length === 0 || + (!allSelected && limitReached); const handleSelectShelf = () => { if (allSelected) removeMany(objects.map(o => o.id)); else selectMany(objects); @@ -410,17 +416,26 @@ export function Shelf(props: ShelfProps): JSX.Element {
- {canSelectShelf && ( - + )} + {isSidebarOpen && ( +