Compare commits

...

107 Commits

Author SHA1 Message Date
f7a4a9512c fix: persist a2ui surfaces in the database for chats to re-hydrate on
opening an old chat, unless manually dismissed
2026-05-27 20:13:33 +02:00
141c2bfc89 removed fixed codesmell document 2026-05-27 19:20:43 +02:00
a5ac74db91 fix(style): add missing @impl true to all handle_call clauses in Publishing GenServer (CSM-036) 2026-05-27 19:19:50 +02:00
beca4d992f fix(docs): document UILocale process-dict invariant and add enforcement tests (CSM-035) 2026-05-27 19:16:42 +02:00
9e6d93a4b3 fix(safety): replace File.read! with File.read and error-tuple handling in preview_assets and templates (CSM-034) 2026-05-27 19:10:13 +02:00
e29dfb490a fix(perf): replace Enum.each + individual inserts with preloaded keys and batch upsert in embeddings (CSM-033) 2026-05-27 19:03:21 +02:00
f2b340ba86 fix(style): replace Map.get with dot access and pattern matching where keys are guaranteed (CSM-032) 2026-05-27 18:33:42 +02:00
d18e0ef7f2 fix(rendering): replace inline try/rescue with with-chains and safe_liquex_render helpers (CSM-031) 2026-05-27 18:12:23 +02:00
2d796cee83 updated specgaps for A3-1 2026-05-27 17:56:19 +02:00
b052d59376 fix(fs): handle File.mkdir_p errors and remove bang variants in sidecars and release packaging (CSM-030) 2026-05-11 20:25:06 +02:00
4a089b0856 fix(perf): bind length/1 to variables before loop bodies in route paths and sidebar (CSM-029) 2026-05-11 20:18:56 +02:00
2632649cdc fix(rendering): replace raising read_template_file with try_read in macro templates (CSM-028) 2026-05-11 20:09:49 +02:00
782511d523 fix(templates): replace equality check with pattern matching in rewrite_template_file (CSM-027) 2026-05-11 20:06:15 +02:00
1cb59d7a78 chore: update the spec gaps with decisionm points 2026-05-11 12:19:16 +02:00
9844f3555a chore: analyse specs against code 2026-05-11 11:56:34 +02:00
99dc1c2216 chore: remove redundant export-only tests, add test audit procedure
Deleted chat_editor_test.exs and import_editor_test.exs which only
checked function_exported?/Code.ensure_loaded? without exercising any
behavior — both components are already tested via LiveView rendering
in shell_live_test.exs and import_shell_live_test.exs respectively.

Added TESTAUDIT.md documenting the procedure for periodic test suite
audits to catch non-behavioral tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-11 10:35:24 +02:00
71fb99af16 chore: unit tests adaption to idea of gate test 2026-05-11 10:21:00 +02:00
0808b27057 chore: moved from delay-based tests to deterministic gated server tests
for chat
2026-05-11 10:20:46 +02:00
ac4f5a3580 fix: eliminate TOCTOU race in template file system by reading directly instead of checking existence (CSM-026) 2026-05-11 09:26:54 +02:00
43a435f35d fix: derive pagefind language prefixes from project settings (CSM-025)
Replace hardcoded ["de/", "fr/", "it/", "es/"] with prefixes computed
from plan.blog_languages, so arbitrary language codes work correctly.
Also mark CSM-024 as fixed (done in CSM-005).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-11 09:19:31 +02:00
7b383d31ab fix: decompose monolithic functions into focused helpers (CSM-023)
Break up SRP violations in Templates.do_update_template/2 and
Capabilities.for_project/2 by extracting domain-specific builder
functions and single-responsibility private helpers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-11 09:15:48 +02:00
09df925e9b chore: allow git push 2026-05-11 09:10:08 +02:00
a4ecbabc21 fix: return error tuples instead of silent {:ok, ""} in execute_macro (CSM-022)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-11 09:07:44 +02:00
2be43ca06d fix: replace cond blocks with pattern matching and case (CSM-021)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-11 09:04:56 +02:00
d231f42363 chore: allowed git add and ecto migrate 2026-05-10 12:37:24 +02:00
7c00279b9d fix: flatten nested case blocks with with chains (CSM-020)
Replace deeply nested case expressions with flat with chains in
import_definitions, publishing, and templates modules. Also replaced
Repo.update!() with Repo.update() in the publishing update_job handler.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-10 12:36:42 +02:00
b6f9cf58e1 fix: add @spec to all public functions across 24 modules (CSM-019)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-10 11:40:42 +02:00
3f77488e33 fix: add @moduledoc to all public modules (CSM-018)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-10 11:30:08 +02:00
5c17751d55 fix: fixed CSM-017 2026-05-09 17:33:51 +02:00
e4452ca504 chore: unit tests for CSM-016 2026-05-09 17:18:43 +02:00
ce80f28e60 fix: fix CSM-016 for real (previous commit was 015) 2026-05-09 17:18:32 +02:00
f1de11a205 fix: fixed CSM-016 2026-05-09 16:57:59 +02:00
ff219fd110 chore: unit test for csm-014 2026-05-09 16:38:18 +02:00
de7ea12c9c fix: fixed CSM-014 2026-05-09 16:38:00 +02:00
1beffe6b07 chore: unit test for csm-013 2026-05-09 15:10:24 +02:00
999632dbe7 fix: fixed CSM-013 2026-05-09 15:10:04 +02:00
44b88056e3 fix: fix CSM-012 2026-05-09 15:04:10 +02:00
35b3818d58 chore: unit test for CSM-011 2026-05-09 14:52:48 +02:00
37db52c024 fix: fixed CSM-011 2026-05-09 14:52:29 +02:00
f1445120fc fix: fix CSM-010 2026-05-09 14:31:44 +02:00
14dfbd8829 fix: fixed CSM-009 2026-05-09 14:22:56 +02:00
24e9e9a022 fix: fixed CSM-008 2026-05-09 14:17:32 +02:00
88c689ee55 fix: fixed CSM-007 2026-05-09 10:41:28 +02:00
e5429f7265 fix: implement CSM-006 2026-05-08 20:39:50 +02:00
93a4159c31 fix: fix CSM-006 2026-05-08 20:26:08 +02:00
06d80e2924 fix: tests for CSM-005 2026-05-08 20:09:18 +02:00
291dff697c fix: fix CSM-005 2026-05-08 20:09:02 +02:00
9944b70ab1 fix: fixed CSM-004 2026-05-08 19:52:59 +02:00
723b8c6433 fix: worked on CSM-003 2026-05-07 21:49:59 +02:00
92334256cf fix: fix CSM-002 2026-05-07 16:52:53 +02:00
d3f45ba0dd fix: CSM-001 done 2026-05-06 19:33:54 +02:00
3ce6010b87 updated codesmell file with new analysis 2026-05-06 18:48:00 +02:00
f704aba288 chore: added a new code smell todo 2026-05-05 20:44:58 +02:00
5282fcd241 chore: update docs 2026-05-04 13:11:23 +02:00
7756d9f83c feat: last gaps on tailwind migration 2026-05-04 13:00:42 +02:00
4ab0bc7b4e feat: gaps in tailwind migration closed 2026-05-04 12:27:07 +02:00
eca89e51d2 feat: phase 5 of tailwind migration 2026-05-04 12:02:13 +02:00
8e715eec8b feat: phase 4 of tailwind migration 2026-05-04 11:39:31 +02:00
35017f9793 feat: p hase 3 of tailwind migration 2026-05-04 11:12:17 +02:00
b17e9cc3f8 feat: rework of the full CSS machine to tailwind and modular CSS
Co-authored-by: Copilot <copilot@github.com>
2026-05-04 10:15:01 +02:00
6b6c985187 fix: better styling for docs 2026-05-04 07:01:43 +02:00
cb46b45cda feat: added doc rendering 2026-05-04 06:47:28 +02:00
43a4610ce7 chore: noise in tests 2026-05-04 06:18:06 +02:00
4de8492c4f feat: complete change to gettext from homebrew i18n solution 2026-05-03 22:28:25 +02:00
4bee8cf1db fix: proper menu translation 2026-05-03 19:31:26 +02:00
dbb93a66f6 chore: moved a bunch of bridges out of the ShellLive module into its own
module
2026-05-03 19:09:24 +02:00
483c13aaa3 chore: more of the overlay and sidebar stuff 2026-05-03 18:56:10 +02:00
f3d8fbcbdc chore: moved more code out of ShellLive into their own files 2026-05-03 18:54:32 +02:00
c16afa4c00 chore: switched MiscEditor to LiveComponent 2026-05-03 18:21:48 +02:00
0f193929da chore: converted import editor to LiveComponent 2026-05-03 17:57:43 +02:00
fa76cdf11d chore: converted ai chat to a live component 2026-05-03 17:20:52 +02:00
98243cbd16 feat: some more work on completing AI translation features 2026-05-03 15:32:54 +02:00
657ed58e80 fix: provide target language for AI suggestions 2026-05-03 14:50:20 +02:00
556f33711f fix: fixed media quick actions usage for images 2026-05-03 14:24:59 +02:00
5bc2b4a338 chore: convert media to live component 2026-05-03 13:09:27 +02:00
9f17954ce3 overlay for post suggestions uses AI now 2026-05-03 12:42:03 +02:00
b9797809aa fix: metadata and excerpt collapsible 2026-05-03 11:38:14 +02:00
4fee1a6333 chore: converted post editor to live component 2026-05-03 11:32:43 +02:00
0075f25ef7 chore: converted scripts and templates to live components 2026-05-03 10:00:22 +02:00
8d7e7419d4 chore: convert menu editor to live component 2026-05-03 09:35:49 +02:00
ce54e973ad chore: converted preferences editor to live component 2026-05-03 09:19:27 +02:00
6c7fde6b95 changed tags editor into live component 2026-05-03 09:03:54 +02:00
eb8f5698e3 feat: just some work on tcp handling and god modules 2026-05-03 08:45:31 +02:00
2be751400d fix: parity in behaviour for scripts, templates and posts 2026-05-02 19:50:13 +02:00
73e066c330 fix: tag editor hopefully working and fixes to test runner 2026-05-02 11:24:51 +02:00
a4ea24faa2 fix: fixed import action for orphaned files 2026-05-02 11:08:33 +02:00
45040f9f66 fix: fixed duplicate elements in ai chat 2026-05-02 10:55:14 +02:00
4cf0f5281b feat: fill missing translations implemented 2026-05-02 10:33:19 +02:00
24f114c24e fix: made menus more stable and verified and hooke up stuff that got lost 2026-05-02 09:37:24 +02:00
07fab7d1ab feat: delete buttons on sidebar entries 2026-05-02 09:15:54 +02:00
c118412f56 fix: handling of tab titles on restore 2026-05-02 09:03:03 +02:00
e0f13e325b fix: tab titles on ai chats on reload 2026-05-02 08:47:27 +02:00
631ceb0521 fix: still fighting crashes on close and weird AI chat behaviou 2026-05-02 08:40:36 +02:00
7db8f6d36b fix: more work on chat and chat titles 2026-05-01 23:34:37 +02:00
c495a2ed0a fix: A2UI now behaves better 2026-05-01 23:15:04 +02:00
64a5eb525d fix: count_posts paginated before aggregation 2026-05-01 22:56:29 +02:00
fef722c4c9 fix: some more work on close-crashing 2026-05-01 22:45:36 +02:00
11df11dbdb fix: A2UI surfaces 2026-05-01 22:41:10 +02:00
a5193240ad fix: model selector works now 2026-05-01 22:35:24 +02:00
d3aa7f2438 fix: added delete buttons on chats and have chat titeling (maybe) 2026-05-01 22:29:06 +02:00
a17c549817 fix: more work on A2UI 2026-05-01 22:22:59 +02:00
391a7f216f fix: more fixes to AI chat 2026-05-01 22:13:21 +02:00
c25720bf6e fix: shutdown moved to standard functionality 2026-05-01 22:00:30 +02:00
e4db1d6d62 feat: added tool support setup for models 2026-05-01 21:49:31 +02:00
f8b8ccabbd fix: ai chat styling and some crashes 2026-05-01 21:39:05 +02:00
b5ebea6ff2 fix: AI tools better described now 2026-05-01 20:32:46 +02:00
dd0c05b785 fix: fixes for AI chat 2026-05-01 20:22:12 +02:00
290 changed files with 66542 additions and 15593 deletions

View File

@@ -0,0 +1,2 @@
- [Fix all test failures](feedback_fix_all_failures.md) — Never dismiss failures as pre-existing or flaky; investigate and fix
- [Debug targeted](feedback_targeted_debugging.md) — Analyze the code and fix; don't brute-force with repeated suite runs

View File

@@ -0,0 +1,13 @@
---
name: Fix all test failures including flaky ones
description: Never dismiss test failures as pre-existing or flaky — investigate root cause and stabilize
type: feedback
---
All test failures must be fixed, even if they appear unrelated to current changes. The test suite was clean before, so any failure is my responsibility.
Flaky tests are deeper problems waiting to surface. Running a test in isolation and seeing it pass is never enough — must find out why it was flaky in the full suite run and make it stable.
**Why:** Dismissing failures as "pre-existing" or "flaky" is wrong. Flaky tests indicate real issues (race conditions, test pollution, shared state) that will bite harder later.
**How to apply:** After making changes, if any test fails: investigate the root cause, fix it, and verify it passes reliably in the full suite. Never stash, never skip, never re-run and hope. Never dismiss ordering-dependent failures — find and fix the shared state or race condition.

View File

@@ -0,0 +1,11 @@
---
name: Debug targeted, don't brute-force
description: When investigating flaky tests, analyze the code and fix — don't brute-force with repeated full suite runs
type: feedback
---
If you already know which test is failing and why, fix it. Don't waste time running the full suite 20 times hoping to capture output.
**Why:** It's slow, wasteful, and avoids thinking. Analyze the code, understand the race, fix it.
**How to apply:** When a test fails, read the test, understand what it does, identify the root cause, and fix it. Only re-run to verify the fix, not to gather more data you already have.

View File

@@ -0,0 +1,14 @@
{
"permissions": {
"allow": [
"Bash(mix compile *)",
"Bash(mix test *)",
"Bash(mix dialyzer *)",
"Bash(mix ecto.migrate)",
"Bash(git add *)",
"Bash(git push *)",
"Bash(git -C /Users/gb/Projects/bDS2 status)",
"Bash(git status *)"
]
}
}

View File

@@ -20,6 +20,7 @@ This document provides context and best practices for GitHub Copilot when workin
- you must use ecto to generate migrations and snapshots
- on MacOS we use native menus and you have to hook them into the intercept for new menu items
- there are two areas of localization, you sometimes need both (menus for example)
- localization is done with elixier gettext and you need mix gettext.extract to update translation files
- all automatic AI activities must be gated by airplane (offline) mode of the app and either use the local model or inform the user via toast
- metadata needs to be flushed to the filesystem and needs to be included in metadata diff tool and in rebuild from filesystem. All three aspects have to be in sync with each other.
- if you add new metadata, add them to publishing, metadata-diff and rebuild-from-database

4275
API.md

File diff suppressed because it is too large Load Diff

474
DOCUMENTATION.md Normal file
View File

@@ -0,0 +1,474 @@
# bDS2 User Guide
## In this article
- [Who this guide is for](#who-this-guide-is-for)
- [How bDS2 works](#how-bds2-works)
- [Getting started](#getting-started)
- [Understanding the interface](#understanding-the-interface)
- [Working with posts](#working-with-posts)
- [Working with pages](#working-with-pages)
- [Working with media](#working-with-media)
- [Working with translations](#working-with-translations)
- [Using macros](#using-macros)
- [Using scripting](#using-scripting)
- [Using the AI assistant](#using-the-ai-assistant)
- [Organizing with tags](#organizing-with-tags)
- [Using blogmarks](#using-blogmarks)
- [Importing from WordPress (WXR)](#importing-from-wordpress-wxr)
- [Using Git (Source Control)](#using-git-source-control)
- [Configuring settings](#configuring-settings)
- [Checking and repairing metadata](#checking-and-repairing-metadata)
- [Managing templates](#managing-templates)
- [Generating and publishing](#generating-and-publishing)
- [Typical editorial workflows](#typical-editorial-workflows)
- [Working fully offline](#working-fully-offline)
- [Troubleshooting and recovery](#troubleshooting-and-recovery)
- [Team conventions](#team-conventions)
## Who this guide is for
This guide is for people who use bDS2 day to day to create, edit, organize, translate, generate, and publish blog content. It is written for editors, content managers, and project owners who need reliable guidance on what each part of the application does and how to use it safely.
If you need implementation notes, project architecture, or development setup, use the repository README. This guide stays focused on end-user operation and editorial decisions.
### Key takeaways
- bDS2 documentation should help with real editorial work, not only isolated clicks.
- Each chapter explains purpose first, then usage.
- Safe content handling and recoverability matter throughout the application.
[↑ Back to In this article](#in-this-article)
---
## How bDS2 works
bDS2 is a local-first writing and publishing workspace. You can draft, revise, structure, preview, and publish content on your machine without depending on constant internet access. Optional remote Git synchronization and AI-assisted workflows extend that model, but they do not replace it.
Three states matter in day-to-day work. A draft is your in-progress state. Publishing marks a local content state as published inside your project. A Git commit creates a recoverable snapshot that can be reviewed, synchronized, and restored. These actions are related, but they are not the same operation.
The recommended sequence remains simple: edit in draft, publish when the content is ready, then commit immediately. That is the safest pattern for protecting work and keeping project history understandable.
### Key takeaways
- bDS2 is designed for local reliability first.
- Publish and commit are different actions and both matter.
- The safe default lifecycle is: Draft -> Publish -> Commit.
[↑ Back to In this article](#in-this-article)
---
## Getting started
Before you begin editorial work, confirm that the project context is correct. Open bDS2 and select the right project. If this is a new project, create it and define its identity early, including project name and description.
Next, open Settings and verify the project data path and Public Base URL. The data path should match your backup strategy. The Public Base URL should be set early because sitemap and feed generation depend on it.
Finally, define language and author defaults. These defaults reduce repetitive edits and keep output consistent when multiple contributors work in the same project.
### Key takeaways
- Set project identity, data location, and Public Base URL at the beginning.
- Configure language and author defaults before regular editing starts.
- Early setup decisions reduce later cleanup.
[↑ Back to In this article](#in-this-article)
---
## Understanding the interface
The bDS2 interface is organized around workflows rather than isolated forms. The Activity Bar on the left moves between major areas such as Posts, Pages, Media, Tags, Import, Source Control, and Settings. The Sidebar changes with the active area and helps with filtering, selection, and navigation. The Editor area is where most work happens and supports tabbed editing for content, configuration, and analysis views.
The bottom panel and status area matter during longer operations such as imports, rebuild actions, metadata scans, and media work. Toasts provide quick feedback. The Output panel provides deeper detail when something needs attention.
Tab behavior is optimized for quick scanning and focused editing. Single click often opens a transient tab. Double click or explicit actions pin a tab for longer work.
### Key takeaways
- Use the Activity Bar for section-level context switching.
- Use the Sidebar for finding and narrowing content.
- Pin tabs when you move from inspection to editing.
[↑ Back to In this article](#in-this-article)
---
## Working with posts
The Posts section is for chronological content such as articles, notes, and recurring updates. In most editorial teams, Posts are the primary outward-facing stream.
A post combines title, body content, category, tags, excerpt, and status. Titles establish topic. Body content carries the narrative. Categories provide broad structure. Tags support finer discovery. Status should be used intentionally so collaborative workflows stay clear.
A reliable post workflow is: draft to completion, review structure and metadata, preview the result, publish when editorially ready, then commit immediately.
When you want help refining post metadata, use Quick Actions in the post editor and review AI suggestions for title, summary, and slug. Treat this as editorial assistance, not an automatic rewrite.
### Key takeaways
- Use Posts for date-oriented and regularly updated content.
- Categories and tags serve different purposes: broad grouping versus precise discovery.
- Publish only when editorially ready, then commit right away.
[↑ Back to In this article](#in-this-article)
---
## Working with pages
Pages are for durable, non-chronological content such as About, Contact, legal notices, and other structural information. Use Pages when content should stay stable in navigation and should not be interpreted as part of a time-based feed.
Because pages are revisited over longer periods, naming consistency matters. Keep titles and slugs predictable, avoid unnecessary structural churn, and follow your project navigation conventions.
The working pattern is similar to posts: draft, review, preview, publish, commit. The difference is editorial intent: pages prioritize clarity and long-term maintainability over release cadence.
### Key takeaways
- Use Pages for stable structural content.
- Keep titles and slugs consistent for maintainability.
- Apply the same safe lifecycle: Draft -> Publish -> Commit.
[↑ Back to In this article](#in-this-article)
---
## Working with media
The Media section is where you import, describe, and maintain assets used by posts and pages. It is not only a file list; it is also where accessibility and descriptive quality are enforced through metadata.
When importing media, add metadata while context is still fresh. Alt text should describe meaning for accessibility. Captions should support reader understanding. Media tags should help later retrieval and reuse.
You can also drag image files into the post editor or paste screenshots from the clipboard. bDS2 imports the image into the media library, links it to the current post, and inserts the Markdown image at the cursor position.
### Key takeaways
- Media management includes metadata quality, not only file import.
- Add alt text and captions during import, not as a postponed task.
- Commit content and related media in the same change when possible.
[↑ Back to In this article](#in-this-article)
---
## Working with translations
bDS2 supports translating both posts and media metadata into multiple languages. Translations are stored separately from canonical content so localized variants do not drift into unrelated records.
### Post translations
Each post has a canonical language and can have translations for additional languages. Translations keep their own title, excerpt, and content, while canonical metadata such as category, tags, slug, and publish state stays centralized.
The post editor shows the current language, existing translations, and missing languages. Posts marked Do Not Translate are excluded from automatic translation and from alternate language trees during site generation.
Published translation body content follows the same filesystem rule as published posts: the body lives in the file, not in the database.
### Media translations
Media items can have translated title, alt text, and caption values per language. The binary asset stays shared; only descriptive text varies by language.
### Automatic translation cascade
When blog languages are configured, bDS2 can fill missing translations for posts and linked media. Automatic translation respects airplane mode and the configured AI runtime. If an automatic action cannot run in the current AI mode, bDS2 reports that through the UI instead of silently inventing a result.
### Key takeaways
- Post translations store title, excerpt, and content separately from the canonical post.
- Media translations store translated descriptive text while the asset stays shared.
- Automatic translation keeps posts and linked media aligned across configured languages.
- Do Not Translate excludes content from multi-language workflows.
[↑ Back to In this article](#in-this-article)
---
## Using macros
Macros let you insert dynamic content blocks directly inside Markdown by using `[[macro_name ...]]` syntax. bDS2 expands these macros during preview and generated output using local assets only.
Built-in macros include YouTube, Vimeo, gallery, photo archive, and tag cloud helpers. Use them when you want reusable rich blocks without dropping into raw HTML.
### Key takeaways
- Macros are inserted directly in Markdown and expanded during preview and publishing.
- Use macro parameters to control behavior without leaving the editor.
- Built-in macros remain the first choice for common embedded content blocks.
[↑ Back to In this article](#in-this-article)
---
## Using scripting
Scripts in bDS2 are Lua files stored in your project's `scripts/` directory. Published scripts are written as `.lua` files with frontmatter metadata, so they stay portable and Git-reviewable.
Each script has a Kind (`macro`, `transform`, or `utility`) and an Entrypoint. Utility and transform scripts typically default to `main`. Macro scripts default to `render`.
### Transform scripts
Transform scripts run during blogmark import to normalize or enrich incoming post data before the post is created. The entrypoint receives a post table and can optionally receive a context table.
```lua
function main(post, context)
local title = (post.title or ""):gsub("^%s+", ""):gsub("%s+$", "")
if title ~= "" and not title:match("^%[Clipped%]") then
post.title = "[Clipped] " .. title
end
post.categories = { "Inbox", "Research" }
return post
end
```
`context.source` identifies the import source. `context.url` contains the original bookmarked URL when that information exists.
### Macro scripts
Macro scripts let you create custom `[[macro_name ...]]` blocks that expand during preview and generation. The entrypoint receives a context table and the current post table.
```lua
function render(context, post)
local params = context.params or {}
local title = (post and post.title) or "Unknown"
local label = params.label or ""
return {
html = "<p>" .. title .. ": " .. label .. "</p>"
}
end
```
Built-in macros take priority over custom Lua macros that reuse the same slug.
### API access
Lua scripts can call the application API through `bds`. The in-app API tab is rendered from the live Lua capability map, and [API.md](API.md) is generated from the same source.
```lua
local result = bds.posts.get("post-id")
```
### Key takeaways
- Scripts in bDS2 are Lua files, not Python files.
- Published scripts are stored as `.lua` files with frontmatter metadata.
- `main` is the usual entrypoint for utility and transform scripts; `render` is the usual entrypoint for macros.
- The scripting API is documented with Lua examples and kept in sync with the live runtime.
[↑ Back to In this article](#in-this-article)
---
## Using the AI assistant
The AI assistant is integrated into bDS2 to help with editorial tasks such as search, analysis, metadata suggestions, translation, and structured content inspection.
The assistant works on your project data. Depending on configuration, requests can run against the configured online endpoint or the airplane-mode endpoint. Automatic AI actions remain gated by airplane mode rules in the app, and bDS2 surfaces status through toasts and the Output area instead of silently bypassing that policy.
The assistant can present results as text, tables, cards, charts, metrics, lists, forms, and tabbed views. Ask plainly for the result you need, or request a specific presentation when that helps your workflow.
### Key takeaways
- The assistant works with your project content and metadata.
- AI configuration can be online or airplane-mode based, depending on your setup.
- Automatic AI actions respect airplane mode and report availability through the UI.
- Ask for a table, chart, list, or form when a specific shape is useful.
[↑ Back to In this article](#in-this-article)
---
## Organizing with tags
Tags are your precision taxonomy tool. Over time, even well-managed projects accumulate near-duplicate tags, naming inconsistencies, and labels that no longer help readers or editors. Use the Tags area to keep taxonomy useful.
After significant taxonomy cleanup, create a focused commit that captures the change clearly.
### Key takeaways
- Tags improve discovery only if naming stays consistent.
- Merge and rename operations should be deliberate and reviewed.
- Commit taxonomy changes in focused snapshots.
[↑ Back to In this article](#in-this-article)
---
## Using blogmarks
Blogmarks provide a quick way to save links from the browser directly into bDS2 as new posts. Generate the bookmarklet from Settings, add it to your browser bar, and click it when you want to capture a page into the current project.
Transform scripts can normalize incoming blogmark posts before creation. Use them for title cleanup, default tags, or source-specific formatting.
### Key takeaways
- Blogmarks turn the browser into a one-click content capture tool.
- Generate the bookmarklet from Settings and add it to your browser bar.
- Use transform scripts to enrich incoming posts automatically.
[↑ Back to In this article](#in-this-article)
---
## Importing from WordPress (WXR)
The Import section supports structured migration from WordPress exports. Treat import as a staged workflow: analyze first, adjust mappings, then execute. For larger sites, iterative passes are usually safer than a single rigid import.
### Key takeaways
- Treat WXR import as analyze, adjust, execute.
- Iterative passes are safer than one large import.
- Validate representative output before committing migrated content.
[↑ Back to In this article](#in-this-article)
---
## Using Git (Source Control)
Source Control in bDS2 is the foundation for reliable recovery and collaboration. Publishing marks local editorial state, but Git commits provide durable history.
In a normal cycle, synchronize first, complete editorial changes, publish when ready, commit with a specific message, then push when you want to share the result.
### Key takeaways
- Git provides recoverable history; publishing alone does not.
- A stable rhythm is: sync, edit, publish, commit, push.
- Specific commit messages improve teamwork and recovery.
[↑ Back to In this article](#in-this-article)
---
## Configuring settings
Settings define how the project behaves. Project settings control identity, paths, public URL context, and render languages. Editor settings shape day-to-day working defaults. AI settings are optional and should enhance, not define, your editorial workflow.
Maintenance actions such as rebuilds and diff scans are repair tools for specific situations, not part of routine editing.
### Key takeaways
- Settings affect long-term consistency across the project.
- Optional integrations should not replace the core workflow.
- Rebuild actions are corrective tools, not daily habits.
[↑ Back to In this article](#in-this-article)
---
## Checking and repairing metadata
Over time, metadata stored in the database and metadata stored on disk can drift apart, especially after external edits, merges, or file operations. The Metadata Diff tool detects these inconsistencies and lets you repair them without rebuilding everything.
The scan covers posts, media, scripts, and templates. Results are grouped by entity type, and field pills let you focus on one kind of difference at a time.
Use DB to File when the database is correct. Use File to DB when the filesystem is correct.
### Key takeaways
- Metadata Diff compares database records against files on disk.
- Field pills help you bulk-repair one difference type at a time.
- Use it after external changes, not as part of routine editing.
[↑ Back to In this article](#in-this-article)
---
## Managing templates
Templates control the Liquid layout used when bDS2 generates HTML pages. Template kinds determine where they are used: `post`, `list`, `not-found`, and `partial`.
Templates are stored as files with frontmatter metadata in the project data directory, so they are portable and Git-reviewable.
### Key takeaways
- Templates define the generated HTML layout.
- Four template kinds cover page, list, not-found, and reusable partial rendering.
- Templates are filesystem-backed and Git-friendly.
[↑ Back to In this article](#in-this-article)
---
## Generating and publishing
Publishing in bDS2 is a staged process: publish content locally, generate or validate-and-apply site changes, commit the result, then deploy when ready.
Full generation builds the entire static site. Site validation detects missing, extra, and updated routes so bDS2 can re-render only what changed. This is the practical incremental workflow for most daily editorial changes.
When blog languages are configured, generation produces language-aware route trees, per-language feeds, and alternate language metadata.
### Key takeaways
- Full generation produces the complete site.
- Validate and Apply is the efficient daily workflow for incremental publishing.
- Public Base URL must be set before generation.
- Commit generated output before deploying for recoverability.
[↑ Back to In this article](#in-this-article)
---
## Typical editorial workflows
Short link posts benefit from a lightweight workflow: create, add concise context, classify, preview once, publish, commit. Long-form articles benefit from a fuller cycle: draft thoroughly, add media, review metadata, preview carefully, publish, commit content and media together.
Across both patterns, the safety baseline stays the same: Draft -> Publish -> Commit.
### Key takeaways
- Use a lightweight workflow for short notes and links.
- Use a fuller workflow for long-form content with media.
- Keep the same safety baseline in both cases.
[↑ Back to In this article](#in-this-article)
---
## Working fully offline
bDS2 is designed so core editorial work can continue without network access. You can create and revise content, manage metadata, preview locally, and publish within local project state while offline.
When AI is involved, airplane mode determines which automatic actions are allowed and which endpoint class is used. Keep local commits frequent even when you are not pushing to a remote.
### Key takeaways
- Core editing and publishing workflows work offline.
- Local commits still matter when no remote is available.
- Reconnect and synchronize in a controlled order.
[↑ Back to In this article](#in-this-article)
---
## Troubleshooting and recovery
If content looks correct locally but is missing for collaborators, the usual cause is that changes were published but not committed and pushed. Check repository status, create a commit, then push to the expected remote.
If content lists or references become inconsistent after manual file changes, start with Metadata Diff. If broader inconsistency remains, use rebuild tools to realign database and filesystem state.
### Key takeaways
- Most missing remote content issues are commit or push gaps.
- Metadata Diff is the first repair tool after external file changes.
- Frequent meaningful commits are the strongest safety net.
[↑ Back to In this article](#in-this-article)
---
## Team conventions
Shared conventions reduce ambiguity and merge friction. Teams should agree on category definitions, tag naming rules, publish-readiness criteria, and commit message patterns.
A practical minimum rule is simple: any content considered published should be committed promptly.
### Key takeaways
- Explicit conventions improve speed and reduce avoidable conflict.
- Start with a small rule set and enforce it consistently.
- Minimum standard: published content should be committed promptly.
[↑ Back to In this article](#in-this-article)

166
README.md
View File

@@ -1,52 +1,122 @@
# bDS2
bDS2 is the Elixir rewrite of bDS, the offline-first desktop blogging workspace in [../bDS](/Users/gb/Projects/bDS). The repository now contains a substantial BEAM application: Ecto persistence, filesystem-backed content workflows, rendering/generation/publishing pipelines, AI and MCP integrations, and a bundled desktop shell served by the Elixir runtime.
bDS2 is the Elixir rewrite of bDS, the offline-first desktop blogging workspace. It is no longer just a rewrite scaffold: the repository now contains the main desktop runtime, Ecto persistence, filesystem-backed content workflows, rendering and publishing pipelines, Lua scripting, AI and MCP integration, and a Phoenix LiveView shell embedded in a native desktop window.
The Allium specifications in [specs/](/Users/gb/Projects/bDS2/specs) remain the behavioral contract for the rewrite. For current implementation status and the parity roadmap, see [PLAN.md](/Users/gb/Projects/bDS2/PLAN.md).
The Allium specs in [specs/](/Users/gb/Projects/bDS2/specs) remain the behavioral contract for the rewrite. For end-user operation, see [DOCUMENTATION.md](/Users/gb/Projects/bDS2/DOCUMENTATION.md). For the scripting surface, see [API.md](/Users/gb/Projects/bDS2/API.md).
## Scope
## Current Status
The rewrite aims to preserve the product behavior of bDS while replacing the technical stack.
The major architectural rework is in place.
Behaviour that should remain stable includes:
- The desktop UI is served by Phoenix LiveView inside the desktop shell rather than by a separate handwritten frontend runtime.
- Assets use Phoenix-default Tailwind and esbuild tooling from [assets/](/Users/gb/Projects/bDS2/assets) into [priv/static/](/Users/gb/Projects/bDS2/priv/static).
- Core editorial flows are implemented in the main application: posts, media, tags, templates, scripts, imports, preview, generation, publishing, maintenance, AI, and MCP.
- Localization is now a first-class architectural concern rather than an afterthought: UI chrome and rendered site output have separate locale flows, and post/media translation workflows are built into the domain model.
- Offline-first editorial workflows.
- Filesystem-backed content with stable frontmatter, media sidecars, templates, scripts, and menu formats.
- Project, post, media, translation, tag, template, generation, preview, publishing, AI, and MCP workflows.
- Generated site output, search behavior, metadata synchronization, and rebuild behavior where those are part of the product contract.
The rewrite still aims to preserve the product behavior of bDS while replacing the technical stack. The contract is product behavior, not the old implementation language or framework choices.
The following are intentionally not part of the behavioral contract:
## Architecture Overview
- The implementation language.
- Desktop container or UI framework.
- ORM choice.
- Internal state management, concurrency model, or runtime libraries.
### Runtime
## Scripting Direction
[BDS.Application](/Users/gb/Projects/bDS2/lib/bds/application.ex) is the supervision root. It starts the Phoenix endpoint, database, preview and publishing workers, task supervisors, scripting jobs, and the desktop server/window adapters.
bDS2 should use Lua as its user-facing scripting language.
At a high level, the stack is:
The reason is host fit, not language fashion: Lua has a better embedding story for the BEAM than Python does, while still being small, expressive, and suitable for user-authored macros, transforms, and utility scripts. The current direction is:
- Native windowing through the `:desktop` integration.
- Phoenix endpoint and LiveView shell for the actual app UI.
- Ecto + SQLite for indexed state, editor state, and app data.
- Filesystem-backed project data for published content, media, sidecars, scripts, templates, generated output, and rebuild workflows.
- Lua script files as the persisted user script format.
- A BEAM-hosted execution boundary with explicit host capabilities instead of unrestricted runtime access.
- Bounded but long-running script execution for user-authored code, with explicit progress reporting through host APIs.
### Desktop Shell
The initial runtime baseline in this repository uses a dedicated Elixir scripting boundary with a Luerl-backed Lua adapter. The goal is to keep scripting integration native to the BEAM while making sandboxing and host capability exposure explicit at the application boundary.
The desktop workbench lives under [lib/bds/desktop/](/Users/gb/Projects/bDS2/lib/bds/desktop). The main screen is [BDS.Desktop.ShellLive](/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live.ex), with feature-specific editors and sidebar logic under [lib/bds/desktop/shell_live/](/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live).
This keeps the scripting surface lightweight and aligned with the Elixir host application. Python remains a possible integration boundary for specialized tasks, but it is no longer the default scripting model for the rewrite.
If you are tracing UI behavior, start there first:
## Repository Layout
- LiveView event routing, workbench state, overlays, and menu handling live in the desktop shell modules.
- HEEx templates under the same tree now own most common layout and state styling.
- Monaco remains a vendor drop under [priv/ui/monaco/](/Users/gb/Projects/bDS2/priv/ui/monaco).
- [mix.exs](/Users/gb/Projects/bDS2/mix.exs): Mix project definition.
- [config/](/Users/gb/Projects/bDS2/config): Elixir and Ecto configuration.
- [lib/](/Users/gb/Projects/bDS2/lib): application bootstrap and shared runtime modules.
- [priv/repo/](/Users/gb/Projects/bDS2/priv/repo): Ecto migrations.
- [specs/](/Users/gb/Projects/bDS2/specs): Allium specs distilled from the existing bDS product and being normalized for implementation-agnostic use.
### Domain Modules
Most application behavior lives under [lib/bds/](/Users/gb/Projects/bDS2/lib/bds):
- posts, media, tags, templates, scripts, and project settings
- metadata, frontmatter, sidecars, rebuild, and maintenance
- rendering, generation, preview, and publishing
- AI runtimes, chat tooling, embeddings, and MCP
- scripting capabilities and generated API docs
The repo has been pushed toward smaller feature-focused modules rather than one large mixed runtime. For new work, prefer finding the owning feature module instead of adding more behavior to broad catch-all files.
### Storage Model
The database is important, but it is not the whole source of truth.
- Ecto models hold app state, indexes, editor state, and workflow data.
- The filesystem holds published content artifacts and sidecar metadata that must stay stable and reviewable.
- Rebuild and metadata-diff flows exist because database state and filesystem state are expected to stay in sync.
When you change persisted behavior, think in both directions: database writes and filesystem writes/readback.
## Localization And i18n
Localization now has two separate layers, and confusing them causes bugs.
### 1. UI Localization
UI chrome, menus, dashboard text, editor labels, and toasts use Gettext through [BDS.Gettext](/Users/gb/Projects/bDS2/lib/bds/gettext.ex) and the `ui` domain. Locale normalization lives in [BDS.I18n](/Users/gb/Projects/bDS2/lib/bds/i18n.ex), and the desktop shell binds the active UI locale through [BDS.Desktop.UILocale](/Users/gb/Projects/bDS2/lib/bds/desktop/ui_locale.ex).
In practice, this is the language of the app itself.
### 2. Render Localization
Rendered site output uses a separate locale flow. Archive labels, pagination text, template-facing render strings, and generated site language handling use the `render` Gettext domain and the project's `main_language` and `blog_languages` settings.
In practice, this is the language of the blog output, not necessarily the UI.
### 3. Content Translation
Posts and media have translation-aware workflows. Post translations and media metadata translations are modeled explicitly, and generation/preview/publishing use the project's configured languages when building output.
Relevant translation resources live under:
- [priv/gettext/](/Users/gb/Projects/bDS2/priv/gettext) for Gettext catalogs
- [priv/i18n/](/Users/gb/Projects/bDS2/priv/i18n) for additional locale data used by the app
If you touch i18n-sensitive behavior, check whether the change belongs to UI locale, render locale, or content translation. They are related, but they are not interchangeable.
## Frontend And Assets
Frontend source now follows the Phoenix asset layout:
- [assets/css/](/Users/gb/Projects/bDS2/assets/css) for Tailwind-based CSS modules
- [assets/js/](/Users/gb/Projects/bDS2/assets/js) for LiveView hooks, bridges, Monaco integration, and UI helpers
- [priv/static/assets/](/Users/gb/Projects/bDS2/priv/static/assets) for generated outputs
The rule of thumb is simple:
- common layout, spacing, state, and typography belong in HEEx and small shared UI primitives
- authored CSS stays for tokens and desktop-specific selectors
- JavaScript stays focused on LiveView hooks, editor integration, drag/drop, and browser APIs
## Repository Map
- [mix.exs](/Users/gb/Projects/bDS2/mix.exs): Mix project definition, aliases, releases, and dependencies
- [config/](/Users/gb/Projects/bDS2/config): runtime, dev, test, and asset configuration
- [lib/bds/](/Users/gb/Projects/bDS2/lib/bds): core application modules
- [lib/bds/desktop/](/Users/gb/Projects/bDS2/lib/bds/desktop): desktop endpoint, shell, menus, controllers, and window integration
- [assets/](/Users/gb/Projects/bDS2/assets): Tailwind and esbuild source
- [priv/repo/](/Users/gb/Projects/bDS2/priv/repo): Ecto migrations and snapshots
- [priv/gettext/](/Users/gb/Projects/bDS2/priv/gettext): UI and render translation catalogs
- [specs/](/Users/gb/Projects/bDS2/specs): Allium behavior specs
- [DOCUMENTATION.md](/Users/gb/Projects/bDS2/DOCUMENTATION.md): end-user guide
- [API.md](/Users/gb/Projects/bDS2/API.md): generated scripting API reference
## macOS Development Setup
If you are setting up a new macOS machine, start with the toolchain.
If you are setting up a new macOS machine, install the toolchain first.
### 1. Install Xcode Command Line Tools
@@ -56,8 +126,6 @@ xcode-select --install
### 2. Install Homebrew
If Homebrew is not already installed:
```bash
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
```
@@ -69,36 +137,28 @@ brew update
brew install erlang elixir sqlite
```
Verify the installation:
```bash
elixir --version
mix --version
sqlite3 --version
```
### 4. Fetch Dependencies
### 4. Fetch Dependencies And Set Up The App
```bash
cd /Users/gb/Projects/bDS2
mix deps.get
mix setup
mix assets.setup
```
### 5. Create the Local Database
```bash
mix ecto.create
mix ecto.migrate
```
### 6. Run Tests
## Development Workflow
Useful commands:
```bash
mix compile --warnings-as-errors
mix test
mix dialyzer
mix assets.build
```
## Development Notes
Notes for developers:
- Use `mix test` for validation during development.
- The application behavior is defined by the Allium specs in [specs/](/Users/gb/Projects/bDS2/specs).
- Use [PLAN.md](/Users/gb/Projects/bDS2/PLAN.md) for implementation status and the parity roadmap.
- Specs in [specs/](/Users/gb/Projects/bDS2/specs) define the intended product behavior.
- [DOCUMENTATION.md](/Users/gb/Projects/bDS2/DOCUMENTATION.md) is for end users, not implementation details.
- [API.md](/Users/gb/Projects/bDS2/API.md) is generated from the live scripting capability map and should stay in sync with runtime changes.
- When changing persistence or localization behavior, check both the database side and the filesystem/render side before assuming the change is complete.

191
SPECAUDIT.md Normal file
View File

@@ -0,0 +1,191 @@
# Spec Audit Process
This document describes the repeatable process for auditing the Allium specifications against the bDS2 codebase and test suite. Run it whenever specs or code change materially.
## Overview
The audit produces three categories of findings:
1. **Spec-claims-not-in-code** — spec describes behavior the code does not implement
2. **Code-not-in-spec** — code implements behavior the spec does not describe
3. **Spec-claims-not-in-tests** — spec invariants/rules/behaviors lack test coverage
## Step 1: Map the Territory
```bash
# List all spec files
ls specs/*.allium
# List all source modules
ls lib/bds/ lib/bds/**/
# List all test files
ls test/bds/ test/bds/**/
```
Record the mapping between specs and code/test files. Use `specs/bds.allium` as the index — it lists every `use` directive with its domain label.
## Step 2: Extract Spec Claims
For each `.allium` file, extract:
| Claim Type | Pattern | Example |
|---|---|---|
| **invariant** | `invariant Name:` or lines describing always-true properties | `UniqueSlugPerProject: slugs unique within project` |
| **rule** | `rule Name { requires: ... ensures: ... }` | `CreatePost: creates with slug, status=draft` |
| **guarantee** | `guarantee Name:` | `SandboxedExecution: no filesystem/process loading` |
| **config** | `config { key = value }` | `macro_timeout = 10.seconds` |
| **behavior** | Explicit claims in comments or entity descriptions | `"HomeAlwaysPresent: menu always has Home entry"` |
Record the spec file name, claim name, claim type, and line number for each.
## Step 3: Compare Spec Claims Against Code
For each claim, find the corresponding code and verify:
### 3a. Entity/field existence
- Does the Ecto schema have the fields the spec declares?
- Are relationships (has_many, belongs_to) present?
- Are enum/status values complete?
```bash
# Check schema fields
grep -n "field :" lib/bds/posts/post.ex
grep -n "has_many\|belongs_to" lib/bds/posts/post.ex
```
### 3b. Rule implementation
- Does the code enforce the `requires` preconditions?
- Does the code produce the `ensures` postconditions?
- Are side-effects (FTS, embeddings, file writes) triggered?
```bash
# Check function implementation
grep -n "def create_post" lib/bds/posts.ex
grep -n "def publish_post" lib/bds/posts.ex
```
### 3c. Invariant enforcement
- Are constraints enforced at the schema level (unique_index, check_constraint)?
- Are constraints enforced in changeset validations?
- Are constraints enforced in business logic?
```bash
# Check database constraints
grep -n "unique_index\|check_constraint" priv/repo/migrations/*.ex
grep -n "unique_constraint\|validate_" lib/bds/posts/post.ex
```
### 3d. File format compliance
- Does the serialization format match the spec's frontmatter values?
- Are conditional fields omitted when falsy?
- Are required fields always present?
```bash
# Check serialization
grep -n "serialize\|write_file\|Frontmatter" lib/bds/frontmatter.ex lib/bds/posts/file_sync.ex
```
## Step 4: Compare Code Against Spec Claims
Search for code that implements behavior NOT described in any spec:
### 4a. Public API functions not in any spec rule
```bash
# List public functions in a module
grep -n "def " lib/bds/posts.ex | grep -v "defp"
```
### 4b. Schema fields not in any spec entity
```bash
# List all fields
grep -n "field :" lib/bds/posts/post.ex
```
### 4c. Side effects not in engine_side_effects.allium
```bash
# Check what happens after CRUD operations
grep -n "sync_post\|sync_media\|Search\.\|Embeddings\.\|AutoTranslation" lib/bds/posts.ex lib/bds/media.ex
```
### 4d. UI features not in any editor spec
```bash
# Check HEEx templates for UI elements
grep -n "phx-click\|data-phx-" lib/bds/desktop/post_editor_html/post_editor.html.heex
```
## Step 5: Compare Spec Claims Against Tests
For each invariant, rule, and guarantee, search for a test that verifies it:
### 5a. Direct test search
```bash
# Search test names and bodies
grep -rn "test \"" test/bds/posts_test.exs | head -30
grep -rn "test \"" test/bds/media_test.exs | head -30
```
### 5b. Invariant coverage check
For each invariant, determine:
- **YES**: Test explicitly verifies the invariant (creates violation, expects rejection)
- **PARTIAL**: Test verifies the happy path but not violation scenarios
- **NO**: No test exists
### 5c. Rule coverage check
For each rule, determine:
- **YES**: Test exercises `requires` precondition and `ensures` postcondition
- **PARTIAL**: Test exercises the happy path but not preconditions or all postconditions
- **NO**: No test exists
### 5d. Side-effect chain coverage
For each side-effect rule in `engine_side_effects.allium`, check whether a test verifies ALL `ensures` clauses fire together (not just individually).
## Step 6: Classify Findings
Each gap falls into one of these categories with a recommended action:
| Category | Direction | Action |
|---|---|---|
| **Spec correct, code wrong** | Spec → Code | Fix the code |
| **Code correct, spec drifted** | Code → Spec | Update the spec |
| **Code behavior, no spec** | Code → Spec | Distill into spec |
| **Spec claim, no test** | Spec → Test | Write test |
| **Internal spec inconsistency** | Spec → Spec | Align specs |
| **Decision needed** | Both | Resolve with stakeholder |
## Step 7: Produce SPECGAPS.md
Consolidate all findings into `SPECGAPS.md` with:
- Gap ID for tracking
- Clear description of the gap
- Which spec file and line
- Which code file and line
- Recommended path (fix code / update spec / write test / decide)
- Priority (HIGH/MEDIUM/LOW)
## Step 8: Validate
After making changes:
```bash
# Run full test suite
mix test
# Run dialyzer
mix dialyzer
# Validate allium specs (if tool available)
# Use the allium CLI to validate spec files
```
## Re-running the Audit
1. Start from Step 2 — re-extract claims from updated specs
2. Run Steps 3-5 against current code and tests
3. Compare against previous SPECGAPS.md to identify resolved and new gaps
4. Update SPECGAPS.md
The audit should be re-run after:
- Adding new spec files or significant spec changes
- Adding new features or refactoring code
- Adding new test files
- Before any release milestone

192
SPECGAPS.md Normal file
View File

@@ -0,0 +1,192 @@
# Spec Gaps — Allium Specs vs Code vs Tests
Gap categories: **SC** = spec correct, fix code | **CS** = code correct, update spec | **ST** = write test | **SD** = decide | **SI** = fix internal spec inconsistency
---
## A. Spec Claims Not Fulfilled by Code
### A1. Code Must Change (spec is normative)
| ID | Gap | Spec | Code | Path |
|---|---|---|---|---|
| A1-1 | No `archived→draft` or `archived→published` transition | post.allium:121-122 | No code path to unarchive | Fix code: implement unarchive transitions |
| A1-2 | `DeletePost` must delete translations + translation files | post.allium:209-212 | `delete_post/1` skips translation cleanup | Fix code: delete PostTranslation rows + files |
| A1-3 | Publish must delete old file when path changes | engine_side_effects.allium:73-74 | `publish_post` does not delete old file | Fix code: add old file deletion on path change |
| A1-4 | `doNotTranslate: false` written to frontmatter despite "only when true" | frontmatter.allium:398 | `lib/bds/frontmatter.ex:38-39` writes false | Fix code: omit `doNotTranslate` when false |
| A1-5 | Auto-save after 3000ms idle | editor_post.allium:183-188 | No auto-save timer | Fix code: implement auto-save on idle + unmount + tab switch |
| A1-6 | On-demand rendering in preview server | preview.allium:53-93 | Server serves static pre-generated files | Fix code: implement on-demand template rendering for post/archive/language routes |
| A1-7 | Template lookup must use all 4 levels (post→tag→category→default) | template_context.allium:267-277 | Only levels 1 and 4 implemented; tag/category fallback unused | Fix code: implement levels 2-3 in template_selection.ex |
| A1-8 | `ValidateLiquid`/`ValidateScript` before publish | template.allium:110, script.allium:165 | No validation gate before publish | Fix code: add validation step before publish |
| A1-9 | 17 preset colors + custom hex in tag picker | editor_tags.allium | Native `<input type="color">`, no preset palette | Fix code: implement preset color palette popover |
| A1-10 | Template file written on create | engine_side_effects.allium:151-153 | Draft templates have `file_path=""` | Fix code: write template file on create |
| A1-11 | Graceful shutdown with inflight request tracking | preview.allium:47-48 | Kills acceptor process, no inflight tracking | Fix code: track inflight requests, drain before shutdown |
| A1-12 | Real Pagefind integration for search | generation.allium:208 | Stub only: `pagefind-ui.js` is one-liner, `PagefindUI` never defined, search-runtime.js silently bails, client-side search non-functional | Fix code: bundle real Pagefind, build proper fragment index, wire PagefindUI |
### A2. Spec Should Update (code is normative)
| ID | Gap | Spec | Code | Path |
|---|---|---|---|---|
| A2-1 | WYSIWYG/visual editor mode (3 modes) | editor_post.allium:159-164 | Only markdown+preview; visual normalizes to markdown | Drop from spec or mark future |
| A2-2 | Template/Script are global entities | template.allium, script.allium | Both have `project_id`, per-project uniqueness | Update spec to per-project scoping |
| A2-3 | TagsFile uses `{tags: [...]}` wrapper | frontmatter.allium:255-273 | Code writes bare array `[...]` | Update spec |
| A2-4 | Sidecar is "YAML-like, not gray-matter" | frontmatter.allium:174 | Code wraps with `---` delimiters | Update spec to gray-matter style |
| A2-5 | Translation frontmatter omits status/timestamps | frontmatter.allium:107-117 | Code writes status, createdAt, updatedAt, publishedAt | Update spec to match written fields |
| A2-6 | Search index has single `stemmed_content` | search.allium:40-54 | FTS5 per-field stemmed columns | Update spec to per-field model |
| A2-7 | Tag archives are single-page | generation.allium:142-147 | Code paginates | Update spec |
| A2-8 | Date archives year+month only | generation.allium:151-159 | Code also generates day-level | Update spec |
| A2-9 | Menu is DB entity | menu.allium:20-26 | Purely file-based OPML, no DB table | Update spec to file-only model |
| A2-10 | Panel tabs: problems, terminal | layout.allium:235-240 | `[:tasks, :output, :post_links, :git_log]` | Update spec |
| A2-11 | Git sidebar: commit input, history, push/pull | sidebar_views.allium | Only "Working tree" item | Mark as partial/TODO in spec |
| A2-12 | Slug timestamp fallback after 999 | post.allium:21 | Unbounded numeric suffix | Update spec or fix code |
| A2-13 | Thumbnail generation is async | engine_side_effects.allium:117 | Synchronous | Update spec or fix code |
| A2-14 | AiModelModality: :video vs :file/:tool | schema.allium:291 | Code has `:file`, `:tool` instead of `:video` | Update spec to :file/:tool |
| A2-15 | JSON key convention: snake_case vs camelCase | frontmatter.allium values | Code uses camelCase for all metadata JSON | Update spec to camelCase |
| A2-16 | Snowball stemmer language list | search.allium:26-31 | Library determines which have algorithms vs passthrough | Update spec: don't enumerate; just say "Snowball stemmers via library" |
| A2-17 | `provider_package_ref` on AiModel | schema.allium:282 | Not in code; legacy field not needed | Drop from spec |
---
## B. Code Behavior Not in Spec
### B1. Must Add to Spec (domain-level, affects behavior)
| ID | Behavior | Code Location | Path |
|---|---|---|---|
| B1-1 | Chat inline surfaces (9 types: card, chart, form, list, metric, mindmap, table, tabs, text/json) | `lib/bds/ui/chat/tool_surfaces.ex:6-15` | Distill into spec |
| B1-2 | Auto-translation system (AutoTranslation.maybe_schedule, media cascade, batch fill) | `lib/bds/posts/auto_translation.ex` | Distill into spec |
| B1-3 | 3 extra settings sections (Technology, MCP, Data Maintenance) | `lib/bds/ui/settings_editor/` | Distill into spec |
| B1-4 | Style/Theme as separate tab (`:style`), not settings section | `lib/bds/ui/style_editor.ex` | Distill into spec |
| B1-5 | `published_*` snapshot fields on Post for diffing | `lib/bds/posts/post.ex:61-65` | Add to post.allium entity |
| B1-6 | Full rendering subsystem (Liquex, Filters, Labels, LinksAndLanguages, PostRendering) | `lib/bds/rendering/` | Distill into spec |
| B1-7 | 404.html generation | `lib/bds/generation/outputs.ex:344-345` | Add to generation.allium |
| B1-8 | `linkedPostIds` in media sidecar | `lib/bds/media/sidecars.ex:42` | Add to frontmatter.allium MediaSidecar |
| B1-9 | `projectId` in template/script frontmatter | `templates.ex:337`, `scripts.ex:268` | Add to frontmatter.allium |
| B1-10 | Media translation editing modal | `media_editor.html.heex:275-303` | Add to editor_media.allium |
| B1-11 | Menu editor drag-drop, indent/unindent/move | `lib/bds/desktop/menu_editor/tree_ops.ex` | Add to editor_misc.allium |
| B1-12 | `:language_picker` overlay with flag emojis | `shell_overlay.html.heex:116-139` | Add to modals.allium |
| B1-13 | `:confirm_dialog` generic confirmation | `shell_overlay.html.heex:171-187` | Add to modals.allium |
| B1-14 | Publish actions for scripts and templates | `script_editor.html.heex:10-12`, `template_editor.html.heex:10-12` | Add to editor_script.allium, editor_template.allium |
| B1-15 | `:import` as full editor tab | `lib/bds/ui/import_editor.ex` | Add to tabs.allium |
| B1-16 | `:documentation`/`:api_documentation` tab types | `lib/bds/desktop/misc_editor/` | Add to tabs.allium |
| B1-17 | Metadata diff covers embedding, media_translation, post_translation as entity types | `lib/bds/maintenance/repair.ex` | Add to metadata_diff.allium |
| B1-18 | Finished task TTL eviction (1h, keep last 10) | `lib/bds/tasks.ex:365-386` | Add to task.allium |
| B1-19 | `discard_post_changes/1` | `lib/bds/posts.ex:201-227` | Add to post.allium |
| B1-20 | `replace_media_file/2` with checksum/backup | `lib/bds/media.ex:288-337` | Add to media.allium |
### B2. Lower Priority (implementation detail or minor)
| ID | Behavior | Code Location |
|---|---|---|
| B2-1 | `editor_body/1` content resolver | `lib/bds/posts.ex:229-252` |
| B2-2 | `sync_post_from_file/1` single-post reimport | `lib/bds/posts.ex:254-279` |
| B2-3 | `import_orphan_post_file/1` | `lib/bds/posts.ex:289-291` |
| B2-4 | `dashboard_stats/1`, `post_counts_by_year_month/1` | `lib/bds/posts.ex:378-413` |
| B2-5 | `regenerate_missing_thumbnails/2` | `lib/bds/media.ex:47-48` |
| B2-6 | Cache dir computation | `lib/bds/projects.ex:101-106` |
| B2-7 | `remove_stale_published_templates` | `lib/bds/templates.ex:524-552` |
| B2-8 | Rendering Labels module (30+ i18n strings) | `lib/bds/rendering/labels.ex` |
| B2-9 | Progress reporting during reindex | `lib/bds/generation/progress.ex` |
---
## C. Internal Spec Inconsistencies
All reconciled to follow code. Specs must be self-consistent and match code.
| ID | Conflict | Resolution | Path |
|---|---|---|---|
| C-1 | schema.allium ChatMessage has no cache tokens; ai.allium ChatMessage has `cache_read_tokens`/`cache_write_tokens` | Code has cache tokens → align schema.allium with ai.allium | Update schema.allium |
| C-2 | media.allium SidecarFile mentions `linkedPostIds`; frontmatter.allium MediaSidecar does NOT list it | Code writes `linkedPostIds` → add to frontmatter.allium | Update frontmatter.allium |
| C-3 | translation.allium says status/timestamps omitted; frontmatter.allium TranslationFrontmatter defines only 5 fields; code writes 8+ fields | Code writes status/timestamps → update both specs to match code | Update translation.allium + frontmatter.allium |
---
## D. Spec Claims Not Covered by Tests
### D1. No Test Coverage (HIGH priority — invariants/guarantees)
| ID | Claim | Spec | Path |
|---|---|---|---|
| D1-1 | UniqueMediaTranslation invariant | media.allium:108 | Write test: create duplicate media translation, expect rejection |
| D1-2 | UniqueTranslationPerLanguage invariant | translation.allium:94 | Write test: create duplicate post translation, expect rejection |
| D1-3 | BundledDefaultTemplatesExistOutsideProjectData | template.allium:65 | Write test: render with no Template rows, bundled template found |
| D1-4 | UserTemplateDirectoryOverridesBundledDefaults | template.allium:75 | Write test: project template overrides bundled same-slug |
| D1-5 | LiquidTagSubset (5 tags only) | template.allium:179 | Write test: unsupported tag raises error |
| D1-6 | LiquidFilterSubset (4 standard + 2 custom) | template.allium:191 | Write test: unsupported filter raises error |
| D1-7 | LiquidOperatorSubset | template.allium:210 | Write test: unsupported operator raises error |
| D1-8 | MacroTimeout guarantee | script.allium:94-95 | Write test: macro times out within budget |
| D1-9 | ExecuteTransform rule (pipeline, ordering, toast budget) | script.allium:229-263 | Write test: transform pipeline executes in order, toast budget enforced |
| D1-10 | TransformPipelineContinuation | script.allium:247-249 | Write test: error in transform doesn't halt pipeline |
| D1-11 | ChatContextTruncation invariant | ai.allium:375-379 | Write test: long chat history trimmed to context window |
| D1-12 | BoundedToolLoop enforcement | ai.allium:381-385 | Write test: tool rounds bounded by chat_max_tool_rounds |
| D1-13 | DiscardPostChangesSideEffects | engine_side_effects.allium:99-104 | Write test: FTS updated after discard |
| D1-14 | ReplaceMediaFileSideEffects | engine_side_effects.allium:128-134 | Write test: file replaced, thumbnails regenerated |
| D1-15 | Drag-and-drop image chain | action_patterns.allium:84-103 | Write integration test |
| D1-16 | DebouncedPersistence (5s) | embedding.allium:204-208 | Write test: index persistence debounced |
| D1-17 | Protected categories cannot be deleted | editor_settings.allium:81-84 | Write test: article/aside/page/picture deletion rejected |
| D1-18 | HomeItemProtection (menu) | editor_misc.allium:206-209 | Write test: cannot move/reorder/delete Home |
### D2. No Test Coverage (MEDIUM priority — rules/behaviors)
| ID | Claim | Spec | Path |
|---|---|---|---|
| D2-1 | RemoveCategory rule | metadata.allium:100 | Write test: remove category, verify list+settings+JSON updated |
| D2-2 | CreateAndPublishTemplate rule | template.allium:105 | Write test: create+publish in one step |
| D2-3 | CreateAndPublishScript rule | script.allium:160 | Write test: create+publish in one step |
| D2-4 | UniqueScriptSlug dedup | script.allium:115 | Write test: two scripts same title → dedup slug |
| D2-5 | FrontmatterRoundtrip invariant | post.allium:223 | Write test: write file, read back, assert all DB fields match |
| D2-6 | SidecarRoundtrip invariant | media.allium:198 | Write test: write sidecar, read back, assert all fields match |
| D2-7 | ConditionalPostFields: nil fields absent from frontmatter | frontmatter.allium:398 | Write test: post with nil excerpt/author/language → fields not in file |
| D2-8 | ConditionalMediaFields: nil fields absent from sidecar | frontmatter.allium:417 | Write test: media with nil title/alt → fields not in sidecar |
| D2-9 | max_posts_per_page 1..500 constraint | metadata.allium:75-77 | Write test: values outside range rejected |
| D2-10 | SandboxedExecution: restricted capabilities blocked | script.allium:84-88 | Write test: filesystem/process/package loading blocked |
| D2-11 | TransformToastBudget enforcement | script.allium:251-258 | Write test: per-script and total toast limits enforced |
| D2-12 | ProgressThrottled: 250ms throttle | task.allium:110-113 | Write test: rapid progress reports throttled |
| D2-13 | archived→draft transition | post.allium:121 | Write test: unarchive post → draft |
| D2-14 | archived→published transition | post.allium:122 | Write test: unarchive post → published |
| D2-15 | AppNoopNotifier: app writes don't produce notification rows | cli_sync.allium:64-68 | Write test: app mutation produces no notification row |
| D2-16 | ValidateMedia rule | media_processing.allium:318-343 | Write test: missing/corrupted/orphan media detected |
| D2-17 | ContentHashSkipsUnchanged during reindex | embedding.allium:199-202 | Write test: unchanged content_hash skips re-embedding |
### D3. Partial Test Coverage (needs expansion)
| ID | Claim | Spec | Gap | Path |
|---|---|---|---|---|
| D3-1 | PublishPost: content=null after publish | post.allium:186 | Not explicitly tested | Add assertion |
| D3-2 | PublishPost: old file deleted on path change | engine_side_effects.allium:73-74 | Not tested | Add test |
| D3-3 | UpsertPostTranslation: do_not_translate guard | translation.allium:113 | Indirectly covered only | Add direct test |
| D3-4 | PublishTemplate: Liquid validation prerequisite | template.allium:139 | Not tested as publish gate | Add test |
| D3-5 | PublishScript: validation prerequisite | script.allium:181 | Not tested as publish gate | Add test |
| D3-6 | ExecuteMacro failure degrades to empty | script.allium:199 | Returns error tuple, not empty | Fix code or update spec |
| D3-7 | TemplateFrontmatter roundtrip | template.allium:53 | Slug verified, no full parse-back | Add roundtrip test |
| D3-8 | DefaultCategories for fresh project | metadata.allium:60 | Defaults present after add, not verified fresh | Add fresh-project test |
| D3-9 | FtsIncludesTranslations | translation.allium:178 | Tested for one language; expand | Test all stemmer languages |
| D3-10 | PostCanonicalUrl format | post.allium:33-40 | Constructed in links test, not asserted as invariant | Add format assertion |
| D3-11 | Slug generation: German transliteration | post.allium:14-22 | "Föö Bär" → "foo-bar-blog" tested; expand ä/ö/ü/ß/ÄÖÜ | Expand test |
### D4. UI Test Coverage Gaps (whole-editor specs)
| ID | Spec | Covered | Not Covered |
|---|---|---|---|
| D4-1 | editor_media.allium | AI analysis, delete | Translate, replace file, link-to-post, translation CRUD, detect language |
| D4-2 | editor_settings.allium | AI endpoints, airplane toggle, rebuild | Protected categories, MCP agents, style/theme, search filter, categories CRUD |
| D4-3 | editor_chat.allium | Chat creation, pinned tab | API key screen, message rendering, input area, model selector, inline surfaces |
| D4-4 | editor_script.allium | Editor layout, create defaults | Save, syntax check, run, delete |
| D4-5 | editor_template.allium | Editor layout, create defaults | Save with validation, validate, delete with references |
| D4-6 | editor_tags.allium | Sync/discover, merge | Cloud sizing, color picker, delete confirmation, create form |
| D4-7 | editor_misc.allium | Menu add/save, metadata diff, validation | Menu protection, import analysis, translation fix, duplicate dismiss, git diff |
---
## Priority Order for Resolution
1. **A1-1 through A1-12** — code must follow spec (includes auto-save, on-demand preview, template lookup, validation gates, real Pagefind, graceful shutdown)
2. **D1-1 through D1-18** — untested invariants/guarantees
3. **C-1 through C-3** — internal spec inconsistencies (reconcile to code)
4. **B1-1 through B1-6** — major code behaviors missing from spec
5. **A2-1 through A2-17** — spec drift (code is normative, update spec)
6. **D2-1 through D2-17** — untested rules
7. **D3-1 through D3-11** — partial test coverage
8. **B1-7 through B1-20** — minor code behaviors missing from spec
9. **D4-1 through D4-7** — UI test coverage

87
TESTAUDIT.md Normal file
View File

@@ -0,0 +1,87 @@
# Test Audit Procedure
Periodic review of the unit test suite to ensure every test exercises production
code against real assumptions and behavior.
## Scope
All `*_test.exs` files under `test/`.
## What counts as a valid unit test
A valid unit test **calls at least one production function** from `lib/bds/` and
**asserts on its return value, side effects, or observable behavior**.
Acceptable patterns:
- Calling a production function and asserting its return value.
- Calling a production function with injected test doubles (fake HTTP clients,
fake runtimes) and asserting the production code's orchestration logic.
- Mounting a LiveView or rendering a LiveComponent and asserting HTML output
or database state after interactions.
- Sending events to a GenServer and asserting state transitions.
### Source-property tests (acceptable, not flagged)
Tests that verify structural properties of source code are acceptable and should
not be flagged during this audit. Examples:
- Checking that all public functions have `@spec` annotations (AST parsing).
- Asserting absence of `String.to_atom` or `cond do` in specific files.
- Verifying CSS/JS/template assets contain expected class names or imports.
- Checking that `API.md` matches the output of a documentation generator.
- Verifying database indexes exist via `EXPLAIN QUERY PLAN`.
- Asserting `.allium` spec files have consistent parameter signatures.
- Checking config files for expected values.
- Verifying function decomposition patterns in source.
These are linting/contract/consistency checks. They serve a purpose but are
distinct from behavioral tests.
## What gets flagged
1. **Export-existence-only tests** — tests that call `function_exported?/3` or
`Code.ensure_loaded?/1` without ever invoking the function. These verify
compilation, not behavior. They are redundant when the same module is already
tested via rendering or direct calls in another test file.
2. **Mock-only tests** — tests that define a fake/stub module and only assert
on that fake's behavior without routing through any production code path.
3. **Trivially-passing tests** — tests whose assertions succeed regardless of
whether the production code is correct (e.g., asserting on a hardcoded value
that never touches production logic).
## How to run the audit
Ask Claude Code to:
> Analyse the unit tests of the project and check if all of them actually call
> proper production code or if there are tests that essentially only test
> scaffolds, mocks and helper functions. Every unit test must test proper
> production code against assumptions and behaviour. Source-property tests
> (structure, @spec, asset presence, schema verification, doc staleness) are
> acceptable and should not be flagged.
The audit should:
1. Read every `*_test.exs` file under `test/` in full.
2. For each test block, identify which production function (if any) is called.
3. Flag any test that falls into the categories above.
4. Report flagged tests with file path, line number, and explanation.
## Audit log
### 2026-05-11
Reviewed all 71 test files (69 after cleanup). Found 2 redundant files:
- `test/bds/desktop/shell_live/chat_editor_test.exs` — single test only called
`function_exported?` for `ChatEditor`. The component was already fully tested
via `render_component` in `shell_live_test.exs`. **Deleted.**
- `test/bds/desktop/shell_live/import_editor_test.exs` — single test only called
`Code.ensure_loaded?` + `function_exported?` for `ImportEditor`. The component
was already exercised in `import_shell_live_test.exs`. **Deleted.**
Result after cleanup: 646 tests, 0 failures, 4 skipped.

19
assets/css/app.css Normal file
View File

@@ -0,0 +1,19 @@
@import "tailwindcss" source(none);
@source "../css";
@source "../js";
@source "../../lib/bds/desktop";
@import "./tokens.css";
@import "./shell.css";
@import "./sidebar.css";
@import "./tabs.css";
@import "./editor.css";
@import "./forms.css";
@import "./panel.css";
@import "./assistant.css";
@import "./overlays.css";
@import "./menu_editor.css";
@import "./media_editor.css";
@import "./import_editor.css";
@import "./utilities.css";

223
assets/css/assistant.css Normal file
View File

@@ -0,0 +1,223 @@
.settings-view-shell,
.style-view,
.tags-view-shell,
.scripts-view-shell,
.templates-view-shell,
.chat-panel {
height: 100%;
background: var(--vscode-editor-background);
}
.chat-panel {
color: var(--vscode-editor-foreground);
}
.chat-panel-header {
border-bottom: 1px solid var(--vscode-panel-border);
background: var(--vscode-sideBar-background);
}
.chat-panel-title {
flex: 1;
min-width: 0;
gap: 10px;
overflow: visible;
font-size: 14px;
font-weight: 600;
}
.chat-panel-title-main {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-panel-header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.chat-model-selector-wrap {
position: relative;
display: inline-flex;
min-width: 0;
}
.chat-model-selector-button,
.chat-model-selector-option {
border: 1px solid var(--vscode-input-border);
background: var(--vscode-input-background);
color: var(--vscode-input-foreground);
}
.chat-model-selector-menu {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: auto;
border: 1px solid var(--vscode-dropdown-border, var(--vscode-panel-border));
background: var(--vscode-dropdown-background, var(--vscode-sideBar-background));
color: var(--vscode-dropdown-foreground, var(--vscode-foreground));
z-index: 20;
}
.chat-panel .chat-model-selector-button.chat-model-selector-inline {
display: inline-flex;
align-items: center;
gap: 6px;
}
.chat-panel .chat-model-selector-caret {
position: static;
font-size: 10px;
}
.chat-messages,
.chat-surface-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.chat-messages {
}
.chat-message {
display: flex;
max-width: 100%;
}
.chat-message.user {
justify-content: flex-end;
}
.chat-message-content {
max-width: min(760px, 100%);
border: 1px solid var(--vscode-panel-border);
border-radius: 6px;
padding: 12px 14px;
background: var(--vscode-sideBar-background);
color: var(--vscode-editor-foreground);
}
.chat-panel .chat-message.user .chat-message-content {
background: transparent;
color: var(--vscode-list-activeSelectionForeground);
border: 0;
padding: 6px 12px;
line-height: 1.35;
}
.chat-tool-surface-table {
width: 100%;
border-collapse: collapse;
}
.chat-tool-surface-table th,
.chat-tool-surface-table td {
border-bottom: 1px solid var(--vscode-panel-border);
padding: 6px 8px;
text-align: left;
}
.chat-tool-surface-json {
overflow: auto;
padding: 10px 12px;
border: 1px solid var(--vscode-panel-border);
border-radius: 4px;
background: var(--vscode-textCodeBlock-background);
}
.chat-panel .chat-input-container {
--chat-input-line-height: 20px;
--chat-input-min-height: 20px;
border-top: 1px solid var(--vscode-panel-border);
padding: 8px 16px;
background: var(--vscode-sideBar-background);
}
.chat-panel .chat-input-wrapper {
min-height: 30px;
border: 1px solid var(--vscode-input-border);
border-radius: 6px;
padding: 4px 6px;
background: var(--vscode-input-background);
}
.chat-panel .chat-input-wrapper:focus-within {
border-color: var(--vscode-focusBorder);
}
.chat-panel .chat-input {
flex: 1;
box-sizing: border-box;
height: var(--chat-input-min-height);
min-height: var(--chat-input-min-height);
margin: 0;
padding: 6px 8px;
line-height: var(--chat-input-line-height);
max-height: 160px;
resize: vertical;
border: 0;
background: transparent;
color: var(--vscode-input-foreground);
overflow-y: hidden;
}
.chat-panel .chat-input::placeholder {
color: var(--vscode-input-placeholderForeground);
}
.chat-panel .chat-send-button {
flex: 0 0 auto;
width: 22px;
height: 22px;
max-width: 22px;
max-height: 22px;
padding: 0;
background: var(--vscode-button-background);
color: var(--vscode-button-foreground);
}
.chat-panel .chat-send-button:hover:not(:disabled) {
background: var(--vscode-button-hoverBackground);
}
.chat-panel .chat-send-button:disabled {
opacity: 0.5;
}
@media (max-width: 720px) {
.chat-panel-header {
align-items: stretch;
flex-direction: column;
padding: 10px 12px;
}
.chat-panel-title {
width: 100%;
flex-wrap: wrap;
}
.chat-model-selector-wrap {
width: 100%;
}
.chat-panel .chat-model-selector-button.chat-model-selector-inline {
justify-content: space-between;
width: 100%;
}
.chat-messages {
padding: 12px;
}
.chat-message-content {
max-width: 100%;
}
.chat-panel .chat-input-container {
padding: 8px 12px;
}
}

956
assets/css/editor.css Normal file
View File

@@ -0,0 +1,956 @@
.editor-shell {
flex: 1;
min-height: 0;
overflow: auto;
background: var(--vscode-editor-background);
}
.editor-frame {
display: grid;
grid-template-columns: minmax(0, 1fr) 240px;
gap: 16px;
padding: 14px 16px;
}
.editor-main,
.editor-meta,
.panel-shell,
.assistant-card {
min-width: 0;
}
.editor-kicker {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--vscode-descriptionForeground);
}
.editor-title {
margin: 10px 0 6px;
font-size: 24px;
font-weight: 600;
}
.editor-subtitle {
margin: 0 0 14px;
}
.editor-toolbar {
display: flex;
gap: 8px;
margin-bottom: 14px;
}
.editor-toolbar-button {
border: 1px solid var(--vscode-panel-border);
background: transparent;
color: var(--vscode-foreground);
padding: 4px 8px;
border-radius: 3px;
}
.editor-toolbar-button:hover,
.panel-tab:hover {
background: var(--vscode-toolbar-hoverBackground);
}
.editor-section {
padding-top: 4px;
}
.editor-section h2 {
margin: 0 0 8px;
font-size: 16px;
}
.editor-list {
margin: 0;
padding-left: 18px;
line-height: 1.5;
}
.editor-list.compact li {
margin-bottom: 6px;
}
.editor-meta {
border-left: 1px solid var(--vscode-panel-border);
padding-left: 16px;
}
.editor-meta-row {
display: flex;
flex-direction: column;
gap: 3px;
padding: 10px 0;
border-bottom: 1px solid var(--vscode-panel-border);
}
.post-editor .post-editor-markdown-surface,
.scripts-monaco.monaco-editor-shell,
.templates-monaco.monaco-editor-shell {
min-height: 0;
background: var(--vscode-editor-background);
color: var(--vscode-editor-foreground);
border-color: var(--vscode-panel-border);
}
.post-editor .monaco-editor-instance,
.scripts-monaco .monaco-editor-instance,
.templates-monaco .monaco-editor-instance {
min-height: 0;
background: var(--vscode-editor-background);
}
.monaco-editor-shell .monaco-editor,
.monaco-editor-shell .monaco-editor .margin,
.monaco-editor-shell .monaco-editor-background,
.monaco-editor-shell .monaco-editor .inputarea.ime-input {
background-color: var(--vscode-editor-background) !important;
}
.monaco-editor-shell .monaco-editor,
.monaco-editor-shell .monaco-editor .view-line {
color: var(--vscode-editor-foreground) !important;
}
.monaco-editor-shell .monaco-editor .line-numbers {
color: var(--vscode-editorLineNumber-foreground, #858585) !important;
}
.monaco-editor-shell .monaco-editor .current-line,
.monaco-editor-shell .monaco-editor .view-overlays .current-line {
border-color: var(--vscode-editor-lineHighlightBorder, transparent) !important;
}
.help-doc-view {
--doc-bg: var(--panel-1, #1e1e1e);
--doc-surface: var(--panel-2, #252526);
--doc-border: var(--line, #3c3c3c);
--doc-text: var(--vscode-editor-foreground, #d4d4d4);
--doc-muted: var(--vscode-descriptionForeground, #9da3ad);
--doc-link: var(--vscode-textLink-foreground, #9cdcfe);
--doc-code-bg: var(--vscode-textCodeBlock-background, rgba(0, 0, 0, 0.2));
--doc-hover: var(--vscode-list-hoverBackground, rgba(255, 255, 255, 0.06));
}
.help-doc-view .misc-editor-content {
padding: 0;
overflow: hidden;
}
.documentation-view,
.documentation-scroll {
background: var(--doc-bg, var(--vscode-editor-background));
}
.documentation-view {
display: flex;
flex-direction: column;
min-height: 0;
height: 100%;
}
.documentation-scroll {
flex: 1;
min-height: 0;
overflow: auto;
padding: 28px 24px 40px;
}
.documentation-content {
max-width: 920px;
margin: 0 auto;
color: var(--doc-text, var(--vscode-editor-foreground));
}
.documentation-article,
.help-doc-markdown {
background: var(--doc-surface);
padding: 18px 20px 24px;
border: 1px solid var(--doc-border);
border-radius: 10px;
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.18);
}
.documentation-content.markdown-body > .documentation-article > :first-child {
margin-top: 0;
}
.documentation-content.markdown-body > .documentation-article > :last-child {
margin-bottom: 0;
}
.documentation-content.markdown-body h1,
.documentation-content.markdown-body h2,
.documentation-content.markdown-body h3 {
color: var(--doc-text);
border-bottom: 1px solid var(--doc-border);
padding-bottom: 6px;
line-height: 1.25;
}
.documentation-content.markdown-body h1 {
font-size: 1.9rem;
}
.documentation-content.markdown-body h2 {
margin-top: 2rem;
font-size: 1.35rem;
}
.documentation-content.markdown-body h3 {
margin-top: 1.6rem;
font-size: 1.05rem;
}
.documentation-content.markdown-body p,
.documentation-content.markdown-body li,
.documentation-content.markdown-body td,
.documentation-content.markdown-body th {
line-height: 1.6;
}
.documentation-content.markdown-body a {
color: var(--doc-link);
text-decoration-thickness: 1px;
text-underline-offset: 0.14em;
}
.documentation-content.markdown-body a:hover {
color: var(--doc-text);
}
.documentation-content.markdown-body hr {
border: 0;
border-top: 1px solid var(--doc-border);
opacity: 0.8;
}
.documentation-content.markdown-body code {
background: var(--doc-code-bg);
padding: 0.12em 0.4em;
border-radius: 4px;
font: 0.92em/1.45 "SFMono-Regular", Menlo, Monaco, Consolas, monospace;
}
.documentation-content.markdown-body pre {
margin: 0.9rem 0 1.2rem;
background: var(--doc-code-bg);
border: 1px solid var(--doc-border);
border-radius: 8px;
padding: 14px 16px;
overflow: auto;
}
.documentation-content.markdown-body pre code {
padding: 0;
background: transparent;
font-size: 0.9em;
}
.documentation-content.markdown-body blockquote {
margin: 1rem 0;
padding: 0 0 0 12px;
border-left: 3px solid var(--doc-border);
color: var(--doc-muted);
}
.documentation-content.markdown-body table {
width: 100%;
margin: 1rem 0 1.4rem;
border-collapse: collapse;
display: table;
}
.documentation-content.markdown-body th,
.documentation-content.markdown-body td {
border: 1px solid var(--doc-border);
padding: 8px 10px;
text-align: left;
vertical-align: top;
}
.documentation-content.markdown-body th {
background: var(--doc-hover);
font-weight: 700;
}
.documentation-content.markdown-body ul,
.documentation-content.markdown-body ol {
margin: 0.85rem 0 1rem;
padding-left: 1.5rem;
display: block;
}
.documentation-content.markdown-body ul {
list-style: disc;
}
.documentation-content.markdown-body ol {
list-style: decimal;
}
.documentation-content.markdown-body li {
margin: 0.3rem 0;
}
.documentation-content.markdown-body li > ul,
.documentation-content.markdown-body li > ol {
margin-top: 0.35rem;
margin-bottom: 0.35rem;
}
.documentation-content.markdown-body strong {
color: var(--doc-text);
}
.documentation-content.markdown-body img {
max-width: 100%;
height: auto;
}
.post-editor,
.scripts-view-shell,
.templates-view-shell {
flex: 1;
display: flex;
flex-direction: column;
background-color: var(--vscode-editor-background);
overflow: hidden;
}
.post-editor .editor-tab-dirty {
color: var(--vscode-notificationsWarningIcon-foreground, var(--vscode-editorWarning-foreground));
font-size: 10px;
}
.post-editor .editor-tab-meta {
color: var(--vscode-descriptionForeground);
font-size: 11px;
white-space: nowrap;
}
.post-editor .quick-actions-wrapper {
position: relative;
display: inline-block;
}
.post-editor .quick-actions-btn {
display: flex;
align-items: center;
gap: 4px;
white-space: nowrap;
}
.post-editor .quick-actions-btn-icon {
font-size: 12px;
line-height: 1;
}
.post-editor .quick-actions-divider {
height: 1px;
background: var(--vscode-dropdown-border, #454545);
}
.post-editor .quick-action-icon {
font-size: 16px;
flex-shrink: 0;
margin-top: 2px;
}
.post-editor .quick-action-text strong {
font-size: 13px;
font-weight: 500;
}
.post-editor .quick-action-text small {
font-size: 11px;
opacity: 0.7;
}
.post-editor .status-badge,
.scripts-view-shell .status-badge,
.templates-view-shell .status-badge {
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
}
.post-editor .status-badge.status-draft,
.scripts-view-shell .status-badge.status-draft,
.templates-view-shell .status-badge.status-draft {
background-color: rgba(204, 167, 0, 0.2);
color: var(--vscode-notificationsWarningIcon-foreground, var(--vscode-editorWarning-foreground));
}
.post-editor .status-badge.status-published,
.scripts-view-shell .status-badge.status-published,
.templates-view-shell .status-badge.status-published {
background-color: rgba(115, 201, 145, 0.2);
color: var(--vscode-testing-iconPassed);
}
.post-editor .status-badge.status-archived,
.scripts-view-shell .status-badge.status-archived,
.templates-view-shell .status-badge.status-archived {
background-color: rgba(133, 133, 133, 0.2);
color: var(--vscode-descriptionForeground);
}
.post-editor .auto-save-indicator {
font-size: 11px;
color: var(--vscode-descriptionForeground);
font-style: italic;
}
.post-editor .metadata-toggle-header {
}
.post-editor .metadata-toggle {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 4px;
background: none;
border: none;
color: var(--vscode-descriptionForeground);
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
cursor: pointer;
transition: color 0.15s;
flex-shrink: 0;
}
.post-editor .metadata-toggle:hover {
color: var(--vscode-foreground);
}
.post-editor .metadata-toggle-chevron {
font-size: 10px;
}
.post-editor .editor-header-row.is-collapsed {
display: none;
}
.post-editor .editor-media-panel {
width: 200px;
flex-shrink: 0;
}
.post-editor .editor-field label,
.post-editor .editor-body label,
.post-editor .post-editor-links-label {
font-size: 11px;
font-weight: 500;
color: var(--vscode-descriptionForeground);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.post-editor .editor-checkbox-label {
display: inline-flex;
align-items: center;
gap: 8px;
text-transform: none;
letter-spacing: 0;
color: var(--vscode-foreground);
}
.post-editor .post-editor-input.is-readonly {
opacity: 0.7;
cursor: not-allowed;
}
.post-editor .post-editor-excerpt {
min-height: 96px;
}
.post-editor .tag-input-container {
position: relative;
width: 100%;
}
.post-editor .tag-input-container.is-disabled {
opacity: 0.72;
}
.post-editor .tag-input-wrapper {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
padding: 6px 8px;
min-height: 38px;
border: 1px solid var(--vscode-input-border, #3c3c3c);
border-radius: 4px;
background: var(--vscode-input-background, #3c3c3c);
cursor: text;
}
.post-editor .tag-input-wrapper:focus-within {
border-color: var(--vscode-focusBorder, #007fd4);
outline: none;
}
.post-editor .tag-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
font-size: 0.85rem;
background: var(--vscode-badge-background, #4d4d4d);
border: 1px solid var(--vscode-widget-border, #454545);
border-radius: 4px;
color: var(--vscode-badge-foreground, #ffffff);
white-space: nowrap;
}
.post-editor .tag-chip.has-color {
border-radius: 12px;
padding: 3px 10px;
}
.post-editor .tag-chip-remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
padding: 0;
margin-left: 2px;
border: none;
background: transparent;
color: inherit;
font-size: 1rem;
line-height: 1;
cursor: pointer;
opacity: 0.6;
border-radius: 50%;
transition: opacity 0.15s, background 0.15s;
}
.post-editor .tag-chip-remove:hover {
opacity: 1;
background: rgba(0, 0, 0, 0.1);
}
.post-editor .tag-chip.has-color .tag-chip-remove:hover {
background: rgba(0, 0, 0, 0.2);
}
.post-editor .tag-input-field {
flex: 1;
min-width: 120px;
padding: 2px 4px;
border: none;
background: transparent;
color: var(--vscode-input-foreground, #cccccc);
font-family: inherit;
font-size: 0.9rem;
outline: none;
}
.post-editor .tag-input-field::placeholder {
color: var(--vscode-input-placeholderForeground, #a6a6a6);
}
.post-editor .tag-input-field:disabled {
cursor: not-allowed;
}
.post-editor .tag-suggestions {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 4px;
padding: 4px;
background: var(--vscode-dropdown-background, #3c3c3c);
border: 1px solid var(--vscode-widget-border, #454545);
border-radius: 6px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(0, 0, 0, 0.2);
z-index: 1000;
max-height: 240px;
overflow-y: auto;
}
.post-editor .tag-suggestion {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 12px;
border: none;
background: transparent;
color: var(--vscode-dropdown-foreground, #f0f0f0);
font-family: inherit;
font-size: 0.9rem;
text-align: left;
cursor: pointer;
border-radius: 4px;
transition: background 0.1s;
}
.post-editor .tag-suggestion:hover,
.post-editor .tag-suggestion.selected {
background: var(--vscode-list-hoverBackground, #2a2d2e);
}
.post-editor .tag-suggestion-color {
width: 12px;
height: 12px;
border-radius: 50%;
flex-shrink: 0;
}
.post-editor .tag-suggestion-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.post-editor .tag-suggestion.create-new {
border-top: 1px solid var(--vscode-widget-border, #454545);
margin-top: 4px;
padding: 6px 8px;
padding-top: 12px;
color: var(--vscode-notificationsInfoIcon-foreground, #75beff);
}
.post-editor .tag-suggestion.create-new:first-child {
border-top: none;
margin-top: 0;
padding-top: 8px;
}
.post-editor .tag-suggestion-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border: 1px dashed currentColor;
border-radius: 4px;
font-size: 0.9rem;
font-weight: 600;
}
.post-editor .editor-language-row select {
flex: 1;
min-width: 0;
}
.post-editor .editor-translation-flag {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
border: 1px solid transparent;
border-radius: 999px;
background: transparent;
font-size: 14px;
line-height: 1;
cursor: pointer;
flex: 0 0 auto;
}
.post-editor .editor-translation-flag.status-draft {
opacity: 0.82;
}
.post-editor .editor-translation-flag.status-archived {
opacity: 0.45;
filter: grayscale(0.35);
}
.post-editor .editor-translation-flag.active {
border-color: var(--vscode-testing-iconQueued, #cca700);
background: color-mix(in srgb, var(--vscode-testing-iconQueued, #cca700) 14%, transparent);
}
.post-editor .editor-translation-flag:hover {
background: color-mix(in srgb, var(--vscode-list-hoverBackground) 75%, transparent);
}
.post-editor .post-editor-links-panel,
.post-editor .post-editor-side-panel {
padding: 12px;
border: 1px solid var(--vscode-panel-border);
border-radius: 8px;
background: color-mix(in srgb, var(--vscode-editor-background) 82%, white 3%);
}
.post-editor .post-editor-side-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.post-editor .post-editor-links-columns {
display: flex;
gap: 18px;
align-items: flex-start;
margin-top: 10px;
}
.post-editor .post-editor-links-columns > div {
flex: 1;
min-width: 0;
}
.post-editor .post-editor-empty,
.post-editor .post-editor-media-meta {
color: var(--vscode-descriptionForeground);
font-size: 12px;
}
.post-editor .post-editor-media-list {
list-style: none;
margin: 10px 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.post-editor .post-editor-media-item {
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px 10px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.03);
}
.post-editor .editor-body {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
min-height: 320px;
}
.post-editor .editor-toolbar {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.post-editor .editor-toolbar-left {
display: flex;
align-items: center;
justify-content: flex-start;
}
.post-editor .editor-toolbar-center {
display: flex;
align-items: center;
justify-content: center;
}
.post-editor .editor-toolbar-right {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
min-width: 0;
flex-wrap: wrap;
}
.post-editor .editor-mode-toggle {
display: flex;
gap: 4px;
}
.post-editor .editor-mode-toggle button,
.post-editor .editor-toolbar-button {
padding: 4px 12px;
font-size: 12px;
border-radius: 4px;
border: none;
cursor: pointer;
transition: background-color 0.15s;
}
.post-editor .editor-mode-toggle button {
background-color: var(--vscode-button-secondaryBackground, rgba(255, 255, 255, 0.08));
color: var(--vscode-button-secondaryForeground, var(--vscode-foreground));
}
.post-editor .editor-mode-toggle button:hover,
.post-editor .editor-toolbar-button:hover {
background-color: var(--vscode-button-secondaryHoverBackground, var(--vscode-toolbar-hoverBackground));
}
.post-editor .editor-mode-toggle button.active {
background-color: var(--vscode-button-background, var(--accent-color));
color: var(--vscode-button-foreground, #ffffff);
}
.post-editor .editor-toolbar-button {
background: var(--vscode-button-secondaryBackground, rgba(255, 255, 255, 0.08));
color: var(--vscode-button-secondaryForeground, var(--vscode-foreground));
}
.post-editor .editor-excerpt-panel.is-collapsed {
display: none;
}
.post-editor .gallery-button {
padding: 4px 12px;
font-size: 12px;
border-radius: 4px;
background-color: var(--vscode-button-secondaryBackground);
color: var(--vscode-button-secondaryForeground);
border: none;
cursor: pointer;
transition: background-color 0.15s;
}
.post-editor .gallery-button:hover {
background-color: var(--vscode-button-secondaryHoverBackground);
}
.post-editor .insert-post-link-button,
.post-editor .insert-media-button {
padding: 4px 8px;
font-size: 14px;
border-radius: 4px;
background-color: var(--vscode-button-secondaryBackground);
color: var(--vscode-button-secondaryForeground);
border: none;
cursor: pointer;
transition: background-color 0.15s;
}
.post-editor .insert-post-link-button:hover,
.post-editor .insert-media-button:hover {
background-color: var(--vscode-button-secondaryHoverBackground);
}
.post-editor .editor-preview {
flex: 1;
background-color: var(--vscode-input-background);
border-radius: 4px;
overflow: hidden;
position: relative;
min-height: 240px;
padding: 0;
border: none;
}
.post-editor .editor-preview {
flex: 1;
min-height: 240px;
padding: 14px;
background-color: var(--vscode-input-background);
border: 1px solid var(--vscode-panel-border);
border-radius: 4px;
overflow: auto;
line-height: 1.6;
}
.post-editor .editor-preview-frame {
width: 100%;
min-height: 520px;
border: none;
background: #ffffff;
}
.post-editor .post-editor-markdown-surface {
position: relative;
flex: 1;
min-height: 380px;
border: 1px solid var(--vscode-input-border, var(--vscode-panel-border));
border-radius: 4px;
background: var(--vscode-input-background);
overflow: hidden;
}
.post-editor .monaco-editor-shell,
.scripts-monaco.monaco-editor-shell,
.templates-monaco.monaco-editor-shell {
position: relative;
}
.monaco-editor-instance {
width: 100%;
height: 100%;
min-height: 100%;
}
.post-editor .monaco-editor-instance {
min-height: 380px;
}
.scripts-monaco .monaco-editor-instance,
.templates-monaco .monaco-editor-instance {
min-height: 420px;
}
.monaco-editor-input {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: pre;
border: 0;
}
.post-editor .editor-footer {
display: flex;
align-items: center;
gap: 16px;
padding: 8px 16px;
border-top: 1px solid var(--vscode-panel-border);
background-color: var(--vscode-sideBar-background);
color: var(--vscode-descriptionForeground);
font-size: 12px;
flex-wrap: wrap;
}
@media (max-width: 980px) {
.post-editor .editor-header,
.scripts-view-shell .ui-editor-header,
.templates-view-shell .ui-editor-header,
.post-editor .metadata-toggle-header,
.post-editor .editor-toolbar {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.post-editor .editor-header-row,
.post-editor .editor-field-row,
.post-editor .post-editor-links-columns {
flex-direction: column;
}
.post-editor .editor-media-panel {
width: 100%;
}
.post-editor .editor-toolbar-right,
.post-editor .ui-editor-actions,
.scripts-view-shell .ui-editor-actions,
.templates-view-shell .ui-editor-actions {
justify-content: flex-start;
}
}

141
assets/css/forms.css Normal file
View File

@@ -0,0 +1,141 @@
.settings-view,
.style-view {
height: 100%;
display: flex;
flex-direction: column;
}
.settings-header,
.style-view-header {
padding: 18px 20px;
border-bottom: 1px solid var(--line, #3c3c3c);
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.settings-search input {
width: min(320px, 40vw);
}
.settings-content {
padding: 20px;
overflow: auto;
display: flex;
flex-direction: column;
gap: 18px;
}
.setting-section {
border: 1px solid var(--line, #3c3c3c);
border-radius: 12px;
background: var(--panel-2, #252526);
}
.setting-section-header {
padding: 14px 16px;
border-bottom: 1px solid var(--line, #3c3c3c);
}
.setting-section-content {
padding: 16px;
display: flex;
flex-direction: column;
gap: 14px;
}
.setting-row {
display: grid;
grid-template-columns: minmax(180px, 240px) minmax(0, 1fr);
gap: 16px;
align-items: start;
}
.setting-label {
font-weight: 600;
}
.setting-control,
.setting-input-group {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.setting-actions {
padding: 0 16px 16px;
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.style-theme-picker {
padding: 20px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 14px;
}
.style-theme-option {
border: 1px solid var(--line, #3c3c3c);
background: var(--panel-2, #252526);
border-radius: 14px;
padding: 14px;
text-align: left;
cursor: pointer;
}
.style-theme-option.selected {
border-color: var(--accent-color);
box-shadow: 0 0 0 1px var(--accent-color);
}
.style-theme-swatch {
display: flex;
flex-direction: column;
gap: 12px;
}
.style-theme-tones {
display: grid;
grid-template-columns: 2fr 1fr 1fr;
gap: 8px;
}
.style-theme-tone {
height: 42px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
}
.style-apply-row {
padding: 0 20px 20px;
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
}
.style-preview-container {
padding: 0 20px 20px;
flex: 1;
min-height: 0;
}
.style-preview-frame {
width: 100%;
height: 100%;
min-height: 420px;
border: 1px solid var(--line, #3c3c3c);
border-radius: 14px;
background: #ffffff;
}
@media (max-width: 1100px) {
.setting-row {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,689 @@
.import-analysis {
display: flex;
flex-direction: column;
gap: 16px;
padding: 18px 20px 26px;
color: var(--vscode-foreground);
}
.import-analysis-header {
display: flex;
flex-direction: column;
gap: 8px;
}
.import-analysis-header p {
margin: 0;
color: var(--vscode-descriptionForeground);
font-size: 13px;
line-height: 1.5;
}
.import-definition-name {
width: min(480px, 100%);
border: 1px solid var(--vscode-input-border, transparent);
background: var(--vscode-input-background);
color: var(--vscode-input-foreground, var(--vscode-foreground));
border-radius: 6px;
padding: 10px 12px;
font-size: 18px;
font-weight: 600;
}
.import-file-selectors {
display: grid;
gap: 12px;
}
.import-file-row {
display: grid;
grid-template-columns: 150px minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
padding: 12px 14px;
border: 1px solid var(--vscode-panel-border);
border-radius: 8px;
background: color-mix(in srgb, var(--vscode-editor-background) 76%, var(--vscode-input-background));
}
.import-file-row label {
font-size: 12px;
font-weight: 600;
color: var(--vscode-descriptionForeground);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.import-file-path {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--vscode-editor-font-family, ui-monospace, monospace);
font-size: 12px;
}
.import-file-path.placeholder {
color: var(--vscode-descriptionForeground);
}
.import-analysis button,
.import-analysis select {
border: 1px solid var(--vscode-button-border, transparent);
border-radius: 6px;
font-size: 12px;
}
.import-analysis button {
background: var(--vscode-button-secondaryBackground, var(--vscode-button-background));
color: var(--vscode-button-secondaryForeground, var(--vscode-button-foreground));
padding: 8px 12px;
cursor: pointer;
}
.import-analysis button:hover:not(:disabled) {
background: var(--vscode-button-secondaryHoverBackground, var(--vscode-button-hoverBackground));
}
.import-analyze-btn,
.import-execute-btn {
background: var(--vscode-button-background) !important;
color: var(--vscode-button-foreground) !important;
}
.import-analysis button:disabled {
opacity: 0.65;
cursor: not-allowed;
}
.import-loading {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
border: 1px solid var(--vscode-panel-border);
border-radius: 10px;
background: color-mix(in srgb, var(--vscode-editor-background) 84%, var(--vscode-input-background));
}
.import-spinner {
width: 18px;
height: 18px;
border: 2px solid var(--vscode-descriptionForeground);
border-top-color: var(--vscode-button-background);
border-radius: 50%;
animation: import-spinner-rotate 0.8s linear infinite;
flex-shrink: 0;
}
.import-progress {
display: flex;
flex-direction: column;
gap: 2px;
}
.import-progress-step {
font-size: 13px;
color: var(--vscode-foreground);
}
.import-progress-detail {
font-size: 11px;
color: var(--vscode-descriptionForeground);
}
@keyframes import-spinner-rotate {
to {
transform: rotate(360deg);
}
}
.import-site-info {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.import-site-info-item,
.import-stat-card,
.import-date-distribution,
.import-detail-section,
.import-execute-section {
border: 1px solid var(--vscode-panel-border);
border-radius: 10px;
background: color-mix(in srgb, var(--vscode-editor-background) 84%, var(--vscode-input-background));
}
.import-site-info-item {
display: flex;
flex-direction: column;
gap: 6px;
padding: 14px;
}
.info-label {
font-size: 11px;
font-weight: 600;
color: var(--vscode-descriptionForeground);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.info-value {
font-size: 13px;
font-weight: 500;
overflow-wrap: anywhere;
}
.import-stat-cards {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 12px;
}
.import-stat-card {
padding: 14px;
}
.import-stat-card h3,
.import-date-distribution h3,
.import-detail-section h3,
.taxonomy-group h4 {
margin: 0;
}
.import-stat-number {
margin-top: 10px;
font-size: 28px;
font-weight: 700;
line-height: 1;
}
.import-stat-breakdown,
.import-execute-summary,
.import-taxonomy-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.import-stat-breakdown {
margin-top: 12px;
}
.import-stat-tag,
.import-count-tag,
.import-taxonomy-pill,
.macro-status-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 9px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
}
.stat-new,
.import-taxonomy-pill.new-tax {
background: rgba(117, 190, 255, 0.16);
color: #75beff;
}
.stat-update,
.stat-mapped,
.import-taxonomy-pill.exists,
.import-taxonomy-pill.mapped,
.macro-status-badge.mapped,
.import-execution-complete {
background: rgba(115, 201, 145, 0.16);
color: #73c991;
}
.stat-conflict {
background: rgba(255, 166, 87, 0.16);
color: #ffb169;
}
.stat-duplicate,
.stat-missing,
.macro-status-badge.unmapped,
.import-execution-error {
background: rgba(204, 167, 0, 0.16);
color: #cca700;
}
.import-date-distribution,
.import-detail-section,
.import-execute-section {
padding: 16px;
}
.import-section-toggle {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 0;
border: none !important;
background: transparent !important;
color: inherit !important;
font-size: 16px !important;
font-weight: 600;
text-align: left;
}
.import-section-toggle:hover {
background: transparent !important;
opacity: 0.9;
}
.toggle-icon {
font-size: 12px;
color: var(--vscode-descriptionForeground);
}
.distribution-bars {
display: grid;
gap: 10px;
margin-top: 14px;
}
.distribution-row {
display: grid;
grid-template-columns: 56px minmax(0, 1fr) 72px;
gap: 10px;
align-items: center;
}
.distribution-year,
.distribution-count,
.slug-cell {
font-family: var(--vscode-editor-font-family, ui-monospace, monospace);
font-size: 11px;
}
.distribution-bar-container {
height: 10px;
border-radius: 999px;
overflow: hidden;
background: var(--vscode-input-background);
}
.distribution-bar {
height: 100%;
min-width: 8px;
border-radius: inherit;
}
.distribution-bar-posts {
background: linear-gradient(90deg, rgba(117, 190, 255, 0.8), rgba(117, 190, 255, 0.35));
}
.import-execute-section {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.import-execute-summary {
color: var(--vscode-descriptionForeground);
}
.import-execution-complete,
.import-execution-error {
padding: 10px 12px;
border-radius: 8px;
font-size: 12px;
font-weight: 600;
}
.import-execution-progress {
display: grid;
gap: 10px;
padding: 16px;
border: 1px solid var(--vscode-panel-border);
border-radius: 10px;
background: color-mix(in srgb, var(--vscode-editor-background) 84%, var(--vscode-input-background));
}
.import-execution-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.import-execution-header h3 {
margin: 0;
font-size: 14px;
}
.import-progress-bar {
height: 10px;
border-radius: 999px;
overflow: hidden;
background: var(--vscode-input-background);
}
.import-progress-fill {
height: 100%;
background: linear-gradient(90deg, rgba(117, 190, 255, 0.85), rgba(117, 190, 255, 0.45));
}
.import-progress-info {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
font-size: 12px;
}
.import-phase {
font-weight: 600;
}
.import-detail,
.import-counter {
color: var(--vscode-descriptionForeground);
}
.import-detail-table {
width: 100%;
border-collapse: collapse;
margin-top: 14px;
}
.import-detail-table th,
.import-detail-table td {
padding: 10px 8px;
text-align: left;
border-bottom: 1px solid var(--vscode-panel-border);
vertical-align: middle;
font-size: 12px;
}
.import-detail-table th {
font-size: 11px;
color: var(--vscode-descriptionForeground);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.import-detail-table .status-badge {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 4px 9px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.import-detail-table .status-badge.new {
background: rgba(117, 190, 255, 0.16);
color: #75beff;
}
.import-detail-table .status-badge.update {
background: rgba(115, 201, 145, 0.16);
color: #73c991;
}
.import-detail-table .status-badge.conflict {
background: rgba(255, 166, 87, 0.16);
color: #ffb169;
}
.import-detail-table .status-badge.duplicate,
.import-detail-table .status-badge.missing {
background: rgba(204, 167, 0, 0.16);
color: #cca700;
}
.categories-cell,
.existing-match,
.mime-type-cell,
.post-type-cell {
font-size: 11px;
color: var(--vscode-descriptionForeground);
}
.mime-type-cell,
.post-type-cell,
.existing-match,
.slug-cell {
font-family: var(--vscode-editor-font-family, ui-monospace, monospace);
}
.resolution-select,
.taxonomy-mapping-input {
min-width: 150px;
background: var(--vscode-dropdown-background, var(--vscode-input-background));
color: var(--vscode-dropdown-foreground, var(--vscode-foreground));
border: 1px solid var(--vscode-dropdown-border, var(--vscode-panel-border));
padding: 6px 8px;
}
.taxonomy-analyze-row {
display: flex;
align-items: center;
gap: 12px;
padding: 0 0 12px;
margin-top: 12px;
border-bottom: 1px solid var(--vscode-panel-border);
}
.taxonomy-analyze-dropdown {
position: relative;
}
.taxonomy-analyze-btn {
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.taxonomy-model-dropdown {
position: absolute;
top: calc(100% + 6px);
left: 0;
min-width: 220px;
max-height: 280px;
overflow-y: auto;
background: var(--vscode-dropdown-background, var(--vscode-sideBar-background));
border: 1px solid var(--vscode-dropdown-border, var(--vscode-panel-border));
border-radius: 6px;
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.24);
z-index: 20;
}
.taxonomy-model-option {
width: 100%;
display: block;
border: none !important;
border-radius: 0 !important;
background: transparent !important;
color: var(--vscode-foreground) !important;
text-align: left;
padding: 8px 12px !important;
}
.taxonomy-model-option:hover {
background: var(--vscode-list-hoverBackground) !important;
}
.taxonomy-analyze-hint {
font-size: 11px;
color: var(--vscode-descriptionForeground);
}
.import-taxonomy-groups {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
margin-top: 14px;
}
.taxonomy-group {
display: flex;
flex-direction: column;
gap: 12px;
}
.import-taxonomy-entry,
.import-taxonomy-edit-form {
display: inline-flex;
align-items: center;
gap: 8px;
}
.import-taxonomy-entry,
.import-taxonomy-edit-form {
flex-wrap: wrap;
}
.import-taxonomy-pill {
border: none;
cursor: default;
}
button.import-taxonomy-pill {
cursor: pointer;
}
.mapped-target {
background: rgba(115, 201, 145, 0.1);
}
.taxonomy-mapping-arrow {
color: var(--vscode-descriptionForeground);
font-size: 12px;
}
.taxonomy-mapping-input {
min-width: 170px;
border-radius: 6px;
}
.taxonomy-edit-btn,
.taxonomy-clear-btn {
min-width: 28px;
min-height: 28px;
padding: 0 8px !important;
display: inline-flex;
align-items: center;
justify-content: center;
}
.taxonomy-edit-btn.ghost,
.taxonomy-clear-btn {
background: transparent !important;
border: 1px solid var(--vscode-panel-border) !important;
color: var(--vscode-descriptionForeground) !important;
}
.macros-list {
display: grid;
gap: 10px;
margin-top: 14px;
}
.macro-item {
border: 1px solid var(--vscode-panel-border);
border-radius: 8px;
background: var(--vscode-input-background);
}
.macro-item.unmapped {
border-left: 3px solid #cca700;
}
.macro-header {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
}
.macro-name,
.import-taxonomy-pill {
font-family: var(--vscode-editor-font-family, ui-monospace, monospace);
}
.macro-count {
margin-left: auto;
font-size: 11px;
color: var(--vscode-descriptionForeground);
}
.import-empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 56px 20px;
color: var(--vscode-descriptionForeground);
border: 1px dashed var(--vscode-panel-border);
border-radius: 12px;
}
.import-empty-state p {
margin: 0;
font-size: 13px;
}
@media (max-width: 1100px) {
.import-site-info,
.import-stat-cards,
.import-taxonomy-groups {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 780px) {
.import-analysis {
padding: 14px;
}
.import-file-row,
.distribution-row,
.import-execute-section,
.import-site-info,
.import-stat-cards,
.import-taxonomy-groups {
grid-template-columns: 1fr;
}
.import-execute-section {
align-items: stretch;
}
.import-file-row {
align-items: stretch;
}
.import-analysis button,
.resolution-select,
.taxonomy-mapping-input {
width: 100%;
}
.taxonomy-analyze-row {
flex-direction: column;
align-items: stretch;
}
.import-taxonomy-entry,
.import-taxonomy-edit-form {
width: 100%;
flex-direction: column;
align-items: stretch;
}
}

325
assets/css/media_editor.css Normal file
View File

@@ -0,0 +1,325 @@
[data-testid="media-editor"] .editor-tab-dirty {
color: var(--vscode-notificationsWarningIcon-foreground, var(--vscode-editorWarning-foreground));
font-size: 10px;
}
[data-testid="media-editor"] .ui-editor-actions button {
padding: 4px 10px;
font-size: 12px;
}
[data-testid="media-editor"] .ui-editor-actions button.danger:hover {
background-color: var(--vscode-notificationsErrorIcon-foreground);
}
[data-testid="media-editor"] .auto-save-indicator {
font-size: 11px;
color: var(--vscode-descriptionForeground);
font-style: italic;
}
[data-testid="media-editor"] .quick-actions-wrapper {
position: relative;
}
[data-testid="media-editor"] .quick-actions-btn {
display: inline-flex;
align-items: center;
gap: 6px;
}
[data-testid="media-editor"] .quick-actions-btn-icon {
font-size: 12px;
line-height: 1;
}
[data-testid="media-editor"] .quick-actions-divider {
height: 1px;
background: var(--vscode-dropdown-border, #454545);
}
[data-testid="media-editor"] .quick-action-icon {
font-size: 16px;
flex-shrink: 0;
margin-top: 2px;
}
[data-testid="media-editor"] .quick-action-text strong {
font-size: 13px;
font-weight: 500;
}
[data-testid="media-editor"] .quick-action-text small {
font-size: 11px;
opacity: 0.7;
}
[data-testid="media-editor"] > .editor-content.media-editor {
flex-direction: row;
align-items: stretch;
gap: 24px;
}
[data-testid="media-editor"] .editor-field label {
font-size: 11px;
font-weight: 500;
color: var(--vscode-descriptionForeground);
text-transform: uppercase;
letter-spacing: 0.5px;
}
[data-testid="media-editor"] .post-editor-input.disabled,
[data-testid="media-editor"] .post-editor-input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
[data-testid="media-editor"] .media-preview {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--vscode-input-background);
border-radius: 8px;
min-height: 300px;
overflow: hidden;
}
[data-testid="media-editor"] .media-preview-placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
color: var(--vscode-descriptionForeground);
}
[data-testid="media-editor"] .media-preview-image {
display: flex;
align-items: center;
justify-content: center;
align-self: stretch;
width: 100%;
height: 100%;
min-height: 0;
padding: 16px;
box-sizing: border-box;
}
[data-testid="media-editor"] .media-preview-image img {
width: 100%;
height: 100%;
object-fit: contain;
border-radius: 4px;
}
[data-testid="media-editor"] .media-details {
width: 320px;
gap: 12px;
flex-shrink: 0;
}
[data-testid="media-editor"] .media-details textarea {
resize: vertical;
}
[data-testid="media-editor"] .linked-posts-section label {
display: flex;
justify-content: space-between;
align-items: center;
}
[data-testid="media-editor"] .add-link-btn {
background: var(--vscode-button-secondaryBackground);
border: none;
color: var(--vscode-button-secondaryForeground);
padding: 2px 8px;
border-radius: 3px;
cursor: pointer;
font-size: 11px;
}
[data-testid="media-editor"] .add-link-btn:hover {
background: var(--vscode-button-secondaryHoverBackground);
}
[data-testid="media-editor"] .post-picker {
background: var(--vscode-dropdown-background);
border: 1px solid var(--vscode-dropdown-border);
border-radius: 4px;
margin-top: 8px;
max-height: 250px;
overflow-y: auto;
}
[data-testid="media-editor"] .post-picker-search {
padding: 8px;
border-bottom: 1px solid var(--vscode-dropdown-border);
position: sticky;
top: 0;
background: var(--vscode-dropdown-background);
}
[data-testid="media-editor"] .post-picker-search input {
width: 100%;
padding: 6px 10px;
background: var(--vscode-input-background);
border: 1px solid var(--vscode-input-border);
border-radius: 3px;
color: var(--vscode-input-foreground);
font-size: 12px;
}
[data-testid="media-editor"] .post-picker-search input:focus {
outline: none;
border-color: var(--vscode-focusBorder);
}
[data-testid="media-editor"] .post-picker-list {
padding: 4px;
}
[data-testid="media-editor"] .post-picker-item {
width: 100%;
padding: 6px 8px;
cursor: pointer;
border: none;
border-radius: 3px;
background: transparent;
color: inherit;
font-size: 12px;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
[data-testid="media-editor"] .post-picker-item:hover {
background: var(--vscode-list-hoverBackground);
}
[data-testid="media-editor"] .post-picker-more {
padding: 6px 8px;
color: var(--vscode-descriptionForeground);
font-size: 11px;
font-style: italic;
}
[data-testid="media-editor"] .no-posts,
[data-testid="media-editor"] .no-linked-posts {
padding: 12px 8px;
color: var(--vscode-descriptionForeground);
font-size: 12px;
font-style: italic;
}
[data-testid="media-editor"] .linked-posts-list {
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 4px;
}
[data-testid="media-editor"] .linked-post-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 8px;
background: var(--vscode-sideBar-background);
border-radius: 4px;
}
[data-testid="media-editor"] .linked-post-title,
[data-testid="media-editor"] .linked-post-link {
flex: 1;
min-width: 0;
border: none;
background: transparent;
padding: 0;
color: inherit;
text-align: left;
cursor: pointer;
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
[data-testid="media-editor"] .linked-post-title:hover,
[data-testid="media-editor"] .linked-post-link:hover {
color: var(--vscode-textLink-foreground);
text-decoration: underline;
}
[data-testid="media-editor"] .linked-post-item .unlink-btn {
background: none;
border: none;
color: var(--vscode-descriptionForeground);
cursor: pointer;
padding: 0 4px;
font-size: 14px;
opacity: 0;
transition: opacity 0.1s;
}
[data-testid="media-editor"] .linked-post-item:hover .unlink-btn {
opacity: 1;
}
[data-testid="media-editor"] .linked-post-item .unlink-btn:hover {
color: var(--vscode-errorForeground);
}
.translation-modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.68);
display: flex;
align-items: center;
justify-content: center;
pointer-events: auto;
z-index: 10001;
}
.translation-modal {
width: min(640px, calc(100vw - 32px));
background: #1e1e1e;
border: 1px solid #3c3c3c;
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
.translation-modal-header,
.translation-modal-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 20px;
}
.translation-modal-header {
border-bottom: 1px solid #3c3c3c;
}
.translation-modal-footer {
border-top: 1px solid #3c3c3c;
justify-content: flex-end;
gap: 10px;
}
.translation-modal-body {
padding: 20px;
display: flex;
flex-direction: column;
gap: 14px;
}
.translation-modal-close {
border: none;
background: transparent;
color: #c5c5c5;
cursor: pointer;
font-size: 20px;
line-height: 1;
}

259
assets/css/menu_editor.css Normal file
View File

@@ -0,0 +1,259 @@
.menu-editor-header {
}
.menu-editor-header h2 {
margin: 0;
}
.menu-editor-header p {
margin: 0.25rem 0 0;
color: var(--vscode-descriptionForeground);
}
.menu-editor-tree-wrap {
border: 1px solid var(--vscode-panel-border);
border-radius: 6px;
background: var(--vscode-editor-background);
padding: 0.5rem;
min-height: 0;
}
.menu-editor-toolbar {
margin-bottom: 0.5rem;
padding-bottom: 0.4rem;
border-bottom: 1px solid var(--vscode-panel-border);
}
.menu-editor-tool {
width: 1.8rem;
height: 1.8rem;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid transparent;
border-radius: 4px;
background: transparent;
color: var(--vscode-foreground);
cursor: pointer;
padding: 0;
}
.menu-editor-tool:hover:not(:disabled) {
background: var(--vscode-toolbar-hoverBackground);
border-color: var(--vscode-panel-border);
}
.menu-editor-tool:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.menu-editor-tree-shell {
flex: 1;
min-height: 0;
overflow: auto;
}
.menu-editor-tree-level {
list-style: none;
margin: 0;
padding: 0;
}
.menu-editor-tree-item {
margin: 0;
padding: 0;
}
.menu-editor-row {
--menu-editor-indent: calc(var(--menu-editor-depth) * 1rem);
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.3rem 0.45rem 0.3rem calc(0.4rem + var(--menu-editor-indent));
border-radius: 4px;
cursor: pointer;
position: relative;
}
.menu-editor-row.is-selected {
background: var(--vscode-list-activeSelectionBackground);
color: var(--vscode-list-activeSelectionForeground);
}
.menu-editor-row.is-dragging {
opacity: 0.45;
}
.menu-editor-row.is-drop-before::before,
.menu-editor-row.is-drop-after::after {
content: "";
position: absolute;
left: calc(0.4rem + var(--menu-editor-indent));
right: 0.45rem;
height: 2px;
background: var(--vscode-focusBorder);
}
.menu-editor-row.is-drop-before::before {
top: 0;
}
.menu-editor-row.is-drop-after::after {
bottom: 0;
}
.menu-editor-row.is-drop-inside {
box-shadow: inset 0 0 0 1px var(--vscode-focusBorder);
background: var(--vscode-list-hoverBackground);
}
.menu-editor-row-handle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1rem;
min-width: 1rem;
color: var(--vscode-descriptionForeground);
cursor: grab;
user-select: none;
}
.menu-editor-row-handle:active {
cursor: grabbing;
}
.menu-editor-row-kind {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1rem;
min-width: 1rem;
opacity: 0.9;
}
.menu-editor-row-title {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.menu-editor-row-title.is-editing {
white-space: normal;
overflow: visible;
text-overflow: clip;
}
.menu-editor-entry-form {
display: block;
}
.menu-editor-inline-input {
width: 100%;
border: 1px solid var(--vscode-focusBorder);
border-radius: 4px;
background: var(--vscode-input-background);
color: var(--vscode-input-foreground);
padding: 0.25rem 0.45rem;
min-height: 1.8rem;
}
.menu-editor-inline-search {
margin-top: 0.5rem;
border-top: 1px solid var(--vscode-panel-border);
padding-top: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
max-height: 18rem;
overflow: hidden;
}
.menu-editor-inline-search-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.menu-editor-inline-search-head strong {
display: block;
font-size: 0.8rem;
}
.menu-editor-inline-search-head span {
color: var(--vscode-descriptionForeground);
font-size: 0.75rem;
}
.menu-editor-inline-actions {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.menu-editor-inline-action {
border: 1px solid var(--vscode-button-border, transparent);
border-radius: 4px;
background: var(--vscode-button-secondaryBackground);
color: var(--vscode-button-secondaryForeground);
padding: 0.2rem 0.5rem;
cursor: pointer;
}
.menu-editor-inline-action:hover {
background: var(--vscode-button-secondaryHoverBackground);
}
.menu-editor-picker-list {
display: flex;
flex-direction: column;
gap: 0.35rem;
max-height: 16rem;
overflow-y: auto;
}
.menu-editor-picker-item {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
border: 1px solid var(--vscode-panel-border);
border-radius: 4px;
background: var(--vscode-input-background);
color: var(--vscode-input-foreground);
padding: 0.45rem 0.55rem;
text-align: left;
cursor: pointer;
}
.menu-editor-picker-item:hover {
border-color: var(--vscode-focusBorder);
background: var(--vscode-list-hoverBackground);
}
.menu-editor-picker-item small,
.menu-editor-picker-state {
color: var(--vscode-descriptionForeground);
}
.menu-editor-empty {
color: var(--vscode-descriptionForeground);
padding: 0.5rem 0.25rem;
}
@media (max-width: 720px) {
.menu-editor-inline-search-head {
flex-direction: column;
align-items: flex-start;
}
.menu-editor-inline-actions {
width: 100%;
justify-content: flex-start;
flex-wrap: wrap;
}
}

336
assets/css/overlays.css Normal file
View File

@@ -0,0 +1,336 @@
.overlay-root {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 10000;
}
.overlay-root:empty {
display: none;
}
.editor-shared-actions {
position: relative;
margin-bottom: 14px;
}
.ai-suggestions-modal-backdrop,
.insert-modal-backdrop,
.language-picker-modal-backdrop,
.confirm-delete-modal-backdrop,
.confirm-dialog-overlay,
.gallery-overlay,
.lightbox-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.68);
display: flex;
align-items: center;
justify-content: center;
pointer-events: auto;
}
.ai-suggestions-modal,
.insert-modal,
.language-picker-modal,
.confirm-delete-modal,
.confirm-dialog,
.gallery-overlay-content {
background: #1e1e1e;
border: 1px solid #3c3c3c;
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
.ai-suggestions-modal,
.language-picker-modal,
.confirm-delete-modal,
.confirm-dialog {
width: min(680px, calc(100vw - 32px));
max-height: calc(100vh - 48px);
display: flex;
flex-direction: column;
}
.insert-modal {
width: min(680px, calc(100vw - 32px));
max-height: calc(100vh - 48px);
display: flex;
flex-direction: column;
overflow: hidden;
}
.gallery-overlay-content {
width: min(980px, calc(100vw - 48px));
max-height: calc(100vh - 48px);
display: flex;
flex-direction: column;
overflow: hidden;
}
.ai-suggestions-modal-header,
.language-picker-modal-header,
.confirm-delete-modal-header,
.insert-modal-header,
.gallery-overlay-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 20px;
border-bottom: 1px solid #3c3c3c;
}
.insert-modal-header {
flex-direction: column;
align-items: stretch;
gap: 12px;
}
.insert-modal-header.media-header-only {
flex-direction: row;
align-items: center;
}
.ai-suggestions-modal-header h2,
.language-picker-modal-header h2,
.confirm-delete-modal-header h2,
.gallery-overlay-header h2,
.insert-modal-title,
.confirm-dialog h3 {
margin: 0;
color: #ffffff;
}
.ai-suggestions-modal-close,
.confirm-delete-modal-close,
.gallery-overlay-close,
.shared-popover-close,
.lightbox-close {
border: none;
background: transparent;
color: #c5c5c5;
cursor: pointer;
font-size: 20px;
line-height: 1;
}
.ai-suggestions-modal-body,
.language-picker-modal-body,
.confirm-delete-modal-body {
padding: 20px;
overflow: auto;
}
.ai-suggestions-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.ai-suggestion-item {
display: flex;
gap: 12px;
padding: 16px;
border: 1px solid #3c3c3c;
border-radius: 6px;
background: #252526;
}
.ai-suggestion-checkbox {
position: relative;
display: flex;
align-items: flex-start;
cursor: pointer;
}
.ai-suggestion-checkbox input {
position: absolute;
opacity: 0;
}
.checkmark {
width: 20px;
height: 20px;
border: 2px solid #555555;
border-radius: 4px;
display: inline-flex;
align-items: center;
justify-content: center;
background: #1e1e1e;
}
.ai-suggestion-checkbox input:checked + .checkmark,
.ai-suggestion-checkbox input:checked ~ .checkmark {
background: #0078d4;
border-color: #0078d4;
}
.ai-suggestion-checkbox input:checked + .checkmark::after,
.ai-suggestion-checkbox input:checked ~ .checkmark::after {
content: "✓";
color: #ffffff;
font-size: 12px;
}
.ai-suggestion-content {
flex: 1;
min-width: 0;
}
.ai-suggestion-label {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
font-weight: 600;
}
.ai-suggestion-has-value,
.language-picker-badge,
.insert-modal-similarity-badge {
display: inline-flex;
align-items: center;
padding: 2px 6px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
color: #c5c5c5;
font-size: 11px;
}
.ai-suggestion-comparison {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
gap: 12px;
align-items: center;
}
.ai-suggestion-column {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 12px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.03);
}
.ai-suggestion-column.muted {
color: #9d9d9d;
}
.ai-suggestion-column.highlighted {
border: 1px solid rgba(0, 122, 204, 0.4);
color: #ffffff;
}
.ai-suggestion-column-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.ai-suggestion-arrow {
color: #9d9d9d;
}
.ai-suggestion-value {
min-height: 1.4em;
}
.ai-suggestion-value.loading {
color: var(--accent-color);
font-style: italic;
}
.ai-suggestions-error {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px 16px;
margin-bottom: 16px;
border-radius: 6px;
background: rgba(220, 50, 50, 0.12);
border: 1px solid rgba(220, 50, 50, 0.35);
color: #ff6b6b;
}
.ai-suggestions-modal-footer,
.confirm-delete-modal-footer,
.confirm-dialog-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 16px 20px;
border-top: 1px solid #3c3c3c;
}
.button-cancel,
.button-delete,
.button-apply,
.confirm-dialog-actions button,
.insert-modal-submit,
.language-picker-row,
.shared-popover-entry,
.colour-swatch {
cursor: pointer;
}
.button-cancel,
.confirm-dialog-actions button,
.insert-modal-submit {
border: 1px solid #4c4c4c;
border-radius: 4px;
padding: 8px 14px;
background: transparent;
color: #f0f0f0;
}
.button-apply,
.confirm-dialog-actions .primary,
.insert-modal-submit {
background: #0e639c;
border-color: #0e639c;
}
.button-delete {
border: none;
border-radius: 4px;
padding: 8px 14px;
background: #c73c3c;
color: #ffffff;
}
.insert-modal-tabs {
display: flex;
margin: 0 -20px;
}
.insert-modal-tab {
flex: 1;
border: none;
border-bottom: 2px solid transparent;
background: transparent;
color: #9d9d9d;
padding: 10px 16px;
}
.insert-modal-tab.active {
color: #ffffff;
border-bottom-color: #0e639c;
background: #252526;
}
.insert-modal-search {
border-bottom: 1px solid #3c3c3c;
}
.insert-modal-input,
.shared-popover-input {
width: 100%;
border: none;
background: transparent;
color: #f0f0f0;
padding: 14px 20px;
font: inherit;
}

541
assets/css/panel.css Normal file
View File

@@ -0,0 +1,541 @@
.panel-shell {
min-height: 160px;
max-height: 160px;
border-top: 1px solid var(--line);
}
.panel-shell.is-hidden {
display: none;
}
.panel-tabs {
display: flex;
gap: 8px;
}
.panel-tabs {
display: flex;
gap: 2px;
}
.panel-tab {
background: transparent;
border: none;
padding: 6px 12px;
font-size: 12px;
color: var(--vscode-tab-inactiveForeground);
cursor: pointer;
border-bottom: 2px solid transparent;
border-radius: 0;
}
.panel-tab:hover {
color: var(--vscode-tab-activeForeground);
background: transparent;
}
.panel-tab.active {
color: var(--vscode-tab-activeForeground);
border-bottom-color: var(--vscode-focusBorder);
background: transparent;
}
.assistant-content {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px;
}
.assistant-sidebar-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.assistant-sidebar-heading {
display: flex;
flex-direction: column;
gap: 4px;
}
.assistant-sidebar-description,
.assistant-sidebar-context-text,
.assistant-sidebar-message-content {
color: var(--vscode-descriptionForeground);
}
.assistant-sidebar-status {
border-radius: 999px;
border: 1px solid var(--vscode-panel-border);
padding: 2px 8px;
font-size: 11px;
line-height: 1.4;
}
.assistant-sidebar-status.is-offline {
background: rgba(255, 196, 0, 0.18);
border-color: rgba(255, 196, 0, 0.35);
color: var(--vscode-editor-foreground);
}
.assistant-sidebar-context {
display: flex;
flex-direction: column;
gap: 10px;
padding: 8px;
border: 1px solid var(--vscode-panel-border);
border-radius: 6px;
background: var(--vscode-editorWidget-background, rgba(0, 0, 0, 0.2));
}
.assistant-sidebar-context-row {
display: flex;
justify-content: space-between;
gap: 12px;
}
.assistant-sidebar-context-label,
.assistant-sidebar-message-role {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--vscode-descriptionForeground);
}
.assistant-sidebar-context-value {
text-align: right;
color: var(--vscode-editor-foreground);
}
.assistant-sidebar-context-text,
.assistant-sidebar-message-content {
margin: 0;
white-space: pre-wrap;
}
.assistant-sidebar-prompt-form,
.assistant-sidebar-welcome,
.assistant-sidebar-transcript {
display: flex;
flex-direction: column;
gap: 10px;
}
.assistant-sidebar-prompt {
width: 100%;
min-height: 120px;
resize: vertical;
border: 1px solid var(--vscode-input-border);
border-radius: 6px;
background: var(--vscode-input-background);
color: var(--vscode-input-foreground);
padding: 10px;
font: inherit;
}
.assistant-sidebar-prompt:focus {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: 1px;
}
.assistant-sidebar-start-button {
align-self: flex-start;
border: 1px solid var(--vscode-button-border, transparent);
border-radius: 999px;
background: var(--vscode-button-background);
color: var(--vscode-button-foreground);
padding: 7px 14px;
cursor: pointer;
}
.assistant-sidebar-start-button:disabled {
cursor: default;
opacity: 0.55;
}
.assistant-card,
.assistant-sidebar-message {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px;
border: 1px solid var(--vscode-panel-border);
border-radius: 6px;
border-bottom-width: 1px;
background: var(--vscode-editorWidget-background, rgba(0, 0, 0, 0.2));
}
.assistant-sidebar-message.user {
background: var(--vscode-list-hoverBackground);
}
.assistant-sidebar-message.assistant {
background: var(--vscode-editorWidget-background, rgba(0, 0, 0, 0.2));
}
.status-bar {
height: 22px;
display: flex;
align-items: center;
justify-content: space-between;
background-color: var(--vscode-statusBar-background);
color: var(--vscode-statusBar-foreground);
font-size: 12px;
padding: 0 8px;
user-select: none;
flex-wrap: nowrap;
gap: 0;
border-top: none;
}
.status-bar-left,
.status-bar-right {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
min-width: 0;
}
.status-bar-item {
display: flex;
align-items: center;
gap: 6px;
padding: 0 8px;
height: 100%;
max-width: none;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
border-radius: 0;
background: transparent;
font-size: 12px;
}
.status-bar-item .task-message-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 300px;
}
.task-spinner {
width: 10px;
height: 10px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.panel-content {
padding: 8px;
}
.task-list {
gap: 4px;
}
.output-list,
.git-log-list {
gap: 6px;
}
.task-entry {
padding: 8px;
border-bottom: none;
border-radius: 4px;
background-color: var(--vscode-sideBar-background);
}
.output-entry {
padding: 8px;
border-bottom: none;
border-radius: 4px;
background-color: var(--vscode-sideBar-background);
font-size: 12px;
color: var(--vscode-editor-foreground);
}
@media (max-width: 1100px) {
.editor-frame {
grid-template-columns: 1fr;
}
.assistant-sidebar-shell {
display: none;
}
.dashboard-grid {
grid-template-columns: 1fr;
}
}
.text-muted {
color: var(--vscode-descriptionForeground);
}
.editor-empty {
flex: 1;
display: flex;
align-items: flex-start;
justify-content: center;
background-color: var(--vscode-editor-background);
overflow-y: auto;
padding: 40px 20px;
}
.dashboard-content {
max-width: 720px;
width: 100%;
}
.dashboard-content h1 {
font-size: 24px;
font-weight: 400;
margin: 0 0 4px;
color: var(--vscode-editor-foreground);
}
.dashboard-content > .text-muted {
margin-bottom: 24px;
display: block;
}
.dashboard-stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
margin-bottom: 24px;
}
.stat-card {
padding: 16px;
background-color: var(--vscode-sideBar-background);
border-radius: 6px;
}
.stat-number {
font-size: 32px;
font-weight: 600;
color: var(--vscode-editor-foreground);
line-height: 1;
margin-bottom: 4px;
}
.stat-label {
font-size: 12px;
color: var(--vscode-descriptionForeground);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 10px;
}
.stat-breakdown {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.stat-tag {
font-size: 11px;
padding: 2px 8px;
border-radius: 3px;
background-color: var(--vscode-input-background);
color: var(--vscode-descriptionForeground);
}
.stat-published {
color: var(--vscode-testing-iconPassed);
}
.stat-draft {
color: var(--vscode-editorWarning-foreground);
}
.stat-archived {
color: var(--vscode-descriptionForeground);
}
.dashboard-section {
background-color: var(--vscode-sideBar-background);
border-radius: 6px;
padding: 16px;
margin-bottom: 12px;
}
.dashboard-section h4 {
font-size: 11px;
font-weight: 600;
color: var(--vscode-descriptionForeground);
text-transform: uppercase;
letter-spacing: 0.5px;
margin: 0 0 12px;
}
.timeline-chart {
display: flex;
align-items: flex-end;
gap: 4px;
height: 100px;
}
.timeline-bar-container {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
height: 100%;
}
.timeline-bar {
width: 100%;
max-width: 40px;
background-color: var(--vscode-activityBarBadge-background);
border-radius: 3px 3px 0 0;
margin-top: auto;
min-height: 4px;
position: relative;
transition: opacity 0.15s;
}
.timeline-bar:hover {
opacity: 0.8;
}
.timeline-bar-count {
position: absolute;
top: -16px;
left: 50%;
transform: translateX(-50%);
font-size: 10px;
color: var(--vscode-descriptionForeground);
}
.timeline-bar-label {
display: flex;
flex-direction: column;
align-items: center;
font-size: 9px;
color: var(--vscode-descriptionForeground);
margin-top: 4px;
line-height: 1.15;
}
.timeline-bar-label-month {
white-space: nowrap;
}
.timeline-bar-label-year {
font-size: 8px;
}
.tag-cloud {
display: flex;
flex-wrap: wrap;
gap: 6px 10px;
align-items: baseline;
line-height: 1.6;
}
.dashboard-tag {
padding: 2px 8px;
border-radius: 10px;
background-color: var(--vscode-input-background);
color: var(--vscode-editor-foreground);
cursor: default;
transition: opacity 0.15s;
white-space: nowrap;
}
.dashboard-tag:hover {
opacity: 0.75;
}
.dashboard-tag.has-color {
border-radius: 12px;
}
.dashboard-tag.has-color:hover {
opacity: 0.85;
}
.tag-cloud-more {
font-size: 11px;
}
.tag-count {
font-size: 10px;
opacity: 0.5;
margin-left: 2px;
}
.dashboard-category {
font-size: 12px;
border: 1px solid var(--vscode-input-border);
}
.recent-posts-list {
display: flex;
flex-direction: column;
}
.recent-post-item {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 8px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
width: 100%;
border: none;
background: transparent;
text-align: left;
color: inherit;
}
.recent-post-item:hover {
background-color: var(--vscode-list-hoverBackground);
}
.recent-post-title {
flex: 1;
color: var(--vscode-editor-foreground);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.recent-post-status {
font-size: 10px;
padding: 1px 6px;
border-radius: 3px;
background-color: var(--vscode-input-background);
text-transform: uppercase;
letter-spacing: 0.3px;
}
.recent-post-status.status-published {
color: var(--vscode-testing-iconPassed);
}
.recent-post-status.status-draft {
color: var(--vscode-editorWarning-foreground);
}
.recent-post-status.status-archived {
color: var(--vscode-descriptionForeground);
}
.recent-post-date {
color: var(--vscode-descriptionForeground);
white-space: nowrap;
}

807
assets/css/shell.css Normal file
View File

@@ -0,0 +1,807 @@
.app {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background-color: var(--vscode-editor-background);
}
.app-main {
flex: 1;
display: flex;
overflow: hidden;
min-height: 0;
}
.app-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
}
.window-titlebar {
position: relative;
height: 34px;
display: flex;
align-items: center;
justify-content: space-between;
background-color: var(--vscode-editorGroupHeader-tabsBackground);
border-bottom: 1px solid var(--vscode-editorGroupHeader-tabsBorder);
flex-shrink: 0;
app-region: drag;
-webkit-app-region: drag;
padding-right: calc(10px + var(--bds-titlebar-overlay-right, 0px));
}
.window-titlebar-menu-bar {
display: flex;
align-items: center;
height: 100%;
margin-left: 6px;
gap: 2px;
app-region: no-drag;
-webkit-app-region: no-drag;
z-index: 2;
}
.window-titlebar-menu-group {
position: relative;
display: flex;
align-items: center;
height: 100%;
}
.window-titlebar-menu-bar.is-hidden {
display: none;
}
.window-titlebar.is-mac .window-titlebar-menu-bar {
margin-left: max(var(--bds-titlebar-macos-left-inset, 78px), calc(6px + var(--bds-titlebar-overlay-left, 0px)));
}
.window-titlebar-menu-button {
height: 24px;
border: none;
background: transparent;
color: var(--vscode-titleBar-activeForeground);
padding: 0 8px;
border-radius: 4px;
font-size: 12px;
line-height: 1;
cursor: pointer;
}
.window-titlebar-menu-button:hover,
.window-titlebar-action-button:hover {
background-color: var(--vscode-toolbar-hoverBackground);
}
.window-titlebar-menu-button.is-active {
background-color: var(--vscode-toolbar-hoverBackground);
}
.window-titlebar-menu-button:focus,
.window-titlebar-menu-button:focus-visible,
.window-titlebar-action-button:focus,
.window-titlebar-action-button:focus-visible {
outline: none;
box-shadow: none;
}
.window-titlebar-menu-dropdown {
position: absolute;
top: 30px;
left: 0;
min-width: 210px;
padding: 6px;
display: flex;
flex-direction: column;
gap: 2px;
background-color: var(--vscode-menu-background, var(--vscode-editorWidget-background));
border: 1px solid var(--vscode-menu-border, var(--vscode-panel-border));
border-radius: 6px;
box-shadow: var(--vscode-widget-shadow, 0 8px 24px rgba(0, 0, 0, 0.4));
app-region: no-drag;
-webkit-app-region: no-drag;
z-index: 10;
}
.window-titlebar-menu-item {
border: none;
background: transparent;
color: var(--vscode-menu-foreground, var(--vscode-foreground));
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
text-align: left;
border-radius: 4px;
padding: 6px 8px;
font-size: 12px;
cursor: pointer;
}
.window-titlebar-menu-item:focus,
.window-titlebar-menu-item:focus-visible {
outline: none;
box-shadow: none;
background-color: var(--vscode-toolbar-hoverBackground);
}
.window-titlebar-menu-item:hover,
.window-titlebar-menu-item.is-keyboard-active {
background-color: var(--vscode-menu-selectionBackground, var(--vscode-toolbar-hoverBackground));
}
.window-titlebar-menu-item-accelerator {
opacity: 0.8;
}
.window-titlebar-menu-separator {
height: 1px;
margin: 4px 2px;
background-color: var(--vscode-menu-separatorBackground, rgba(255, 255, 255, 0.08));
}
.window-titlebar-drag-region {
flex: 1;
height: 100%;
}
.window-titlebar-title {
position: absolute;
left: 50%;
transform: translateX(-50%);
max-width: 45%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: var(--vscode-titleBar-activeForeground);
font-size: 12px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
user-select: none;
pointer-events: none;
}
.window-titlebar-actions {
height: 100%;
display: flex;
align-items: center;
margin-right: 6px;
app-region: no-drag;
-webkit-app-region: no-drag;
}
.window-titlebar-action-button {
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
padding: 0;
line-height: 0;
background: transparent;
border: none;
color: var(--vscode-foreground);
cursor: pointer;
border-radius: 4px;
}
.window-titlebar-sidebar-icon,
.window-titlebar-panel-icon,
.window-titlebar-assistant-icon {
width: 14px;
height: 14px;
border: 1.5px solid currentColor;
border-radius: 2px;
display: block;
position: relative;
overflow: hidden;
}
.window-titlebar-sidebar-icon::before {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 33.3333%;
width: 1.5px;
transform: translateX(-50%);
background-color: currentColor;
}
.window-titlebar-panel-icon::before {
content: "";
position: absolute;
left: 0;
right: 0;
top: 66.6667%;
height: 1.5px;
transform: translateY(-50%);
background-color: currentColor;
}
.window-titlebar-assistant-icon::before {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 66.6667%;
width: 1.5px;
transform: translateX(-50%);
background-color: currentColor;
}
.window-titlebar-sidebar-pane,
.window-titlebar-panel-pane,
.window-titlebar-assistant-pane {
position: absolute;
background-color: currentColor;
transition: opacity 120ms ease;
}
.window-titlebar-sidebar-pane {
left: 0;
top: 0;
width: 33.3333%;
height: 100%;
}
.window-titlebar-panel-pane {
left: 0;
bottom: 0;
width: 100%;
height: 33.3333%;
}
.window-titlebar-assistant-pane {
right: 0;
top: 0;
width: 33.3333%;
height: 100%;
}
.window-titlebar-sidebar-icon.is-inactive .window-titlebar-sidebar-pane,
.window-titlebar-panel-icon.is-inactive .window-titlebar-panel-pane,
.window-titlebar-assistant-icon.is-inactive .window-titlebar-assistant-pane {
opacity: 0;
}
.panel-shell {
height: 200px;
border-top: 1px solid var(--vscode-panel-border);
background: var(--vscode-panel-background);
display: flex;
flex-direction: column;
}
.panel-shell.is-hidden {
display: none;
}
.editor-toolbar-button.is-destructive {
color: #f48771;
}
.shell-overlay-backdrop,
.gallery-overlay-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.68);
display: flex;
align-items: center;
justify-content: center;
pointer-events: auto;
z-index: 10000;
}
.shell-overlay-dismiss {
position: absolute;
inset: 0;
border: none;
background: transparent;
padding: 0;
}
.gallery-overlay {
position: relative;
width: min(980px, calc(100vw - 48px));
max-height: calc(100vh - 48px);
display: flex;
flex-direction: column;
overflow: hidden;
background: #1e1e1e;
border: 1px solid #3c3c3c;
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
z-index: 1;
}
.insert-modal-media-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px;
padding: 16px;
}
.insert-modal-media-item {
display: flex;
flex-direction: column;
gap: 8px;
border: 1px solid #3c3c3c;
border-radius: 8px;
background: #252526;
color: inherit;
padding: 10px;
text-align: left;
}
.insert-modal-media-thumb {
width: 100%;
min-height: 112px;
border-radius: 6px;
object-fit: cover;
background: rgba(255, 255, 255, 0.04);
}
.insert-modal-media-title {
font-weight: 600;
color: #ffffff;
}
.language-picker-options {
display: flex;
flex-direction: column;
gap: 8px;
}
.language-picker-option {
width: 100%;
display: grid;
grid-template-columns: 28px 1fr auto;
gap: 12px;
align-items: center;
border: none;
border-radius: 4px;
padding: 12px 16px;
background: transparent;
color: inherit;
text-align: left;
}
.language-picker-label,
.language-picker-status,
.lightbox-counter {
color: #9d9d9d;
font-size: 12px;
}
.lightbox-counter {
margin-top: 4px;
}
@media (max-width: 720px) {
.insert-modal-media-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.panel-header {
height: 35px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 8px;
background-color: var(--vscode-sideBar-background);
border-bottom: 1px solid var(--vscode-panel-border);
}
.panel-tabs {
display: flex;
align-items: stretch;
height: 100%;
}
.panel-tab {
border: none;
background: transparent;
color: var(--vscode-descriptionForeground);
padding: 0 12px;
cursor: pointer;
}
.panel-tab.active {
color: var(--vscode-tab-activeForeground);
}
.panel-close {
background: transparent;
border: none;
color: var(--vscode-descriptionForeground);
font-size: 18px;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: 4px;
padding: 0;
}
.panel-close:hover {
background-color: var(--vscode-list-hoverBackground);
color: var(--vscode-editor-foreground);
}
.panel-content {
flex: 1;
overflow: auto;
padding: 12px 14px;
}
.panel-entry,
.assistant-card {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 12px;
border-bottom: 1px solid var(--vscode-panel-border);
}
.output-list,
.git-log-list {
display: flex;
flex-direction: column;
}
.task-list {
display: flex;
flex-direction: column;
}
.task-entry-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.task-status {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--vscode-descriptionForeground);
}
.task-status-running {
color: var(--vscode-terminal-ansiGreen, var(--vscode-statusBar-foreground));
}
.task-status-pending {
color: var(--vscode-terminal-ansiYellow, var(--vscode-statusBar-foreground));
}
.panel-empty-state {
min-height: 100%;
justify-content: center;
}
.status-bar {
height: 22px;
background: var(--vscode-statusBar-background);
color: var(--vscode-statusBar-foreground);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 8px;
font-size: 12px;
flex-shrink: 0;
}
.status-bar-left,
.status-bar-right {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.status-bar-left {
flex-shrink: 1;
min-width: 0;
}
.status-shell-controls {
display: flex;
align-items: stretch;
gap: 2px;
flex-shrink: 0;
}
.status-shell-toggle-button {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 100%;
padding: 0;
line-height: 0;
background: transparent;
border: none;
color: inherit;
cursor: pointer;
border-radius: 3px;
}
.status-shell-toggle-button:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.status-shell-toggle-button:focus,
.status-shell-toggle-button:focus-visible {
outline: none;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.45);
}
.status-shell-toggle-button .window-titlebar-sidebar-icon,
.status-shell-toggle-button .window-titlebar-panel-icon,
.status-shell-toggle-button .window-titlebar-assistant-icon {
width: 12px;
height: 12px;
}
.status-bar-item {
display: flex;
align-items: center;
gap: 6px;
padding: 0 8px;
height: 100%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.status-bar-item:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.status-bar-task-button {
border: none;
background: transparent;
color: inherit;
cursor: pointer;
}
.status-bar-item.theme-badge {
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 3px;
}
.status-bar-item.language-badge {
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 3px;
gap: 4px;
}
.status-bar-item.offline-badge {
border: none;
background: transparent;
color: inherit;
cursor: pointer;
opacity: 0.4;
font-size: 13px;
padding: 0 4px;
}
.status-bar-item.offline-badge.active {
background-color: rgba(255, 196, 0, 0.28);
opacity: 1;
}
.project-selector {
position: relative;
flex-shrink: 0;
}
.project-selector-trigger {
display: flex;
align-items: center;
gap: 6px;
padding: 0 8px;
height: 22px;
background: transparent;
border: none;
color: var(--vscode-statusBar-foreground);
cursor: pointer;
font-size: 12px;
text-align: left;
}
.project-selector-trigger:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.project-selector-trigger:focus {
outline: none;
}
.project-icon,
.dropdown-arrow,
.project-check-icon {
flex-shrink: 0;
}
.project-name,
.project-item-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.project-name {
max-width: 180px;
}
.dropdown-arrow {
opacity: 0.6;
}
.project-dropdown {
position: absolute;
left: 0;
bottom: 100%;
min-width: 220px;
margin-bottom: 4px;
background-color: #252526;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 4px;
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.3);
z-index: 1000;
overflow: hidden;
}
.project-dropdown-header {
padding: 8px 12px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--vscode-descriptionForeground);
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
}
.project-list {
max-height: 200px;
overflow-y: auto;
}
.project-item {
display: flex;
align-items: center;
width: 100%;
gap: 8px;
padding: 8px 12px;
border: none;
background: transparent;
color: inherit;
cursor: pointer;
}
.project-item:hover,
.project-item.active {
background-color: var(--vscode-list-hoverBackground);
}
.project-item.active .project-check-icon {
color: #89d185;
}
.project-dropdown-footer {
padding: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.12);
display: grid;
gap: 6px;
}
.create-project-btn,
.existing-project-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
width: 100%;
padding: 6px 12px;
background-color: rgba(255, 255, 255, 0.12);
color: inherit;
border: none;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
}
.create-project-btn:hover,
.existing-project-btn:hover {
background-color: rgba(255, 255, 255, 0.18);
}
.status-bar-language-select {
background: transparent;
border: none;
color: inherit;
font: inherit;
padding: 0;
}
.status-bar-language-select:focus {
outline: none;
}
.status-bar-count {
font-size: 11px;
opacity: 0.85;
}
.status-bar-item.brand {
font-weight: 600;
}
@media (max-width: 960px) {
.editor-frame {
grid-template-columns: minmax(0, 1fr);
}
.editor-meta {
border-left: none;
border-top: 1px solid var(--vscode-panel-border);
padding-left: 0;
padding-top: 10px;
}
}
.editor-section ul {
margin: 12px 0 0;
padding-left: 18px;
}
.editor-toolbar {
display: flex;
gap: 10px;
}
.editor-toolbar button {
padding: 9px 14px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--panel-3);
color: var(--ink);
}
.editor-meta {
display: flex;
flex-direction: column;
gap: 12px;
}
.editor-meta-card,
.assistant-card,
.panel-entry {
padding: 16px;
}
.sidebar-header,
.assistant-header,
.panel-header {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 16px 18px;
border-bottom: 1px solid var(--line);
}

1049
assets/css/sidebar.css Normal file

File diff suppressed because it is too large Load Diff

189
assets/css/tabs.css Normal file
View File

@@ -0,0 +1,189 @@
.tab-bar {
display: flex;
align-items: center;
background-color: var(--vscode-editorGroupHeader-tabsBackground);
border-bottom: 1px solid var(--vscode-editorGroupHeader-tabsBorder);
height: 35px;
overflow: hidden;
flex-shrink: 0;
position: relative;
}
.tab-bar-tabs {
display: flex;
align-items: center;
height: 100%;
overflow-x: auto;
overflow-y: hidden;
flex: 1;
}
.tab-bar-tabs::-webkit-scrollbar {
height: 0;
display: none;
}
.tab-bar-empty {
display: flex;
align-items: center;
height: 100%;
padding: 0 12px;
color: var(--vscode-descriptionForeground);
font-size: 12px;
}
.tab {
display: flex;
align-items: center;
gap: 4px;
padding: 0 6px 0 10px;
height: 100%;
min-width: 100px;
max-width: 180px;
cursor: pointer;
background-color: var(--vscode-tab-inactiveBackground);
border: none;
border-right: 1px solid var(--vscode-tab-border);
color: var(--vscode-tab-inactiveForeground);
font-size: 13px;
user-select: none;
position: relative;
flex-shrink: 0;
}
.tab-select {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
flex: 1;
height: 100%;
padding: 0;
background: transparent;
border: none;
color: inherit;
font: inherit;
cursor: inherit;
}
.tab:hover {
background-color: var(--vscode-list-hoverBackground);
}
.tab.active {
background-color: var(--vscode-tab-activeBackground);
color: var(--vscode-tab-activeForeground);
}
.tab.active::after {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background-color: var(--vscode-focusBorder);
}
.tab.transient .tab-title {
font-style: italic;
}
.tab-actions {
display: flex;
align-items: center;
gap: 2px;
margin-left: auto;
flex-shrink: 0;
}
.tab-dirty-indicator {
color: var(--vscode-editorWarning-foreground, #e2c08d);
font-size: 10px;
line-height: 1;
}
.tab-icon {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
opacity: 0.85;
}
.tab-title,
.status-bar-item {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tab-close {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
line-height: 1;
color: var(--vscode-icon-foreground, #c5c5c5);
border-radius: 3px;
cursor: pointer;
flex-shrink: 0;
border: none;
background: transparent;
padding: 0;
opacity: 0;
}
.tab:hover .tab-close {
opacity: 0.7;
}
.tab.active .tab-close {
opacity: 0.7;
}
.tab-close:hover {
opacity: 1 !important;
background-color: var(--vscode-toolbar-hoverBackground);
color: var(--vscode-tab-activeForeground);
}
.tab-close:active {
background-color: var(--vscode-toolbar-activeBackground, rgba(99, 102, 103, 0.31));
}
.tab.dirty .tab-dirty-indicator {
display: block;
}
.tab.dirty .tab-close {
display: none;
}
.tab.dirty:hover .tab-close {
display: flex;
opacity: 0.7;
}
.tab.dirty:hover .tab-dirty-indicator {
display: none;
}
.tab:focus-visible {
outline: 1px solid var(--vscode-focusBorder, #007fd4);
outline-offset: -1px;
}
.output-item-details {
margin: 4px 0 0;
padding: 8px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.03);
color: inherit;
font: 11px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace;
white-space: pre-wrap;
user-select: text;
}

166
assets/css/tokens.css Normal file
View File

@@ -0,0 +1,166 @@
@theme {
--color-shell-bg: #1e1e1e;
--color-sidebar-bg: #252526;
--color-activity-bg: #333333;
--color-panel-bg: #1e1e1e;
--color-tab-active-bg: #1e1e1e;
--color-tab-inactive-bg: #2d2d2d;
--color-focus-border: #007fd4;
--color-input-bg: rgba(255, 255, 255, 0.06);
--color-input-border: rgba(255, 255, 255, 0.12);
--color-status-bg: #007acc;
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
--text-shell: 13px;
--spacing-titlebar: 34px;
--spacing-tabbar: 35px;
--spacing-statusbar: 22px;
}
:root {
--accent-color: #007acc;
--accent-color-transparent: rgba(0, 122, 204, 0.25);
--vscode-editor-background: #1e1e1e;
--vscode-editor-foreground: #cccccc;
--vscode-sideBar-background: #252526;
--vscode-activityBar-background: #333333;
--vscode-activityBar-foreground: #ffffff;
--vscode-panel-background: #1e1e1e;
--vscode-titleBar-activeBackground: #252526;
--vscode-titleBar-activeForeground: #cccccc;
--vscode-statusBar-background: #007acc;
--vscode-statusBar-foreground: #ffffff;
--vscode-tab-activeBackground: #1e1e1e;
--vscode-tab-inactiveBackground: #2d2d2d;
--vscode-tab-activeForeground: #ffffff;
--vscode-tab-inactiveForeground: #969696;
--vscode-editorGroupHeader-tabsBackground: #252526;
--vscode-editorGroupHeader-tabsBorder: #1e1e1e;
--vscode-toolbar-hoverBackground: rgba(90, 93, 94, 0.31);
--vscode-toolbar-activeBackground: rgba(99, 102, 103, 0.31);
--vscode-foreground: #cccccc;
--vscode-descriptionForeground: #858585;
--vscode-panel-border: #80808059;
--vscode-sideBar-border: #80808059;
--vscode-tab-border: #252526;
--vscode-focusBorder: #007fd4;
--vscode-input-background: rgba(255, 255, 255, 0.06);
--vscode-input-border: rgba(255, 255, 255, 0.12);
--vscode-list-hoverBackground: #2a2d2e;
--vscode-list-activeSelectionBackground: #094771;
--vscode-list-activeSelectionForeground: #ffffff;
--vscode-activityBarBadge-background: #007acc;
--vscode-activityBarBadge-foreground: #ffffff;
--vscode-testing-iconPassed: #73c991;
--vscode-editorWarning-foreground: #cca700;
--vscode-input-foreground: #cccccc;
--vscode-input-placeholderForeground: #a6a6a6;
--vscode-font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
--vscode-font-size: 13px;
--panel-1: var(--vscode-editor-background);
--panel-2: var(--vscode-sideBar-background);
--panel-3: var(--vscode-input-background);
--ink: var(--vscode-foreground);
--line: var(--vscode-panel-border);
--accent: var(--vscode-focusBorder);
--accent-soft: var(--vscode-list-hoverBackground);
--success: var(--vscode-testing-iconPassed);
--sidebar-width: 280px;
--assistant-width: 360px;
color-scheme: dark;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
width: 100%;
height: 100%;
background: var(--vscode-editor-background);
color: var(--vscode-foreground);
}
body {
overflow: hidden;
user-select: none;
font-family: var(--vscode-font-family);
font-size: var(--vscode-font-size);
}
body > [data-phx-session],
body > [data-phx-main] {
width: 100%;
height: 100%;
min-height: 0;
}
button {
font-family: var(--vscode-font-family);
font-size: var(--vscode-font-size);
color: var(--vscode-button-foreground);
background-color: var(--vscode-button-background);
border: none;
padding: 6px 14px;
cursor: pointer;
border-radius: 2px;
}
button:hover {
background-color: var(--vscode-button-hoverBackground);
}
button:focus {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: 2px;
}
button.secondary {
background-color: var(--vscode-button-secondaryBackground);
}
button.secondary:hover {
background-color: #4a4d51;
}
button.compact {
padding: 4px 8px;
font-size: 12px;
}
button.primary {
background-color: var(--vscode-button-background);
font-weight: 500;
}
button.primary:hover {
background-color: var(--vscode-button-hoverBackground);
}
button.success {
background-color: #28a745;
}
button.success:hover {
background-color: #218838;
}
button.danger {
background-color: #dc3545;
}
button.danger:hover {
background-color: #c82333;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
button svg,
button svg * {
pointer-events: none;
}

301
assets/css/utilities.css Normal file
View File

@@ -0,0 +1,301 @@
@layer components {
.ui-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 28px;
padding: 4px 10px;
border: 1px solid transparent;
border-radius: 4px;
font: inherit;
line-height: 1.2;
cursor: pointer;
user-select: none;
}
.ui-button:hover:not(:disabled) {
background: var(--vscode-button-hoverBackground, #0e639c);
}
.ui-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.ui-button-primary {
color: var(--vscode-button-foreground, #ffffff);
background: var(--vscode-button-background, var(--vscode-focusBorder));
}
.ui-button-primary:hover:not(:disabled) {
background: var(--vscode-button-hoverBackground, #0e639c);
}
.ui-button-secondary {
color: var(--vscode-button-secondaryForeground, var(--vscode-foreground));
background: var(--vscode-button-secondaryBackground, rgba(255, 255, 255, 0.08));
border-color: var(--vscode-button-border, transparent);
}
.ui-button-secondary:hover:not(:disabled) {
background: var(--vscode-button-secondaryHoverBackground, #4a4d51);
}
.ui-button-danger {
color: var(--vscode-errorForeground, #f48771);
background: transparent;
border-color: color-mix(in srgb, var(--vscode-errorForeground, #f48771) 45%, transparent);
}
.ui-button-danger:hover:not(:disabled) {
background: color-mix(in srgb, var(--vscode-errorForeground, #f48771) 14%, transparent);
}
.ui-button-compact {
min-height: 24px;
padding: 3px 8px;
font-size: 12px;
}
.ui-input,
.ui-textarea {
width: 100%;
padding: 8px 10px;
border: 1px solid var(--vscode-input-border, var(--vscode-panel-border));
border-radius: 4px;
background: var(--vscode-input-background, rgba(255, 255, 255, 0.06));
color: var(--vscode-input-foreground, var(--vscode-foreground));
font: inherit;
}
.ui-textarea {
line-height: 1.5;
resize: vertical;
}
.ui-input:focus,
.ui-textarea:focus {
outline: 1px solid var(--vscode-focusBorder, #007fd4);
outline-offset: 1px;
}
.ui-input-readonly,
.ui-input[readonly] {
opacity: 0.7;
cursor: not-allowed;
}
.ui-input-disabled,
.ui-input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.ui-tab {
border: none;
color: var(--vscode-tab-inactiveForeground, var(--vscode-foreground));
background: transparent;
}
.ui-tab:hover {
color: var(--vscode-tab-activeForeground, var(--vscode-foreground));
}
.ui-tab-active {
color: var(--vscode-tab-activeForeground, var(--vscode-foreground));
}
.ui-badge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.ui-panel-entry {
border: 1px solid var(--vscode-panel-border);
border-radius: 4px;
background-color: var(--vscode-sideBar-background);
}
.ui-empty-state {
display: flex;
flex-direction: column;
gap: 6px;
color: var(--vscode-descriptionForeground);
}
.ui-editor-shell {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--vscode-editor-background);
}
.ui-editor-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
min-height: 35px;
padding: 0 12px;
border-bottom: 1px solid var(--vscode-panel-border);
background: var(--vscode-tab-activeBackground);
}
.ui-editor-tab-current {
display: inline-flex;
max-width: 100%;
align-items: center;
gap: 6px;
overflow: hidden;
padding: 6px 12px;
border-radius: 4px 4px 0 0;
background: var(--vscode-tab-activeBackground);
color: var(--vscode-tab-activeForeground);
}
.ui-editor-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
flex-wrap: wrap;
}
.ui-toolbar {
display: flex;
align-items: center;
gap: 12px;
min-height: 32px;
}
.ui-toolbar-group {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.ui-field-stack {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.ui-field-stack > label,
.ui-field-label {
font-size: 11px;
font-weight: 500;
color: var(--vscode-descriptionForeground);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.ui-field-grid-2,
.ui-field-grid-3 {
display: grid;
gap: 16px;
}
.ui-dropdown-menu {
background: var(--vscode-dropdown-background, var(--vscode-sideBar-background));
border: 1px solid var(--vscode-dropdown-border, var(--vscode-panel-border));
border-radius: 6px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
overflow: hidden;
}
.ui-dropdown-item {
display: flex;
align-items: flex-start;
gap: 10px;
width: 100%;
padding: 10px 12px;
border: none;
background: transparent;
color: var(--vscode-dropdown-foreground, var(--vscode-foreground));
cursor: pointer;
text-align: left;
transition: background 0.1s;
}
.ui-dropdown-item:hover:not(:disabled) {
background: var(--vscode-list-hoverBackground, #2a2d2e);
}
.ui-dropdown-item:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.ui-section-card {
border: 1px solid var(--vscode-panel-border);
border-radius: 8px;
background: color-mix(in srgb, var(--vscode-editor-background) 84%, var(--vscode-input-background));
}
.btn-base {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 28px;
padding: 4px 10px;
border: 1px solid transparent;
border-radius: 4px;
font: inherit;
line-height: 1.2;
cursor: pointer;
user-select: none;
}
.btn-theme-primary {
color: var(--vscode-button-foreground, #ffffff);
background: var(--vscode-button-background, var(--vscode-focusBorder));
}
.btn-theme-primary:hover {
background: var(--vscode-button-hoverBackground, #0e639c);
}
.btn-theme-danger {
color: var(--vscode-errorForeground, #f48771);
background: transparent;
border-color: color-mix(in srgb, var(--vscode-errorForeground, #f48771) 45%, transparent);
}
.btn-theme-danger:hover {
background: color-mix(in srgb, var(--vscode-errorForeground, #f48771) 14%, transparent);
}
.panel-entry {
border: 1px solid var(--vscode-panel-border);
border-radius: 4px;
background-color: var(--vscode-sideBar-background);
}
.monaco-host {
min-width: 0;
min-height: 0;
overflow: hidden;
}
}
@media (min-width: 768px) {
.ui-field-grid-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.ui-field-grid-3 {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
}
}

29
assets/js/app.js Normal file
View File

@@ -0,0 +1,29 @@
import { Socket } from "phoenix";
import { LiveSocket } from "phoenix_live_view";
import "phoenix_html";
import { Hooks } from "./hooks/index.js";
document.addEventListener("DOMContentLoaded", () => {
const csrfToken = document
.querySelector("meta[name='csrf-token']")
.getAttribute("content");
const liveSocket = new LiveSocket("/live", Socket, {
params: { _csrf_token: csrfToken },
hooks: Hooks,
metadata: {
keydown: (event) => ({
key: event.key,
meta: event.metaKey,
ctrl: event.ctrlKey,
alt: event.altKey,
shift: event.shiftKey,
tag: event.target?.tagName || null,
contentEditable: event.target?.isContentEditable || false
})
}
});
liveSocket.connect();
window.liveSocket = liveSocket;
});

View File

@@ -0,0 +1,19 @@
import { clamp } from "../utils/dom.js";
export const applyAppZoom = (nextZoom) => {
const zoom = clamp(Math.round(nextZoom * 100) / 100, 0.5, 2);
window.__bdsAppZoom = zoom;
document.documentElement.style.zoom = String(zoom);
};
export const runDocumentCommand = (command) => {
if (typeof document.execCommand !== "function") {
return false;
}
try {
return document.execCommand(command);
} catch (_error) {
return false;
}
};

View File

@@ -0,0 +1,58 @@
import { activeMonacoEditor, runMonacoEditorAction } from "../monaco/services.js";
import { applyAppZoom, runDocumentCommand } from "./document_commands.js";
export const runMenuRuntimeCommand = (action) => {
const editor = activeMonacoEditor();
switch (action) {
case "undo":
return editor ? runMonacoEditorAction(editor, "undo") : runDocumentCommand("undo");
case "redo":
return editor ? runMonacoEditorAction(editor, "redo") : runDocumentCommand("redo");
case "cut":
return editor
? runMonacoEditorAction(editor, "editor.action.clipboardCutAction")
: runDocumentCommand("cut");
case "copy":
return editor
? runMonacoEditorAction(editor, "editor.action.clipboardCopyAction")
: runDocumentCommand("copy");
case "paste":
return editor
? runMonacoEditorAction(editor, "editor.action.clipboardPasteAction")
: runDocumentCommand("paste");
case "delete":
return editor ? runMonacoEditorAction(editor, "deleteLeft") : runDocumentCommand("delete");
case "select_all":
return editor
? runMonacoEditorAction(editor, "editor.action.selectAll")
: runDocumentCommand("selectAll");
case "find":
return editor ? runMonacoEditorAction(editor, "actions.find") : false;
case "replace":
return editor ? runMonacoEditorAction(editor, "editor.action.startFindReplaceAction") : false;
case "reload":
case "force_reload":
window.location.reload();
return true;
case "reset_zoom":
applyAppZoom(1);
return true;
case "zoom_in":
applyAppZoom((window.__bdsAppZoom || 1) + 0.1);
return true;
case "zoom_out":
applyAppZoom((window.__bdsAppZoom || 1) - 0.1);
return true;
case "toggle_full_screen":
if (document.fullscreenElement) {
document.exitFullscreen?.();
} else {
document.documentElement.requestFullscreen?.();
}
return true;
default:
return false;
}
};

View File

@@ -0,0 +1,39 @@
export const syncTitlebarOverlayInsets = () => {
const rootStyle = document.documentElement.style;
const setInsets = (left, right) => {
rootStyle.setProperty("--bds-titlebar-overlay-left", `${left}px`);
rootStyle.setProperty("--bds-titlebar-overlay-right", `${right}px`);
};
const overlay = navigator.windowControlsOverlay;
if (!overlay) {
setInsets(0, 0);
return () => {};
}
const updateInsets = () => {
if (!overlay.visible) {
setInsets(0, 0);
return;
}
const titlebarRect = overlay.getTitlebarAreaRect();
const viewportWidth = window.innerWidth || document.documentElement.clientWidth || titlebarRect.right;
const leftInset = Math.max(0, Math.round(titlebarRect.left));
const rightInset = Math.max(0, Math.round(viewportWidth - titlebarRect.right));
setInsets(leftInset, rightInset);
};
const onGeometryChange = () => updateInsets();
const onResize = () => updateInsets();
updateInsets();
overlay.addEventListener("geometrychange", onGeometryChange);
window.addEventListener("resize", onResize);
return () => {
overlay.removeEventListener("geometrychange", onGeometryChange);
window.removeEventListener("resize", onResize);
};
};

4
assets/js/constants.js Normal file
View File

@@ -0,0 +1,4 @@
export const SIDEBAR_STORAGE_KEY = "bds-panel-sidebar";
export const ASSISTANT_STORAGE_KEY = "bds-panel-assistant-sidebar";
export const UI_LANGUAGE_STORAGE_KEY = "bds-ui-language";
export const WORKBENCH_SESSION_STORAGE_KEY_PREFIX = "bds-workbench-";

View File

@@ -0,0 +1,232 @@
import {
SIDEBAR_STORAGE_KEY,
ASSISTANT_STORAGE_KEY,
UI_LANGUAGE_STORAGE_KEY,
WORKBENCH_SESSION_STORAGE_KEY_PREFIX
} from "../constants.js";
import {
parseJsonObject,
setMediaThumbnailLoaded,
syncMediaThumbnailState,
clamp
} from "../utils/dom.js";
import { shellWidth, setShellWidth, persistWidth, readStoredSize } from "../utils/layout.js";
import {
parseShortcutConfig,
normalizeShortcutKey,
shortcutMatchesEvent,
shortcutTargetIsEditable
} from "../utils/shortcuts.js";
import { syncTitlebarOverlayInsets } from "../bridges/titlebar_overlay.js";
import { runMenuRuntimeCommand } from "../bridges/menu_runtime.js";
export const AppShell = {
mounted() {
this.shortcuts = parseShortcutConfig(this.el.dataset.shortcuts);
this.currentProjectId = this.el.dataset.projectId || "";
this.syncStoredLayout();
this.syncStoredUiLanguage();
this.destroyOverlaySync = syncTitlebarOverlayInsets();
this.workbenchStorageKey = (projectId) =>
projectId ? `${WORKBENCH_SESSION_STORAGE_KEY_PREFIX}${projectId}` : null;
this.restoreStoredWorkbenchSession = () => {
const projectId = this.el.dataset.projectId || "";
const storageKey = this.workbenchStorageKey(projectId);
if (!storageKey) {
return false;
}
const session = parseJsonObject(window.localStorage.getItem(storageKey));
if (!session) {
return false;
}
this.pushEvent("restore_workbench_session", { session });
return true;
};
this.persistWorkbenchSession = () => {
const projectId = this.el.dataset.projectId || "";
const storageKey = this.workbenchStorageKey(projectId);
const session = this.el.dataset.workbenchSession;
if (!storageKey || !session) {
return;
}
window.localStorage.setItem(storageKey, session);
};
this.handleMouseDown = (event) => {
const handle = event.target.closest("[data-role='resize-handle']");
if (!handle || !this.el.contains(handle)) {
return;
}
event.preventDefault();
const target = handle.dataset.resize;
const startX = event.clientX;
const startWidth =
target === "assistant"
? shellWidth("[data-testid='assistant-shell']")
: shellWidth("[data-testid='sidebar-shell']");
const min = target === "assistant" ? 280 : 200;
const max = target === "assistant" ? 640 : 500;
const invert = target === "assistant";
const onMouseMove = (moveEvent) => {
const delta = invert ? startX - moveEvent.clientX : moveEvent.clientX - startX;
const width = clamp(startWidth + delta, min, max);
const selector = target === "assistant" ? "[data-testid='assistant-shell']" : "[data-testid='sidebar-shell']";
setShellWidth(selector, width);
persistWidth(target, width);
};
const onMouseUp = (upEvent) => {
const delta = invert ? startX - upEvent.clientX : upEvent.clientX - startX;
const width = clamp(startWidth + delta, min, max);
persistWidth(target, width);
this.pushEvent("resize_panel", { target, width });
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
};
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp);
};
this.el.addEventListener("mousedown", this.handleMouseDown);
this.handleNativeMenuAction = (event) => {
const action = event.detail?.action;
const ackId = event.detail?.ackId;
if (action) {
this.pushEvent("native_menu_action", { action }, () => {
if (ackId) {
window.dispatchEvent(
new CustomEvent("bds:native-menu-action-ack", { detail: { ackId } })
);
}
});
}
};
this.handleChange = (event) => {
const select = event.target.closest(".status-bar-language-select");
if (select && this.el.contains(select)) {
window.localStorage.setItem(UI_LANGUAGE_STORAGE_KEY, select.value);
}
};
this.handleShortcutKeyDown = (event) => {
if (shortcutTargetIsEditable(event)) {
return;
}
const shortcut = this.shortcuts.find((candidate) => shortcutMatchesEvent(candidate, event));
if (!shortcut) {
return;
}
event.preventDefault();
event.stopPropagation();
this.pushEvent("shortcut", {
key: normalizeShortcutKey(event.key),
meta: event.metaKey,
ctrl: event.ctrlKey,
alt: event.altKey,
shift: event.shiftKey,
tag: event.target?.tagName || null,
contentEditable: event.target?.isContentEditable || false
});
};
this.handleThumbnailLoad = (event) => {
if (event.target instanceof HTMLImageElement && event.target.classList.contains("media-thumbnail-image")) {
setMediaThumbnailLoaded(event.target, true);
}
};
this.handleThumbnailError = (event) => {
if (event.target instanceof HTMLImageElement && event.target.classList.contains("media-thumbnail-image")) {
setMediaThumbnailLoaded(event.target, false);
}
};
this.handleEvent("menu-runtime-command", ({ action }) => {
if (action) {
runMenuRuntimeCommand(String(action));
}
});
this.handleEvent("url-state", ({ path }) => {
if (path && window.location.pathname + window.location.search !== path) {
window.history.replaceState({}, "", path);
}
});
window.addEventListener("bds:native-menu-action", this.handleNativeMenuAction);
window.addEventListener("keydown", this.handleShortcutKeyDown, true);
this.el.addEventListener("load", this.handleThumbnailLoad, true);
this.el.addEventListener("error", this.handleThumbnailError, true);
this.el.addEventListener("change", this.handleChange);
syncMediaThumbnailState(this.el);
this.restoreStoredWorkbenchSession();
},
updated() {
const nextProjectId = this.el.dataset.projectId || "";
if (nextProjectId !== this.currentProjectId) {
this.currentProjectId = nextProjectId;
if (this.restoreStoredWorkbenchSession()) {
return;
}
}
syncMediaThumbnailState(this.el);
this.persistWorkbenchSession();
},
destroyed() {
this.el.removeEventListener("mousedown", this.handleMouseDown);
this.el.removeEventListener("load", this.handleThumbnailLoad, true);
this.el.removeEventListener("error", this.handleThumbnailError, true);
this.el.removeEventListener("change", this.handleChange);
window.removeEventListener("bds:native-menu-action", this.handleNativeMenuAction);
window.removeEventListener("keydown", this.handleShortcutKeyDown, true);
if (this.destroyOverlaySync) {
this.destroyOverlaySync();
}
},
syncStoredLayout() {
this.pushEvent("sync_layout", {
sidebar_width: readStoredSize(SIDEBAR_STORAGE_KEY, shellWidth("[data-testid='sidebar-shell']"), 200, 500),
assistant_sidebar_width: readStoredSize(ASSISTANT_STORAGE_KEY, 360, 280, 640)
});
},
syncStoredUiLanguage() {
const stored = window.localStorage.getItem(UI_LANGUAGE_STORAGE_KEY);
if (stored) {
this.pushEvent("sync_ui_language", { language: stored });
}
}
};

View File

@@ -0,0 +1,139 @@
export const ChatSurface = {
mounted() {
this.stickToBottom = true;
this.scrollContainer = null;
this.autoResize = () => {
const textarea = this.el.querySelector(".chat-input");
if (!textarea) {
return;
}
const styles = getComputedStyle(textarea);
const minHeight = parseFloat(styles.getPropertyValue("--chat-input-min-height")) || 20;
const maxHeight = parseFloat(styles.getPropertyValue("--chat-input-max-height")) || 160;
textarea.rows = 1;
textarea.style.minHeight = `${minHeight}px`;
if (textarea.value.trim() === "") {
textarea.style.height = `${minHeight}px`;
textarea.style.maxHeight = `${minHeight}px`;
textarea.style.overflowY = "hidden";
return;
}
textarea.style.maxHeight = `${maxHeight}px`;
textarea.style.height = "0px";
const nextHeight = Math.min(Math.max(textarea.scrollHeight, minHeight), maxHeight);
textarea.style.height = `${nextHeight}px`;
textarea.style.overflowY = nextHeight >= maxHeight ? "auto" : "hidden";
};
this.syncScrollContainer = () => {
const nextContainer = this.el.querySelector(".chat-messages");
if (nextContainer === this.scrollContainer) {
return;
}
if (this.scrollContainer) {
this.scrollContainer.removeEventListener("scroll", this.handleScroll);
}
this.scrollContainer = nextContainer;
if (this.scrollContainer) {
this.scrollContainer.addEventListener("scroll", this.handleScroll);
}
};
this.scrollToBottom = (force = false) => {
if (!this.scrollContainer) {
return;
}
if (force || this.stickToBottom) {
this.scrollContainer.scrollTop = this.scrollContainer.scrollHeight;
}
};
this.syncExpandedSurfaces = () => {
this.el
.querySelectorAll(".chat-inline-surface[data-expanded='true']")
.forEach((surface) => {
surface.open = true;
});
};
this.surfaceObserver = new MutationObserver(() => {
this.syncExpandedSurfaces();
});
this.handleScroll = () => {
if (!this.scrollContainer) {
this.stickToBottom = true;
return;
}
const distanceFromBottom =
this.scrollContainer.scrollHeight -
this.scrollContainer.scrollTop -
this.scrollContainer.clientHeight;
this.stickToBottom = distanceFromBottom < 48;
};
this.handleInput = (event) => {
if (!event.target.closest(".chat-input")) {
return;
}
this.stickToBottom = true;
this.autoResize();
};
this.handleKeyDown = (event) => {
if (!event.target.closest(".chat-input")) {
return;
}
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
event.preventDefault();
const sendButton = this.el.querySelector("[data-testid='chat-send-button']");
if (sendButton && !sendButton.disabled) {
sendButton.click();
}
}
};
this.el.addEventListener("input", this.handleInput);
this.el.addEventListener("keydown", this.handleKeyDown);
this.syncScrollContainer();
this.syncExpandedSurfaces();
this.surfaceObserver.observe(this.el, { childList: true, subtree: true });
this.autoResize();
window.requestAnimationFrame(() => this.scrollToBottom(true));
},
updated() {
this.syncScrollContainer();
this.syncExpandedSurfaces();
this.autoResize();
window.requestAnimationFrame(() => this.scrollToBottom());
},
destroyed() {
this.surfaceObserver.disconnect();
this.el.removeEventListener("input", this.handleInput);
this.el.removeEventListener("keydown", this.handleKeyDown);
if (this.scrollContainer) {
this.scrollContainer.removeEventListener("scroll", this.handleScroll);
}
}
};

18
assets/js/hooks/index.js Normal file
View File

@@ -0,0 +1,18 @@
import { AppShell } from "./app_shell.js";
import { SidebarInteractions } from "./sidebar_interactions.js";
import { SettingsSectionScroll, TagsSectionScroll } from "./section_scroll.js";
import { ChatSurface } from "./chat_surface.js";
import { MenuEditorTree } from "./menu_editor_tree.js";
import { MonacoEditor } from "./monaco_editor.js";
import { MonacoDiffEditor } from "./monaco_diff_editor.js";
export const Hooks = {
AppShell,
SidebarInteractions,
SettingsSectionScroll,
TagsSectionScroll,
ChatSurface,
MenuEditorTree,
MonacoEditor,
MonacoDiffEditor
};

View File

@@ -0,0 +1,134 @@
export const MenuEditorTree = {
mounted() {
this.dragItemId = null;
this.dragSourceEl = null;
this.dropTargetEl = null;
this.dropPosition = null;
this.clearDropTarget = () => {
if (this.dropTargetEl) {
this.dropTargetEl.classList.remove("is-drop-before", "is-drop-after", "is-drop-inside");
}
this.dropTargetEl = null;
this.dropPosition = null;
};
this.setDropTarget = (row, position) => {
if (this.dropTargetEl === row && this.dropPosition === position) {
return;
}
this.clearDropTarget();
this.dropTargetEl = row;
this.dropPosition = position;
row.classList.add(`is-drop-${position}`);
};
this.handleDragStart = (event) => {
const handle = event.target.closest("[data-menu-drag-handle='true']");
const row = event.target.closest("[data-menu-item-id]");
if (!handle || !row || !this.el.contains(row)) {
return;
}
this.dragItemId = row.dataset.menuItemId || null;
this.dragSourceEl = row;
row.classList.add("is-dragging");
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", this.dragItemId || "");
}
};
this.handleDragOver = (event) => {
const row = event.target.closest("[data-menu-item-id]");
if (!this.dragItemId || !row || !this.el.contains(row)) {
this.clearDropTarget();
return;
}
const targetItemId = row.dataset.menuItemId || "";
if (!targetItemId || targetItemId === this.dragItemId) {
this.clearDropTarget();
return;
}
event.preventDefault();
const rect = row.getBoundingClientRect();
const offsetY = event.clientY - rect.top;
const allowInside = row.dataset.menuCanDropInside === "true";
const insideBandTop = rect.height * 0.3;
const insideBandBottom = rect.height * 0.7;
const position =
allowInside && offsetY >= insideBandTop && offsetY <= insideBandBottom
? "inside"
: offsetY < rect.height / 2
? "before"
: "after";
this.setDropTarget(row, position);
if (event.dataTransfer) {
event.dataTransfer.dropEffect = "move";
}
};
this.handleDrop = (event) => {
const row = event.target.closest("[data-menu-item-id]");
if (!this.dragItemId || !row || !this.el.contains(row) || !this.dropPosition) {
this.clearDropTarget();
return;
}
event.preventDefault();
this.pushEvent("menu_editor_drop_item", {
drag_item_id: this.dragItemId,
target_item_id: row.dataset.menuItemId,
position: this.dropPosition
});
this.clearDropTarget();
};
this.handleDragLeave = (event) => {
const related = event.relatedTarget;
if (this.dropTargetEl && (!related || !this.dropTargetEl.contains(related))) {
this.clearDropTarget();
}
};
this.handleDragEnd = () => {
if (this.dragSourceEl) {
this.dragSourceEl.classList.remove("is-dragging");
}
this.dragItemId = null;
this.dragSourceEl = null;
this.clearDropTarget();
};
this.el.addEventListener("dragstart", this.handleDragStart);
this.el.addEventListener("dragover", this.handleDragOver);
this.el.addEventListener("drop", this.handleDrop);
this.el.addEventListener("dragleave", this.handleDragLeave);
this.el.addEventListener("dragend", this.handleDragEnd);
},
destroyed() {
this.el.removeEventListener("dragstart", this.handleDragStart);
this.el.removeEventListener("dragover", this.handleDragOver);
this.el.removeEventListener("drop", this.handleDrop);
this.el.removeEventListener("dragleave", this.handleDragLeave);
this.el.removeEventListener("dragend", this.handleDragEnd);
}
};

View File

@@ -0,0 +1,129 @@
import { loadMonaco, ensureMonacoTheme, diffModelPath } from "../monaco/services.js";
export const MonacoDiffEditor = {
mounted() {
this.host = this.el.querySelector(".monaco-diff-editor-instance");
this.originalInput = this.el.querySelector(".monaco-diff-original");
this.modifiedInput = this.el.querySelector(".monaco-diff-modified");
this.filePath = this.el.dataset.monacoDiffFilePath || "working-tree";
this.language = this.el.dataset.monacoDiffLanguage || "plaintext";
this.viewStyle = this.el.dataset.monacoDiffViewStyle || "inline";
this.wordWrap = this.el.dataset.monacoDiffWordWrap || "off";
this.hideUnchanged = this.el.dataset.monacoDiffHideUnchanged === "true";
this.readValues = () => ({
original: this.originalInput?.value || "",
modified: this.modifiedInput?.value || ""
});
this.applyDataset = () => {
this.filePath = this.el.dataset.monacoDiffFilePath || "working-tree";
this.language = this.el.dataset.monacoDiffLanguage || "plaintext";
this.viewStyle = this.el.dataset.monacoDiffViewStyle || "inline";
this.wordWrap = this.el.dataset.monacoDiffWordWrap || "off";
this.hideUnchanged = this.el.dataset.monacoDiffHideUnchanged === "true";
};
this.setModels = (monaco) => {
const values = this.readValues();
this.originalModel?.dispose();
this.modifiedModel?.dispose();
this.originalModel = monaco.editor.createModel(
values.original,
this.language,
monaco.Uri.parse(diffModelPath(this.filePath, "original"))
);
this.modifiedModel = monaco.editor.createModel(
values.modified,
this.language,
monaco.Uri.parse(diffModelPath(this.filePath, "modified"))
);
this.editor.setModel({ original: this.originalModel, modified: this.modifiedModel });
this.lastFilePath = this.filePath;
};
loadMonaco()
.then((monaco) => {
if (!this.host) {
return;
}
ensureMonacoTheme(monaco);
this.editor = monaco.editor.createDiffEditor(this.host, {
theme: "bds-theme",
automaticLayout: true,
readOnly: true,
renderSideBySide: this.viewStyle === "side-by-side",
minimap: { enabled: false },
scrollBeyondLastLine: false,
lineNumbers: "on",
diffCodeLens: false,
originalEditable: false,
wordWrap: this.wordWrap,
hideUnchangedRegions: { enabled: this.hideUnchanged },
ignoreTrimWhitespace: false
});
this.setModels(monaco);
})
.catch((error) => {
console.error("Failed to load Monaco diff editor", error);
});
},
updated() {
this.host = this.el.querySelector(".monaco-diff-editor-instance");
this.originalInput = this.el.querySelector(".monaco-diff-original");
this.modifiedInput = this.el.querySelector(".monaco-diff-modified");
this.applyDataset();
if (!this.editor) {
return;
}
loadMonaco().then((monaco) => {
ensureMonacoTheme(monaco);
monaco.editor.setTheme("bds-theme");
this.editor.updateOptions({
renderSideBySide: this.viewStyle === "side-by-side",
wordWrap: this.wordWrap,
hideUnchangedRegions: { enabled: this.hideUnchanged }
});
if (this.lastFilePath !== this.filePath) {
this.setModels(monaco);
return;
}
const values = this.readValues();
if (this.originalModel && this.originalModel.getLanguageId() !== this.language) {
monaco.editor.setModelLanguage(this.originalModel, this.language);
}
if (this.modifiedModel && this.modifiedModel.getLanguageId() !== this.language) {
monaco.editor.setModelLanguage(this.modifiedModel, this.language);
}
if (this.originalModel && this.originalModel.getValue() !== values.original) {
this.originalModel.setValue(values.original);
}
if (this.modifiedModel && this.modifiedModel.getValue() !== values.modified) {
this.modifiedModel.setValue(values.modified);
}
});
},
destroyed() {
this.originalModel?.dispose();
this.modifiedModel?.dispose();
this.editor?.dispose();
}
};

View File

@@ -0,0 +1,238 @@
import {
loadMonaco,
ensureMonacoTheme,
registerMonacoEditor,
unregisterMonacoEditor
} from "../monaco/services.js";
export const MonacoEditor = {
mounted() {
this.textarea = document.getElementById(this.el.dataset.monacoInputId) || this.el.querySelector("textarea");
this.host = this.el.querySelector(".monaco-editor-instance");
this.language = this.el.dataset.monacoLanguage || "plaintext";
this.wordWrap = this.el.dataset.monacoWordWrap || "off";
this.editorId = this.el.dataset.monacoEditorId || "";
this.insertEvent = this.el.dataset.monacoInsertEvent || "";
this.syncTimer = null;
this.isApplyingRemoteUpdate = false;
this.lastKnownValue = this.textarea?.value || "";
this.syncEditorFromTextarea = () => {
this.textarea = document.getElementById(this.el.dataset.monacoInputId) || this.el.querySelector("textarea");
if (!this.textarea || !this.editor) {
return;
}
const value = this.textarea.value || "";
if (this.editor.getValue() !== value) {
this.isApplyingRemoteUpdate = true;
this.editor.setValue(value);
this.isApplyingRemoteUpdate = false;
}
this.lastKnownValue = value;
};
this.layoutEditorSoon = () => {
window.requestAnimationFrame(() => {
window.requestAnimationFrame(() => {
if (!this.editor) {
return;
}
this.editor.layout();
});
});
};
this.waitForMonacoVisibleSize = () =>
new Promise((resolve) => {
let settled = false;
let attempts = 0;
const hasVisibleSize = () => {
const rect = this.host?.getBoundingClientRect();
return Boolean(rect && rect.width > 0 && rect.height > 0);
};
const finish = () => {
if (settled) {
return;
}
settled = true;
this.visibleSizeObserver?.disconnect();
this.visibleSizeObserver = null;
resolve();
};
const check = () => {
if (hasVisibleSize() || attempts >= 20) {
finish();
return;
}
attempts += 1;
window.requestAnimationFrame(check);
};
if (hasVisibleSize()) {
finish();
return;
}
if (window.ResizeObserver && this.host) {
this.visibleSizeObserver = new ResizeObserver(() => {
if (hasVisibleSize()) {
finish();
}
});
this.visibleSizeObserver.observe(this.host);
}
window.requestAnimationFrame(check);
});
this.queueSync = () => {
if (!this.textarea || !this.editor) {
return;
}
window.clearTimeout(this.syncTimer);
this.syncTimer = window.setTimeout(() => {
if (!this.textarea || !this.editor) {
return;
}
const value = this.editor.getValue();
if (this.textarea.value === value) {
return;
}
this.lastKnownValue = value;
this.textarea.value = value;
this.textarea.dispatchEvent(new Event("input", { bubbles: true }));
}, 120);
};
this.handleInsert = ({ id, content }) => {
if (!this.editor || !content || String(id) !== String(this.editorId)) {
return;
}
const model = this.editor.getModel();
const selection = this.editor.getSelection();
if (!model || !selection) {
return;
}
const value = this.editor.getValue();
const start = model.getOffsetAt(selection.getStartPosition());
const end = model.getOffsetAt(selection.getEndPosition());
const before = value.slice(0, start);
const after = value.slice(end);
const separator = before !== "" && !before.endsWith("\n") ? "\n" : "";
const suffix = after !== "" && !content.endsWith("\n") ? "\n" : "";
const inserted = `${separator}${content}${suffix}`;
this.editor.executeEdits("bds-insert-content", [
{
range: selection,
text: inserted,
forceMoveMarkers: true
}
]);
this.editor.focus();
};
loadMonaco()
.then(async (monaco) => {
if (!this.host || !this.textarea) {
return;
}
await this.waitForMonacoVisibleSize();
ensureMonacoTheme(monaco);
this.editor = monaco.editor.create(this.host, {
value: this.textarea.value || "",
language: this.language,
theme: "bds-theme",
automaticLayout: true,
minimap: { enabled: false },
scrollBeyondLastLine: false,
wordWrap: this.wordWrap,
lineNumbers: "on",
lineNumbersMinChars: 3,
fontSize: 14,
fontFamily: "'Cascadia Code', 'Consolas', 'Courier New', monospace",
padding: { top: 12, bottom: 12 },
roundedSelection: false,
renderLineHighlight: "line",
formatOnPaste: true,
cursorStyle: "line",
cursorBlinking: "smooth",
quickSuggestions: this.language === "markdown-with-macros" ? false : true,
tabSize: 2,
insertSpaces: true
});
registerMonacoEditor(this.editorId || this.el.id, this.editor);
monaco.editor.setTheme("bds-theme");
this.syncEditorFromTextarea();
this.layoutEditorSoon();
this.changeSubscription = this.editor.onDidChangeModelContent(() => {
if (this.isApplyingRemoteUpdate) {
return;
}
this.queueSync();
});
if (this.insertEvent) {
this.handleEvent(this.insertEvent, this.handleInsert);
}
})
.catch((error) => {
console.error("Failed to load Monaco editor", error);
});
},
updated() {
this.textarea = document.getElementById(this.el.dataset.monacoInputId) || this.el.querySelector("textarea");
this.host = this.el.querySelector(".monaco-editor-instance");
this.language = this.el.dataset.monacoLanguage || this.language || "plaintext";
this.wordWrap = this.el.dataset.monacoWordWrap || this.wordWrap || "off";
if (!this.editor || !this.textarea) {
return;
}
loadMonaco().then((monaco) => {
ensureMonacoTheme(monaco);
monaco.editor.setTheme("bds-theme");
if (this.editor.getModel()?.getLanguageId() !== this.language) {
monaco.editor.setModelLanguage(this.editor.getModel(), this.language);
}
this.editor.updateOptions({ wordWrap: this.wordWrap });
});
this.syncEditorFromTextarea();
this.layoutEditorSoon();
},
destroyed() {
window.clearTimeout(this.syncTimer);
this.visibleSizeObserver?.disconnect();
this.changeSubscription?.dispose();
unregisterMonacoEditor(this.editorId || this.el.id);
this.editor?.dispose();
}
};

View File

@@ -0,0 +1,31 @@
const makeSectionScrollHook = (datasetKey) => ({
mounted() {
this.lastTargetId = null;
this.scrollToSelectedSection();
},
updated() {
this.scrollToSelectedSection();
},
scrollToSelectedSection() {
const targetId = this.el.dataset[datasetKey];
if (!targetId || targetId === this.lastTargetId) {
return;
}
this.lastTargetId = targetId;
window.requestAnimationFrame(() => {
const target = document.getElementById(targetId);
if (target && this.el.contains(target)) {
target.scrollIntoView({ block: "start", behavior: "smooth" });
}
});
}
});
export const SettingsSectionScroll = makeSectionScrollHook("settingsScrollTarget");
export const TagsSectionScroll = makeSectionScrollHook("tagsScrollTarget");

View File

@@ -0,0 +1,24 @@
export const SidebarInteractions = {
mounted() {
this.handleDblClick = (event) => {
const button = event.target.closest("[data-testid='sidebar-open-item']");
if (!button || !this.el.contains(button)) {
return;
}
this.pushEvent("pin_sidebar_item", {
route: button.dataset.route,
id: button.dataset.itemId,
title: button.dataset.openTitle || "",
subtitle: button.dataset.openSubtitle || ""
});
};
this.el.addEventListener("dblclick", this.handleDblClick);
},
destroyed() {
this.el.removeEventListener("dblclick", this.handleDblClick);
}
};

View File

@@ -0,0 +1,145 @@
let liquidLanguageRegistered = false;
let markdownWithMacrosRegistered = false;
export const registerLiquidLanguage = (monaco) => {
if (liquidLanguageRegistered) {
return;
}
monaco.languages.register({ id: "liquid" });
monaco.languages.setLanguageConfiguration("liquid", {
comments: {
blockComment: ["{% comment %}", "{% endcomment %}"]
},
brackets: [
["{", "}"],
["[", "]"],
["(", ")"]
],
autoClosingPairs: [
{ open: "{", close: "}" },
{ open: "[", close: "]" },
{ open: "(", close: ")" },
{ open: '"', close: '"' },
{ open: "'", close: "'" }
],
surroundingPairs: [
{ open: "{", close: "}" },
{ open: "[", close: "]" },
{ open: "(", close: ")" },
{ open: '"', close: '"' },
{ open: "'", close: "'" }
]
});
monaco.languages.setMonarchTokensProvider("liquid", {
defaultToken: "",
tokenizer: {
root: [
[/\{\{-?/, { token: "delimiter.output", next: "@liquidOutput" }],
[/\{%-?\s*comment\b[^%]*-?%\}/, { token: "comment.block", next: "@liquidComment" }],
[/\{%-?/, { token: "delimiter.tag", next: "@liquidTag" }],
[/<!DOCTYPE/i, "metatag"],
[/<!--/, { token: "comment", next: "@htmlComment" }],
[/(<)(script)/i, ["delimiter.html", "tag.html"], "@scriptTag"],
[/(<)(style)/i, ["delimiter.html", "tag.html"], "@styleTag"],
[/(<\/)([\w:-]+)/, ["delimiter.html", "tag.html"]],
[/(<)([\w:-]+)/, ["delimiter.html", "tag.html"], "@htmlTag"],
[/[^<{]+/, ""],
[/./, ""]
],
liquidOutput: [
[/-?\}\}/, { token: "delimiter.output", next: "@pop" }],
[/\|\s*[a-zA-Z_][\w-]*/, "keyword"],
[/\b(?:true|false|nil|blank|empty)\b/, "keyword"],
[/\b\d+(?:\.\d+)?\b/, "number"],
[/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/, "string"],
[/[a-zA-Z_][\w.-]*/, "identifier"],
[/[,:()[\]]/, "delimiter"]
],
liquidTag: [
[/-?%\}/, { token: "delimiter.tag", next: "@pop" }],
[/\b(?:assign|capture|case|comment|cycle|decrement|echo|elsif|else|endcase|endcapture|endif|endfor|endunless|endcomment|for|if|include|increment|liquid|paginate|raw|render|tablerow|unless|when)\b/, "keyword"],
[/\|\s*[a-zA-Z_][\w-]*/, "keyword"],
[/\b(?:true|false|nil|blank|empty|contains)\b/, "keyword"],
[/\b\d+(?:\.\d+)?\b/, "number"],
[/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/, "string"],
[/[><=!]=?|\.|:/, "operator"],
[/[a-zA-Z_][\w.-]*/, "identifier"],
[/[,:()[\]]/, "delimiter"]
],
liquidComment: [
[/\{%-?\s*endcomment\s*-?%\}/, { token: "comment.block", next: "@pop" }],
[/./, "comment.block"]
],
htmlComment: [
[/-->/, { token: "comment", next: "@pop" }],
[/./, "comment"]
],
htmlTag: [
[/\/>/, { token: "delimiter.html", next: "@pop" }],
[/>/, { token: "delimiter.html", next: "@pop" }],
[/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/, "attribute.value"],
[/[\w:-]+/, "attribute.name"],
[/=/, "delimiter"]
],
scriptTag: [
[/>/, { token: "delimiter.html", next: "@pop" }],
[/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/, "attribute.value"],
[/[\w:-]+/, "attribute.name"],
[/=/, "delimiter"]
],
styleTag: [
[/>/, { token: "delimiter.html", next: "@pop" }],
[/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/, "attribute.value"],
[/[\w:-]+/, "attribute.name"],
[/=/, "delimiter"]
]
}
});
liquidLanguageRegistered = true;
};
export const registerMarkdownWithMacrosLanguage = (monaco) => {
if (markdownWithMacrosRegistered) {
return;
}
monaco.languages.register({ id: "markdown-with-macros" });
monaco.languages.setMonarchTokensProvider("markdown-with-macros", {
defaultToken: "",
tokenPostfix: ".md",
tokenizer: {
root: [
[/\[\[[a-zA-Z][\w-]*/, { token: "keyword.macro", next: "@macroParams" }],
[/^#{1,6}\s.*$/, "keyword.header"],
[/^\s*>+/, "string.quote"],
[/^\s*[-+*]\s/, "keyword"],
[/^\s*\d+\.\s/, "keyword"],
[/^\s*```\w*/, { token: "string.code", next: "@codeblock" }],
[/\*\*[^*]+\*\*/, "strong"],
[/\*[^*]+\*/, "emphasis"],
[/__[^_]+__/, "strong"],
[/_[^_]+_/, "emphasis"],
[/`[^`]+`/, "variable"],
[/!?\[[^\]]*\]\([^)]*\)/, "string.link"],
[/!?\[[^\]]*\]\[[^\]]*\]/, "string.link"]
],
macroParams: [
[/\]\]/, { token: "keyword.macro", next: "@root" }],
[/[a-zA-Z][\w-]*(?=\s*=)/, "attribute.name"],
[/=/, "delimiter"],
[/"[^"]*"/, "string"],
[/\s+/, "white"],
[/[^\]"=\s]+/, "attribute.value"]
],
codeblock: [
[/^\s*```\s*$/, { token: "string.code", next: "@root" }],
[/.*$/, "variable.source"]
]
}
});
markdownWithMacrosRegistered = true;
};

View File

@@ -0,0 +1,88 @@
import { loadScript } from "../utils/script_loader.js";
import { ensureMonacoTheme } from "./theme.js";
import { registerLiquidLanguage, registerMarkdownWithMacrosLanguage } from "./languages.js";
let monacoLoaderPromise;
const monacoEditors = new Map();
export const loadMonaco = () => {
if (window.monaco?.editor) {
ensureMonacoTheme(window.monaco);
registerLiquidLanguage(window.monaco);
registerMarkdownWithMacrosLanguage(window.monaco);
return Promise.resolve(window.monaco);
}
if (monacoLoaderPromise) {
return monacoLoaderPromise;
}
monacoLoaderPromise = loadScript("/monaco/vs/loader.js")
.then(
() =>
new Promise((resolve, reject) => {
window.require.config({ paths: { vs: "/monaco/vs" } });
window.require(["vs/editor/editor.main"], () => {
ensureMonacoTheme(window.monaco);
registerLiquidLanguage(window.monaco);
registerMarkdownWithMacrosLanguage(window.monaco);
resolve(window.monaco);
}, reject);
})
)
.catch((error) => {
monacoLoaderPromise = null;
throw error;
});
return monacoLoaderPromise;
};
export const registerMonacoEditor = (key, editor) => {
if (key) {
monacoEditors.set(key, editor);
}
};
export const unregisterMonacoEditor = (key) => {
if (key) {
monacoEditors.delete(key);
}
};
export const activeMonacoEditor = () => {
for (const editor of monacoEditors.values()) {
if (typeof editor?.hasTextFocus === "function" && editor.hasTextFocus()) {
return editor;
}
}
return null;
};
export const runMonacoEditorAction = (editor, actionId, triggerId = actionId) => {
if (!editor) {
return false;
}
const action = typeof editor.getAction === "function" ? editor.getAction(actionId) : null;
if (action && typeof action.run === "function") {
action.run();
return true;
}
if (typeof editor.trigger === "function") {
editor.trigger("bds-menu", triggerId, null);
return true;
}
return false;
};
export const diffModelPath = (filePath, side) => {
const normalized = String(filePath || "working-tree").replace(/^\/+/, "");
return `inmemory://model/git-diff/${side}/${normalized}`;
};
export { ensureMonacoTheme };

62
assets/js/monaco/theme.js Normal file
View File

@@ -0,0 +1,62 @@
import { cssVar, normalizeMonacoColor } from "../utils/color.js";
let monacoThemeSignature = null;
export const ensureMonacoTheme = (monaco) => {
const background = normalizeMonacoColor(
cssVar("--vscode-editor-background", cssVar("--vscode-input-background", "#1e1e1e")),
"#1e1e1e"
);
const foreground = normalizeMonacoColor(cssVar("--vscode-editor-foreground", "#d4d4d4"), "#d4d4d4");
const lineNumber = normalizeMonacoColor(cssVar("--vscode-editorLineNumber-foreground", "#858585"), "#858585");
const activeLineNumber = normalizeMonacoColor(
cssVar("--vscode-editorLineNumber-activeForeground", foreground),
foreground
);
const selection = normalizeMonacoColor(cssVar("--vscode-editor-selectionBackground", "#264f78"), "#264f78");
const inactiveSelection = normalizeMonacoColor(
cssVar("--vscode-editor-inactiveSelectionBackground", "#3a3d41"),
"#3a3d41"
);
const cursor = normalizeMonacoColor(cssVar("--vscode-editorCursor-foreground", foreground), foreground);
const border = normalizeMonacoColor(cssVar("--vscode-panel-border", "#3c3c3c"), "#3c3c3c");
const lineHighlight = normalizeMonacoColor(
cssVar("--vscode-editor-lineHighlightBackground", background),
background
);
const signature = [background, foreground, lineNumber, activeLineNumber, selection, inactiveSelection, cursor, border].join("|");
if (signature === monacoThemeSignature) {
monaco.editor.setTheme("bds-theme");
return;
}
monaco.editor.defineTheme("bds-theme", {
base: "vs-dark",
inherit: true,
rules: [
{ token: "keyword.macro", foreground: "C586C0", fontStyle: "bold" },
{ token: "attribute.name", foreground: "9CDCFE" },
{ token: "attribute.value", foreground: "CE9178" }
],
colors: {
"editor.background": background,
"editor.foreground": foreground,
"editor.lineHighlightBackground": lineHighlight,
"editorCursor.foreground": cursor,
"editor.selectionBackground": selection,
"editor.inactiveSelectionBackground": inactiveSelection,
"editorLineNumber.foreground": lineNumber,
"editorLineNumber.activeForeground": activeLineNumber,
"editorIndentGuide.background1": border,
"editorIndentGuide.activeBackground1": foreground,
"editorWidget.border": border,
"editorGutter.background": background,
"focusBorder": border,
"input.border": border
}
});
monacoThemeSignature = signature;
monaco.editor.setTheme("bds-theme");
};

46
assets/js/utils/color.js Normal file
View File

@@ -0,0 +1,46 @@
import { clamp } from "./dom.js";
export const cssVar = (name, fallback) => {
const value = window.getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return value || fallback;
};
const parseRgbColor = (value) => {
if (!value) {
return null;
}
const hex = value.match(/^#([0-9a-f]{6})$/i);
if (hex) {
return {
r: Number.parseInt(hex[1].slice(0, 2), 16),
g: Number.parseInt(hex[1].slice(2, 4), 16),
b: Number.parseInt(hex[1].slice(4, 6), 16)
};
}
const rgb = value.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)/i);
if (!rgb) {
return null;
}
return {
r: Number.parseInt(rgb[1], 10),
g: Number.parseInt(rgb[2], 10),
b: Number.parseInt(rgb[3], 10)
};
};
export const normalizeMonacoColor = (value, fallback) => {
const rgb = parseRgbColor(value);
if (!rgb) {
return fallback;
}
return `#${[rgb.r, rgb.g, rgb.b]
.map((channel) => clamp(channel, 0, 255).toString(16).padStart(2, "0"))
.join("")}`;
};

34
assets/js/utils/dom.js Normal file
View File

@@ -0,0 +1,34 @@
export const clamp = (value, min, max) => Math.max(min, Math.min(value, max));
export const parseJsonObject = (value) => {
if (!value) {
return null;
}
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
} catch (_error) {
return null;
}
};
export const setMediaThumbnailLoaded = (image, loaded) => {
const thumbnail = image?.closest(".media-thumbnail");
if (!thumbnail) {
return;
}
if (loaded) {
thumbnail.classList.add("is-loaded");
} else {
thumbnail.classList.remove("is-loaded");
}
};
export const syncMediaThumbnailState = (root) => {
root.querySelectorAll(".media-thumbnail-image").forEach((image) => {
setMediaThumbnailLoaded(image, Boolean(image.complete && image.naturalWidth > 0));
});
};

43
assets/js/utils/layout.js Normal file
View File

@@ -0,0 +1,43 @@
import { clamp } from "./dom.js";
import { SIDEBAR_STORAGE_KEY, ASSISTANT_STORAGE_KEY } from "../constants.js";
export const shellWidth = (selector) => {
const shell = document.querySelector(selector);
if (!shell) {
return 0;
}
const width = Number.parseInt(shell.style.width || "0", 10);
return Number.isNaN(width) ? Math.round(shell.getBoundingClientRect().width) : width;
};
export const setShellWidth = (selector, width) => {
const shell = document.querySelector(selector);
if (shell) {
shell.style.width = `${width}px`;
shell.classList.remove("is-hidden");
}
};
export const persistWidth = (target, width) => {
const key = target === "assistant" ? ASSISTANT_STORAGE_KEY : SIDEBAR_STORAGE_KEY;
window.localStorage.setItem(key, String(width));
};
export const readStoredSize = (key, fallback, min, max) => {
const raw = window.localStorage.getItem(key);
if (!raw) {
return fallback;
}
const parsed = Number.parseInt(raw, 10);
if (Number.isNaN(parsed)) {
return fallback;
}
return clamp(parsed, min, max);
};

View File

@@ -0,0 +1,33 @@
export const loadScript = (src) =>
new Promise((resolve, reject) => {
const existing = document.querySelector(`script[src="${src}"]`);
if (existing) {
if (existing.dataset.loaded === "true") {
resolve();
return;
}
existing.addEventListener("load", () => resolve(), { once: true });
existing.addEventListener("error", () => reject(new Error(`Failed to load ${src}`)), {
once: true
});
return;
}
const script = document.createElement("script");
script.src = src;
script.async = true;
script.addEventListener(
"load",
() => {
script.dataset.loaded = "true";
resolve();
},
{ once: true }
);
script.addEventListener("error", () => reject(new Error(`Failed to load ${src}`)), {
once: true
});
document.head.appendChild(script);
});

View File

@@ -0,0 +1,30 @@
export const normalizeShortcutKey = (key) => String(key || "").toLowerCase();
export const shortcutTargetIsEditable = (event) => {
const tag = event.target?.tagName || null;
return event.target?.isContentEditable || ["INPUT", "TEXTAREA", "SELECT"].includes(tag);
};
export const shortcutMatchesEvent = (shortcut, event) => {
const primary = event.metaKey || event.ctrlKey;
return (
normalizeShortcutKey(event.key) === normalizeShortcutKey(shortcut.key) &&
primary === Boolean(shortcut.primary) &&
event.shiftKey === Boolean(shortcut.shift) &&
event.altKey === Boolean(shortcut.alt)
);
};
export const parseShortcutConfig = (value) => {
if (!value) {
return [];
}
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed : [];
} catch (_error) {
return [];
}
};

View File

@@ -6,6 +6,7 @@ config :bds,
config :bds, BDS.Repo,
database: Path.expand("../priv/data/bds_dev.db", __DIR__),
pool_size: 5,
journal_mode: :wal,
busy_timeout: 15_000,
log: false,
stacktrace: true,
@@ -27,6 +28,31 @@ config :bds, BDS.Desktop.Endpoint,
pubsub_server: BDS.PubSub,
live_view: [signing_salt: "desktop-live-view"]
config :tailwind,
version: "4.1.14",
default: [
cd: Path.expand("..", __DIR__),
args: ~w(
--input=assets/css/app.css
--output=priv/static/assets/app.css
)
]
config :esbuild,
version: "0.25.4",
default: [
cd: Path.expand("../assets", __DIR__),
args: ~w(
js/app.js
--bundle
--target=es2022
--outdir=../priv/static/assets
--external:/fonts/*
--external:/images/*
),
env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
]
config :bds, :scripting,
runtime: BDS.Scripting.Lua,
timeout: 300_000,

View File

@@ -1,3 +1,9 @@
import Config
config :bds, BDS.Repo, pool_size: 5
config :bds, BDS.Desktop.Endpoint,
watchers: [
tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]},
esbuild: {Esbuild, :install_and_run, [:default, ~w(--watch)]}
]

View File

@@ -3,6 +3,8 @@ import Config
config :bds, BDS.Repo,
database: Path.expand("../priv/data/bds_test.db", __DIR__),
pool: Ecto.Adapters.SQL.Sandbox,
pool_size: 5
pool_size: 5,
journal_mode: :wal,
busy_timeout: 15_000
config :logger, level: :warning

View File

@@ -1,5 +1,8 @@
defmodule BDS.AI do
@moduledoc false
@moduledoc """
Public interface for AI features — endpoint configuration, secret management,
model catalog access, and dispatching chat and one-shot inference requests.
"""
alias BDS.AI.Catalog
alias BDS.AI.Chat
@@ -59,11 +62,9 @@ defmodule BDS.AI do
model = get_setting("ai.#{kind_key}.model")
encrypted_api_key = get_setting(encrypted_key("ai.#{kind_key}.api_key"))
cond do
is_nil(url) and is_nil(model) and is_nil(encrypted_api_key) ->
if is_nil(url) and is_nil(model) and is_nil(encrypted_api_key) do
{:ok, nil}
true ->
else
with {:ok, api_key} <- get_secret(encrypted_api_key, backend) do
{:ok, %{kind: kind, url: url, api_key: api_key, model: model}}
end
@@ -163,9 +164,15 @@ defmodule BDS.AI do
@spec get_chat_conversation(String.t()) :: BDS.AI.ChatConversation.t() | nil
defdelegate get_chat_conversation(conversation_id), to: Chat
@spec delete_chat_conversation(String.t()) :: {:ok, :deleted} | {:error, :not_found | term()}
defdelegate delete_chat_conversation(conversation_id), to: Chat
@spec available_chat_models(String.t() | nil) :: [map()]
defdelegate available_chat_models(current_model \\ nil), to: Chat
@spec effective_chat_model(BDS.AI.ChatConversation.t() | map() | nil) :: String.t() | nil
defdelegate effective_chat_model(conversation), to: Chat
@spec set_conversation_model(String.t(), String.t()) ::
{:ok, map()} | {:error, :not_found | Ecto.Changeset.t()}
defdelegate set_conversation_model(conversation_id, model_id), to: Chat
@@ -179,4 +186,12 @@ defmodule BDS.AI do
@spec cancel_chat(String.t()) :: :ok
defdelegate cancel_chat(conversation_id), to: Chat
@spec get_surface_state(String.t()) :: map()
defdelegate get_surface_state(conversation_id), to: Chat
@spec put_surface_state(String.t(), map(), map(), MapSet.t()) ::
{:ok, map()} | {:error, term()}
defdelegate put_surface_state(conversation_id, surface_data, surface_tabs, dismissed_surfaces),
to: Chat
end

View File

@@ -15,6 +15,7 @@ defmodule BDS.AI.Catalog do
alias BDS.AI.Model
alias BDS.AI.ModelModality
alias BDS.AI.OpenAICompatibleRuntime
alias BDS.MapUtils
alias BDS.Persistence
alias BDS.Repo
@@ -111,7 +112,8 @@ defmodule BDS.AI.Catalog do
def put_model_capabilities(model_id, attrs) when is_binary(model_id) and is_map(attrs) do
capabilities = %{
supports_attachment: truthy?(BDS.MapUtils.attr(attrs, :supports_attachment)),
supports_tool_calls: truthy?(BDS.MapUtils.attr(attrs, :supports_tool_calls))
supports_tool_calls: truthy?(BDS.MapUtils.attr(attrs, :supports_tool_calls)),
disables_reasoning: truthy?(BDS.MapUtils.attr(attrs, :disables_reasoning))
}
put_setting("ai.model_capabilities.#{model_id}", Jason.encode!(capabilities))
@@ -163,7 +165,8 @@ defmodule BDS.AI.Catalog do
@spec model_capabilities(String.t()) :: %{
supports_attachment: boolean(),
supports_tool_calls: boolean()
supports_tool_calls: boolean(),
disables_reasoning: boolean()
}
def model_capabilities(model_id) do
overrides = decode_model_capabilities_override(model_id)
@@ -173,7 +176,8 @@ defmodule BDS.AI.Catalog do
{:ok, model} ->
%{
supports_attachment: model.supports_attachment or "image" in model.input_modalities,
supports_tool_calls: model.supports_tool_calls
supports_tool_calls: model.supports_tool_calls,
disables_reasoning: false
}
_other ->
@@ -196,7 +200,8 @@ defmodule BDS.AI.Catalog do
String.contains?(normalized, "llava"),
supports_tool_calls:
String.contains?(normalized, "gpt") or String.contains?(normalized, "claude") or
String.contains?(normalized, "tool")
String.contains?(normalized, "tool"),
disables_reasoning: false
}
end
@@ -207,9 +212,7 @@ defmodule BDS.AI.Catalog do
end
end
defp atomize_map_keys(map) do
Enum.into(map, %{}, fn {key, value} -> {String.to_atom(key), value} end)
end
defp atomize_map_keys(map), do: MapUtils.safe_atomize_keys(map)
defp persist_catalog(payload) do
now = Persistence.now_ms()
@@ -309,7 +312,7 @@ defmodule BDS.AI.Catalog do
defp parse_modality("audio"), do: :audio
defp parse_modality("file"), do: :file
defp parse_modality("tool"), do: :tool
defp parse_modality(other) when is_binary(other), do: String.to_atom(other)
defp parse_modality(other) when is_binary(other), do: MapUtils.safe_atomize_key(other)
defp encode_nullable(nil), do: nil
defp encode_nullable(value), do: Jason.encode!(value)

View File

@@ -2,6 +2,7 @@ defmodule BDS.AI.Chat do
@moduledoc false
import Ecto.Query
require Logger
alias BDS.AI
alias BDS.AI.Catalog
@@ -23,6 +24,8 @@ defmodule BDS.AI.Chat do
@default_system_prompt "You are the bDS AI backend. Be precise, prefer structured JSON when asked, and avoid inventing blog facts."
@default_max_output_tokens 16_384
@title_max_output_tokens 256
@chat_title_max_length 30
@chat_max_tool_rounds 10
@default_context_window 128_000
@@ -59,6 +62,60 @@ defmodule BDS.AI.Chat do
Repo.get(ChatConversation, conversation_id)
end
@spec get_surface_state(String.t()) :: map()
def get_surface_state(conversation_id) when is_binary(conversation_id) do
case Repo.get(ChatConversation, conversation_id) do
%ChatConversation{surface_state: state} when is_map(state) -> state
_other -> %{}
end
end
@spec put_surface_state(String.t(), map(), map(), MapSet.t()) ::
{:ok, map()} | {:error, term()}
def put_surface_state(conversation_id, surface_data, surface_tabs, dismissed_surfaces)
when is_binary(conversation_id) do
case Repo.get(ChatConversation, conversation_id) do
nil ->
{:error, :not_found}
%ChatConversation{} = conversation ->
state = %{
"surface_data" => surface_data,
"surface_tabs" => surface_tabs,
"dismissed_surfaces" => MapSet.to_list(dismissed_surfaces)
}
conversation
|> ChatConversation.changeset(%{
surface_state: state,
updated_at: Persistence.now_ms()
})
|> Repo.update()
|> case do
{:ok, _updated} -> {:ok, state}
error -> error
end
end
end
@spec delete_chat_conversation(String.t()) :: {:ok, :deleted} | {:error, :not_found | term()}
def delete_chat_conversation(conversation_id) when is_binary(conversation_id) do
case Repo.get(ChatConversation, conversation_id) do
nil ->
{:error, :not_found}
%ChatConversation{} = conversation ->
Repo.delete_all(
from message in ChatMessage, where: message.conversation_id == ^conversation_id
)
case Repo.delete(conversation) do
{:ok, _conversation} -> {:ok, :deleted}
{:error, reason} -> {:error, reason}
end
end
end
@spec available_chat_models(String.t() | nil) :: [map()]
def available_chat_models(current_model \\ nil) do
endpoint_models = configured_chat_models()
@@ -87,6 +144,15 @@ defmodule BDS.AI.Chat do
end)
end
@spec effective_chat_model(ChatConversation.t() | map() | nil) :: String.t() | nil
def effective_chat_model(%ChatConversation{} = conversation) do
resolve_effective_chat_model(conversation.model)
end
def effective_chat_model(%{model: model}), do: resolve_effective_chat_model(model)
def effective_chat_model(%{"model" => model}), do: resolve_effective_chat_model(model)
def effective_chat_model(_conversation), do: resolve_effective_chat_model(nil)
@spec set_conversation_model(String.t(), String.t()) ::
{:ok, map()} | {:error, :not_found | Ecto.Changeset.t()}
def set_conversation_model(conversation_id, model_id)
@@ -264,6 +330,25 @@ defmodule BDS.AI.Chat do
end
end
defp resolve_effective_chat_model(model) when is_binary(model) and model != "", do: model
defp resolve_effective_chat_model(_model) do
mode = if AI.airplane_mode?(), do: :airplane, else: :online
preference_key = if mode == :airplane, do: :airplane_chat, else: :chat
case Runtime.model_preference_value(preference_key) do
model when is_binary(model) and model != "" ->
model
_other ->
case AI.get_endpoint(mode) do
{:ok, %{model: model}} when is_binary(model) and model != "" -> model
_other -> nil
end
end
end
defp catalog_provider_name_map do
Repo.all(from provider in CatalogProvider, select: {provider.id, provider.name})
|> Map.new()
@@ -303,7 +388,7 @@ defmodule BDS.AI.Chat do
defp fallback_provider_name(_provider), do: "Other"
defp do_send_chat_message(conversation, _user_message, opts) do
defp do_send_chat_message(conversation, user_message, opts) do
runtime = Keyword.get(opts, :runtime, OpenAICompatibleRuntime)
project_id = Keyword.get(opts, :project_id, active_project_id())
@@ -327,11 +412,133 @@ defmodule BDS.AI.Chat do
runtime,
opts,
@chat_max_tool_rounds
) do
),
{:ok, reply} <-
maybe_generate_chat_title(conversation.id, user_message.content, reply, opts) do
{:ok, reply}
end
end
defp maybe_generate_chat_title(conversation_id, user_content, reply, opts) do
conversation = Repo.get!(ChatConversation, conversation_id)
cond do
chat_user_message_count(conversation_id) < 1 ->
Logger.debug("Chat title generation skipped reason=:no_user_messages")
{:ok, reply}
not generated_chat_title?(conversation.title, conversation.model) ->
Logger.debug(
"Chat title generation skipped reason=:conversation_already_titled title=#{inspect(conversation.title)}"
)
{:ok, reply}
true ->
Logger.debug("Chat title generation requested conversation_id=#{conversation_id}")
case generate_chat_title(user_content, opts) do
{:ok, title} when is_binary(title) and title != "" ->
now = Persistence.now_ms()
conversation
|> ChatConversation.changeset(%{title: title, updated_at: now})
|> Repo.update()
|> case do
{:ok, updated_conversation} ->
{:ok, %{reply | conversation: format_conversation(updated_conversation)}}
{:error, _reason} ->
{:ok, reply}
end
_other ->
{:ok, reply}
end
end
end
defp generate_chat_title(user_content, opts) when is_binary(user_content) do
runtime = Keyword.get(opts, :runtime, OpenAICompatibleRuntime)
with {:ok, endpoint, model, mode} <- Runtime.resolve_target(:chat_title, opts),
:ok <- Runtime.validate_target(:chat_title, model, mode),
request <- build_chat_title_request(user_content, model),
{:ok, response} <-
runtime.generate(Runtime.endpoint_with_model(endpoint, model), request, opts) do
title = sanitize_chat_title(Map.get(response, :content))
if title == "" do
Logger.warning("Chat title generation returned an empty title",
model: model,
content: inspect(Map.get(response, :content)),
usage: inspect(Map.get(response, :usage))
)
end
{:ok, title}
else
{:error, reason} = error ->
Logger.warning("Chat title generation failed", reason: inspect(reason))
error
other ->
Logger.warning("Chat title generation failed", reason: inspect(other))
other
end
end
defp build_chat_title_request(user_content, model) do
%{
operation: :chat_title,
model: model,
max_output_tokens: @title_max_output_tokens,
messages: [
%{
"role" => "system",
"content" =>
"Generate an ultra-short title (2-3 words, max 25 characters) for this conversation. Focus ONLY on the topic. Ignore any capability disclaimers. Do not include reasoning. Output ONLY the title text."
},
%{"role" => "user", "content" => "Topic: #{String.slice(user_content, 0, 100)}"}
]
}
end
defp sanitize_chat_title(title) when is_binary(title) do
title =
title
|> String.trim()
|> String.trim_leading("\"")
|> String.trim_leading("'")
|> String.trim_trailing("\"")
|> String.trim_trailing("'")
|> String.trim_trailing(".")
|> String.trim_trailing("!")
|> String.trim_trailing("?")
if String.length(title) > @chat_title_max_length do
String.slice(title, 0, @chat_title_max_length - 3) <> "..."
else
title
end
end
defp sanitize_chat_title(_title), do: ""
defp chat_user_message_count(conversation_id) do
Repo.aggregate(
from(message in ChatMessage,
where: message.conversation_id == ^conversation_id and message.role == :user
),
:count,
:id
)
end
defp generated_chat_title?(title, model) do
title in [generated_chat_title(nil), generated_chat_title(model)]
end
defp chat_round(
_conversation,
_messages,
@@ -452,7 +659,7 @@ defmodule BDS.AI.Chat do
end
defp build_chat_request(conversation, messages, model, project_id, tools) do
system_message = %{"role" => "system", "content" => chat_system_prompt(project_id)}
system_message = %{"role" => "system", "content" => chat_system_prompt(project_id, tools)}
%{
operation: :chat,
@@ -479,10 +686,44 @@ defmodule BDS.AI.Chat do
case Catalog.decode_nullable_json(message.tool_calls) do
nil -> base
tool_calls -> Map.put(base, "tool_calls", tool_calls)
tool_calls -> Map.put(base, "tool_calls", tool_calls_for_runtime(tool_calls))
end
end
defp tool_calls_for_runtime(tool_calls) when is_list(tool_calls) do
Enum.map(tool_calls, &tool_call_for_runtime/1)
end
defp tool_calls_for_runtime(tool_calls), do: tool_calls
defp tool_call_for_runtime(%{"type" => "function", "function" => %{} = _function} = tool_call) do
tool_call
end
defp tool_call_for_runtime(%{"id" => id, "name" => name} = tool_call) do
%{
"id" => id,
"type" => "function",
"function" => %{
"name" => name,
"arguments" => Jason.encode!(tool_call["arguments"] || %{})
}
}
end
defp tool_call_for_runtime(%{id: id, name: name} = tool_call) do
%{
"id" => id,
"type" => "function",
"function" => %{
"name" => name,
"arguments" => Jason.encode!(Map.get(tool_call, :arguments) || %{})
}
}
end
defp tool_call_for_runtime(tool_call), do: tool_call
defp truncate_chat_messages(messages, model, tools) do
context_window = model_context_window(model)
reserve = min(@default_max_output_tokens, max(div(context_window, 4), 512))
@@ -511,15 +752,46 @@ defmodule BDS.AI.Chat do
ChatTools.available_specs(project_id, Catalog.model_capabilities(model))
end
defp chat_system_prompt(project_id) do
defp chat_system_prompt(project_id, tools) do
base = get_setting("ai.system_prompt") || @default_system_prompt
case project_stats_summary(project_id) do
nil -> base
summary -> base <> "\n\nCurrent blog statistics:\n" <> summary
with true <- tools != [],
summary when is_binary(summary) <- project_stats_summary(project_id) do
base <> "\n\nCurrent blog statistics:\n" <> summary <> "\n\n" <> blog_tool_guidance()
else
_other -> base
end
end
defp blog_tool_guidance do
Enum.join(
[
"Available blog data tools:",
"- Use get_blog_stats for aggregate counts of posts, media, tags, and categories.",
"- Use search_posts for full-text blog search and filtered post lookup by category, tag, language, year, month, or status.",
"- Use read_post to read a post by ID, or read_post_by_slug to read a post by slug.",
"- Use read_post_by_slug to read full post content and metadata when a slug is known.",
"- Use list_posts when asked for post titles, slugs, URLs, statuses, backlinks, or recent/top/latest post lists. This is allowed project data access.",
"- Use get_media for one media item by ID, list_media for media titles, filenames, MIME types, or recent media lists, and view_image for visual image inspection.",
"- Use update_post_metadata and update_media_metadata when asked to change titles, excerpts, tags, categories, alt text, or captions.",
"- Use get_post_backlinks, get_post_outlinks, get_post_media, and get_media_posts for relationship questions.",
"- Use list_tags, list_categories, and count_posts for taxonomy and grouped analytics questions.",
"If a requested blog fact is available through these tools, call the tool instead of saying you cannot access the data.",
"",
"Available UI Render Tools:",
"- Use render_chart to show data as a bar, stacked-bar, line, area, pie, donut, or heatmap chart. Use it when presenting statistics or comparisons. Prefer heatmap over tables with emoji or color indicators for intensity grids or calendar-style activity.",
"- Use render_table for tabular data, comparisons, and structured listings.",
"- Use render_form to collect structured user input.",
"- Use render_card for summaries, highlights, or actionable items.",
"- Use render_metric for a single KPI or important statistic.",
"- Use render_list for bullet lists, checklists, or simple enumerations.",
"- Use render_tabs to organize multiple views into switchable tabs; tab content can contain text, metrics, lists, charts, and tables.",
"When presenting data, statistics, or comparisons, prefer render tools over plain text. When building any visualization, render it as soon as you have enough data."
],
"\n"
)
end
defp project_stats_summary(nil), do: nil
defp project_stats_summary(project_id) do

View File

@@ -11,6 +11,7 @@ defmodule BDS.AI.ChatConversation do
title: String.t() | nil,
model: String.t() | nil,
copilot_session_id: String.t() | nil,
surface_state: map() | nil,
created_at: integer() | nil,
updated_at: integer() | nil
}
@@ -19,13 +20,14 @@ defmodule BDS.AI.ChatConversation do
field :title, :string
field :model, :string
field :copilot_session_id, :string
field :surface_state, :map
field :created_at, :integer
field :updated_at, :integer
end
def changeset(conversation, attrs) do
conversation
|> cast(attrs, [:id, :title, :model, :copilot_session_id, :created_at, :updated_at],
|> cast(attrs, [:id, :title, :model, :copilot_session_id, :surface_state, :created_at, :updated_at],
empty_values: [nil]
)
|> validate_required([:id, :title, :created_at, :updated_at])

View File

@@ -36,7 +36,9 @@ defmodule BDS.AI.ChatMessage do
:cache_read_tokens,
:cache_write_tokens,
:created_at
], empty_values: [nil])
],
empty_values: [nil]
)
|> validate_required([:conversation_id, :role, :created_at])
|> assoc_constraint(:conversation)
end

View File

@@ -4,13 +4,22 @@ defmodule BDS.AI.ChatTools do
import Ecto.Query
alias BDS.AI.Chat
alias BDS.Media, as: MediaContext
alias BDS.Media.Media
alias BDS.MCP.Queries
alias BDS.Posts, as: PostsContext
alias BDS.Posts.Post
alias BDS.Posts.PostMedia
alias BDS.Projects.Project
alias BDS.Repo
alias BDS.Search
@spec execute(String.t(), map(), String.t() | nil) :: map()
def execute("blog_stats", _arguments, project_id) do
execute("get_blog_stats", %{}, project_id)
end
def execute("get_blog_stats", _arguments, project_id) do
project_id = project_id || active_project_id()
%{
@@ -23,20 +32,91 @@ defmodule BDS.AI.ChatTools do
}
end
def execute("list_posts", arguments, project_id) do
limit = normalize_limit(arguments["limit"])
def execute("check_term", arguments, project_id) do
project_id = project_id || active_project_id()
term = normalize_term(arguments["term"])
Repo.all(
from(post in Post,
where: post.project_id == ^project_id,
order_by: [desc: post.updated_at],
limit: ^limit,
select: %{id: post.id, title: post.title, slug: post.slug, status: post.status}
)
)
posts = Repo.all(from post in Post, where: post.project_id == ^project_id)
tag_post_count =
Enum.count(posts, fn post ->
Enum.any?(post.tags || [], &(normalize_term(&1) == term))
end)
category_post_count =
Enum.count(posts, fn post ->
Enum.any?(post.categories || [], &(normalize_term(&1) == term))
end)
%{
is_category: category_post_count > 0,
category_post_count: category_post_count,
is_tag: tag_post_count > 0,
tag_post_count: tag_post_count
}
end
def execute("search_posts", arguments, project_id) do
project_id = project_id || active_project_id()
filters = search_filters(arguments)
{:ok, result} = Search.search_posts(project_id, arguments["query"] || "", filters)
%{
posts: Enum.map(result.posts, &Queries.post_summary/1),
total: result.total,
offset: result.offset,
limit: result.limit,
has_more: result.offset + result.limit < result.total
}
end
def execute("read_post_by_slug", arguments, project_id) do
project_id = project_id || active_project_id()
case Repo.get_by(Post, project_id: project_id, slug: arguments["slug"]) do
%Post{} = post -> %{post: Queries.post_detail(post)}
nil -> %{error: "not_found"}
end
end
def execute("read_post", arguments, project_id) do
project_id = project_id || active_project_id()
case Repo.get_by(Post,
id: arguments["postId"] || arguments["post_id"],
project_id: project_id
) do
%Post{} = post -> %{post: Queries.post_detail(post)}
nil -> %{success: false, error: "not_found"}
end
end
def execute("list_posts", arguments, project_id) do
project_id = project_id || active_project_id()
limit = normalize_limit(arguments["limit"])
offset = normalize_offset(arguments["offset"])
filters = search_filters(arguments) |> Map.merge(%{limit: limit, offset: offset})
{:ok, result} = Search.search_posts(project_id, "", filters)
%{
posts:
Enum.map(result.posts, fn post ->
post
|> Queries.post_summary()
|> Map.put("url", "/posts/#{post.slug}")
|> Map.put("updated_at", post.updated_at)
end),
total: result.total,
offset: result.offset,
limit: result.limit,
has_more: result.offset + result.limit < result.total
}
end
def execute("list_media", arguments, project_id) do
project_id = project_id || active_project_id()
limit = normalize_limit(arguments["limit"])
Repo.all(
@@ -48,12 +128,160 @@ defmodule BDS.AI.ChatTools do
id: media.id,
title: media.title,
mime_type: media.mime_type,
filename: media.filename
filename: media.filename,
updated_at: media.updated_at
}
)
)
end
def execute("get_media", arguments, project_id) do
project_id = project_id || active_project_id()
case Repo.get_by(Media,
id: arguments["mediaId"] || arguments["media_id"],
project_id: project_id
) do
%Media{} = media -> %{media: media_summary(media)}
nil -> %{success: false, error: "not_found"}
end
end
def execute("view_image", arguments, project_id) do
project_id = project_id || active_project_id()
media_id = arguments["mediaId"] || arguments["media_id"]
size = arguments["size"] || "medium"
case Repo.get_by(Media, id: media_id, project_id: project_id) do
%Media{mime_type: "image/" <> _rest} = media ->
case thumbnail_data_url(project_id, media, size) do
nil -> %{success: false, error: "thumbnail_not_available"}
data_url -> %{success: true, media: media_summary(media), data_url: data_url}
end
%Media{} = media ->
%{success: false, error: "not_image", mime_type: media.mime_type}
nil ->
%{success: false, error: "not_found"}
end
end
def execute("update_post_metadata", arguments, project_id) do
project_id = project_id || active_project_id()
post_id = arguments["postId"] || arguments["post_id"]
with %Post{} <- Repo.get_by(Post, id: post_id, project_id: project_id),
attrs <- metadata_attrs(arguments, ["title", "excerpt", "tags", "categories"]),
false <- attrs == %{},
{:ok, post} <- PostsContext.update_post(post_id, attrs) do
%{success: true, post: Queries.post_detail(post)}
else
nil -> %{success: false, error: "not_found"}
true -> %{success: false, error: "no_updates_provided"}
{:error, reason} -> %{success: false, error: inspect(reason)}
end
end
def execute("update_media_metadata", arguments, project_id) do
project_id = project_id || active_project_id()
media_id = arguments["mediaId"] || arguments["media_id"]
with %Media{} <- Repo.get_by(Media, id: media_id, project_id: project_id),
attrs <- metadata_attrs(arguments, ["title", "alt", "caption", "tags"]),
false <- attrs == %{},
{:ok, media} <- MediaContext.update_media(media_id, attrs) do
%{success: true, media: media_summary(media)}
else
nil -> %{success: false, error: "not_found"}
true -> %{success: false, error: "no_updates_provided"}
{:error, reason} -> %{success: false, error: inspect(reason)}
end
end
def execute("list_tags", _arguments, project_id) do
project_id = project_id || active_project_id()
%{
tags: counted_terms(project_id, :tags),
count: length(counted_terms(project_id, :tags))
}
end
def execute("list_categories", _arguments, project_id) do
project_id = project_id || active_project_id()
%{
categories: counted_terms(project_id, :categories),
count: length(counted_terms(project_id, :categories))
}
end
def execute("count_posts", arguments, project_id) do
project_id = project_id || active_project_id()
group_by = List.wrap(arguments["groupBy"] || arguments["group_by"]) |> Enum.map(&to_string/1)
result = search_all_counted_posts(project_id, arguments)
groups =
result.posts
|> Enum.flat_map(&Queries.group_rows(&1, group_by))
|> Enum.group_by(& &1, fn _row -> 1 end)
|> Enum.map(fn {row, counts} -> Map.put(row, "count", length(counts)) end)
|> Enum.sort_by(&Map.to_list/1)
%{groups: groups, total_posts: result.total}
end
def execute("get_post_backlinks", arguments, project_id) do
project_id = project_id || active_project_id()
case Repo.get_by(Post,
id: arguments["postId"] || arguments["post_id"],
project_id: project_id
) do
%Post{} = post ->
%{success: true, post_id: post.id, linked_by: Queries.linked_posts(post.id, :incoming)}
nil ->
%{success: false, error: "not_found"}
end
end
def execute("get_post_outlinks", arguments, project_id) do
project_id = project_id || active_project_id()
case Repo.get_by(Post,
id: arguments["postId"] || arguments["post_id"],
project_id: project_id
) do
%Post{} = post ->
%{success: true, post_id: post.id, links_to: Queries.linked_posts(post.id, :outgoing)}
nil ->
%{success: false, error: "not_found"}
end
end
def execute("get_post_media", arguments, project_id) do
project_id = project_id || active_project_id()
post_id = arguments["postId"] || arguments["post_id"]
case Repo.get_by(Post, id: post_id, project_id: project_id) do
%Post{} = post -> %{success: true, post_id: post.id, media: post_media(project_id, post.id)}
nil -> %{success: false, error: "not_found"}
end
end
def execute("get_media_posts", arguments, project_id) do
project_id = project_id || active_project_id()
media_id = arguments["mediaId"] || arguments["media_id"]
case Repo.get_by(Media, id: media_id, project_id: project_id) do
%Media{} = media -> %{success: true, media_id: media.id, posts: media_posts(media.id)}
nil -> %{success: false, error: "not_found"}
end
end
def execute("render_table", arguments, _project_id) do
%{
type: "table",
@@ -67,7 +295,7 @@ defmodule BDS.AI.ChatTools do
%{
type: "chart",
title: arguments["title"],
chart_type: arguments["chart_type"] || "bar",
chart_type: arguments["chartType"] || arguments["chart_type"] || "bar",
series: arguments["series"] || []
}
end
@@ -142,19 +370,184 @@ defmodule BDS.AI.ChatTools do
"properties" => %{}
})
},
%{
name: "get_blog_stats",
spec:
tool_spec(
"get_blog_stats",
"Get comprehensive blog statistics: total posts, media count, unique tag count, and unique category count. Use this first when you need to understand the scope of the data.",
%{"type" => "object", "properties" => %{}}
)
},
%{
name: "check_term",
spec:
tool_spec(
"check_term",
"Check whether a term exists as a category, tag, or both. Returns post counts for each. Use before search_posts or list_posts when unsure whether a term is a category or tag.",
%{
"type" => "object",
"properties" => %{"term" => %{"type" => "string"}},
"required" => ["term"]
}
)
},
%{
name: "search_posts",
spec:
tool_spec(
"search_posts",
"Search blog posts using full-text search. Can filter by category, tags, language, missing translation language, year, month, or status. Returns paginated concrete post data with titles, slugs, tags, categories, backlinks, and links_to.",
post_search_schema(true)
)
},
%{
name: "read_post",
spec:
tool_spec(
"read_post",
"Read full content and metadata of a specific blog post by ID. Includes backlinks, links_to, tags, categories, excerpt, status, language, and available languages.",
%{
"type" => "object",
"properties" => %{"postId" => %{"type" => "string"}},
"required" => ["postId"]
}
)
},
%{
name: "read_post_by_slug",
spec:
tool_spec(
"read_post_by_slug",
"Read full content and metadata of a specific blog post by slug. Includes backlinks, links_to, tags, categories, excerpt, status, language, and available languages.",
%{
"type" => "object",
"properties" => %{"slug" => %{"type" => "string"}},
"required" => ["slug"]
}
)
},
%{
name: "list_posts",
spec:
tool_spec("list_posts", "List recent posts in the active project", limit_schema())
tool_spec(
"list_posts",
"List blog posts with optional filtering by status, category, tags, language, year, or month. Returns paginated concrete post data with titles, slugs, URLs, statuses, tags, categories, backlinks, and links_to. Use for recent, latest, top, or title-list requests.",
post_search_schema(false)
)
},
%{
name: "get_media",
spec:
tool_spec(
"get_media",
"Get information about a specific media file by ID, including title, alt text, caption, tags, filename, MIME type, dimensions, and update time.",
media_id_schema()
)
},
%{
name: "list_media",
spec:
tool_spec(
"list_media",
"List recent media items in the active project",
"List concrete media data in the active project, including titles, filenames, MIME types, and update times.",
limit_schema()
)
},
%{
name: "view_image",
spec:
tool_spec(
"view_image",
"View an image thumbnail as a local data URL for visual inspection. Only works with image media files.",
media_id_schema(%{
"size" => %{"type" => "string", "enum" => ["small", "medium", "large"]}
})
)
},
%{
name: "update_post_metadata",
spec:
tool_spec(
"update_post_metadata",
"Update metadata for a blog post: title, excerpt, tags, or categories. Does not update post body content.",
update_post_metadata_schema()
)
},
%{
name: "update_media_metadata",
spec:
tool_spec(
"update_media_metadata",
"Update metadata for a media file: title, alt text, caption, or tags.",
update_media_metadata_schema()
)
},
%{
name: "list_tags",
spec:
tool_spec(
"list_tags",
"List all tags used across blog posts with post counts.",
%{
"type" => "object",
"properties" => %{}
}
)
},
%{
name: "list_categories",
spec:
tool_spec(
"list_categories",
"List all categories used across blog posts with post counts.",
%{"type" => "object", "properties" => %{}}
)
},
%{
name: "count_posts",
spec:
tool_spec(
"count_posts",
"Count posts grouped by dimensions such as year, month, tag, category, or status. Use for analytics, distributions, and heat maps without transferring full post content.",
count_posts_schema()
)
},
%{
name: "get_post_backlinks",
spec:
tool_spec(
"get_post_backlinks",
"Get all posts that link to a specific post.",
post_id_schema()
)
},
%{
name: "get_post_outlinks",
spec:
tool_spec(
"get_post_outlinks",
"Get all posts that a specific post links to.",
post_id_schema()
)
},
%{
name: "get_post_media",
spec:
tool_spec(
"get_post_media",
"Get media files linked to a specific post.",
post_id_schema()
)
},
%{
name: "get_media_posts",
spec:
tool_spec(
"get_media_posts",
"Get posts that use a specific media file.",
media_id_schema()
)
}
]
else
@@ -166,14 +559,18 @@ defmodule BDS.AI.ChatTools do
%{
name: "render_card",
spec:
tool_spec("render_card", "Return a structured card payload", render_card_schema())
tool_spec(
"render_card",
"Render an information card in the chat UI. Use this for displaying a summary, highlight, or actionable item.",
render_card_schema()
)
},
%{
name: "render_table",
spec:
tool_spec(
"render_table",
"Return a structured table payload",
"Render a data table in the chat UI. Use this when the user asks for tabular data, comparisons, or structured information.",
render_table_schema()
)
},
@@ -182,40 +579,52 @@ defmodule BDS.AI.ChatTools do
spec:
tool_spec(
"render_chart",
"Return a structured chart payload",
"Render an interactive chart in the chat UI. Use this when the user asks for a chart, graph, or data visualization. Supports bar, stacked-bar, line, area, pie, donut, and heatmap charts. Use stacked-bar for multi-segment bars and heatmap for grid/matrix visualizations.",
render_chart_schema()
)
},
%{
name: "render_form",
spec:
tool_spec("render_form", "Return a structured form payload", render_form_schema())
tool_spec(
"render_form",
"Render an interactive form in the chat UI. Use this when you need to collect structured input from the user.",
render_form_schema()
)
},
%{
name: "render_metric",
spec:
tool_spec(
"render_metric",
"Return a structured metric payload",
"Render a single metric/KPI display in the chat UI. Use this for showing a single important value with a label.",
render_metric_schema()
)
},
%{
name: "render_list",
spec:
tool_spec("render_list", "Return a structured list payload", render_list_schema())
tool_spec(
"render_list",
"Render a list of items in the chat UI. Use this for displaying bullet-point style lists, checklists, or simple enumerations.",
render_list_schema()
)
},
%{
name: "render_tabs",
spec:
tool_spec("render_tabs", "Return a structured tabs payload", render_tabs_schema())
tool_spec(
"render_tabs",
"Render a tabbed interface in the chat UI. Use this to organize information into multiple tabs that the user can switch between.",
render_tabs_schema()
)
},
%{
name: "render_mindmap",
spec:
tool_spec(
"render_mindmap",
"Return a structured mindmap payload",
"Render a mind map diagram in the chat UI. Use this when the user asks for a mind map, concept map, topic tree, brainstorming diagram, or hierarchical overview of ideas.",
render_mindmap_schema()
)
}
@@ -245,13 +654,106 @@ defmodule BDS.AI.ChatTools do
}
end
defp post_search_schema(require_query) do
schema = %{
"type" => "object",
"properties" => %{
"query" => %{"type" => "string"},
"status" => %{"type" => "string", "enum" => ["draft", "published", "archived"]},
"category" => %{"type" => "string"},
"tags" => %{"type" => "array", "items" => %{"type" => "string"}},
"language" => %{"type" => "string"},
"missingTranslationLanguage" => %{"type" => "string"},
"year" => %{"type" => "integer"},
"month" => %{"type" => "integer", "minimum" => 1, "maximum" => 12},
"limit" => %{"type" => "integer", "minimum" => 1, "maximum" => 50},
"offset" => %{"type" => "integer", "minimum" => 0}
}
}
if require_query, do: Map.put(schema, "required", ["query"]), else: schema
end
defp count_posts_schema do
%{
"type" => "object",
"properties" => %{
"groupBy" => %{
"type" => "array",
"items" => %{
"type" => "string",
"enum" => ["year", "month", "tag", "category", "status"]
}
},
"year" => %{"type" => "integer"},
"month" => %{"type" => "integer", "minimum" => 1, "maximum" => 12},
"status" => %{"type" => "string", "enum" => ["draft", "published", "archived"]},
"category" => %{"type" => "string"},
"tags" => %{"type" => "array", "items" => %{"type" => "string"}}
},
"required" => ["groupBy"]
}
end
defp post_id_schema do
%{
"type" => "object",
"properties" => %{"postId" => %{"type" => "string"}},
"required" => ["postId"]
}
end
defp media_id_schema(extra_properties \\ %{}) do
%{
"type" => "object",
"properties" => Map.merge(%{"mediaId" => %{"type" => "string"}}, extra_properties),
"required" => ["mediaId"]
}
end
defp update_post_metadata_schema do
%{
"type" => "object",
"properties" => %{
"postId" => %{"type" => "string"},
"title" => %{"type" => "string"},
"excerpt" => %{"type" => "string"},
"tags" => %{"type" => "array", "items" => %{"type" => "string"}},
"categories" => %{"type" => "array", "items" => %{"type" => "string"}}
},
"required" => ["postId"]
}
end
defp update_media_metadata_schema do
%{
"type" => "object",
"properties" => %{
"mediaId" => %{"type" => "string"},
"title" => %{"type" => "string"},
"alt" => %{"type" => "string"},
"caption" => %{"type" => "string"},
"tags" => %{"type" => "array", "items" => %{"type" => "string"}}
},
"required" => ["mediaId"]
}
end
defp render_table_schema do
%{
"type" => "object",
"properties" => %{
"title" => %{"type" => "string"},
"columns" => %{"type" => "array"},
"rows" => %{"type" => "array"}
"title" => %{"type" => "string", "description" => "Optional table title"},
"columns" => %{
"type" => "array",
"items" => %{"type" => "string"},
"description" => "Column header names"
},
"rows" => %{
"type" => "array",
"items" => %{"type" => "array", "items" => %{"type" => "string"}},
"description" => "Table rows, each row is an array of cell values"
}
}
}
end
@@ -260,11 +762,41 @@ defmodule BDS.AI.ChatTools do
%{
"type" => "object",
"properties" => %{
"title" => %{"type" => "string"},
"chart_type" => %{"type" => "string"},
"series" => %{"type" => "array"}
"chartType" => %{
"type" => "string",
"enum" => ["bar", "stacked-bar", "line", "area", "pie", "donut", "heatmap"],
"description" =>
"The type of chart to render. Use stacked-bar for multi-segment bars. Use heatmap for grid/matrix visualizations."
},
"title" => %{"type" => "string", "description" => "Optional chart title"},
"series" => %{
"type" => "array",
"description" => "Array of data points.",
"items" => %{
"type" => "object",
"properties" => %{
"label" => %{"type" => "string", "description" => "Data point label"},
"value" => %{"type" => "number", "description" => "Data point value"},
"segments" => %{
"type" => "array",
"description" =>
"Segments within this data point. Required for stacked-bar and heatmap charts.",
"items" => %{
"type" => "object",
"properties" => %{
"label" => %{"type" => "string"},
"value" => %{"type" => "number"}
},
"required" => ["label", "value"]
}
}
},
"required" => ["label"]
}
}
},
"required" => ["chartType", "series"]
}
end
defp render_form_schema do
@@ -334,6 +866,124 @@ defmodule BDS.AI.ChatTools do
defp normalize_limit(value) when is_integer(value) and value > 0 and value <= 50, do: value
defp normalize_limit(_value), do: 10
defp normalize_offset(value) when is_integer(value) and value >= 0, do: value
defp normalize_offset(_value), do: 0
defp search_filters(arguments) do
%{}
|> maybe_put(:category, arguments["category"])
|> maybe_put(:tags, arguments["tags"])
|> maybe_put(:language, arguments["language"])
|> maybe_put(:missing_translation_language, arguments["missingTranslationLanguage"])
|> maybe_put(:year, arguments["year"])
|> maybe_put(:month, arguments["month"])
|> maybe_put(:status, BDS.BoundedAtoms.post_status(arguments["status"]))
|> Map.put(:offset, normalize_offset(arguments["offset"]))
|> Map.put(:limit, normalize_limit(arguments["limit"]))
end
defp search_all_counted_posts(project_id, arguments) do
filters = search_filters(arguments) |> Map.put(:offset, 0) |> Map.put(:limit, 1)
{:ok, %{total: total}} = Search.search_posts(project_id, "", filters)
filters = Map.put(filters, :limit, max(total, 1))
{:ok, result} = Search.search_posts(project_id, "", filters)
result
end
defp maybe_put(map, _key, nil), do: map
defp maybe_put(map, _key, ""), do: map
defp maybe_put(map, key, value), do: Map.put(map, key, value)
defp counted_terms(project_id, field) do
Repo.all(
from post in Post, where: post.project_id == ^project_id, select: field(post, ^field)
)
|> List.flatten()
|> Enum.reject(&blank?/1)
|> Enum.frequencies()
|> Enum.map(fn {term, count} -> %{name: term, count: count} end)
|> Enum.sort_by(&String.downcase(to_string(&1.name)))
end
defp metadata_attrs(arguments, keys) do
Enum.reduce(keys, %{}, fn key, acc ->
maybe_put(acc, BDS.MapUtils.safe_atomize_key(key), arguments[key])
end)
end
defp media_summary(%Media{} = media) do
%{
id: media.id,
filename: media.filename,
original_name: media.original_name,
mime_type: media.mime_type,
size: media.size,
width: media.width,
height: media.height,
title: media.title,
alt: media.alt,
caption: media.caption,
author: media.author,
language: media.language,
tags: media.tags || [],
created_at: media.created_at,
updated_at: media.updated_at
}
end
defp post_media(project_id, post_id) do
Repo.all(
from media in Media,
join: post_media in PostMedia,
on: post_media.media_id == media.id,
where: post_media.project_id == ^project_id and post_media.post_id == ^post_id,
order_by: [asc: post_media.sort_order, asc: media.updated_at]
)
|> Enum.map(&media_summary/1)
end
defp media_posts(media_id) do
MediaContext.list_linked_posts(media_id)
|> Enum.map(fn post ->
%{"id" => post.post_id, "title" => post.title, "sort_order" => post.sort_order}
end)
end
defp thumbnail_data_url(project_id, media, size) do
project = Repo.get!(Project, project_id)
size_key = thumbnail_size(size)
relative_path = MediaContext.thumbnail_paths(media)[size_key]
absolute_path = Path.join(project.data_path, relative_path || "")
with true <- is_binary(relative_path),
true <- File.exists?(absolute_path),
{:ok, binary} <- File.read(absolute_path) do
"data:#{thumbnail_mime(absolute_path)};base64," <> Base.encode64(binary)
else
_other -> nil
end
end
defp thumbnail_size("small"), do: :small
defp thumbnail_size("large"), do: :large
defp thumbnail_size(_size), do: :medium
defp thumbnail_mime(path) do
case Path.extname(path) |> String.downcase() do
".jpg" -> "image/jpeg"
".jpeg" -> "image/jpeg"
".png" -> "image/png"
".webp" -> "image/webp"
_other -> "application/octet-stream"
end
end
defp blank?(value), do: is_nil(value) or String.trim(to_string(value)) == ""
defp normalize_term(value), do: value |> to_string() |> String.downcase()
defp active_project_id do
Repo.one(from(project in Project, where: project.is_active == true, select: project.id))
end

View File

@@ -60,7 +60,9 @@ defmodule BDS.AI.Model do
:interleaved,
:status,
:updated_at
], empty_values: [nil])
],
empty_values: [nil]
)
|> validate_required([
:provider,
:model_id,

View File

@@ -1,12 +1,16 @@
defmodule BDS.AI.OneShot do
@moduledoc false
require Logger
alias BDS.AI.Chat
alias BDS.AI.OpenAICompatibleRuntime
alias BDS.AI.Runtime
alias BDS.Media.Media
alias BDS.MapUtils
alias BDS.Posts
alias BDS.Posts.Post
alias BDS.Projects
alias BDS.Repo
@default_max_output_tokens 16_384
@@ -162,12 +166,13 @@ defmodule BDS.AI.OneShot do
end
end
defp run_one_shot(operation, payload, opts, formatter) do
defp run_one_shot(:analyze_image = operation, payload, opts, formatter) do
runtime = Keyword.get(opts, :runtime, OpenAICompatibleRuntime)
with {:ok, endpoint, model, mode} <- Runtime.resolve_target(operation, opts),
:ok <- Runtime.validate_target(operation, model, mode),
request <- build_one_shot_request(operation, payload, model),
{:ok, payload} <- resolve_image_data_url(payload),
request <- build_one_shot_request(operation, payload, model, opts),
{:ok, response} <-
runtime.generate(Runtime.endpoint_with_model(endpoint, model), request, opts),
{:ok, json} <- extract_json_response(response),
@@ -177,55 +182,100 @@ defmodule BDS.AI.OneShot do
end
end
defp build_one_shot_request(operation, payload, model) do
defp run_one_shot(operation, payload, opts, formatter) do
runtime = Keyword.get(opts, :runtime, OpenAICompatibleRuntime)
with {:ok, endpoint, model, mode} <- Runtime.resolve_target(operation, opts),
:ok <- Runtime.validate_target(operation, model, mode),
request <- build_one_shot_request(operation, payload, model, opts),
{:ok, response} <-
runtime.generate(Runtime.endpoint_with_model(endpoint, model), request, opts),
{:ok, json} <- extract_json_response(response),
usage <- Chat.normalize_usage(response.usage),
{:ok, result} <- formatter.(json, usage) do
{:ok, result}
end
end
defp build_one_shot_request(operation, payload, model, opts) do
language = Keyword.get(opts, :language)
source_language =
case Keyword.get(opts, :source_language) || Map.get(payload, :language) do
value when value in [nil, ""] -> nil
value -> value
end
%{
operation: operation,
model: model,
max_output_tokens: @default_max_output_tokens,
messages: [
%{"role" => "system", "content" => one_shot_system_prompt(operation)},
%{"role" => "user", "content" => one_shot_user_content(operation, payload)}
%{
"role" => "system",
"content" => one_shot_system_prompt(operation, language, source_language)
},
%{
"role" => "user",
"content" => one_shot_user_content(operation, payload, language, source_language)
}
]
}
end
defp one_shot_system_prompt(:detect_language) do
defp one_shot_system_prompt(:detect_language, _language, _source_language) do
"Return JSON with exactly one key: language_code."
end
defp one_shot_system_prompt(:analyze_taxonomy) do
defp one_shot_system_prompt(:analyze_taxonomy, _language, _source_language) do
"Return JSON with keys tags and categories, each an array of short strings."
end
defp one_shot_system_prompt(:import_taxonomy_mapping) do
defp one_shot_system_prompt(:import_taxonomy_mapping, _language, _source_language) do
"You are helping import WordPress taxonomy into an existing blog. Return JSON with exactly two keys: categoryMappings and tagMappings. Each value must be an object mapping imported term names to existing project term names. Only map when the imported term should reuse an existing term to avoid duplicates. Do not invent target terms. Leave unmapped items out of the objects."
end
defp one_shot_system_prompt(:analyze_post) do
"Return JSON with keys title, excerpt, and slug."
defp one_shot_system_prompt(:analyze_post, nil, _source_language) do
"Return JSON with keys title, excerpt, and slug. Each value must be a single string (not an array or object)."
end
defp one_shot_system_prompt(:translate_post) do
defp one_shot_system_prompt(:analyze_post, language, _source_language) do
"Return JSON with keys title, excerpt, and slug. Each value must be a single string (not an array or object). Respond in #{language_name(language)}."
end
defp one_shot_system_prompt(:translate_post, _language, nil) do
"Return JSON with keys title, excerpt, and content. Preserve Markdown structure."
end
defp one_shot_system_prompt(:analyze_image) do
defp one_shot_system_prompt(:translate_post, _language, source_language) do
"Return JSON with keys title, excerpt, and content. Preserve Markdown structure. Translate from #{language_name(source_language)} to the requested language."
end
defp one_shot_system_prompt(:analyze_image, nil, _source_language) do
"Return JSON with keys title, alt, and caption for the provided image."
end
defp one_shot_system_prompt(:translate_media) do
defp one_shot_system_prompt(:analyze_image, language, _source_language) do
"Return JSON with keys title, alt, and caption for the provided image. Respond in #{language_name(language)}."
end
defp one_shot_system_prompt(:translate_media, _language, nil) do
"Return JSON with keys title, alt, and caption translated to the requested language."
end
defp one_shot_user_content(:detect_language, %{text: text}) do
defp one_shot_system_prompt(:translate_media, _language, source_language) do
"Return JSON with keys title, alt, and caption. Translate from #{language_name(source_language)} to the requested language."
end
defp one_shot_user_content(:detect_language, %{text: text}, _language, _source_language) do
"Detect the language of this text: #{text}"
end
defp one_shot_user_content(:analyze_taxonomy, post) do
defp one_shot_user_content(:analyze_taxonomy, post, _language, _source_language) do
"Suggest categories and tags for the following post.\nTitle: #{post.title}\nExcerpt: #{post.excerpt}\nContent: #{truncate_text(post.content, 2000)}"
end
defp one_shot_user_content(:import_taxonomy_mapping, payload) do
defp one_shot_user_content(:import_taxonomy_mapping, payload, _language, _source_language) do
[
"Analyze these imported taxonomy terms and suggest which ones should map to existing project terms.",
"",
@@ -246,15 +296,23 @@ defmodule BDS.AI.OneShot do
|> Enum.join("\n")
end
defp one_shot_user_content(:analyze_post, post) do
defp one_shot_user_content(:analyze_post, post, nil, _source_language) do
"Suggest an improved title, excerpt, and slug.\nTitle: #{post.title}\nExcerpt: #{post.excerpt}\nContent: #{truncate_text(post.content, 2000)}"
end
defp one_shot_user_content(:translate_post, post) do
"Translate this post to #{post.target_language}.\nTitle: #{post.title}\nExcerpt: #{post.excerpt}\nContent: #{post.content}"
defp one_shot_user_content(:analyze_post, post, language, _source_language) do
"Suggest an improved title, excerpt, and slug in #{language_name(language)}.\nTitle: #{post.title}\nExcerpt: #{post.excerpt}\nContent: #{truncate_text(post.content, 2000)}"
end
defp one_shot_user_content(:analyze_image, media) do
defp one_shot_user_content(:translate_post, post, _language, nil) do
"Translate this post to #{language_name(post.target_language)}.\nTitle: #{post.title}\nExcerpt: #{post.excerpt}\nContent: #{post.content}"
end
defp one_shot_user_content(:translate_post, post, _language, source_language) do
"Translate this post from #{language_name(source_language)} to #{language_name(post.target_language)}.\nTitle: #{post.title}\nExcerpt: #{post.excerpt}\nContent: #{post.content}"
end
defp one_shot_user_content(:analyze_image, media, nil, _source_language) do
[
%{
"type" => "text",
@@ -264,23 +322,64 @@ defmodule BDS.AI.OneShot do
]
end
defp one_shot_user_content(:translate_media, media) do
"Translate this media metadata to #{media.target_language}.\nTitle: #{media.title}\nAlt: #{media.alt}\nCaption: #{media.caption}"
defp one_shot_user_content(:analyze_image, media, language, _source_language) do
[
%{
"type" => "text",
"text" =>
"Analyze this image and return title, alt text, and caption in #{language_name(language)}."
},
%{"type" => "image_url", "image_url" => %{"url" => media.image_url}}
]
end
defp one_shot_user_content(:translate_media, media, _language, nil) do
"Translate this media metadata to #{language_name(media.target_language)}.\nTitle: #{media.title}\nAlt: #{media.alt}\nCaption: #{media.caption}"
end
defp one_shot_user_content(:translate_media, media, _language, source_language) do
"Translate this media metadata from #{language_name(source_language)} to #{language_name(media.target_language)}.\nTitle: #{media.title}\nAlt: #{media.alt}\nCaption: #{media.caption}"
end
defp language_name("de"), do: "German"
defp language_name("en"), do: "English"
defp language_name("fr"), do: "French"
defp language_name("it"), do: "Italian"
defp language_name("es"), do: "Spanish"
defp language_name(language), do: String.capitalize(to_string(language))
defp extract_json_response(%{json: json}) when is_map(json), do: {:ok, json}
defp extract_json_response(%{content: content}) when is_binary(content) do
case Jason.decode(content) do
{:ok, json} when is_map(json) -> {:ok, json}
_other -> {:error, %{kind: :invalid_json_response}}
{:ok, json} when is_map(json) ->
{:ok, json}
_other ->
Logger.error(
"AI extract_json_response failed to parse content as JSON. Content: #{String.slice(content, 0, 1000)}"
)
{:error, %{kind: :invalid_json_response, content: content}}
end
end
defp extract_json_response(_response), do: {:error, %{kind: :invalid_json_response}}
defp extract_json_response(response) do
Logger.error(
"AI extract_json_response received response with no JSON and no content: #{inspect(Map.take(response, [:content, :json, :tool_calls]))}"
)
{:error, %{kind: :invalid_json_response}}
end
defp normalize_post_input(%Post{} = post) do
{:ok, %{title: post.title || "", excerpt: post.excerpt || "", content: post.content || ""}}
{:ok,
%{
title: post.title || "",
excerpt: post.excerpt || "",
content: Posts.editor_body(post),
language: post.language || ""
}}
end
defp normalize_post_input(post_id) when is_binary(post_id) do
@@ -295,7 +394,8 @@ defmodule BDS.AI.OneShot do
%{
title: MapUtils.attr(attrs, :title) || "",
excerpt: MapUtils.attr(attrs, :excerpt) || "",
content: MapUtils.attr(attrs, :content) || ""
content: MapUtils.attr(attrs, :content) || "",
language: MapUtils.attr(attrs, :language) || ""
}}
end
@@ -306,7 +406,10 @@ defmodule BDS.AI.OneShot do
title: media.title || "",
alt: media.alt || "",
caption: media.caption || "",
image_url: Map.get(media, :image_url) || media_path_to_file_url(media.file_path)
image_url: Map.get(media, :image_url),
file_path: media.file_path,
project_id: media.project_id,
language: media.language || ""
}}
end
@@ -324,15 +427,80 @@ defmodule BDS.AI.OneShot do
title: MapUtils.attr(attrs, :title) || "",
alt: MapUtils.attr(attrs, :alt) || "",
caption: MapUtils.attr(attrs, :caption) || "",
image_url: MapUtils.attr(attrs, :image_url)
image_url: MapUtils.attr(attrs, :image_url),
file_path: MapUtils.attr(attrs, :file_path),
project_id: MapUtils.attr(attrs, :project_id),
language: MapUtils.attr(attrs, :language) || ""
}}
end
defp ensure_image_media(%{mime_type: "image/" <> _rest}), do: :ok
defp ensure_image_media(_media), do: {:error, %{kind: :invalid_media_type}}
defp media_path_to_file_url(nil), do: nil
defp media_path_to_file_url(path), do: "file://" <> path
defp resolve_image_data_url(%{image_url: "data:" <> _} = media) do
Logger.debug("AI analyze_image using existing data URL")
{:ok, media}
end
defp resolve_image_data_url(%{image_url: "http" <> _} = media) do
Logger.debug("AI analyze_image using HTTP URL: #{media.image_url}")
{:ok, media}
end
defp resolve_image_data_url(%{image_url: "file://" <> path, mime_type: mime_type} = media) do
with {:ok, binary} <- File.read(path) do
data_url = "data:#{mime_type};base64," <> Base.encode64(binary)
Logger.debug(
"AI analyze_image converted file://#{path} to data URL (#{byte_size(data_url)} chars)"
)
{:ok, %{media | image_url: data_url}}
else
{:error, reason} ->
Logger.error("AI analyze_image failed to read file://#{path}: #{inspect(reason)}")
{:error, :file_not_found}
end
end
defp resolve_image_data_url(
%{file_path: file_path, project_id: project_id, mime_type: mime_type} = media
)
when is_binary(file_path) and is_binary(project_id) do
case Projects.get_project(project_id) do
nil ->
Logger.error("AI analyze_image project not found: #{project_id}")
{:error, :file_not_found}
project ->
absolute_path = Path.join(Projects.project_data_dir(project), file_path)
case File.read(absolute_path) do
{:ok, binary} ->
data_url = "data:#{mime_type};base64," <> Base.encode64(binary)
Logger.debug(
"AI analyze_image converted #{absolute_path} to data URL (#{byte_size(data_url)} chars)"
)
{:ok, %{media | image_url: data_url}}
{:error, reason} ->
Logger.error("AI analyze_image failed to read #{absolute_path}: #{inspect(reason)}")
{:error, :file_not_found}
end
end
end
defp resolve_image_data_url(%{image_url: url} = media) when is_binary(url) and url != "" do
Logger.debug("AI analyze_image using URL: #{url}")
{:ok, media}
end
defp resolve_image_data_url(_media) do
Logger.error("AI analyze_image missing image source (no file_path, project_id, or image_url)")
{:error, :missing_image_source}
end
defp normalize_string_list(values) do
values

View File

@@ -1,6 +1,8 @@
defmodule BDS.AI.OpenAICompatibleRuntime do
@moduledoc false
require Logger
alias BDS.AI.HttpClient
def list_models(endpoint, opts \\ []) when is_map(endpoint) and is_list(opts) do
@@ -36,14 +38,46 @@ defmodule BDS.AI.OpenAICompatibleRuntime do
"messages" => request.messages,
"max_tokens" => request.max_output_tokens
}
|> maybe_put_tools(request.tools)
|> maybe_disable_thinking(request.model)
|> maybe_put_tools(Map.get(request, :tools, []))
with {:ok, response} <- HttpClient.post(url, headers, Jason.encode!(payload)),
200 <- response.status do
normalize_response(response.body)
else
status when is_integer(status) -> {:error, %{kind: :http_error, status: status}}
{:error, reason} -> {:error, %{kind: :http_error, reason: reason}}
payload_json = Jason.encode!(payload)
Logger.debug(
"AI OpenAI-compatible request operation=#{inspect(Map.get(request, :operation))} model=#{inspect(request.model)} url=#{url} tools=#{payload |> Map.get("tools", []) |> length()} payload_size=#{byte_size(payload_json)}"
)
case HttpClient.post(url, headers, payload_json) do
{:ok, %{status: 200, body: body}} ->
result = normalize_response(body)
case result do
{:ok, %{json: nil, content: content}} when is_binary(content) ->
Logger.debug(
"AI OpenAI-compatible response parsed but content is not valid JSON. Content: #{String.slice(content, 0, 500)}"
)
{:ok, _} ->
:ok
{:error, reason} ->
Logger.error(
"AI OpenAI-compatible response normalization failed: #{inspect(reason)} body=#{String.slice(body, 0, 1000)}"
)
end
result
{:ok, %{status: status, body: body}} ->
Logger.error(
"AI OpenAI-compatible HTTP error status=#{status} body=#{String.slice(body, 0, 2000)}"
)
{:error, %{kind: :http_error, status: status, body: body}}
{:error, reason} ->
Logger.error("AI OpenAI-compatible HTTP request failed: #{inspect(reason)}")
{:error, %{kind: :http_error, reason: reason}}
end
end
@@ -136,6 +170,18 @@ defmodule BDS.AI.OpenAICompatibleRuntime do
|> Map.put("tool_choice", "auto")
end
defp maybe_disable_thinking(payload, model) when is_binary(model) do
if BDS.AI.Catalog.model_capabilities(model).disables_reasoning do
Map.update(payload, "chat_template_kwargs", %{"enable_thinking" => false}, fn kwargs ->
Map.put(kwargs || %{}, "enable_thinking", false)
end)
else
payload
end
end
defp maybe_disable_thinking(payload, _model), do: payload
defp normalize_tool_calls(tool_calls) do
Enum.map(tool_calls, fn tool_call ->
%{

View File

@@ -32,10 +32,18 @@ defmodule BDS.AI.Runtime do
@spec validate_target(atom(), String.t(), :airplane | :online) :: :ok | {:error, term()}
def validate_target(:analyze_image, model, _mode) do
if Catalog.model_capabilities(model).supports_attachment do
capabilities = Catalog.model_capabilities(model)
cond do
capabilities.supports_attachment ->
:ok
capabilities.supports_attachment == false ->
{:error,
%{kind: :model_capability_missing, capability: :supports_attachment, model: model}}
true ->
:ok
else
{:error, %{kind: :model_capability_missing, capability: :supports_attachment, model: model}}
end
end

View File

@@ -34,6 +34,7 @@ defmodule BDS.Application do
BDS.Preview,
BDS.Publishing,
{Task.Supervisor, name: BDS.Tasks.TaskSupervisor},
{Task.Supervisor, name: BDS.TCP.TaskSupervisor},
BDS.Scripting.JobStore,
{Task.Supervisor, name: BDS.Scripting.TaskSupervisor},
BDS.Scripting.JobSupervisor

View File

@@ -1,7 +1,11 @@
defmodule BDS.BoundedAtoms do
@moduledoc false
@moduledoc """
Safe conversion of dynamic values to atoms from pre-defined allow-lists,
preventing atom table exhaustion from untrusted input.
"""
alias BDS.UI.Registry
alias BDS.UI.MenuBar
@panel_tabs [:tasks, :output, :post_links, :git_log]
@post_statuses [:draft, :published, :archived]
@@ -37,11 +41,32 @@ defmodule BDS.BoundedAtoms do
:view_posts,
:view_media,
:edit_preferences,
:open_in_browser,
:open_data_folder,
:preview_post,
:edit_menu,
:rebuild_database,
:reindex_text,
:rebuild_embedding_index,
:metadata_diff,
:regenerate_calendar,
:validate_translations,
:fill_missing_translations,
:find_duplicates,
:generate_sitemap,
:validate_site,
:upload_site,
:documentation,
:api_documentation,
:close_tab
]
@menu_actions MenuBar.default_groups(dev_mode?: true)
|> Enum.flat_map(fn group ->
Enum.flat_map(group.items, fn
%{separator: true} -> []
%{id: id} -> [id]
end)
end)
def atom(value, allowed, fallback \\ nil)
@@ -70,6 +95,7 @@ defmodule BDS.BoundedAtoms do
def ai_endpoint(value, fallback \\ nil), do: atom(value, @ai_endpoints, fallback)
def mcp_agent(value, fallback \\ nil), do: atom(value, @mcp_agents, fallback)
def shell_command(value, fallback \\ nil), do: atom(value, @shell_commands, fallback)
def menu_action(value, fallback \\ nil), do: atom(value, @menu_actions, fallback)
defp string_atom(value, allowed, fallback) do
Enum.find(allowed, fallback, &(Atom.to_string(&1) == value))

View File

@@ -375,13 +375,7 @@ defmodule BDS.Desktop.Automation do
defp normalize_simple_reply("ok"), do: :ok
defp normalize_simple_reply(reply), do: reply
defp atomize_map(map) when is_map(map) do
Enum.into(map, %{}, fn {key, value} ->
normalized_key = if is_binary(key), do: String.to_atom(key), else: key
normalized_value = if is_map(value), do: atomize_map(value), else: value
{normalized_key, normalized_value}
end)
end
defp atomize_map(map) when is_map(map), do: BDS.MapUtils.safe_atomize_keys(map)
defp project_root do
Path.expand("../../..", __DIR__)

View File

@@ -16,20 +16,14 @@ defmodule BDS.Desktop.Endpoint do
plug(Plug.Static,
at: "/assets",
from: {:bds, "priv/ui"},
only: ["app.css", "live.js", "monaco"]
from: {:bds, "priv/static/assets"},
only: ["app.css", "app.js"]
)
plug(Plug.Static,
at: "/vendor/phoenix",
from: {:phoenix, "priv/static"},
only: ["phoenix.min.js"]
)
plug(Plug.Static,
at: "/vendor/live_view",
from: {:phoenix_live_view, "priv/static"},
only: ["phoenix_live_view.min.js"]
at: "/monaco",
from: {:bds, "priv/ui/monaco"},
only: ["vs"]
)
plug(BDS.Desktop.Router)

View File

@@ -0,0 +1,12 @@
defmodule BDS.Desktop.ExternalLinks do
@moduledoc false
@github_url "https://github.com/rfc1437/bDS2"
@github_issues_url "#{@github_url}/issues"
@spec github_url() :: String.t()
def github_url, do: @github_url
@spec github_issues_url() :: String.t()
def github_issues_url, do: @github_issues_url
end

View File

@@ -2,11 +2,15 @@ defmodule BDS.Desktop.FilePicker do
@moduledoc false
def choose_file(prompt) when is_binary(prompt) do
if System.get_env("BDS_DESKTOP_AUTOMATION") == "1" do
:cancel
else
case :os.type() do
{:unix, :darwin} -> choose_file_macos(prompt)
_other -> {:error, %{message: "File selection is only supported on macOS desktop"}}
end
end
end
defp choose_file_macos(prompt) do
script = "POSIX path of (choose file with prompt \"#{escape_applescript(prompt)}\")"

View File

@@ -16,9 +16,7 @@ defmodule BDS.Desktop.Layouts do
</head>
<body>
<%= @inner_content %>
<script defer phx-track-static src="/vendor/phoenix/phoenix.min.js"></script>
<script defer phx-track-static src="/vendor/live_view/phoenix_live_view.min.js"></script>
<script defer phx-track-static src="/assets/live.js"></script>
<script defer phx-track-static src="/assets/app.js"></script>
</body>
</html>
"""

View File

@@ -6,16 +6,24 @@ defmodule BDS.Desktop.MainWindow do
alias Desktop.Window
@window_id __MODULE__
@server_name BDS.Desktop.MainWindow.Watcher
@persist_interval_ms 1_000
@default_size {1280, 780}
@default_min_size {800, 600}
@state_file "window-state.json"
def start_link(_opts) do
GenServer.start_link(__MODULE__, :ok)
GenServer.start_link(__MODULE__, :ok, name: @server_name)
end
def window_id, do: @window_id
def server_name, do: @server_name
def persist_now(timeout \\ 100) do
GenServer.call(@server_name, :persist_bounds_now, timeout)
catch
:exit, _reason -> :ok
end
def window_options(extra_opts \\ []) do
desktop_config = Application.get_env(:bds, :desktop, [])
@@ -71,6 +79,7 @@ defmodule BDS.Desktop.MainWindow do
frame ->
apply_restored_bounds(frame)
BDS.Desktop.Shutdown.install_handlers(frame)
schedule_persist()
{:noreply,
@@ -90,8 +99,13 @@ defmodule BDS.Desktop.MainWindow do
end
@impl true
def terminate(_reason, %{frame: frame, last_bounds: last_bounds}) do
if bounds = current_bounds(frame) || last_bounds do
def handle_call(:persist_bounds_now, _from, state) do
{:reply, :ok, persist_current_bounds(state)}
end
@impl true
def terminate(_reason, %{last_bounds: last_bounds}) do
if bounds = last_bounds do
_ = persist_bounds(bounds)
end
@@ -102,6 +116,16 @@ defmodule BDS.Desktop.MainWindow do
Process.send_after(self(), :persist_bounds, @persist_interval_ms)
end
defp persist_current_bounds(%{frame: frame} = state) do
next_bounds = current_bounds(frame) || state.last_bounds
if next_bounds do
_ = persist_bounds(next_bounds)
end
%{state | last_bounds: next_bounds}
end
defp apply_restored_bounds(frame) do
case restore_bounds() do
%{x: x, y: y, width: width, height: height} ->
@@ -126,6 +150,7 @@ defmodule BDS.Desktop.MainWindow do
defp current_bounds(nil), do: nil
defp current_bounds(frame) do
try do
with_wx_env(fn ->
cond do
not :wxWindow.isShown(frame) ->
@@ -143,6 +168,12 @@ defmodule BDS.Desktop.MainWindow do
%{x: x, y: y, width: width, height: height}
end
end)
rescue
ErlangError -> nil
FunctionClauseError -> nil
catch
:exit, _reason -> nil
end
end
defp with_wx_env(fun) do

View File

@@ -2,6 +2,7 @@ defmodule BDS.Desktop.Menu do
@moduledoc false
use BDS.Desktop.MenuCompat
alias BDS.Desktop.Shutdown
alias Desktop.Window
@impl true
@@ -27,7 +28,7 @@ defmodule BDS.Desktop.Menu do
end
def handle_event("quit", menu) do
Window.quit()
Shutdown.request_quit()
{:noreply, menu}
end

View File

@@ -2,10 +2,12 @@ defmodule BDS.Desktop.MenuBar do
@moduledoc false
use BDS.Desktop.MenuCompat
alias BDS.Desktop.{ExternalLinks, ShellData, Shutdown, UILocale}
alias BDS.UI.Commands
alias BDS.UI.MenuBar, as: ShellMenuBar
alias Desktop.OS
alias Desktop.Window
use Gettext, backend: BDS.Gettext
def groups(opts \\ []) do
opts
@@ -21,6 +23,8 @@ defmodule BDS.Desktop.MenuBar do
@impl true
def mount(menu) do
UILocale.put(ShellData.ui_language())
{:ok,
Desktop.Menu.assign(
menu,
@@ -50,12 +54,12 @@ defmodule BDS.Desktop.MenuBar do
@impl true
def handle_event("quit", menu) do
Window.quit()
Shutdown.request_quit()
{:noreply, menu}
end
def handle_event("view_on_github", menu) do
OS.launch_default_browser("https://github.com/rfc1437/bDS")
OS.launch_default_browser(ExternalLinks.github_url())
{:noreply, menu}
end
@@ -74,7 +78,7 @@ defmodule BDS.Desktop.MenuBar do
end
def handle_event("report_issue", menu) do
OS.launch_default_browser("https://github.com/rfc1437/bDS/issues")
OS.launch_default_browser(ExternalLinks.github_issues_url())
{:noreply, menu}
end
@@ -84,6 +88,17 @@ defmodule BDS.Desktop.MenuBar do
end
@impl true
def handle_info({:set_ui_locale, locale}, menu) do
UILocale.put(locale)
{:noreply,
Desktop.Menu.assign(
menu,
:groups,
groups(dev_mode?: Application.get_env(:bds, :dev_routes, false))
)}
end
def handle_info(_, menu) do
{:noreply, menu}
end
@@ -126,58 +141,58 @@ defmodule BDS.Desktop.MenuBar do
defp native_label(label, nil), do: label
defp native_label(label, shortcut), do: label <> "\t" <> shortcut
defp group_label(:file), do: "File"
defp group_label(:edit), do: "Edit"
defp group_label(:view), do: "View"
defp group_label(:blog), do: "Blog"
defp group_label(:help), do: "Help"
defp group_label(:file), do: dgettext("ui", "File")
defp group_label(:edit), do: dgettext("ui", "Edit")
defp group_label(:view), do: dgettext("ui", "View")
defp group_label(:blog), do: dgettext("ui", "Blog")
defp group_label(:help), do: dgettext("ui", "Help")
defp item_label(:new_post), do: "New Post"
defp item_label(:import_media), do: "Import Media"
defp item_label(:save), do: "Save"
defp item_label(:open_in_browser), do: "Open in Browser"
defp item_label(:open_data_folder), do: "Open Data Folder"
defp item_label(:close_tab), do: "Close Tab"
defp item_label(:quit), do: "Quit"
defp item_label(:undo), do: "Undo"
defp item_label(:redo), do: "Redo"
defp item_label(:cut), do: "Cut"
defp item_label(:copy), do: "Copy"
defp item_label(:paste), do: "Paste"
defp item_label(:delete), do: "Delete"
defp item_label(:select_all), do: "Select All"
defp item_label(:find), do: "Find"
defp item_label(:replace), do: "Replace"
defp item_label(:edit_preferences), do: "Preferences"
defp item_label(:view_posts), do: "Posts"
defp item_label(:view_media), do: "Media"
defp item_label(:toggle_sidebar), do: "Toggle Sidebar"
defp item_label(:toggle_panel), do: "Toggle Panel"
defp item_label(:toggle_assistant_sidebar), do: "Toggle Assistant Sidebar"
defp item_label(:toggle_dev_tools), do: "Toggle Dev Tools"
defp item_label(:reload), do: "Reload"
defp item_label(:force_reload), do: "Force Reload"
defp item_label(:reset_zoom), do: "Reset Zoom"
defp item_label(:zoom_in), do: "Zoom In"
defp item_label(:zoom_out), do: "Zoom Out"
defp item_label(:toggle_full_screen), do: "Toggle Full Screen"
defp item_label(:publish_selected), do: "Publish Selected"
defp item_label(:preview_post), do: "Preview Post"
defp item_label(:edit_menu), do: "Edit Menu"
defp item_label(:rebuild_database), do: "Rebuild Database"
defp item_label(:reindex_text), do: "Reindex Text"
defp item_label(:rebuild_embedding_index), do: "Rebuild Embedding Index"
defp item_label(:metadata_diff), do: "Metadata Diff"
defp item_label(:regenerate_calendar), do: "Regenerate Calendar"
defp item_label(:validate_translations), do: "Validate Translations"
defp item_label(:fill_missing_translations), do: "Fill Missing Translations"
defp item_label(:find_duplicates), do: "Find Duplicate Posts"
defp item_label(:generate_sitemap), do: "Generate Site"
defp item_label(:validate_site), do: "Validate Site"
defp item_label(:upload_site), do: "Upload Site"
defp item_label(:about), do: "About"
defp item_label(:documentation), do: "Documentation"
defp item_label(:api_documentation), do: "API Documentation"
defp item_label(:view_on_github), do: "View on GitHub"
defp item_label(:report_issue), do: "Report Issue"
defp item_label(:new_post), do: dgettext("ui", "New Post")
defp item_label(:import_media), do: dgettext("ui", "Import Media")
defp item_label(:save), do: dgettext("ui", "Save")
defp item_label(:open_in_browser), do: dgettext("ui", "Open in Browser")
defp item_label(:open_data_folder), do: dgettext("ui", "Open Data Folder")
defp item_label(:close_tab), do: dgettext("ui", "Close Tab")
defp item_label(:quit), do: dgettext("ui", "Quit")
defp item_label(:undo), do: dgettext("ui", "Undo")
defp item_label(:redo), do: dgettext("ui", "Redo")
defp item_label(:cut), do: dgettext("ui", "Cut")
defp item_label(:copy), do: dgettext("ui", "Copy")
defp item_label(:paste), do: dgettext("ui", "Paste")
defp item_label(:delete), do: dgettext("ui", "Delete")
defp item_label(:select_all), do: dgettext("ui", "Select All")
defp item_label(:find), do: dgettext("ui", "Find")
defp item_label(:replace), do: dgettext("ui", "Replace")
defp item_label(:edit_preferences), do: dgettext("ui", "Preferences")
defp item_label(:view_posts), do: dgettext("ui", "Posts")
defp item_label(:view_media), do: dgettext("ui", "Media")
defp item_label(:toggle_sidebar), do: dgettext("ui", "Toggle Sidebar")
defp item_label(:toggle_panel), do: dgettext("ui", "Toggle Panel")
defp item_label(:toggle_assistant_sidebar), do: dgettext("ui", "Toggle Assistant Sidebar")
defp item_label(:toggle_dev_tools), do: dgettext("ui", "Toggle Dev Tools")
defp item_label(:reload), do: dgettext("ui", "Reload")
defp item_label(:force_reload), do: dgettext("ui", "Force Reload")
defp item_label(:reset_zoom), do: dgettext("ui", "Reset Zoom")
defp item_label(:zoom_in), do: dgettext("ui", "Zoom In")
defp item_label(:zoom_out), do: dgettext("ui", "Zoom Out")
defp item_label(:toggle_full_screen), do: dgettext("ui", "Toggle Full Screen")
defp item_label(:publish_selected), do: dgettext("ui", "Publish Selected")
defp item_label(:preview_post), do: dgettext("ui", "Preview Post")
defp item_label(:edit_menu), do: dgettext("ui", "Edit Menu")
defp item_label(:rebuild_database), do: dgettext("ui", "Rebuild Database")
defp item_label(:reindex_text), do: dgettext("ui", "Reindex Text")
defp item_label(:rebuild_embedding_index), do: dgettext("ui", "Rebuild Embedding Index")
defp item_label(:metadata_diff), do: dgettext("ui", "Metadata Diff")
defp item_label(:regenerate_calendar), do: dgettext("ui", "Regenerate Calendar")
defp item_label(:validate_translations), do: dgettext("ui", "Validate Translations")
defp item_label(:fill_missing_translations), do: dgettext("ui", "Fill Missing Translations")
defp item_label(:find_duplicates), do: dgettext("ui", "Find Duplicate Posts")
defp item_label(:generate_sitemap), do: dgettext("ui", "Generate Site")
defp item_label(:validate_site), do: dgettext("ui", "Validate Site")
defp item_label(:upload_site), do: dgettext("ui", "Upload Site")
defp item_label(:about), do: dgettext("ui", "About")
defp item_label(:documentation), do: dgettext("ui", "Documentation")
defp item_label(:api_documentation), do: dgettext("ui", "API Documentation")
defp item_label(:view_on_github), do: dgettext("ui", "View on GitHub")
defp item_label(:report_issue), do: dgettext("ui", "Report Issue")
end

View File

@@ -48,29 +48,32 @@ defmodule BDS.Desktop.Overlay do
end
def open(:media, :confirm_delete, context) do
delete_details = Map.get(context, :delete_details, %{})
%{
title: title,
entity_name: entity_name,
entity_type: entity_type,
reference_list: reference_list
} = context.delete_details
%{
kind: :confirm_delete,
title: Map.get(delete_details, :title, "Delete"),
entity_name: Map.get(delete_details, :entity_name, ""),
entity_type: Map.get(delete_details, :entity_type, "media"),
reference_count: length(Map.get(delete_details, :reference_list, [])),
reference_list: Map.get(delete_details, :reference_list, [])
title: title,
entity_name: entity_name,
entity_type: entity_type,
reference_count: length(reference_list),
reference_list: reference_list
}
end
def open(:tags, :confirm_delete, context), do: open(:media, :confirm_delete, context)
def open(:tags, :confirm_merge, context) do
merge = Map.get(context, :merge_details, %{})
target = Map.get(merge, :target, "")
count = Map.get(merge, :count, 0)
%{title: title, message: message} = context.merge_details
%{
kind: :confirm_dialog,
title: Map.get(merge, :title, "Merge #{count} tags into #{target}?"),
message: Map.get(merge, :message, "Cannot be undone.")
title: title,
message: message
}
end
@@ -115,8 +118,8 @@ defmodule BDS.Desktop.Overlay do
|> Map.get(:all_media, [])
|> Enum.filter(fn media ->
normalized == "" or
search_matches?(Map.get(media, :title, ""), normalized) or
search_matches?(Map.get(media, :original_name, ""), normalized)
search_matches?(media.title, normalized) or
search_matches?(media.original_name, normalized)
end)
|> Enum.map(&to_insert_media_result/1)
@@ -203,18 +206,22 @@ defmodule BDS.Desktop.Overlay do
def insert_media_result(_overlay, _media_id), do: nil
defp language_picker(context, source_language) do
existing_translations = Map.get(context, :existing_translations, %{})
language_names = Map.get(context, :language_names, %{})
language_flags = Map.get(context, :language_flags, %{})
targets =
context
|> Map.get(:blog_languages, [])
|> Enum.uniq()
|> Enum.reject(&(&1 == source_language))
|> Enum.map(fn code ->
existing_status = Map.get(Map.get(context, :existing_translations, %{}), code)
existing_status = Map.get(existing_translations, code)
%{
code: code,
name: Map.get(Map.get(context, :language_names, %{}), code, String.upcase(code)),
flag_emoji: Map.get(Map.get(context, :language_flags, %{}), code, code),
name: Map.get(language_names, code, String.upcase(code)),
flag_emoji: Map.get(language_flags, code, code),
has_existing_translation: not is_nil(existing_status),
existing_status: existing_status
}
@@ -228,15 +235,43 @@ defmodule BDS.Desktop.Overlay do
}
end
def set_ai_suggestions(%{kind: :ai_suggestions} = overlay, suggestions) do
fields =
Enum.map(overlay.fields, fn field ->
case Map.get(suggestions, field.key) do
nil ->
field
value when is_binary(value) and value != "" ->
%{field | suggested_value: value, loading: false}
_other ->
field
end
end)
%{overlay | fields: fields}
end
def set_ai_suggestions(overlay, _suggestions), do: overlay
def set_ai_suggestions_error(%{kind: :ai_suggestions} = overlay, error_message) do
Map.put(overlay, :error, error_message)
end
def set_ai_suggestions_error(overlay, _error_message), do: overlay
defp normalize_ai_fields(fields) do
Enum.map(fields, fn field ->
Enum.map(fields, fn %{key: key, label: label, current_value: current,
suggested_value: suggested, locked: locked} = field ->
%{
key: to_string(Map.get(field, :key, "")),
label: Map.get(field, :label, ""),
current_value: Map.get(field, :current_value, ""),
suggested_value: Map.get(field, :suggested_value, ""),
accepted: not Map.get(field, :locked, false),
locked: Map.get(field, :locked, false)
key: to_string(key),
label: label,
current_value: current,
suggested_value: suggested,
accepted: not locked,
locked: locked,
loading: Map.get(field, :loading, false)
}
end)
end
@@ -249,7 +284,7 @@ defmodule BDS.Desktop.Overlay do
end
defp gallery_images(context) do
images = Enum.filter(Map.get(context, :media, []), &Map.get(&1, :is_image, false))
images = Enum.filter(Map.get(context, :media, []), & &1.is_image)
post_media_ids = Map.get(context, :post_media_ids, [])
case Enum.filter(images, &(&1.id in post_media_ids)) do
@@ -262,29 +297,29 @@ defmodule BDS.Desktop.Overlay do
%{
post_id: post.id,
title: post.title,
status: to_string(Map.get(post, :status, "draft")),
canonical_url: Map.get(post, :canonical_url, "/posts/#{post.id}"),
similarity_score: Map.get(post, :similarity_score)
status: post.status,
canonical_url: post.canonical_url,
similarity_score: nil
}
end
defp to_insert_media_result(media) do
%{
media_id: media.id,
title: Map.get(media, :title, ""),
original_name: Map.get(media, :original_name, media.id),
is_image: Map.get(media, :is_image, false),
thumbnail_url: Map.get(media, :thumbnail_url)
title: media.title,
original_name: media.original_name,
is_image: media.is_image,
thumbnail_url: media.thumbnail_url
}
end
defp to_gallery_image(media) do
%{
media_id: media.id,
thumbnail_url: Map.get(media, :thumbnail_url),
image_url: Map.get(media, :image_url, Map.get(media, :thumbnail_url)),
alt_text: Map.get(media, :alt_text),
title: Map.get(media, :title, Map.get(media, :original_name, media.id))
thumbnail_url: media.thumbnail_url,
image_url: media.image_url,
alt_text: media.alt_text,
title: media.title
}
end

View File

@@ -279,6 +279,19 @@ defmodule BDS.Desktop.ShellCommands do
end)
end
defp dispatch("regenerate_calendar", project, _params) do
queue_task(project, "regenerate_calendar", "Regenerate Calendar", "Generation", fn report ->
{:ok, generation} = Generation.generate_site(project.id, [:core], on_progress: report)
report.(1.0, "Calendar regenerated")
%{
project_id: project.id,
sections: generation.sections,
generated_count: length(generation.generated_files)
}
end)
end
defp dispatch("repair_metadata_diff", project, params) do
items = normalize_metadata_diff_items(BDS.MapUtils.attr(params, :items, []))
direction = BDS.MapUtils.attr(params, :direction)
@@ -352,6 +365,33 @@ defmodule BDS.Desktop.ShellCommands do
)
end
defp dispatch("fill_missing_translations", project, _params) do
with {:ok, metadata} <- Metadata.get_project_metadata(project.id) do
if translation_fill_enabled?(metadata) do
queue_task(
project,
"fill_missing_translations",
"Fill Missing Translations",
"AI",
fn report ->
{:ok, result} = Posts.fill_missing_translations(project.id, on_progress: report)
Map.put(result, :project_id, project.id)
end
)
else
{:ok,
%{
kind: "output",
action: "fill_missing_translations",
title: "Fill Missing Translations",
message: "All translations are up to date",
project_id: project.id,
level: "info"
}}
end
end
end
defp dispatch("find_duplicates", project, _params) do
queue_task(project, "find_duplicates", "Find Duplicate Posts", "Embeddings", fn report ->
{:ok, pairs} = Embeddings.find_duplicates(project.id, on_progress: report)
@@ -408,6 +448,19 @@ defmodule BDS.Desktop.ShellCommands do
end
end
defp translation_fill_enabled?(metadata) do
([metadata.main_language] ++ metadata.blog_languages)
|> Enum.map(fn language ->
language
|> to_string()
|> String.trim()
|> String.downcase()
end)
|> Enum.reject(&(&1 == ""))
|> Enum.uniq()
|> length() > 1
end
defp rebuild_database_steps(project) do
[
%{

View File

@@ -1,9 +1,12 @@
defmodule BDS.Desktop.ShellData do
@moduledoc false
use Gettext, backend: BDS.Gettext
alias BDS.Git
alias BDS.I18n
alias BDS.Projects
alias BDS.Repo
alias BDS.UI.Dashboard
alias BDS.UI.Sidebar
alias BDS.UI.Workbench
@@ -12,145 +15,6 @@ defmodule BDS.Desktop.ShellData do
Application.get_env(:bds, :desktop)[:title] || "Blogging Desktop Server"
end
def ui_language do
I18n.current_ui_locale()
end
def translations(locale \\ nil) do
I18n.get_ui_translations(effective_ui_language(locale))
end
def supported_ui_languages do
Enum.map(I18n.supported_languages(), fn language ->
%{code: language.code, flag: I18n.flag(language.code)}
end)
end
def translate(key, bindings \\ %{}, locale \\ nil) do
text = Map.get(translations(locale), to_string(key), to_string(key))
Enum.reduce(bindings, text, fn {binding, value}, acc ->
String.replace(acc, "%{#{binding}}", to_string(value))
end)
end
def project_snapshot do
Projects.shell_snapshot()
rescue
error in [Exqlite.Error, DBConnection.OwnershipError] ->
if match?(%Exqlite.Error{}, error) and
not String.contains?(Exception.message(error), "no such table: projects") do
reraise error, __STACKTRACE__
end
default_project_snapshot()
end
def current_project(projects_snapshot) do
Enum.find(projects_snapshot.projects, &(&1.id == projects_snapshot.active_project_id)) ||
List.first(projects_snapshot.projects)
end
def dashboard(project_id) do
Dashboard.snapshot(project_id)
rescue
error in [Exqlite.Error, DBConnection.OwnershipError] ->
if match?(%Exqlite.Error{}, error) and
not String.contains?(Exception.message(error), "no such table") do
reraise error, __STACKTRACE__
end
Dashboard.empty_snapshot()
end
def sidebar_view(project_id, view_id, params \\ %{}) do
Sidebar.view(project_id, view_id, params)
rescue
error in [Exqlite.Error, DBConnection.OwnershipError] ->
if match?(%Exqlite.Error{}, error) and
not String.contains?(Exception.message(error), "no such table") do
reraise error, __STACKTRACE__
end
Sidebar.view(nil, view_id, params)
end
def assistant_cards do
[
%{label: "Offline Gate", text: "Automatic AI actions stay gated by airplane mode."},
%{
label: "Filesystem Sync",
text: "Metadata flush, diffing, and rebuild hooks still need editor wiring."
},
%{label: "Desktop Runtime", text: "The app window is now served from LiveView state."}
]
end
def editor_meta(task_status) do
[
%{label: "Status", value: task_status.running_task_message || "Idle"},
%{label: "Mode", value: "Offline"},
%{label: "Main Language", value: ui_language()}
]
end
def status_bar(workbench, task_status, dashboard, opts \\ []) do
Workbench.status_bar(workbench,
post_count: dashboard.post_stats.total_posts,
media_count: dashboard.media_stats.media_count,
theme_badge: "desktop-shell",
ui_language: Keyword.get(opts, :ui_language, ui_language()),
offline_mode: Keyword.get(opts, :offline_mode, true),
running_task_message: task_status.running_task_message,
running_task_overflow: task_status.running_task_overflow,
active_post_status: nil
)
end
def git_badge_count(project_id, opts \\ [])
def git_badge_count(nil, _opts), do: 0
def git_badge_count("default", _opts), do: 0
def git_badge_count(project_id, opts) when is_binary(project_id) do
provider = Keyword.get(opts, :provider, git_remote_state_provider())
try do
case provider.(project_id, []) do
{:ok, %{behind: behind}} when is_integer(behind) and behind > 0 -> behind
{:ok, %{behind: behind}} when is_binary(behind) -> parse_positive_count(behind)
_other -> 0
end
rescue
error in [DBConnection.OwnershipError, Exqlite.Error] ->
if match?(%Exqlite.Error{}, error) and
not String.contains?(Exception.message(error), "no such table") do
reraise error, __STACKTRACE__
end
0
end
end
def panel_tabs(workbench) do
[:tasks, :output]
|> maybe_add_panel_tab(workbench.editor_route, :post_links)
|> maybe_add_panel_tab(workbench.editor_route, :git_log)
|> Kernel.++([workbench.panel.active_tab])
|> Enum.uniq()
end
defp git_remote_state_provider do
Application.get_env(:bds, :git_remote_state_provider, &Git.remote_state/2)
end
defp parse_positive_count(value) do
case Integer.parse(value) do
{count, _rest} when count > 0 -> count
_other -> 0
end
end
def activity_icon(id) do
case to_string(id) do
"posts" ->
@@ -188,22 +52,141 @@ defmodule BDS.Desktop.ShellData do
end
end
def ui_language do
I18n.current_ui_locale()
end
def supported_ui_languages do
Enum.map(I18n.supported_languages(), fn language ->
%{code: language.code, flag: I18n.flag(language.code)}
end)
end
def project_snapshot do
if Repo.ready?() do
{:ok, Projects.shell_snapshot()}
else
{:error, :not_ready}
end
end
def current_project(projects_snapshot) do
Enum.find(projects_snapshot.projects, &(&1.id == projects_snapshot.active_project_id)) ||
List.first(projects_snapshot.projects)
end
def dashboard(project_id) do
if Repo.ready?() do
{:ok, Dashboard.snapshot(project_id)}
else
{:error, :not_ready}
end
end
def sidebar_view(project_id, view_id, params \\ %{}) do
if Repo.ready?() do
{:ok, Sidebar.view(project_id, view_id, params)}
else
{:error, :not_ready}
end
end
def assistant_cards do
[
%{
label: dgettext("ui", "Offline Gate"),
text: dgettext("ui", "Automatic AI actions stay gated by airplane mode.")
},
%{
label: dgettext("ui", "Filesystem Sync"),
text:
dgettext("ui", "Metadata flush, diffing, and rebuild hooks still need editor wiring.")
},
%{
label: dgettext("ui", "Desktop Runtime"),
text: dgettext("ui", "The app window is now served from LiveView state.")
}
]
end
def editor_meta(task_status) do
[
%{
label: dgettext("ui", "Status"),
value: task_status.running_task_message || dgettext("ui", "Idle")
},
%{label: dgettext("ui", "Mode"), value: dgettext("ui", "Offline")},
%{label: dgettext("ui", "Main Language"), value: ui_language()}
]
end
def status_bar(workbench, task_status, dashboard, opts \\ []) do
Workbench.status_bar(workbench,
post_count: dashboard.post_stats.total_posts,
media_count: dashboard.media_stats.media_count,
theme_badge: "desktop-shell",
ui_language: Keyword.get(opts, :ui_language, ui_language()),
offline_mode: Keyword.get(opts, :offline_mode, true),
running_task_message: task_status.running_task_message,
running_task_overflow: task_status.running_task_overflow,
active_post_status: nil
)
end
def git_badge_count(project_id, opts \\ [])
def git_badge_count(nil, _opts), do: {:ok, 0}
def git_badge_count("default", _opts), do: {:ok, 0}
def git_badge_count(project_id, opts) when is_binary(project_id) do
if not Repo.ready?() do
{:error, :not_ready}
else
provider = Keyword.get(opts, :provider, git_remote_state_provider())
custom_provider? = provider != (&BDS.Git.remote_state/2)
has_git =
custom_provider? ||
case BDS.Projects.get_project(project_id) do
nil -> false
project -> File.dir?(Path.join(BDS.Projects.project_data_dir(project), ".git"))
end
count =
if has_git do
case provider.(project_id, []) do
{:ok, %{behind: behind}} when is_integer(behind) and behind > 0 -> behind
{:ok, %{behind: behind}} when is_binary(behind) -> parse_positive_count(behind)
_other -> 0
end
else
0
end
{:ok, count}
end
end
def panel_tabs(workbench) do
[:tasks, :output]
|> maybe_add_panel_tab(workbench.editor_route, :post_links)
|> maybe_add_panel_tab(workbench.editor_route, :git_log)
|> Kernel.++([workbench.panel.active_tab])
|> Enum.uniq()
end
def dashboard_status_label(status) do
case to_string(status) do
"draft" -> translate("dashboard.status.draft")
"published" -> translate("dashboard.status.published")
"archived" -> translate("dashboard.status.archived")
"draft" -> dgettext("ui", "Draft")
"published" -> dgettext("ui", "Published")
"archived" -> dgettext("ui", "Archived")
other -> other |> String.replace("_", " ") |> String.capitalize()
end
end
def dashboard_post_count_label(count) do
normalized_count = count || 0
key =
if normalized_count == 1, do: "dashboard.postCount.one", else: "dashboard.postCount.other"
translate(key, %{count: normalized_count})
dngettext("ui", "%{count} post", "%{count} posts", normalized_count, count: normalized_count)
end
def dashboard_tag_cloud_items(items) when is_list(items) do
@@ -258,10 +241,10 @@ defmodule BDS.Desktop.ShellData do
def route_label(route) do
case to_string(route) do
"git_log" ->
"Git Log"
dgettext("ui", "Git Log")
"post_links" ->
"Post Links"
dgettext("ui", "Post Links")
other ->
other
@@ -288,11 +271,16 @@ defmodule BDS.Desktop.ShellData do
end
end
defp effective_ui_language(nil) do
BDS.Desktop.UILocale.current() || ui_language()
defp git_remote_state_provider do
Application.get_env(:bds, :git_remote_state_provider, &Git.remote_state/2)
end
defp effective_ui_language(locale), do: locale
defp parse_positive_count(value) do
case Integer.parse(value) do
{count, _rest} when count > 0 -> count
_other -> 0
end
end
defp maybe_add_panel_tab(tabs, :post, :post_links), do: tabs ++ [:post_links]
@@ -301,7 +289,7 @@ defmodule BDS.Desktop.ShellData do
defp maybe_add_panel_tab(tabs, _route, _tab), do: tabs
defp default_project_snapshot do
def default_project_snapshot do
%{
active_project_id: "default",
projects: [

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,224 @@
defmodule BDS.Desktop.ShellLive.Bridges do
@moduledoc false
import Phoenix.Component, only: [assign: 3]
import Phoenix.LiveView, only: [connected?: 1, send_update: 2]
alias BDS.Desktop.ShellData
alias BDS.Desktop.ShellLive.{ChatEditor, PostEditor}
alias BDS.Desktop.ShellLive.{CliSync, SessionUtil}
alias BDS.UI.Workbench
@refreshable_tab_meta_types [:import, :chat]
@spec handle_info(tuple() | atom(), Phoenix.LiveView.Socket.t(), map()) ::
{:noreply, Phoenix.LiveView.Socket.t()}
# ── Generic editor notifications (sent via Notify module) ────────────────
def handle_info({:editor_output, title, message, detail, level}, socket, callbacks) do
{:noreply, callbacks.append_output.(socket, title, message, detail, level)}
end
def handle_info({:editor_tab_meta, type, id, updates}, socket, callbacks)
when is_atom(type) and is_map(updates) do
key = {type, id}
current_meta = Map.get(socket.assigns.tab_meta, key, %{})
next_meta = Map.merge(current_meta, updates)
tab_meta = Map.put(socket.assigns.tab_meta, key, next_meta)
socket = assign(socket, :tab_meta, tab_meta)
if type in @refreshable_tab_meta_types do
{:noreply, callbacks.refresh_sidebar.(socket, socket.assigns.workbench)}
else
{:noreply, socket}
end
end
def handle_info({:editor_dirty, type, id, dirty?}, socket, _callbacks) do
workbench =
if dirty? do
Workbench.mark_dirty(socket.assigns.workbench, type, id)
else
Workbench.clear_dirty(socket.assigns.workbench, type, id)
end
{:noreply, assign(socket, :workbench, workbench)}
end
def handle_info({:editor_command, action, params}, socket, callbacks) do
{:noreply, callbacks.apply_shell_command.(socket, action, params)}
end
# ── Shared actions (already generic) ─────────────────────────────────────
def handle_info({:open_sidebar_item, params, intent}, socket, callbacks) do
{:noreply, callbacks.open_sidebar.(socket, params, intent)}
end
def handle_info(:reload_shell, socket, callbacks) do
{:noreply, callbacks.reload.(socket, socket.assigns.workbench)}
end
def handle_info({:close_tab, type, id}, socket, callbacks) do
{:noreply,
callbacks.refresh_layout.(socket, Workbench.close_tab(socket.assigns.workbench, type, id))}
end
def handle_info(:tags_changed, socket, callbacks) do
{:noreply, callbacks.refresh_content.(socket, socket.assigns.workbench)}
end
def handle_info(:settings_changed, socket, callbacks) do
{:noreply, callbacks.reload.(socket, socket.assigns.workbench)}
end
# ── Chat editor messages (sent from AI streaming, not from Notify) ──────
def handle_info({:chat_tool_call, conversation_id, tool_call}, socket, _callbacks) do
send_update(ChatEditor,
id: "chat-editor-#{conversation_id}",
action: :note_tool_call,
tool_call: tool_call
)
{:noreply, socket}
end
def handle_info({:chat_tool_result, conversation_id, name}, socket, _callbacks) do
send_update(ChatEditor,
id: "chat-editor-#{conversation_id}",
action: :note_tool_result,
name: name
)
{:noreply, socket}
end
def handle_info({:chat_streaming_content, conversation_id, content}, socket, _callbacks) do
send_update(ChatEditor,
id: "chat-editor-#{conversation_id}",
action: :note_streaming_content,
content: content
)
{:noreply, socket}
end
def handle_info({:chat_editor_task_started, conversation_id, ref}, socket, _callbacks) do
refs = Map.put(socket.assigns.chat_editor_request_refs, ref, conversation_id)
{:noreply, assign(socket, :chat_editor_request_refs, refs)}
end
def handle_info({:chat_editor_task_cancelled, _conversation_id, ref}, socket, _callbacks) do
refs = Map.delete(socket.assigns.chat_editor_request_refs, ref)
{:noreply, assign(socket, :chat_editor_request_refs, refs)}
end
def handle_info({:persist_surface_state, conversation_id}, socket, _callbacks) do
send_update(ChatEditor,
id: "chat-editor-#{conversation_id}",
action: :persist_surface_state
)
{:noreply, socket}
end
def handle_info({:chat_editor_toggle_sidebar}, socket, callbacks) do
{:noreply,
callbacks.refresh_layout.(socket, Workbench.toggle_sidebar(socket.assigns.workbench))}
end
def handle_info({:chat_editor_toggle_panel}, socket, callbacks) do
{:noreply,
callbacks.refresh_layout.(socket, Workbench.toggle_panel(socket.assigns.workbench))}
end
def handle_info({:chat_editor_toggle_assistant_sidebar}, socket, callbacks) do
{:noreply,
callbacks.refresh_layout.(
socket,
Workbench.toggle_assistant_sidebar(socket.assigns.workbench)
)}
end
def handle_info({:chat_editor_switch_view, view}, socket, callbacks) do
{:noreply,
callbacks.refresh_sidebar.(socket, Workbench.click_activity(socket.assigns.workbench, view))}
end
# ── Post editor cross-component messages (sent from OverlayManager) ─────
def handle_info({:post_editor_insert_content, post_id, content}, socket, _callbacks) do
send_update(PostEditor,
id: "post-editor-#{post_id}",
action: :insert_content,
content: content
)
{:noreply, socket}
end
def handle_info({:post_editor_translate, post_id, language}, socket, _callbacks) do
send_update(PostEditor, id: "post-editor-#{post_id}", action: :translate, language: language)
{:noreply, socket}
end
def handle_info({:post_editor_apply_ai_suggestions, post_id, fields}, socket, _callbacks) do
send_update(PostEditor,
id: "post-editor-#{post_id}",
action: :apply_ai_suggestions,
fields: fields
)
{:noreply, socket}
end
# ── External system messages ─────────────────────────────────────────────
def handle_info({:entity_changed, payload}, socket, callbacks) when is_map(payload) do
{:noreply, CliSync.apply_entity_change(socket, payload, callbacks.refresh_content)}
end
def handle_info(:refresh_task_status, socket, callbacks) do
raw_task_status = BDS.Tasks.status_snapshot()
socket =
case SessionUtil.next_completed_task_result(socket, raw_task_status) do
nil ->
task_status =
BDS.Desktop.ShellLive.TaskLocalization.localize_task_status(
raw_task_status,
socket.assigns.page_language
)
socket
|> assign(:task_status, task_status)
|> assign(:editor_meta, ShellData.editor_meta(task_status))
|> assign(
:status,
ShellData.status_bar(
socket.assigns.workbench,
task_status,
socket.assigns.dashboard,
ui_language: socket.assigns.page_language,
offline_mode: socket.assigns.offline_mode
)
)
task ->
socket
|> SessionUtil.mark_task_result_handled(task.id)
|> callbacks.apply_shell_command_result.(task.result)
end
if connected?(socket) do
Process.send_after(self(), :refresh_task_status, BDS.Desktop.ShellLive.refresh_interval())
end
{:noreply, socket}
end
def handle_info(_message, socket, _callbacks), do: {:noreply, socket}
end

View File

@@ -1,273 +1,542 @@
defmodule BDS.Desktop.ShellLive.ChatEditor do
@moduledoc false
use Phoenix.Component
require Logger
use Phoenix.LiveComponent
import Phoenix.HTML, only: [raw: 1]
alias BDS.AI
alias BDS.Desktop.ShellData
alias BDS.{AI, BoundedAtoms, MapUtils, Persistence}
alias BDS.Desktop.ShellLive.ChatEditor.{MessageBuild, ModelSelection, ToolTracking}
alias BDS.Desktop.ShellLive.Notify
alias BDS.Desktop.ShellLive.TabHelpers
use Gettext, backend: BDS.Gettext
embed_templates("chat_editor_html/*")
# ── Public API: state assignment ───────────────────────────────────────────
# ── LiveComponent lifecycle ────────────────────────────────────────────────
@spec assign_socket(term()) :: term()
def assign_socket(socket) do
assign(socket, :chat_editor, MessageBuild.build(socket.assigns))
@spec update(map(), Phoenix.LiveView.Socket.t()) :: {:ok, Phoenix.LiveView.Socket.t()}
@impl true
def update(%{action: :finish_request}, %{assigns: %{request: nil}} = socket) do
{:ok, socket}
end
defdelegate build(assigns), to: MessageBuild
def update(%{action: :finish_request, result: result}, socket) do
{:ok, do_finish_request(socket, result)}
end
# ── Public API: model selection ────────────────────────────────────────────
def update(%{action: :note_tool_call, tool_call: tool_call}, socket) do
{:ok, do_note_tool_call(socket, tool_call)}
end
defdelegate toggle_model_selector(socket, reload), to: ModelSelection
defdelegate set_model(socket, model_id, reload, append_output), to: ModelSelection
def update(%{action: :note_tool_result, name: name}, socket) do
{:ok, do_note_tool_result(socket, name)}
end
# ── Public API: input + surface state ──────────────────────────────────────
def update(%{action: :note_streaming_content, content: content}, socket) do
{:ok, do_note_streaming_content(socket, content)}
end
@spec update_input(term(), term(), term()) :: term()
def update_input(socket, value, reload) do
%{id: conversation_id} = socket.assigns.current_tab
def update(%{action: :persist_surface_state}, socket) do
{:ok, persist_surface_state(socket)}
end
def update(assigns, socket) do
socket =
socket
|> assign(assigns)
|> ensure_state()
|> build_data()
{:ok, socket}
end
@spec render(map()) :: Phoenix.LiveView.Rendered.t()
@impl true
def render(%{chat_editor: nil} = assigns), do: ~H"<div></div>"
def render(assigns) do
chat_editor(assigns)
end
# ── Event handlers ─────────────────────────────────────────────────────────
@spec handle_event(String.t(), map(), Phoenix.LiveView.Socket.t()) ::
{:noreply, Phoenix.LiveView.Socket.t()}
@impl true
def handle_event("change_chat_editor_input", %{"message" => message}, socket) do
{:noreply, assign(socket, :input, to_string(message || "")) |> build_data()}
end
def handle_event("toggle_chat_model_selector", _params, socket) do
{:noreply,
assign(socket, :model_selector_open?, not socket.assigns.model_selector_open?)
|> build_data()}
end
def handle_event("select_chat_model", %{"model" => model_id}, socket) do
conversation_id = socket.assigns.conversation_id
case AI.set_conversation_model(conversation_id, model_id) do
{:ok, _conversation} ->
{:noreply, assign(socket, :model_selector_open?, false) |> build_data()}
{:error, reason} ->
Notify.output(dgettext("ui", "Chat"), inspect(reason), "error")
{:noreply, assign(socket, :model_selector_open?, false) |> build_data()}
end
end
def handle_event("send_chat_editor_message", _params, socket) do
{:noreply, do_send_message(socket)}
end
def handle_event("abort_chat_editor_message", _params, socket) do
{:noreply, do_abort_message(socket)}
end
def handle_event(
"change_chat_surface_form",
%{"surface" => %{"id" => surface_id, "fields" => fields}},
socket
) do
next_data = Map.put(socket.assigns.surface_data, surface_id, fields)
{:noreply, assign(socket, :surface_data, next_data) |> schedule_surface_state_persist() |> build_data()}
end
def handle_event(
"select_chat_surface_tab",
%{"surface-id" => surface_id, "index" => index},
socket
) do
socket =
socket
|> assign(
:chat_editor_inputs,
Map.put(socket.assigns.chat_editor_inputs, conversation_id, to_string(value || ""))
:surface_tabs,
Map.put(socket.assigns.surface_tabs, surface_id, parse_integer(index))
)
|> reload.(socket.assigns.workbench)
|> persist_surface_state()
|> build_data()
{:noreply, socket}
end
@spec update_surface_form(term(), term(), term(), term()) :: term()
def update_surface_form(socket, surface_id, fields, reload)
when is_binary(surface_id) and is_map(fields) do
next_data = Map.put(socket.assigns.chat_editor_surface_data, surface_id, fields)
def handle_event("dismiss_chat_surface", %{"surface-id" => surface_id}, socket) do
socket =
socket
|> assign(:chat_editor_surface_data, next_data)
|> reload.(socket.assigns.workbench)
|> assign(:dismissed_surfaces, MapSet.put(socket.assigns.dismissed_surfaces, surface_id))
|> persist_surface_state()
|> build_data()
{:noreply, socket}
end
@spec select_surface_tab(term(), term(), term(), term()) :: term()
def select_surface_tab(socket, surface_id, index, reload)
when is_binary(surface_id) and is_integer(index) and index >= 0 do
socket
|> assign(
:chat_editor_surface_tabs,
Map.put(socket.assigns.chat_editor_surface_tabs, surface_id, index)
def handle_event("chat_surface_action", params, socket) do
{:noreply, do_handle_surface_action(socket, params)}
end
def handle_event("open_chat_settings", _params, socket) do
Notify.open_sidebar_item(
%{
"route" => "settings",
"id" => "settings-ai",
"title" => "Settings",
"subtitle" => "AI"
},
:pin
)
|> reload.(socket.assigns.workbench)
{:noreply, socket}
end
@spec current_surface_data(term(), term()) :: term()
def current_surface_data(socket, surface_id) when is_binary(surface_id) do
Map.get(socket.assigns.chat_editor_surface_data, surface_id, %{})
# ── State initialisation ──────────────────────────────────────────────────
defp ensure_state(socket) do
conversation_id = socket.assigns.current_tab.id
persisted = AI.get_surface_state(conversation_id)
{surface_data, surface_tabs, dismissed_surfaces} =
case persisted do
state when is_map(state) and map_size(state) > 0 ->
{
state["surface_data"] || %{},
state["surface_tabs"] || %{},
MapSet.new(state["dismissed_surfaces"] || [])
}
_other ->
{%{}, %{}, MapSet.new()}
end
@spec set_action_error(term(), term(), term(), term()) :: term()
def set_action_error(socket, conversation_id, message, reload)
when is_binary(conversation_id) and is_binary(message) do
socket
|> assign(
:chat_editor_action_errors,
Map.put(socket.assigns.chat_editor_action_errors, conversation_id, message)
)
|> reload.(socket.assigns.workbench)
defaults = %{
conversation_id: conversation_id,
input: "",
model_selector_open?: false,
request: nil,
surface_data: surface_data,
surface_tabs: surface_tabs,
dismissed_surfaces: dismissed_surfaces,
action_error: nil
}
Enum.reduce(defaults, socket, fn {key, default}, acc ->
if is_nil(Map.get(acc.assigns, key)) do
assign(acc, key, default)
else
acc
end
end)
end
@spec clear_action_error(term(), term(), term()) :: term()
def clear_action_error(socket, conversation_id, reload) when is_binary(conversation_id) do
socket
|> assign(
:chat_editor_action_errors,
Map.delete(socket.assigns.chat_editor_action_errors, conversation_id)
)
|> reload.(socket.assigns.workbench)
# ── Data builder ──────────────────────────────────────────────────────────
defp build_data(socket) do
conversation_id = socket.assigns.conversation_id
request = socket.assigns.request
fake_assigns = %{
current_tab: socket.assigns.current_tab,
chat_editor_requests: if(request, do: %{conversation_id => request}, else: %{}),
chat_model_selectors_open: %{conversation_id => socket.assigns.model_selector_open?},
chat_editor_inputs: %{conversation_id => socket.assigns.input},
chat_editor_surface_data: socket.assigns.surface_data,
chat_editor_surface_tabs: socket.assigns.surface_tabs,
chat_editor_dismissed_surfaces: socket.assigns.dismissed_surfaces,
chat_editor_action_errors: %{conversation_id => socket.assigns.action_error},
offline_mode: socket.assigns.offline_mode
}
chat_editor = MessageBuild.build(fake_assigns)
assign(socket, :chat_editor, chat_editor)
end
# ── Public API: messaging ──────────────────────────────────────────────────
# ── Messaging ──────────────────────────────────────────────────────────────
@spec send_message(term(), term(), term()) :: term()
def send_message(socket, reload, append_output) do
%{id: conversation_id} = socket.assigns.current_tab
message = socket.assigns.chat_editor_inputs |> Map.get(conversation_id, "") |> String.trim()
defp do_send_message(socket) do
conversation_id = socket.assigns.conversation_id
message = String.trim(socket.assigns.input || "")
cond do
message == "" ->
reload.(socket, socket.assigns.workbench)
build_data(socket)
Map.has_key?(socket.assigns.chat_editor_requests, conversation_id) ->
reload.(socket, socket.assigns.workbench)
not is_nil(socket.assigns.request) ->
build_data(socket)
socket.assigns.offline_mode ->
socket
|> append_output.(
translated("Chat"),
translated("Automatic AI actions stay gated by airplane mode."),
nil,
"info"
)
|> reload.(socket.assigns.workbench)
Notify.output(dgettext("ui", "Chat"),
dgettext("ui", "Automatic AI actions stay gated by airplane mode."), "info")
build_data(socket)
ModelSelection.needs_api_key?(false) ->
reload.(socket, socket.assigns.workbench)
build_data(socket)
true ->
live_view_pid = self()
parent = self()
started_at = Persistence.now_ms()
task =
Task.Supervisor.async_nolink(BDS.Tasks.TaskSupervisor, fn ->
AI.send_chat_message(conversation_id, message,
project_id: socket.assigns.projects.active_project_id,
event_target: live_view_pid
project_id: active_project_id(socket),
event_target: parent
)
end)
:ok = allow_repo_sandbox(task.pid)
Notify.parent({:chat_editor_task_started, conversation_id, task.ref})
socket
|> assign(
:chat_editor_inputs,
Map.put(socket.assigns.chat_editor_inputs, conversation_id, "")
)
|> assign(
:chat_editor_requests,
Map.put(socket.assigns.chat_editor_requests, conversation_id, %{
|> assign(:input, "")
|> assign(:request, %{
ref: task.ref,
pid: task.pid,
started_at: started_at,
message: message,
content: "",
tool_events: []
})
)
|> assign(
:chat_editor_request_refs,
Map.put(socket.assigns.chat_editor_request_refs, task.ref, conversation_id)
)
|> assign(
:chat_editor_action_errors,
Map.delete(socket.assigns.chat_editor_action_errors, conversation_id)
)
|> reload.(socket.assigns.workbench)
|> assign(:action_error, nil)
|> build_data()
end
end
@spec abort_message(term(), term()) :: term()
def abort_message(socket, reload) do
%{id: conversation_id} = socket.assigns.current_tab
defp do_abort_message(socket) do
conversation_id = socket.assigns.conversation_id
case Map.get(socket.assigns.chat_editor_requests, conversation_id) do
case socket.assigns.request do
nil ->
reload.(socket, socket.assigns.workbench)
build_data(socket)
%{ref: ref} = _request ->
:ok = AI.cancel_chat(conversation_id)
Notify.parent({:chat_editor_task_cancelled, conversation_id, ref})
socket
|> assign(
:chat_editor_requests,
Map.delete(socket.assigns.chat_editor_requests, conversation_id)
)
|> assign(
:chat_editor_request_refs,
Map.delete(socket.assigns.chat_editor_request_refs, ref)
)
|> reload.(socket.assigns.workbench)
|> assign(:request, nil)
|> clear_streaming_state()
end
end
@spec note_tool_call(term(), term(), term(), term()) :: term()
def note_tool_call(socket, conversation_id, tool_call, reload)
when is_binary(conversation_id) and is_map(tool_call) do
update_request(
socket,
conversation_id,
fn request ->
defp clear_streaming_state(socket) do
input = socket.assigns.input || ""
chat_editor = socket.assigns.chat_editor || %{}
chat_editor =
chat_editor
|> Map.put(:is_streaming, false)
|> Map.put(:pending_user_message, nil)
|> Map.put(:streaming_content, "")
|> Map.put(:streaming_tool_markers, [])
|> Map.put(:streaming_inline_surfaces, [])
|> Map.put(:send_disabled?, String.trim(input) == "")
assign(socket, :chat_editor, chat_editor)
end
defp do_finish_request(socket, result) do
case result do
{:ok, reply} ->
socket
|> update_tab_meta_from_reply(reply)
|> assign(:request, nil)
|> build_data()
{:error, :cancelled} ->
assign(socket, :request, nil) |> build_data()
{:error, %{kind: :endpoint_not_configured}} ->
assign(socket, :request, nil) |> build_data()
{:error, reason} ->
Notify.output(dgettext("ui", "Chat"), format_error(reason), "error")
assign(socket, :request, nil) |> build_data()
end
end
defp do_note_tool_call(socket, tool_call) when is_map(tool_call) do
update_request(socket, fn request ->
update_in(
request.tool_events,
&(&1 ++
[
%{
type: :call,
id: ToolTracking.tool_call_id(tool_call),
name: ToolTracking.tool_call_name(tool_call),
arguments: ToolTracking.tool_call_arguments(tool_call)
}
])
)
end,
reload
)
end)
end
@spec note_tool_result(term(), term(), term(), term()) :: term()
def note_tool_result(socket, conversation_id, name, reload)
when is_binary(conversation_id) and is_binary(name) do
update_request(
socket,
conversation_id,
fn request ->
defp do_note_tool_result(socket, name) when is_binary(name) do
update_request(socket, fn request ->
update_in(request.tool_events, &(&1 ++ [%{type: :result, name: name}]))
end,
reload
)
end)
end
@spec note_streaming_content(term(), term(), term(), term()) :: term()
def note_streaming_content(socket, conversation_id, content, reload)
when is_binary(conversation_id) and is_binary(content) do
update_request(
socket,
conversation_id,
fn request -> %{request | content: content} end,
reload
)
defp do_note_streaming_content(socket, content) when is_binary(content) do
update_request(socket, fn request -> %{request | content: content} end)
end
@spec finish_request(term(), term(), term(), term(), term()) :: term()
def finish_request(socket, ref, result, reload, append_output) when is_reference(ref) do
case Map.pop(socket.assigns.chat_editor_request_refs, ref) do
{nil, _remaining_refs} ->
socket
defp update_request(socket, updater) do
case socket.assigns.request do
nil ->
build_data(socket)
{conversation_id, remaining_refs} ->
socket =
request ->
socket
|> assign(:chat_editor_request_refs, remaining_refs)
|> assign(
:chat_editor_requests,
Map.delete(socket.assigns.chat_editor_requests, conversation_id)
|> assign(:request, updater.(request))
|> build_data()
end
end
defp update_tab_meta_from_reply(socket, reply) do
title =
reply
|> MapUtils.attr(:conversation, %{})
|> MapUtils.attr(:title)
if is_binary(title) and String.trim(title) != "" do
Notify.tab_meta(:chat, socket.assigns.conversation_id, title, "")
end
socket
end
# ── Surface actions ────────────────────────────────────────────────────────
defp do_handle_surface_action(socket, params) do
surface_id = Map.get(params, "surface-id", "")
payload =
params
|> Map.get("payload")
|> decode_payload()
|> maybe_put_form_data(socket, surface_id)
case normalize_action(Map.get(params, "action", "")) do
:open_post ->
case Map.get(payload, "postId") || Map.get(payload, "post_id") do
post_id when is_binary(post_id) and post_id != "" ->
Notify.open_sidebar_item(
%{
"route" => "post",
"id" => post_id,
"title" => TabHelpers.post_title(post_id),
"subtitle" => TabHelpers.post_subtitle(post_id)
},
:pin
)
case result do
{:ok, _reply} ->
reload.(socket, socket.assigns.workbench)
assign(socket, :action_error, nil) |> build_data()
{:error, :cancelled} ->
reload.(socket, socket.assigns.workbench)
{:error, %{kind: :endpoint_not_configured}} ->
reload.(socket, socket.assigns.workbench)
{:error, reason} ->
socket
|> append_output.(translated("Chat"), format_error(reason), nil, "error")
|> reload.(socket.assigns.workbench)
_other ->
set_action_error(socket, "Invalid payload for openPost action")
end
:open_media ->
case Map.get(payload, "mediaId") || Map.get(payload, "media_id") do
media_id when is_binary(media_id) and media_id != "" ->
Notify.open_sidebar_item(
%{
"route" => "media",
"id" => media_id,
"title" => TabHelpers.media_title(media_id),
"subtitle" => TabHelpers.media_subtitle(media_id)
},
:pin
)
assign(socket, :action_error, nil) |> build_data()
_other ->
set_action_error(socket, "Invalid payload for openMedia action")
end
:open_settings ->
Notify.open_sidebar_item(
%{
"route" => "settings",
"id" => "settings-ai",
"title" => "Settings",
"subtitle" => "AI"
},
:pin
)
assign(socket, :action_error, nil) |> build_data()
:open_chat ->
chat_id =
Map.get(payload, "conversationId") || Map.get(payload, "conversation_id") ||
socket.assigns.conversation_id
Notify.open_sidebar_item(
%{
"route" => "chat",
"id" => chat_id,
"title" => Map.get(payload, "title", "Chat"),
"subtitle" => Map.get(payload, "subtitle", "")
},
:pin
)
assign(socket, :action_error, nil) |> build_data()
:switch_view ->
case BoundedAtoms.sidebar_view(Map.get(payload, "view")) do
nil ->
set_action_error(socket, "Invalid payload for switchView action")
view ->
Notify.parent({:chat_editor_switch_view, view})
assign(socket, :action_error, nil) |> build_data()
end
:toggle_sidebar ->
Notify.parent({:chat_editor_toggle_sidebar})
assign(socket, :action_error, nil) |> build_data()
:toggle_panel ->
Notify.parent({:chat_editor_toggle_panel})
assign(socket, :action_error, nil) |> build_data()
:toggle_assistant_sidebar ->
Notify.parent({:chat_editor_toggle_assistant_sidebar})
assign(socket, :action_error, nil) |> build_data()
:unknown ->
set_action_error(socket, "Unsupported assistant action")
end
end
defp set_action_error(socket, message) do
assign(socket, :action_error, message) |> build_data()
end
defp decode_payload(nil), do: %{}
defp decode_payload(""), do: %{}
defp decode_payload(payload) when is_binary(payload) do
case Jason.decode(payload) do
{:ok, decoded} when is_map(decoded) -> decoded
_other -> %{}
end
end
defp decode_payload(_payload), do: %{}
defp maybe_put_form_data(payload, socket, surface_id)
when is_binary(surface_id) and surface_id != "" do
form_data = Map.get(socket.assigns.surface_data, surface_id, %{})
if form_data == %{}, do: payload, else: Map.put(payload, "formData", form_data)
end
defp maybe_put_form_data(payload, _socket, _surface_id), do: payload
defp normalize_action(action) do
action
|> to_string()
|> String.replace("_", "")
|> String.downcase()
|> case do
"openpost" -> :open_post
"openmedia" -> :open_media
"opensettings" -> :open_settings
"openchat" -> :open_chat
"switchview" -> :switch_view
"setactiveview" -> :switch_view
"togglesidebar" -> :toggle_sidebar
"togglepanel" -> :toggle_panel
"openpanel" -> :toggle_panel
"toggleassistantsidebar" -> :toggle_assistant_sidebar
_other -> :unknown
end
end
# ── HEEx-callable helpers ─────────────────────────────────────────────────
@spec message_role_label(term()) :: term()
def message_role_label(:user), do: translated("chat.role.you")
def message_role_label(_role), do: translated("chat.role.assistant")
@spec message_role_label(atom()) :: String.t()
def message_role_label(:user), do: dgettext("ui", "You")
def message_role_label(_role), do: dgettext("ui", "Assistant")
defdelegate tool_call_name(tool_call), to: ToolTracking
defdelegate tool_call_arguments(tool_call), to: ToolTracking
@spec tool_surface_type(term()) :: term()
@spec tool_surface_type(map()) :: String.t()
def tool_surface_type(surface), do: Map.get(surface, :type, "json")
@spec markdown_html(binary()) :: Phoenix.HTML.Safe.t()
def markdown_html(content) when is_binary(content) do
html =
case Earmark.as_html(content, escape: true) do
@@ -279,13 +548,13 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
raw(html)
end
@spec markdown_html(term()) :: term()
def markdown_html(_content), do: ""
@spec payload_json(term()) :: term()
@spec payload_json(map() | nil) :: String.t()
def payload_json(nil), do: "{}"
def payload_json(payload) when is_map(payload), do: Jason.encode!(payload)
@spec chart_width(number(), term()) :: number()
def chart_width(_max_value, value) when not is_number(value), do: 0
def chart_width(max_value, value) when is_number(max_value) and max_value > 0 do
@@ -297,30 +566,39 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|> Float.round(2)
end
@spec chart_width(term(), term()) :: term()
def chart_width(_max_value, _value), do: 0
@spec truthy?(term()) :: boolean()
def truthy?(value) when value in [true, "true", 1, "1", "on"], do: true
@spec truthy?(term()) :: term()
def truthy?(_value), do: false
# ── HEEx components ───────────────────────────────────────────────────────
attr(:markers, :list, required: true)
@spec chat_tool_markers(term()) :: term()
@spec chat_tool_markers(map()) :: Phoenix.LiveView.Rendered.t()
def chat_tool_markers(assigns) do
~H"""
<%= if @markers != [] do %>
<div class="chat-tool-markers">
<%= for marker <- @markers do %>
<div class={["chat-tool-marker", if(marker.complete?, do: "completed", else: "pending")]} data-testid="chat-tool-marker">
<details class={["chat-tool-marker", if(marker.complete?, do: "completed", else: "pending")]} data-testid="chat-tool-marker">
<summary>
<span class="chat-tool-marker-icon"><%= if marker.complete?, do: "✓", else: "●" %></span>
<span class="chat-tool-marker-name"><%= marker.name %></span>
<%= if marker.args_preview not in [nil, ""] do %>
<span class="chat-tool-marker-args">(<%= marker.args_preview %>)</span>
<% end %>
</summary>
<div class="chat-tool-marker-details" data-testid="chat-tool-marker-details">
<div class="chat-tool-marker-detail-label"><%= dgettext("ui", "Arguments") %></div>
<pre><%= Jason.encode!(marker.arguments || %{}, pretty: true) %></pre>
<%= if marker.result not in [nil, ""] do %>
<div class="chat-tool-marker-detail-label"><%= dgettext("ui", "Result") %></div>
<pre><%= marker.result %></pre>
<% end %>
</div>
</details>
<% end %>
</div>
<% end %>
@@ -328,11 +606,18 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
end
attr(:surface, :map, required: true)
attr(:myself, :any, required: false)
@spec chat_surface(term()) :: term()
@spec chat_surface(map()) :: Phoenix.LiveView.Rendered.t()
def chat_surface(assigns) do
~H"""
<article class={["chat-inline-surface", "chat-inline-surface-#{@surface.type}"]} data-testid="chat-inline-surface">
<details id={@surface.id} class={["chat-inline-surface", "chat-inline-surface-#{@surface.type}"]} data-testid="chat-inline-surface" data-expanded={surface_expanded_attr(@surface)} open={Map.get(@surface, :expanded?, false)}>
<summary class="chat-inline-surface-header">
<span class="chat-inline-surface-icon"><%= surface_icon(@surface.type) %></span>
<span class="chat-inline-surface-title"><%= surface_title(@surface) %></span>
<button class="chat-inline-surface-dismiss" type="button" phx-click="dismiss_chat_surface" phx-target={@myself} phx-value-surface-id={@surface.id} aria-label={dgettext("ui", "Dismiss surface")} data-testid="chat-inline-surface-dismiss">×</button>
</summary>
<div class="chat-inline-surface-body">
<%= case @surface.type do %>
<% "card" -> %>
<div class="chat-surface-card">
@@ -350,6 +635,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
class="chat-surface-action-button"
type="button"
phx-click="chat_surface_action"
phx-target={@myself}
phx-value-surface-id={@surface.id}
phx-value-action={action.action}
phx-value-payload={payload_json(action.payload)}
@@ -449,6 +735,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
class={["chat-surface-tab-button", if(index == @surface.selected_index, do: "active")]}
type="button"
phx-click="select_chat_surface_tab"
phx-target={@myself}
phx-value-surface-id={@surface.id}
phx-value-index={index}
>
@@ -472,7 +759,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
<%= if present?(@surface.title) do %>
<h3><%= @surface.title %></h3>
<% end %>
<form class="chat-surface-form" phx-change="change_chat_surface_form">
<form class="chat-surface-form" phx-change="change_chat_surface_form" phx-target={@myself}>
<input type="hidden" name="surface[id]" value={@surface.id} />
<%= for field <- @surface.fields do %>
@@ -509,6 +796,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
class="chat-surface-action-button"
type="button"
phx-click="chat_surface_action"
phx-target={@myself}
phx-value-surface-id={@surface.id}
phx-value-action={@surface.submit_action}
phx-value-payload="{}"
@@ -526,25 +814,72 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
<% _other -> %>
<pre class="chat-tool-surface-json"><%= Jason.encode!(@surface.raw || %{}, pretty: true) %></pre>
<% end %>
</article>
</div>
</details>
"""
end
defp surface_icon("chart"), do: ""
defp surface_icon("table"), do: ""
defp surface_icon("form"), do: ""
defp surface_icon("card"), do: ""
defp surface_icon("metric"), do: "#"
defp surface_icon("list"), do: ""
defp surface_icon("tabs"), do: ""
defp surface_icon(_type), do: ""
defp surface_expanded_attr(surface) do
if Map.get(surface, :expanded?, false), do: "true", else: "false"
end
defp surface_title(surface) do
cond do
present?(Map.get(surface, :title)) -> Map.get(surface, :title)
present?(Map.get(surface, :label)) -> Map.get(surface, :label)
true -> surface.type |> to_string() |> String.capitalize()
end
end
# ── Private helpers ───────────────────────────────────────────────────────
defp update_request(socket, conversation_id, updater, reload) do
case Map.get(socket.assigns.chat_editor_requests, conversation_id) do
nil ->
socket
@surface_state_debounce_ms 500
request ->
socket
|> assign(
:chat_editor_requests,
Map.put(socket.assigns.chat_editor_requests, conversation_id, updater.(request))
defp persist_surface_state(socket) do
conversation_id = socket.assigns.conversation_id
surface_data = socket.assigns.surface_data
surface_tabs = socket.assigns.surface_tabs
dismissed_surfaces = socket.assigns.dismissed_surfaces
case AI.put_surface_state(conversation_id, surface_data, surface_tabs, dismissed_surfaces) do
{:ok, _state} ->
:ok
{:error, reason} ->
Logger.warning("Failed to persist surface state for conversation #{conversation_id}",
reason: inspect(reason)
)
|> reload.(socket.assigns.workbench)
end
socket
end
defp schedule_surface_state_persist(socket) do
if socket.assigns[:surface_state_timer] do
Process.cancel_timer(socket.assigns[:surface_state_timer])
end
timer =
Process.send_after(
self(),
{:persist_surface_state, socket.assigns.conversation_id},
@surface_state_debounce_ms
)
assign(socket, :surface_state_timer, timer)
end
defp active_project_id(socket) do
socket.assigns[:project_id]
end
defp allow_repo_sandbox(pid) when is_pid(pid) do
@@ -589,11 +924,16 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
defp present?(value), do: not is_nil(value)
defp format_error(%{kind: :endpoint_not_configured}),
do: translated("chat.apiKeyRequiredDescription")
do: dgettext("ui", "Configure an API key in Settings to enable AI chat.")
defp format_error(reason), do: inspect(reason)
@spec translated(term(), term()) :: term()
def translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
defp parse_integer(value) when is_integer(value), do: value
defp parse_integer(value) do
case Integer.parse(to_string(value)) do
{int, _} -> int
:error -> 0
end
end
end

View File

@@ -3,8 +3,8 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.MessageBuild do
alias BDS.AI
alias BDS.AI.ChatConversation
alias BDS.Desktop.ShellData
alias BDS.Desktop.ShellLive.ChatEditor.{ModelSelection, ToolSurfaces, ToolTracking}
use Gettext, backend: BDS.Gettext
@spec build(term()) :: term()
def build(%{current_tab: %{type: :chat, id: conversation_id}} = assigns) do
@@ -15,12 +15,16 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.MessageBuild do
%ChatConversation{} = conversation ->
messages = AI.list_chat_messages(conversation.id)
request = Map.get(assigns.chat_editor_requests, conversation.id)
available_models = AI.available_chat_models(conversation.model)
effective_model = AI.effective_chat_model(conversation)
available_models = AI.available_chat_models(effective_model)
streaming_tool_markers = streaming_tool_markers(messages, request)
streaming_content = streaming_content(messages, request)
%{
id: conversation.id,
title: conversation.title || translated("chat.newChat"),
title: conversation.title || dgettext("ui", "New Chat"),
model: conversation.model,
effective_model: effective_model,
available_models: available_models,
available_model_groups: ModelSelection.group_available_models(available_models),
model_selector_open?:
@@ -29,8 +33,10 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.MessageBuild do
messages: build_entries(messages, assigns),
pending_user_message: pending_user_message(messages, request),
is_streaming: not is_nil(request),
streaming_content: streaming_content(request),
streaming_tool_markers: ToolTracking.tool_markers_from_events(request),
streaming_content: streaming_content,
streaming_tool_markers: streaming_tool_markers,
streaming_inline_surfaces:
streaming_inline_surfaces(conversation.id, streaming_tool_markers, assigns),
offline?: Map.get(assigns, :offline_mode, true),
needs_api_key?: ModelSelection.needs_api_key?(Map.get(assigns, :offline_mode, true)),
action_error: Map.get(assigns.chat_editor_action_errors, conversation.id),
@@ -49,7 +55,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.MessageBuild do
case message.role do
:tool ->
if current_entry && current_entry.role == :assistant do
{entries, append_tool_surface(current_entry, message), turn_index}
{entries, append_tool_result(current_entry, message), turn_index}
else
{entries, current_entry, turn_index}
end
@@ -62,6 +68,16 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.MessageBuild do
next_turn_index = turn_index + 1
{entries, start_entry(message, next_turn_index, assigns), next_turn_index}
:assistant ->
next_entry = start_entry(message, turn_index, assigns)
if tool_only_assistant_entry?(current_entry) do
{entries, merge_tool_only_entry(current_entry, next_entry), turn_index}
else
entries = finalize_entry(entries, current_entry)
{entries, next_entry, turn_index}
end
_other ->
entries = finalize_entry(entries, current_entry)
{entries, start_entry(message, turn_index, assigns), turn_index}
@@ -85,35 +101,172 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.MessageBuild do
content: message.content || "",
turn_index: turn_index,
tool_markers: tool_markers,
inline_surfaces: ToolSurfaces.build_render_surfaces(tool_markers, message.id, assigns),
inline_surfaces:
ToolSurfaces.build_render_surfaces(tool_markers, message.id, assigns)
|> mark_surfaces_expanded(assigns),
tool_surfaces: []
}
end
defp append_tool_surface(entry, message) do
entry = ToolTracking.mark_tool_call_completed(entry, message.tool_call_id)
case ToolSurfaces.normalize_tool_surface(message.content) do
nil -> entry
surface -> update_in(entry.tool_surfaces, &(&1 ++ [surface]))
defp append_tool_result(entry, message) do
ToolTracking.mark_tool_call_completed(entry, message.tool_call_id, message.content)
end
defp tool_only_assistant_entry?(%{role: :assistant, content: content} = entry) do
String.trim(content || "") == "" and
(entry.tool_markers != [] or entry.inline_surfaces != [] or entry.tool_surfaces != [])
end
defp tool_only_assistant_entry?(_entry), do: false
defp merge_tool_only_entry(tool_entry, assistant_entry) do
%{
assistant_entry
| tool_markers: tool_entry.tool_markers ++ assistant_entry.tool_markers,
inline_surfaces: tool_entry.inline_surfaces ++ assistant_entry.inline_surfaces,
tool_surfaces: tool_entry.tool_surfaces ++ assistant_entry.tool_surfaces
}
end
defp mark_surfaces_expanded([], _assigns), do: []
defp mark_surfaces_expanded(surfaces, assigns) do
dismissed = Map.get(assigns, :chat_editor_dismissed_surfaces, MapSet.new())
surfaces
|> Enum.reject(&MapSet.member?(dismissed, &1.id))
|> Enum.map(&Map.put(&1, :expanded?, true))
end
defp pending_user_message(_messages, nil), do: nil
defp pending_user_message(messages, %{message: message}) when is_binary(message) do
defp pending_user_message(messages, %{message: message} = request) when is_binary(message) do
cond do
persisted_user_message_for_request?(messages, request) ->
nil
true ->
legacy_pending_user_message(messages, message)
end
end
defp pending_user_message(_messages, _request), do: nil
defp legacy_pending_user_message(messages, message) do
case messages |> Enum.reverse() |> Enum.find(&(&1.role not in [:system, :tool])) do
%{role: :user, content: ^message} -> nil
_other -> message
end
end
defp pending_user_message(_messages, _request), do: nil
defp streaming_content(nil), do: ""
defp streaming_content(%{content: content}) when is_binary(content), do: content
defp streaming_content(_request), do: ""
defp translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
defp streaming_content(messages, request) do
content = streaming_content(request)
if content != "" and persisted_assistant_content_for_request?(messages, request, content) do
""
else
content
end
end
defp streaming_tool_markers(_messages, nil), do: []
defp streaming_tool_markers(messages, request) do
request
|> ToolTracking.tool_markers_from_events()
|> drop_persisted_tool_markers(messages, request)
end
defp streaming_inline_surfaces(_conversation_id, [], _assigns), do: []
defp streaming_inline_surfaces(conversation_id, tool_markers, assigns) do
tool_markers
|> ToolSurfaces.build_render_surfaces("streaming-#{conversation_id}", assigns)
|> mark_surfaces_expanded(assigns)
end
defp persisted_user_message_for_request?(messages, %{message: message} = request)
when is_binary(message) do
messages
|> persisted_messages_for_request(request)
|> Enum.any?(fn persisted_message ->
persisted_message.role == :user and persisted_message.content == message
end)
end
defp persisted_user_message_for_request?(_messages, _request), do: false
defp persisted_assistant_content_for_request?(messages, request, content)
when is_binary(content) and content != "" do
messages
|> persisted_messages_for_request(request)
|> Enum.any?(fn persisted_message ->
persisted_message.role == :assistant and (persisted_message.content || "") == content
end)
end
defp persisted_assistant_content_for_request?(_messages, _request, _content), do: false
defp drop_persisted_tool_markers(tool_markers, messages, request) do
persisted_markers = persisted_tool_markers_for_request(messages, request)
{remaining, _persisted_markers} =
Enum.reduce(tool_markers, {[], persisted_markers}, fn marker,
{remaining, persisted_markers} ->
case pop_matching_tool_marker(persisted_markers, marker) do
{nil, persisted_markers} -> {remaining ++ [marker], persisted_markers}
{_matched, persisted_markers} -> {remaining, persisted_markers}
end
end)
remaining
end
defp persisted_tool_markers_for_request(messages, request) do
messages
|> persisted_messages_for_request(request)
|> Enum.flat_map(fn message ->
if message.role == :assistant do
ToolTracking.normalize_tool_calls(message.tool_calls)
else
[]
end
end)
end
defp pop_matching_tool_marker(tool_markers, marker) do
case Enum.find_index(tool_markers, &same_tool_marker?(&1, marker)) do
nil -> {nil, tool_markers}
index -> {Enum.at(tool_markers, index), List.delete_at(tool_markers, index)}
end
end
defp same_tool_marker?(left, right) do
cond do
is_binary(left.id) and is_binary(right.id) ->
left.id == right.id
true ->
left.name == right.name and (left.arguments || %{}) == (right.arguments || %{})
end
end
defp persisted_messages_for_request(messages, request) do
case request_started_at(request) do
started_at when is_integer(started_at) ->
Enum.filter(messages, fn message ->
is_integer(message.created_at) and message.created_at >= started_at
end)
_other ->
[]
end
end
defp request_started_at(%{started_at: started_at}) when is_integer(started_at), do: started_at
defp request_started_at(_request), do: nil
end

View File

@@ -2,9 +2,9 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ModelSelection do
@moduledoc false
alias BDS.AI
alias BDS.Desktop.ShellData
import Phoenix.Component, only: [assign: 3]
use Gettext, backend: BDS.Gettext
@spec toggle_model_selector(term(), term()) :: term()
def toggle_model_selector(socket, reload) do
@@ -34,7 +34,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ModelSelection do
{:error, reason} ->
socket
|> append_output.(translated("Chat"), inspect(reason), nil, "error")
|> append_output.(dgettext("ui", "Chat"), inspect(reason), nil, "error")
|> reload.(socket.assigns.workbench)
end
end
@@ -78,7 +78,4 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ModelSelection do
defp blank?(value) when is_binary(value), do: String.trim(value) == ""
defp blank?(nil), do: true
defp translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
end

View File

@@ -1,7 +1,7 @@
defmodule BDS.Desktop.ShellLive.ChatEditor.ToolSurfaces do
@moduledoc false
alias BDS.Desktop.ShellData
use Gettext, backend: BDS.Gettext
@render_tool_names MapSet.new([
"render_card",
@@ -85,7 +85,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolSurfaces do
|> List.wrap()
|> Enum.map(fn entry ->
%{
label: map_value(entry, "label", translated("chat.role.assistant")),
label: map_value(entry, "label", dgettext("ui", "Assistant")),
value: numeric_value(map_value(entry, "value", 0)),
segments: List.wrap(map_value(entry, "segments", []))
}
@@ -173,7 +173,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolSurfaces do
fields: fields,
submit_label:
map_value(arguments, "submitLabel") ||
map_value(arguments, "submit_label", translated("chat.stop")),
map_value(arguments, "submit_label", dgettext("ui", "Stop")),
submit_action:
map_value(arguments, "submitAction") ||
map_value(arguments, "submit_action", "submitForm")
@@ -249,7 +249,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolSurfaces do
defp decode_surface_actions(actions) when is_list(actions) do
Enum.map(actions, fn action ->
%{
label: map_value(action, "label", translated("chat.openSettings")),
label: map_value(action, "label", dgettext("ui", "Open Settings")),
action: map_value(action, "action", "openSettings"),
payload: map_value(action, "payload", %{})
}
@@ -287,7 +287,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolSurfaces do
defp map_value(map, key, default \\ nil)
defp map_value(map, key, default) when is_map(map) and is_binary(key) do
Map.get(map, key, Map.get(map, String.to_atom(key), default))
Map.get(map, key, Map.get(map, String.to_existing_atom(key), default))
rescue
ArgumentError -> Map.get(map, key, default)
end
@@ -296,7 +296,4 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolSurfaces do
defp truthy?(value) when value in [true, "true", 1, "1", "on"], do: true
defp truthy?(_value), do: false
defp translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
end

View File

@@ -8,6 +8,14 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolTracking do
BDS.MapUtils.attr(tool_call, :name) || "tool"
end
@spec tool_call_id(term()) :: term()
def tool_call_id(tool_call) when is_map(tool_call) do
BDS.MapUtils.attr(tool_call, :id)
end
@spec tool_call_id(term()) :: term()
def tool_call_id(_tool_call), do: nil
@spec tool_call_arguments(term()) :: term()
def tool_call_arguments(tool_call) when is_map(tool_call) do
BDS.MapUtils.attr(tool_call, :arguments) || BDS.MapUtils.attr(tool_call, :args) || %{}
@@ -22,6 +30,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolTracking do
name: tool_call_name(tool_call),
arguments: arguments,
args_preview: tool_arguments_preview(arguments),
result: nil,
complete?: false
}
end)
@@ -39,11 +48,19 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolTracking do
@spec tool_arguments_preview(term()) :: term()
def tool_arguments_preview(_arguments), do: ""
@spec mark_tool_call_completed(term(), term()) :: term()
def mark_tool_call_completed(entry, tool_call_id) when is_binary(tool_call_id) do
mark_tool_call_completed(entry, tool_call_id, nil)
end
def mark_tool_call_completed(entry, _tool_call_id), do: entry
@spec mark_tool_call_completed(term(), term(), term()) :: term()
def mark_tool_call_completed(entry, tool_call_id, result) when is_binary(tool_call_id) do
update_in(entry.tool_markers, fn markers ->
Enum.map(markers, fn marker ->
if marker.id == tool_call_id do
%{marker | complete?: true}
%{marker | complete?: true, result: result}
else
marker
end
@@ -51,8 +68,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolTracking do
end)
end
@spec mark_tool_call_completed(term(), term()) :: term()
def mark_tool_call_completed(entry, _tool_call_id), do: entry
def mark_tool_call_completed(entry, _tool_call_id, _result), do: entry
@spec tool_markers_from_events(term()) :: term()
def tool_markers_from_events(nil), do: []
@@ -64,10 +80,11 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolTracking do
markers ++
[
%{
id: nil,
id: Map.get(event, :id),
name: event.name,
arguments: event.arguments,
args_preview: tool_arguments_preview(event.arguments || %{}),
result: nil,
complete?: false
}
]

View File

@@ -1,27 +1,29 @@
<div id={"chat-editor-#{@chat_editor.id}"} class="chat-panel" data-testid="chat-editor" phx-hook="ChatSurface">
<div class="chat-panel-header">
<div class="chat-panel-title">
<div id={"chat-editor-#{@chat_editor.id}"} class="chat-panel ui-editor-shell flex h-full min-h-0 flex-col" data-testid="chat-editor" phx-hook="ChatSurface">
<div class="chat-panel-header flex shrink-0 items-center justify-between gap-3 px-4 py-3">
<div class="chat-panel-title flex min-w-0 flex-1 items-center justify-between gap-3">
<span class="chat-panel-title-main">
<%= if @chat_editor.needs_api_key? do %>
<%= translated("chat.setupTitle") %>
<%= dgettext("ui", "AI Chat Setup") %>
<% else %>
<%= @chat_editor.title %>
<% end %>
</div>
</span>
<%= unless @chat_editor.needs_api_key? do %>
<div class="chat-panel-header-actions">
<span class="chat-model-selector-wrap relative shrink-0">
<button
class="chat-model-selector-button"
class="chat-model-selector-button chat-model-selector-inline ui-button ui-button-secondary inline-flex items-center gap-2"
type="button"
phx-click="toggle_chat_model_selector"
phx-target={@myself}
data-testid="chat-model-selector-button"
>
<span><%= @chat_editor.model || translated("chat.newChat") %></span>
<span><%= @chat_editor.effective_model || dgettext("ui", "No model") %></span>
<span class="chat-model-selector-caret">▾</span>
</button>
<%= if @chat_editor.model_selector_open? and @chat_editor.available_models != [] do %>
<div class="chat-model-selector-menu">
<div class="chat-model-selector-menu ui-dropdown-menu absolute right-0 top-full z-10 mt-2 flex min-w-56 flex-col">
<%= for group <- @chat_editor.available_model_groups do %>
<section class="chat-model-provider-group" data-testid="chat-model-provider-group" data-provider={group.provider}>
<%= if length(@chat_editor.available_model_groups) > 1 do %>
@@ -31,11 +33,12 @@
<%= for model <- group.models do %>
<button
class={[
"chat-model-selector-option",
if(model.id == @chat_editor.model, do: "active")
"chat-model-selector-option ui-dropdown-item flex items-center justify-between gap-2 text-left",
if(model.id == @chat_editor.effective_model, do: "active")
]}
type="button"
phx-click="select_chat_model"
phx-target={@myself}
phx-value-model={model.id}
data-testid="chat-model-selector-option"
data-provider={group.provider}
@@ -47,108 +50,72 @@
<% end %>
</div>
<% end %>
</div>
</span>
<% end %>
</div>
</div>
<div class="chat-messages chat-surface-scroll">
<div class="chat-messages chat-surface-scroll min-h-0 flex-1 overflow-auto">
<%= if @chat_editor.needs_api_key? do %>
<div class="chat-welcome chat-api-key-state" data-testid="chat-api-key-required">
<div class="chat-welcome chat-api-key-state ui-section-card flex flex-col items-start gap-3 p-4" data-testid="chat-api-key-required">
<div class="chat-welcome-icon">🔑</div>
<h2><%= translated("chat.apiKeyRequiredTitle") %></h2>
<p><%= translated("chat.apiKeyRequiredDescription") %></p>
<h2><%= dgettext("ui", "API Key Required") %></h2>
<p><%= dgettext("ui", "Configure an API key in Settings to enable AI chat.") %></p>
<div class="api-key-form">
<button class="api-key-submit" type="button" phx-click="open_chat_settings"><%= translated("chat.openSettings") %></button>
<button class="api-key-submit ui-button ui-button-primary" type="button" phx-click="open_chat_settings" phx-target={@myself}><%= dgettext("ui", "Open Settings") %></button>
</div>
</div>
<% else %>
<%= if Enum.empty?(@chat_editor.messages) and not @chat_editor.is_streaming do %>
<div class="chat-welcome">
<div class="chat-welcome ui-section-card flex flex-col items-start gap-3 p-4">
<div class="chat-welcome-icon">🤖</div>
<h2><%= translated("chat.welcomeTitle") %></h2>
<p><%= translated("chat.welcomeDescription") %></p>
<h2><%= dgettext("ui", "Welcome to the AI Assistant") %></h2>
<p><%= dgettext("ui", "I can help you manage your blog with rich visualizations. Try asking me to:") %></p>
<ul>
<li><%= translated("chat.welcomeTipSearch") %></li>
<li><%= translated("chat.welcomeTipChart") %></li>
<li><%= translated("chat.welcomeTipTable") %></li>
<li><%= translated("chat.welcomeTipMetadata") %></li>
<li><%= translated("chat.welcomeTipTabs") %></li>
<li><%= dgettext("ui", "Search for posts about a specific topic") %></li>
<li><%= dgettext("ui", "Show a chart of posts published per month") %></li>
<li><%= dgettext("ui", "Compare my recent posts in a table") %></li>
<li><%= dgettext("ui", "Update metadata for posts or media") %></li>
<li><%= dgettext("ui", "Show post statistics by year in tabs with charts") %></li>
</ul>
</div>
<% else %>
<%= if @chat_editor.pending_user_message do %>
<div class="chat-message user pending" data-testid="chat-pending-user-message">
<div class="chat-message-avatar">👤</div>
<div class="chat-message-content">
<div class="chat-message-header">
<span class="chat-message-role"><%= message_role_label(:user) %></span>
</div>
<div class="chat-message-text"><%= @chat_editor.pending_user_message %></div>
</div>
</div>
<% end %>
<%= for message <- @chat_editor.messages do %>
<div class={["chat-message", to_string(message.role || "assistant")]}>
<div class={["chat-message flex items-start gap-3", to_string(message.role || "assistant")]}>
<div class="chat-message-avatar"><%= if message.role == :user, do: "👤", else: "🤖" %></div>
<div class="chat-message-content">
<div class="chat-message-content ui-section-card">
<div class="chat-message-header"><span class="chat-message-role"><%= message_role_label(message.role) %></span></div>
<.chat_tool_markers markers={message.tool_markers} />
<div class="chat-message-text">
<%= if message.role == :assistant do %>
<%= markdown_html(message.content || "") %>
<% else %>
<%= message.content || "" %>
<% end %>
</div>
</div>
</div>
<div class="chat-message-text"><%= markdown_html(message.content || "") %></div>
<%= for surface <- message.inline_surfaces do %>
<.chat_surface surface={surface} />
<.chat_surface surface={surface} myself={@myself} />
<% end %>
<%= for surface <- message.tool_surfaces do %>
<article class="chat-tool-surface" data-testid="chat-tool-surface">
<%= if surface.title do %>
<h3><%= surface.title %></h3>
<% else %>
<div class="chat-message-text chat-user-message-text" data-testid="chat-user-message-text"><%= message.content || "" %></div>
<% end %>
<%= case tool_surface_type(surface) do %>
<% "table" -> %>
<div class="chat-tool-surface-table-wrap">
<table class="chat-tool-surface-table">
<thead>
<tr>
<%= for column <- surface.columns do %>
<th><%= column %></th>
<% end %>
</tr>
</thead>
<tbody>
<%= for row <- surface.rows do %>
<tr>
<%= for value <- row do %>
<td><%= value %></td>
<% end %>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>
<% _other -> %>
<pre class="chat-tool-surface-json"><%= Jason.encode!(surface.data, pretty: true) %></pre>
<% end %>
</article>
<% end %>
<%= if @chat_editor.pending_user_message do %>
<div class="chat-message user pending flex items-start gap-3" data-testid="chat-pending-user-message">
<div class="chat-message-avatar">👤</div>
<div class="chat-message-content ui-section-card">
<div class="chat-message-header">
<span class="chat-message-role"><%= message_role_label(:user) %></span>
</div>
<div class="chat-message-text chat-user-message-text" data-testid="chat-user-message-text"><%= @chat_editor.pending_user_message %></div>
</div>
</div>
<% end %>
<%= if @chat_editor.is_streaming and (@chat_editor.streaming_content != "" or @chat_editor.streaming_tool_markers != []) do %>
<div class="chat-message assistant streaming" data-testid="chat-streaming-message">
<div class="chat-message assistant streaming flex items-start gap-3" data-testid="chat-streaming-message">
<div class="chat-message-avatar">🤖</div>
<div class="chat-message-content">
<div class="chat-message-content ui-section-card">
<div class="chat-message-header">
<span class="chat-message-role"><%= message_role_label(:assistant) %></span>
<span class="streaming-indicator">●</span>
@@ -158,12 +125,15 @@
<%= if @chat_editor.streaming_content != "" do %>
<div class="chat-message-text"><%= markdown_html(@chat_editor.streaming_content) %></div>
<% end %>
<%= for surface <- @chat_editor.streaming_inline_surfaces do %>
<.chat_surface surface={surface} myself={@myself} />
<% end %>
</div>
</div>
<% end %>
<%= if @chat_editor.is_streaming and @chat_editor.streaming_content == "" and @chat_editor.streaming_tool_markers == [] do %>
<div class="chat-message assistant thinking" data-testid="chat-streaming-thinking">
<div class="chat-message assistant thinking flex items-start gap-3" data-testid="chat-streaming-thinking">
<div class="chat-message-avatar">🤖</div>
<div class="chat-message-content">
<div class="chat-thinking-indicator">
@@ -177,14 +147,14 @@
</div>
<%= unless @chat_editor.needs_api_key? do %>
<div class="chat-input-container" data-testid="chat-input-container">
<div class="chat-input-container ui-field-stack flex shrink-0 flex-col gap-3" data-testid="chat-input-container">
<%= if @chat_editor.is_streaming do %>
<button class="chat-abort-button" data-testid="chat-abort-button" type="button" phx-click="abort_chat_editor_message">◼ <%= translated("chat.stop") %></button>
<button class="chat-abort-button ui-button ui-button-secondary" data-testid="chat-abort-button" type="button" phx-click="abort_chat_editor_message" phx-target={@myself}>◼ <%= dgettext("ui", "Stop") %></button>
<% end %>
<form class="chat-input-wrapper" phx-change="change_chat_editor_input" phx-submit="send_chat_editor_message">
<textarea class="chat-input chat-surface-input" name="message" rows="1" placeholder={translated("chat.inputPlaceholder")} disabled={@chat_editor.is_streaming}><%= @chat_editor.input %></textarea>
<button class="chat-send-button" data-testid="chat-send-button" type="button" phx-click="send_chat_editor_message" disabled={@chat_editor.send_disabled?}>↑</button>
<form class="chat-input-wrapper flex items-end gap-2" phx-change="change_chat_editor_input" phx-submit="send_chat_editor_message" phx-target={@myself}>
<textarea class="chat-input chat-surface-input ui-textarea" name="message" rows="1" placeholder={dgettext("ui", "Type a message...")} disabled={@chat_editor.is_streaming}><%= @chat_editor.input %></textarea>
<button class="chat-send-button ui-button ui-button-primary" data-testid="chat-send-button" type="button" phx-click="send_chat_editor_message" phx-target={@myself} disabled={@chat_editor.send_disabled?}>↑</button>
</form>
<%= if @chat_editor.action_error do %>

View File

@@ -3,144 +3,7 @@ defmodule BDS.Desktop.ShellLive.ChatSurface do
import Phoenix.Component, only: [assign: 3]
alias BDS.BoundedAtoms
alias BDS.Desktop.ShellData
alias BDS.Desktop.ShellLive.{ChatEditor, TabHelpers}
alias BDS.UI.Workbench
@doc """
Handle a chat-surface action from a chat message. Receives callbacks for
`reload_shell/2` and `open_sidebar_item/3` to remain decoupled from
`BDS.Desktop.ShellLive` private state.
"""
def handle_action(socket, params, callbacks) do
surface_id = Map.get(params, "surface-id", "")
payload =
params
|> Map.get("payload")
|> decode_payload()
|> maybe_put_form_data(socket, surface_id)
case normalize_action(Map.get(params, "action", "")) do
:open_post ->
case Map.get(payload, "postId") || Map.get(payload, "post_id") do
post_id when is_binary(post_id) and post_id != "" ->
socket
|> clear_action_error()
|> callbacks.open_sidebar.(
%{
"route" => "post",
"id" => post_id,
"title" => TabHelpers.post_title(post_id),
"subtitle" => TabHelpers.post_subtitle(post_id)
},
:pin
)
_other ->
ChatEditor.set_action_error(
socket,
socket.assigns.current_tab.id,
"Invalid payload for openPost action",
callbacks.reload
)
end
:open_media ->
case Map.get(payload, "mediaId") || Map.get(payload, "media_id") do
media_id when is_binary(media_id) and media_id != "" ->
socket
|> clear_action_error()
|> callbacks.open_sidebar.(
%{
"route" => "media",
"id" => media_id,
"title" => TabHelpers.media_title(media_id),
"subtitle" => TabHelpers.media_subtitle(media_id)
},
:pin
)
_other ->
ChatEditor.set_action_error(
socket,
socket.assigns.current_tab.id,
"Invalid payload for openMedia action",
callbacks.reload
)
end
:open_settings ->
socket
|> clear_action_error()
|> callbacks.open_sidebar.(
%{
"route" => "settings",
"id" => "settings-ai",
"title" => "Settings",
"subtitle" => "AI"
},
:pin
)
:open_chat ->
chat_id =
Map.get(payload, "conversationId") || Map.get(payload, "conversation_id") ||
socket.assigns.current_tab.id
socket
|> clear_action_error()
|> callbacks.open_sidebar.(
%{
"route" => "chat",
"id" => chat_id,
"title" => Map.get(payload, "title", "Chat"),
"subtitle" => Map.get(payload, "subtitle", "")
},
:pin
)
:switch_view ->
case BoundedAtoms.sidebar_view(Map.get(payload, "view")) do
nil ->
ChatEditor.set_action_error(
socket,
socket.assigns.current_tab.id,
"Invalid payload for switchView action",
callbacks.reload
)
view ->
socket
|> clear_action_error()
|> callbacks.reload.(Workbench.click_activity(socket.assigns.workbench, view))
end
:toggle_sidebar ->
socket
|> clear_action_error()
|> callbacks.reload.(Workbench.toggle_sidebar(socket.assigns.workbench))
:toggle_panel ->
socket
|> clear_action_error()
|> callbacks.reload.(Workbench.toggle_panel(socket.assigns.workbench))
:toggle_assistant_sidebar ->
socket
|> clear_action_error()
|> callbacks.reload.(Workbench.toggle_assistant_sidebar(socket.assigns.workbench))
:unknown ->
ChatEditor.set_action_error(
socket,
socket.assigns.current_tab.id,
"Unsupported assistant action",
callbacks.reload
)
end
end
use Gettext, backend: BDS.Gettext
def assistant_turn(prompt, socket) do
[
@@ -149,12 +12,12 @@ defmodule BDS.Desktop.ShellLive.ChatSurface do
]
end
def assistant_project_name(nil), do: translated("Projects")
def assistant_project_name(nil), do: dgettext("ui", "Projects")
def assistant_project_name(project), do: project.name
def assistant_message_label("assistant"), do: translated("Assistant")
def assistant_message_label("user"), do: translated("You")
def assistant_message_label(_role), do: translated("Assistant")
def assistant_message_label("assistant"), do: dgettext("ui", "Assistant")
def assistant_message_label("user"), do: dgettext("ui", "You")
def assistant_message_label(_role), do: dgettext("ui", "Assistant")
def assistant_message_testid(role), do: "assistant-message-#{role}"
@@ -165,76 +28,19 @@ defmodule BDS.Desktop.ShellLive.ChatSurface do
end
end
def clear_action_error(%{assigns: %{current_tab: %{type: :chat, id: conversation_id}}} = socket) do
assign(
socket,
:chat_editor_action_errors,
Map.delete(socket.assigns.chat_editor_action_errors, conversation_id)
)
end
def clear_action_error(socket), do: socket
defp decode_payload(nil), do: %{}
defp decode_payload(""), do: %{}
defp decode_payload(payload) when is_binary(payload) do
case Jason.decode(payload) do
{:ok, decoded} when is_map(decoded) -> decoded
_other -> %{}
end
end
defp decode_payload(_payload), do: %{}
defp maybe_put_form_data(payload, socket, surface_id)
when is_binary(surface_id) and surface_id != "" do
form_data = ChatEditor.current_surface_data(socket, surface_id)
if form_data == %{} do
payload
else
Map.put(payload, "formData", form_data)
end
end
defp maybe_put_form_data(payload, _socket, _surface_id), do: payload
defp normalize_action(action) do
action
|> to_string()
|> String.replace("_", "")
|> String.downcase()
|> case do
"openpost" -> :open_post
"openmedia" -> :open_media
"opensettings" -> :open_settings
"openchat" -> :open_chat
"switchview" -> :switch_view
"setactiveview" -> :switch_view
"togglesidebar" -> :toggle_sidebar
"togglepanel" -> :toggle_panel
"openpanel" -> :toggle_panel
"toggleassistantsidebar" -> :toggle_assistant_sidebar
_other -> :unknown
end
end
defp assistant_reply(socket) do
if socket.assigns.offline_mode do
ShellData.translate(
"Automatic AI actions stay gated by airplane mode.",
%{},
socket.assigns.page_language
BDS.Gettext.lgettext(
socket.assigns.page_language,
"ui",
"Automatic AI actions stay gated by airplane mode."
)
else
ShellData.translate(
"The assistant sidebar chat surface is ready, but model execution is not connected yet.",
%{},
socket.assigns.page_language
BDS.Gettext.lgettext(
socket.assigns.page_language,
"ui",
"The assistant sidebar chat surface is ready, but model execution is not connected yet."
)
end
end
defp translated(text), do: ShellData.translate(text, %{}, BDS.Desktop.UILocale.current())
end

View File

@@ -46,29 +46,6 @@ defmodule BDS.Desktop.ShellLive.CliSync do
|> assign(:workbench, workbench)
|> assign(:shell_overlay, nil)
|> assign(:tab_meta, Map.delete(socket.assigns.tab_meta, {:post, post_id}))
|> assign(:post_editor_drafts, Map.delete(socket.assigns.post_editor_drafts, post_id))
|> assign(
:post_editor_active_languages,
Map.delete(socket.assigns.post_editor_active_languages, post_id)
)
|> assign(
:post_editor_tag_queries,
Map.delete(socket.assigns.post_editor_tag_queries, post_id)
)
|> assign(
:post_editor_category_queries,
Map.delete(socket.assigns.post_editor_category_queries, post_id)
)
|> assign(
:post_editor_quick_actions_open,
Map.delete(socket.assigns.post_editor_quick_actions_open, post_id)
)
|> assign(:post_editor_modes, Map.delete(socket.assigns.post_editor_modes, post_id))
|> assign(:post_editor_expanded, Map.delete(socket.assigns.post_editor_expanded, post_id))
|> assign(
:post_editor_save_states,
Map.delete(socket.assigns.post_editor_save_states, post_id)
)
{socket, workbench}
end
@@ -81,27 +58,6 @@ defmodule BDS.Desktop.ShellLive.CliSync do
|> assign(:workbench, workbench)
|> assign(:shell_overlay, nil)
|> assign(:tab_meta, Map.delete(socket.assigns.tab_meta, {:media, media_id}))
|> assign(:media_editor_drafts, Map.delete(socket.assigns.media_editor_drafts, media_id))
|> assign(
:media_editor_quick_actions_open,
Map.delete(socket.assigns.media_editor_quick_actions_open, media_id)
)
|> assign(
:media_editor_post_pickers_open,
Map.delete(socket.assigns.media_editor_post_pickers_open, media_id)
)
|> assign(
:media_editor_post_picker_queries,
Map.delete(socket.assigns.media_editor_post_picker_queries, media_id)
)
|> assign(
:media_editor_save_states,
Map.delete(socket.assigns.media_editor_save_states, media_id)
)
|> assign(
:media_editor_translation_forms,
Map.delete(socket.assigns.media_editor_translation_forms, media_id)
)
{socket, workbench}
end

View File

@@ -1,361 +0,0 @@
defmodule BDS.Desktop.ShellLive.CodeEntityEditor do
@moduledoc false
use Phoenix.Component
alias BDS.Desktop.ShellData
alias BDS.{MCP, Scripts, Scripting, Templates}
alias BDS.Scripts.Script
alias BDS.Templates.Template
embed_templates("code_entity_editor_html/*")
@spec assign_socket(term()) :: term()
def assign_socket(socket) do
socket
|> assign(:script_editor, build_script(socket.assigns))
|> assign(:template_editor, build_template(socket.assigns))
end
@spec update_script(term(), term(), term()) :: term()
def update_script(socket, params, reload) do
%{id: script_id} = socket.assigns.current_tab
socket
|> assign(
:script_editor_drafts,
Map.put(socket.assigns.script_editor_drafts, script_id, normalize_script_params(params))
)
|> reload.(socket.assigns.workbench)
end
@spec save_script(term(), term(), term()) :: term()
def save_script(socket, reload, append_output) do
%{id: script_id} = socket.assigns.current_tab
case Scripts.get_script(script_id) do
nil ->
reload.(socket, socket.assigns.workbench)
%Script{} = script ->
draft = current_script_draft(socket.assigns, script)
case Scripting.validate(draft["content"] || "") do
:ok ->
case Scripts.update_script(script.id, script_attrs(draft)) do
{:ok, _updated} ->
socket
|> assign(
:script_editor_drafts,
Map.delete(socket.assigns.script_editor_drafts, script.id)
)
|> reload.(socket.assigns.workbench)
{:error, reason} ->
socket
|> append_output.(translated("Scripts"), inspect(reason), nil, "error")
|> reload.(socket.assigns.workbench)
end
{:error, reason} ->
socket
|> append_output.(translated("Scripts"), inspect(reason), nil, "error")
|> reload.(socket.assigns.workbench)
end
end
end
@spec check_script(term(), term(), term()) :: term()
def check_script(socket, reload, append_output) do
%{id: script_id} = socket.assigns.current_tab
case Scripts.get_script(script_id) do
nil ->
reload.(socket, socket.assigns.workbench)
%Script{} = script ->
case Scripting.validate(current_script_draft(socket.assigns, script)["content"] || "") do
:ok ->
append_output.(socket, translated("Scripts"), translated("Syntax is valid"))
|> reload.(socket.assigns.workbench)
{:error, reason} ->
append_output.(socket, translated("Scripts"), inspect(reason), nil, "error")
|> reload.(socket.assigns.workbench)
end
end
end
@spec run_script(term(), term(), term()) :: term()
def run_script(socket, reload, append_output) do
%{id: script_id} = socket.assigns.current_tab
case Scripts.get_script(script_id) do
nil ->
reload.(socket, socket.assigns.workbench)
%Script{} = script ->
draft = current_script_draft(socket.assigns, script)
case Scripting.execute_project_script(
script.project_id,
draft["content"] || "",
draft["entrypoint"] || "main",
[]
) do
{:ok, result} ->
socket
|> append_output.(translated("Scripts"), inspect(result))
|> reload.(socket.assigns.workbench)
{:error, reason} ->
socket
|> append_output.(translated("Scripts"), inspect(reason), nil, "error")
|> reload.(socket.assigns.workbench)
end
end
end
@spec delete_script(term(), term(), term()) :: term()
def delete_script(socket, reload, append_output) do
%{id: script_id} = socket.assigns.current_tab
case Scripts.delete_script(script_id) do
{:ok, _deleted} ->
reload.(socket, BDS.UI.Workbench.close_tab(socket.assigns.workbench, :scripts, script_id))
{:error, reason} ->
append_output.(socket, translated("Scripts"), inspect(reason), nil, "error")
|> reload.(socket.assigns.workbench)
end
end
@spec update_template(term(), term(), term()) :: term()
def update_template(socket, params, reload) do
%{id: template_id} = socket.assigns.current_tab
socket
|> assign(
:template_editor_drafts,
Map.put(
socket.assigns.template_editor_drafts,
template_id,
normalize_template_params(params)
)
)
|> reload.(socket.assigns.workbench)
end
@spec save_template(term(), term(), term()) :: term()
def save_template(socket, reload, append_output) do
%{id: template_id} = socket.assigns.current_tab
case Templates.get_template(template_id) do
nil ->
reload.(socket, socket.assigns.workbench)
%Template{} = template ->
draft = current_template_draft(socket.assigns, template)
with {:ok, %{valid: true}} <- MCP.validate_template(draft["content"] || ""),
{:ok, _updated} <- Templates.update_template(template.id, template_attrs(draft)) do
socket
|> assign(
:template_editor_drafts,
Map.delete(socket.assigns.template_editor_drafts, template.id)
)
|> reload.(socket.assigns.workbench)
else
{:ok, %{valid: false, errors: errors}} ->
append_output.(socket, translated("Templates"), Enum.join(errors, "; "), nil, "error")
|> reload.(socket.assigns.workbench)
{:error, reason} ->
append_output.(socket, translated("Templates"), inspect(reason), nil, "error")
|> reload.(socket.assigns.workbench)
end
end
end
@spec validate_template(term(), term(), term()) :: term()
def validate_template(socket, reload, append_output) do
%{id: template_id} = socket.assigns.current_tab
case Templates.get_template(template_id) do
nil ->
reload.(socket, socket.assigns.workbench)
%Template{} = template ->
case MCP.validate_template(
current_template_draft(socket.assigns, template)["content"] || ""
) do
{:ok, %{valid: true}} ->
append_output.(
socket,
translated("Templates"),
translated("Template syntax is valid")
)
|> reload.(socket.assigns.workbench)
{:ok, %{valid: false, errors: errors}} ->
append_output.(socket, translated("Templates"), Enum.join(errors, "; "), nil, "error")
|> reload.(socket.assigns.workbench)
end
end
end
@spec delete_template(term(), term(), term()) :: term()
def delete_template(socket, reload, append_output) do
%{id: template_id} = socket.assigns.current_tab
case Templates.delete_template(template_id, force: true) do
{:ok, _deleted} ->
reload.(
socket,
BDS.UI.Workbench.close_tab(socket.assigns.workbench, :templates, template_id)
)
{:error, reason} ->
append_output.(socket, translated("Templates"), inspect(reason), nil, "error")
|> reload.(socket.assigns.workbench)
end
end
@spec build_script(term()) :: term()
def build_script(%{current_tab: %{type: :scripts, id: script_id}} = assigns) do
case Scripts.get_script(script_id) do
nil ->
nil
%Script{} = script ->
draft = current_script_draft(assigns, script)
%{
id: script.id,
title: draft["title"],
slug: draft["slug"],
kind: draft["kind"],
entrypoint: draft["entrypoint"],
enabled: draft["enabled"],
content: draft["content"],
entrypoints: discover_entrypoints(draft["content"]),
created_at: script.created_at,
updated_at: script.updated_at
}
end
end
def build_script(_assigns), do: nil
@spec build_template(term()) :: term()
def build_template(%{current_tab: %{type: :templates, id: template_id}} = assigns) do
case Templates.get_template(template_id) do
nil ->
nil
%Template{} = template ->
draft = current_template_draft(assigns, template)
%{
id: template.id,
title: draft["title"],
slug: draft["slug"],
kind: draft["kind"],
enabled: draft["enabled"],
content: draft["content"],
created_at: template.created_at,
updated_at: template.updated_at
}
end
end
def build_template(_assigns), do: nil
@spec translated(term(), term()) :: term()
def translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
@spec format_timestamp(term()) :: term()
def format_timestamp(nil), do: ""
def format_timestamp(timestamp), do: BDS.Persistence.timestamp_to_iso8601(timestamp)
defp normalize_script_params(params) do
%{
"title" => Map.get(params, "title", ""),
"slug" => Map.get(params, "slug", ""),
"kind" => Map.get(params, "kind", "utility"),
"entrypoint" => Map.get(params, "entrypoint", "main"),
"enabled" => Map.get(params, "enabled") in [true, "true", "on", "1", 1],
"content" => Map.get(params, "content", "")
}
end
defp normalize_template_params(params) do
%{
"title" => Map.get(params, "title", ""),
"slug" => Map.get(params, "slug", ""),
"kind" => Map.get(params, "kind", "post"),
"enabled" => Map.get(params, "enabled") in [true, "true", "on", "1", 1],
"content" => Map.get(params, "content", "")
}
end
defp current_script_draft(assigns, %Script{} = script) do
Map.get(assigns.script_editor_drafts, script.id, %{
"title" => script.title || "",
"slug" => script.slug || "",
"kind" => to_string(script.kind || :utility),
"entrypoint" => script.entrypoint || "main",
"enabled" => script.enabled != false,
"content" => script.content || ""
})
end
defp current_template_draft(assigns, %Template{} = template) do
Map.get(assigns.template_editor_drafts, template.id, %{
"title" => template.title || "",
"slug" => template.slug || "",
"kind" => to_string(template.kind || :post),
"enabled" => template.enabled != false,
"content" => template.content || ""
})
end
defp script_attrs(draft) do
%{
title: draft["title"],
slug: draft["slug"],
kind: BDS.BoundedAtoms.script_kind(draft["kind"], :utility),
entrypoint: draft["entrypoint"],
enabled: draft["enabled"],
content: draft["content"]
}
end
defp template_attrs(draft) do
%{
title: draft["title"],
slug: draft["slug"],
kind: normalize_template_kind(draft["kind"]),
enabled: draft["enabled"],
content: draft["content"]
}
end
defp normalize_template_kind("post"), do: :post
defp normalize_template_kind("list"), do: :list
defp normalize_template_kind("not-found"), do: :"not-found"
defp normalize_template_kind("partial"), do: :partial
defp normalize_template_kind(_kind), do: :post
defp discover_entrypoints(content) do
[
"main"
| Regex.scan(~r/function\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(/, content || "",
capture: :all_but_first
)
|> List.flatten()
|> Enum.reject(&(&1 == "main"))
]
end
end

View File

@@ -1,42 +0,0 @@
<div class="scripts-view-shell" data-testid="script-editor">
<div class="editor-header scripts-header">
<div class="editor-tabs"><div class="editor-tab active"><span class="editor-tab-title"><%= @script_editor.title %></span></div></div>
<div class="editor-actions">
<button class="scripts-save-button" type="button" phx-click="save_script_editor"><%= translated("Save") %></button>
<button class="scripts-run-button" type="button" phx-click="run_script_editor"><%= translated("Run") %></button>
<button class="scripts-check-button" type="button" phx-click="check_script_editor"><%= translated("Check Syntax") %></button>
<button class="secondary danger" type="button" phx-click="delete_script_editor"><%= translated("Delete") %></button>
</div>
</div>
<form class="editor-content scripts-view" phx-change="change_script_editor">
<div class="editor-header-row scripts-meta-row">
<div class="editor-meta">
<div class="editor-field-row">
<div class="editor-field"><label><%= translated("Title") %></label><input type="text" name="script_editor[title]" value={@script_editor.title} /></div>
<div class="editor-field"><label><%= translated("Slug") %></label><input type="text" name="script_editor[slug]" value={@script_editor.slug} /></div>
</div>
<div class="editor-field-row">
<div class="editor-field"><label><%= translated("Kind") %></label><select name="script_editor[kind]"><option value="utility" selected={@script_editor.kind == "utility"}>utility</option><option value="macro" selected={@script_editor.kind == "macro"}>macro</option><option value="transform" selected={@script_editor.kind == "transform"}>transform</option></select></div>
<div class="editor-field"><label><%= translated("Entrypoint") %></label><select name="script_editor[entrypoint]"><%= for entrypoint <- @script_editor.entrypoints do %><option value={entrypoint} selected={entrypoint == @script_editor.entrypoint}><%= entrypoint %></option><% end %></select></div>
<div class="editor-field scripts-enabled-field"><label><input type="checkbox" name="script_editor[enabled]" checked={@script_editor.enabled} /> <%= translated("Enabled") %></label></div>
</div>
</div>
</div>
<div class="editor-body scripts-editor">
<div class="editor-toolbar scripts-toolbar"><div class="editor-toolbar-left"><label><%= translated("Content") %></label></div></div>
<div
id={"script-editor-monaco-shell-#{@script_editor.id}"}
class="scripts-monaco monaco-editor-shell"
phx-hook="MonacoEditor"
data-monaco-editor-id={@script_editor.id}
data-monaco-input-id={"script-editor-content-#{@script_editor.id}"}
data-monaco-language="lua"
data-monaco-word-wrap="on"
>
<div id={"script-editor-monaco-#{@script_editor.id}"} class="monaco-editor-instance" phx-update="ignore"></div>
<textarea id={"script-editor-content-#{@script_editor.id}"} class="monaco-editor-input code-editor-textarea" name="script_editor[content]" spellcheck="false"><%= @script_editor.content %></textarea>
</div>
</div>
<div class="editor-footer"><span class="text-muted text-small"><%= translated("Created") %>: <%= format_timestamp(@script_editor.created_at) %></span><span class="text-muted text-small"><%= translated("Updated") %>: <%= format_timestamp(@script_editor.updated_at) %></span></div>
</form>
</div>

View File

@@ -1,40 +0,0 @@
<div class="templates-view-shell" data-testid="template-editor">
<div class="editor-header templates-header">
<div class="editor-tabs"><div class="editor-tab active"><span class="editor-tab-title"><%= @template_editor.title %></span></div></div>
<div class="editor-actions">
<button class="templates-save-button" type="button" phx-click="save_template_editor"><%= translated("Save") %></button>
<button class="templates-validate-button" type="button" phx-click="validate_template_editor"><%= translated("Validate") %></button>
<button class="secondary danger" type="button" phx-click="delete_template_editor"><%= translated("Delete") %></button>
</div>
</div>
<form class="editor-content templates-view" phx-change="change_template_editor">
<div class="editor-header-row templates-meta-row">
<div class="editor-meta">
<div class="editor-field-row">
<div class="editor-field"><label><%= translated("Title") %></label><input type="text" name="template_editor[title]" value={@template_editor.title} /></div>
<div class="editor-field"><label><%= translated("Slug") %></label><input type="text" name="template_editor[slug]" value={@template_editor.slug} /></div>
</div>
<div class="editor-field-row">
<div class="editor-field"><label><%= translated("Kind") %></label><select name="template_editor[kind]"><option value="post" selected={@template_editor.kind == :post or @template_editor.kind == "post"}>post</option><option value="list" selected={@template_editor.kind == :list or @template_editor.kind == "list"}>list</option><option value="not-found" selected={@template_editor.kind == :"not-found" or @template_editor.kind == "not-found"}>not-found</option><option value="partial" selected={@template_editor.kind == :partial or @template_editor.kind == "partial"}>partial</option></select></div>
<div class="editor-field templates-enabled-field"><label><input type="checkbox" name="template_editor[enabled]" checked={@template_editor.enabled} /> <%= translated("Enabled") %></label></div>
</div>
</div>
</div>
<div class="editor-body templates-editor">
<div class="editor-toolbar templates-toolbar"><div class="editor-toolbar-left"><label><%= translated("Content") %></label></div></div>
<div
id={"template-editor-monaco-shell-#{@template_editor.id}"}
class="templates-monaco monaco-editor-shell"
phx-hook="MonacoEditor"
data-monaco-editor-id={@template_editor.id}
data-monaco-input-id={"template-editor-content-#{@template_editor.id}"}
data-monaco-language="liquid"
data-monaco-word-wrap="on"
>
<div id={"template-editor-monaco-#{@template_editor.id}"} class="monaco-editor-instance" phx-update="ignore"></div>
<textarea id={"template-editor-content-#{@template_editor.id}"} class="monaco-editor-input code-editor-textarea" name="template_editor[content]" spellcheck="false"><%= @template_editor.content %></textarea>
</div>
</div>
<div class="editor-footer"><span class="text-muted text-small"><%= translated("Created") %>: <%= format_timestamp(@template_editor.created_at) %></span><span class="text-muted text-small"><%= translated("Updated") %>: <%= format_timestamp(@template_editor.updated_at) %></span></div>
</form>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,8 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
@moduledoc false
alias BDS.{ImportAnalysis, ImportDefinitions, Metadata}
alias BDS.Desktop.{FilePicker, FolderPicker, ShellData}
alias BDS.Desktop.{FilePicker, FolderPicker}
use Gettext, backend: BDS.Gettext
@spec change_definition(term(), term(), term()) :: term()
def change_definition(socket, params, reload) do
@@ -18,7 +19,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
@spec select_uploads_folder(term(), term(), term()) :: term()
def select_uploads_folder(socket, reload, append_output) do
with %{id: definition_id} <- socket.assigns.current_tab do
case FolderPicker.choose_directory(translated("importAnalysis.uploadsFolder")) do
case FolderPicker.choose_directory(dgettext("ui", "Uploads Folder")) do
{:ok, uploads_folder_path} ->
{:ok, _definition} =
ImportDefinitions.update_definition(definition_id, %{
@@ -32,7 +33,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
{:error, %{message: message}} ->
socket
|> append_output.(translated("activity.import"), message, nil, "error")
|> append_output.(dgettext("ui", "Import"), message, nil, "error")
|> reload.(socket.assigns.workbench)
end
else
@@ -44,7 +45,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
def select_and_analyze(socket, reload, append_output) do
with %{id: definition_id} <- socket.assigns.current_tab,
%{} = definition <- ImportDefinitions.get_definition(definition_id) do
case FilePicker.choose_file(translated("importAnalysis.wxrFile")) do
case FilePicker.choose_file(dgettext("ui", "WXR File")) do
{:ok, wxr_file_path} ->
project_id = socket.assigns.projects.active_project_id
@@ -78,7 +79,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
:import_editor_analysis_states,
Map.put(socket.assigns.import_editor_analysis_states, definition_id, %{
loading: true,
step: translated("importAnalysis.analyzingWxr"),
step: dgettext("ui", "Analyzing WXR file..."),
detail: Path.basename(wxr_file_path),
file_path: wxr_file_path,
ref: task.ref
@@ -99,7 +100,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
{:error, %{message: message}} ->
socket
|> append_output.(translated("activity.import"), message, nil, "error")
|> append_output.(dgettext("ui", "Import"), message, nil, "error")
|> reload.(socket.assigns.workbench)
end
else
@@ -167,18 +168,18 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
{:error, reason} ->
socket
|> append_output.(translated("activity.import"), inspect(reason), nil, "error")
|> append_output.(dgettext("ui", "Import"), inspect(reason), nil, "error")
|> reload.(socket.assigns.workbench)
end
{:error, %{message: message}} ->
socket
|> append_output.(translated("activity.import"), message, nil, "error")
|> append_output.(dgettext("ui", "Import"), message, nil, "error")
|> reload.(socket.assigns.workbench)
{:error, reason} ->
socket
|> append_output.(translated("activity.import"), inspect(reason), nil, "error")
|> append_output.(dgettext("ui", "Import"), inspect(reason), nil, "error")
|> reload.(socket.assigns.workbench)
end
end
@@ -200,7 +201,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
:import_editor_analysis_states,
Map.delete(socket.assigns.import_editor_analysis_states, definition_id)
)
|> append_output.(translated("activity.import"), message, nil, "error")
|> append_output.(dgettext("ui", "Import"), message, nil, "error")
|> reload.(socket.assigns.workbench)
end
end
@@ -265,7 +266,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
@spec default_author(term()) :: term()
def default_author(project_id) do
{:ok, metadata} = Metadata.get_project_metadata(project_id)
Map.get(metadata, :default_author)
metadata.default_author
end
@spec suggested_definition_name(term()) :: term()
@@ -294,12 +295,12 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
def translate_phase(step) when is_binary(step) do
case step do
"parsing" -> translated("importAnalysis.analysisPhase.parsing")
"scanning" -> translated("importAnalysis.analysisPhase.scanning")
"taxonomies" -> translated("importAnalysis.analysisPhase.taxonomies")
"posts" -> translated("importAnalysis.analysisPhase.posts")
"media" -> translated("importAnalysis.analysisPhase.media")
"complete" -> translated("importAnalysis.analysisPhase.complete")
"parsing" -> dgettext("ui", "Parsing WXR file...")
"scanning" -> dgettext("ui", "Scanning entries...")
"taxonomies" -> dgettext("ui", "Analyzing taxonomies...")
"posts" -> dgettext("ui", "Analyzing posts...")
"media" -> dgettext("ui", "Analyzing media...")
"complete" -> dgettext("ui", "Analysis complete")
other -> other
end
end
@@ -307,8 +308,5 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
@spec translate_phase(term()) :: term()
def translate_phase(other), do: other
defp translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
defp present?(value), do: value not in [nil, ""]
end

View File

@@ -2,8 +2,8 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do
@moduledoc false
alias BDS.{ImportDefinitions, ImportExecution}
alias BDS.Desktop.ShellData
alias BDS.Desktop.ShellLive.ImportEditor.AnalysisState
use Gettext, backend: BDS.Gettext
@spec execute_import(term(), term(), term()) :: term()
def execute_import(socket, reload, _append_output) do
@@ -129,8 +129,8 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do
})
)
|> append_output.(
translated("activity.import"),
translated("importAnalysis.importComplete", %{count: previous_state.count}),
dgettext("ui", "Import"),
dgettext("ui", "Import completed successfully!", count: previous_state.count),
nil,
"info"
)
@@ -148,7 +148,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do
ref: nil
})
)
|> append_output.(translated("activity.import"), message, nil, "error")
|> append_output.(dgettext("ui", "Import"), message, nil, "error")
|> reload.(socket.assigns.workbench)
{:error, reason} ->
@@ -165,7 +165,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do
ref: nil
})
)
|> append_output.(translated("activity.import"), message, nil, "error")
|> append_output.(dgettext("ui", "Import"), message, nil, "error")
|> reload.(socket.assigns.workbench)
end
end
@@ -208,7 +208,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do
ref: nil
})
)
|> append_output.(translated("activity.import"), message, nil, "error")
|> append_output.(dgettext("ui", "Import"), message, nil, "error")
|> reload.(socket.assigns.workbench)
end
end
@@ -265,16 +265,14 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do
seconds = div(ms, 1000)
if seconds < 60 do
translated("importAnalysis.eta", %{
value: translated("importAnalysis.etaSeconds", %{count: seconds})
})
dgettext("ui", "ETA: %{value}", value: dgettext("ui", "%{count}s", count: seconds))
else
m = div(seconds, 60)
s = rem(seconds, 60)
translated("importAnalysis.eta", %{
value: translated("importAnalysis.etaMinutes", %{minutes: m, seconds: s})
})
dgettext("ui", "ETA: %{value}",
value: dgettext("ui", "%{minutes}m %{seconds}s", minutes: m, seconds: s)
)
end
end
@@ -282,18 +280,15 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do
def translate_execution_phase(phase) when is_binary(phase) do
case phase do
"tags" -> translated("importAnalysis.phase.tags")
"posts" -> translated("importAnalysis.phase.posts")
"media" -> translated("importAnalysis.phase.media")
"pages" -> translated("importAnalysis.phase.pages")
"complete" -> translated("importAnalysis.phase.complete")
"tags" -> dgettext("ui", "Importing tags & categories...")
"posts" -> dgettext("ui", "Importing posts...")
"media" -> dgettext("ui", "Importing media...")
"pages" -> dgettext("ui", "Importing pages...")
"complete" -> dgettext("ui", "Import complete")
other -> other
end
end
@spec translate_execution_phase(term()) :: term()
def translate_execution_phase(other), do: other
defp translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
end

View File

@@ -2,7 +2,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
@moduledoc false
alias BDS.{AI, ImportDefinitions, Metadata, Tags}
alias BDS.Desktop.ShellData
use Gettext, backend: BDS.Gettext
@spec start_taxonomy_edit(term(), term(), term()) :: term()
def start_taxonomy_edit(
@@ -85,11 +85,11 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
socket.assigns.offline_mode ->
socket
|> append_output.(
translated("activity.import"),
ShellData.translate(
"Automatic AI actions stay gated by airplane mode.",
%{},
socket.assigns.page_language
dgettext("ui", "Import"),
BDS.Gettext.lgettext(
socket.assigns.page_language,
"ui",
"Automatic AI actions stay gated by airplane mode."
),
nil,
"info"
@@ -124,8 +124,8 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
socket
|> append_output.(
translated("activity.import"),
translated("importAnalysis.mappedCount", %{count: mapped_count}),
dgettext("ui", "Import"),
dgettext("ui", "%{count} mapped", count: mapped_count),
Map.get(socket.assigns.import_editor_selected_models, definition_id),
"info"
)
@@ -134,7 +134,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
{:error, reason} ->
socket
|> append_output.(
translated("activity.import"),
dgettext("ui", "Import"),
inspect(reason),
Map.get(socket.assigns.import_editor_selected_models, definition_id),
"error"
@@ -222,7 +222,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
{:ok, metadata} = Metadata.get_project_metadata(project_id)
%{
categories: Enum.uniq(Map.get(metadata, :categories, []) || []),
categories: Enum.uniq(metadata.categories || []),
tags: project_id |> Tags.list_tags() |> Enum.map(& &1.name) |> Enum.uniq()
}
end
@@ -274,19 +274,16 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
def taxonomy_mapping_tooltip(item) do
action =
if present?(item.mapped_to),
do: translated("importAnalysis.mappingActionEdit"),
else: translated("importAnalysis.mappingActionAdd")
do: dgettext("ui", "edit"),
else: dgettext("ui", "add")
translated("importAnalysis.mappingTooltip", %{action: action})
dgettext("ui", "Click to %{action} mapping", action: action)
end
@spec maybe_put_option(term(), term(), term()) :: term()
def maybe_put_option(opts, _key, nil), do: opts
def maybe_put_option(opts, key, value), do: Keyword.put(opts, key, value)
defp translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
defp present?(value), do: value not in [nil, ""]
defp blank_to_nil(""), do: nil
defp blank_to_nil(value), do: value

View File

@@ -1,5 +1,5 @@
<div
class="app"
class="app flex h-full w-full flex-col"
id="bds-shell-app"
phx-hook="AppShell"
data-shortcuts={encoded_shortcuts(@client_shortcuts)}
@@ -77,8 +77,8 @@
data-testid="toggle-sidebar"
type="button"
phx-click="toggle_sidebar"
aria-label={translated("Toggle sidebar")}
title={translated("Toggle sidebar")}
aria-label={dgettext("ui", "Toggle sidebar")}
title={dgettext("ui", "Toggle sidebar")}
>
<span class={["window-titlebar-sidebar-icon", if(@workbench.sidebar_visible, do: "is-active", else: "is-inactive")]}>
<span class="window-titlebar-sidebar-pane"></span>
@@ -89,8 +89,8 @@
data-testid="toggle-panel"
type="button"
phx-click="toggle_panel"
aria-label={translated("Toggle panel")}
title={translated("Toggle panel")}
aria-label={dgettext("ui", "Toggle panel")}
title={dgettext("ui", "Toggle panel")}
>
<span class={["window-titlebar-panel-icon", if(@workbench.panel.visible, do: "is-active", else: "is-inactive")]}>
<span class="window-titlebar-panel-pane"></span>
@@ -101,8 +101,8 @@
data-testid="toggle-assistant"
type="button"
phx-click="toggle_assistant_sidebar"
aria-label={translated("Toggle assistant")}
title={translated("Toggle assistant")}
aria-label={dgettext("ui", "Toggle assistant")}
title={dgettext("ui", "Toggle assistant")}
>
<span class={["window-titlebar-assistant-icon", if(@workbench.assistant_sidebar_visible, do: "is-active", else: "is-inactive")]}>
<span class="window-titlebar-assistant-pane"></span>
@@ -112,12 +112,12 @@
</div>
<% end %>
<div class="app-main">
<aside class="activity-bar" data-region="activity-bar">
<div class="activity-bar-top">
<div class="app-main flex min-h-0 flex-1 overflow-hidden">
<aside class="activity-bar flex h-full shrink-0 flex-col items-center justify-between" data-region="activity-bar">
<div class="activity-bar-top flex flex-col items-center gap-1">
<%= for button <- Enum.filter(@activity_buttons, &(&1.activity_group == :top)) do %>
<button
class={["activity-bar-item", if(button.active, do: "active")]}
class={["activity-bar-item inline-flex h-12 w-12 items-center justify-center", if(button.active, do: "active")]}
data-testid="activity-button"
data-view={button.id}
data-active={to_string(button.active)}
@@ -134,10 +134,10 @@
</button>
<% end %>
</div>
<div class="activity-bar-bottom">
<div class="activity-bar-bottom flex flex-col items-center gap-1">
<%= for button <- Enum.filter(@activity_buttons, &(&1.activity_group == :bottom)) do %>
<button
class={["activity-bar-item", if(button.active, do: "active")]}
class={["activity-bar-item inline-flex h-12 w-12 items-center justify-center", if(button.active, do: "active")]}
data-testid="activity-button"
data-view={button.id}
data-active={to_string(button.active)}
@@ -157,29 +157,29 @@
</aside>
<section
class={["sidebar-shell", if(not @workbench.sidebar_visible, do: "is-hidden")]}
class={["sidebar-shell flex min-w-0 shrink-0 overflow-hidden", if(not @workbench.sidebar_visible, do: "is-hidden")]}
data-testid="sidebar-shell"
style={"width: #{if(@workbench.sidebar_visible, do: @workbench.sidebar_width, else: 0)}px;"}
>
<div class="sidebar" data-region="sidebar">
<div id="sidebar-content" class="sidebar-content sidebar-body" phx-hook="SidebarInteractions">
<div class="sidebar flex min-w-0 flex-1 overflow-hidden" data-region="sidebar">
<div id="sidebar-content" class="sidebar-content sidebar-body flex min-h-0 flex-1 flex-col overflow-y-auto" phx-hook="SidebarInteractions">
<div class="sidebar-section">
<% create_action = sidebar_create_action(@workbench.active_view) %>
<div class="sidebar-section-header">
<div class="sidebar-section-header flex items-center justify-between gap-2">
<span><%= String.upcase(sidebar_header_label(@sidebar_header)) %></span>
<%= if create_action || ShellSidebarComponents.filters_enabled?(@sidebar_data) do %>
<div class="sidebar-actions">
<div class="sidebar-actions flex items-center gap-1">
<%= if ShellSidebarComponents.filters_enabled?(@sidebar_data) do %>
<button
class={[
"sidebar-action",
"sidebar-action inline-flex h-8 w-8 items-center justify-center",
if(ShellSidebarComponents.filters_visible?(@sidebar_data), do: "active")
]}
data-testid="sidebar-filter-toggle"
type="button"
phx-click="toggle_sidebar_filters"
aria-label={translated(Map.get(@sidebar_data.filters, :toggle_filters_label))}
title={translated(Map.get(@sidebar_data.filters, :toggle_filters_label))}
aria-label={Map.get(@sidebar_data.filters, :toggle_filters_label)}
title={Map.get(@sidebar_data.filters, :toggle_filters_label)}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M6 12v-1h4v1H6zM4 8v-1h8v1H4zm-2-4v-1h12v1H2z"/>
@@ -188,14 +188,14 @@
<% end %>
<%= if create_action do %>
<button
class="sidebar-action"
class="sidebar-action inline-flex h-8 w-8 items-center justify-center"
data-testid="sidebar-create-action"
data-sidebar-action={create_action.kind}
type="button"
phx-click="create_sidebar_item"
phx-value-kind={create_action.kind}
aria-label={translated(create_action.label)}
title={translated(create_action.label)}
aria-label={create_action.label}
title={create_action.label}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M14 7v1H8v6H7V8H1V7h6V1h1v6h6z"/>
@@ -212,16 +212,16 @@
<div class="resizable-panel-divider sidebar-divider" data-resize="sidebar" data-role="resize-handle"></div>
</section>
<main class="app-content" data-region="content">
<div class="tab-bar" data-region="tab-bar">
<main class="app-content flex min-w-0 flex-1 flex-col overflow-hidden" data-region="content">
<div class="tab-bar flex h-[35px] shrink-0 items-center overflow-hidden" data-region="tab-bar">
<%= if Enum.empty?(@workbench.tabs) do %>
<div class="tab-bar-empty"><%= translated("Dashboard") %></div>
<div class="tab-bar-empty flex h-full items-center px-3 text-sm"><%= dgettext("ui", "Dashboard") %></div>
<% else %>
<div class="tab-bar-tabs">
<div class="tab-bar-tabs flex min-w-0 flex-1 items-stretch overflow-x-auto">
<%= for tab <- @workbench.tabs do %>
<div
class={[
"tab",
"tab flex min-w-0 max-w-[240px] shrink-0 items-stretch",
if(@workbench.active_tab == {tab.type, tab.id}, do: "active"),
if(tab.is_transient, do: "transient"),
if(Workbench.dirty?(@workbench, tab.type, tab.id), do: "dirty")
@@ -231,21 +231,21 @@
tabindex="0"
>
<button
class="tab-select"
class="tab-select flex min-w-0 flex-1 items-center gap-2 overflow-hidden px-3 text-sm"
type="button"
phx-click="select_tab"
phx-value-type={tab.type}
phx-value-id={tab.id}
>
<span class="tab-icon"><%= raw(ShellData.activity_icon(BDS.Desktop.ShellLive.TabHelpers.tab_icon_id(tab))) %></span>
<span class="tab-title"><%= BDS.Desktop.ShellLive.TabHelpers.tab_title(tab, @tab_meta) %></span>
<span class="tab-icon shrink-0"><%= raw(ShellData.activity_icon(BDS.Desktop.ShellLive.TabHelpers.tab_icon_id(tab))) %></span>
<span class="tab-title truncate"><%= BDS.Desktop.ShellLive.TabHelpers.tab_title(tab, @tab_meta) %></span>
</button>
<div class="tab-actions">
<div class="tab-actions flex items-center gap-1 pr-2">
<%= if Workbench.dirty?(@workbench, tab.type, tab.id) do %>
<span class="tab-dirty-indicator">●</span>
<% end %>
<button
class="tab-close"
class="tab-close inline-flex h-6 w-6 items-center justify-center"
data-testid="tab-close"
data-tab-type={tab.type}
data-tab-id={tab.id}
@@ -253,8 +253,8 @@
phx-click="close_tab"
phx-value-type={tab.type}
phx-value-id={tab.id}
aria-label={translated("Close tab")}
title={translated("Close tab")}
aria-label={dgettext("ui", "Close tab")}
title={dgettext("ui", "Close tab")}
>
×
</button>
@@ -265,45 +265,45 @@
<% end %>
</div>
<section class="editor-shell" data-region="editor">
<section class="editor-shell flex min-h-0 flex-1 flex-col overflow-hidden" data-region="editor">
<%= if is_nil(@current_tab) do %>
<div class="editor-empty">
<div class="dashboard-content">
<h1 data-testid="editor-title"><%= translated("dashboard.title") %></h1>
<p class="text-muted"><%= translated("dashboard.subtitle") %></p>
<h1 data-testid="editor-title"><%= dgettext("ui", "Dashboard") %></h1>
<p class="text-muted"><%= dgettext("ui", "Overview of your blog database") %></p>
<div class="dashboard-stats">
<div class="stat-card">
<div class="stat-number"><%= @dashboard.post_stats.total_posts || 0 %></div>
<div class="stat-label"><%= translated("dashboard.stats.totalPosts") %></div>
<div class="stat-label"><%= dgettext("ui", "Total Posts") %></div>
<div class="stat-breakdown">
<span class="stat-tag stat-published"><%= translated("dashboard.stats.published", %{count: @dashboard.post_stats.published_count || 0}) %></span>
<span class="stat-tag stat-draft"><%= translated("dashboard.stats.drafts", %{count: @dashboard.post_stats.draft_count || 0}) %></span>
<span class="stat-tag stat-published"><%= dgettext("ui", "dashboard.stats.published", count: @dashboard.post_stats.published_count || 0) %></span>
<span class="stat-tag stat-draft"><%= dgettext("ui", "dashboard.stats.drafts", count: @dashboard.post_stats.draft_count || 0) %></span>
<%= if (@dashboard.post_stats.archived_count || 0) > 0 do %>
<span class="stat-tag stat-archived"><%= translated("dashboard.stats.archived", %{count: @dashboard.post_stats.archived_count || 0}) %></span>
<span class="stat-tag stat-archived"><%= dgettext("ui", "dashboard.stats.archived", count: @dashboard.post_stats.archived_count || 0) %></span>
<% end %>
</div>
</div>
<div class="stat-card">
<div class="stat-number"><%= @dashboard.media_stats.media_count || 0 %></div>
<div class="stat-label"><%= translated("dashboard.stats.mediaFiles") %></div>
<div class="stat-label"><%= dgettext("ui", "Media Files") %></div>
<div class="stat-breakdown">
<span class="stat-tag"><%= translated("dashboard.stats.images", %{count: @dashboard.media_stats.image_count || 0}) %></span>
<span class="stat-tag"><%= dgettext("ui", "dashboard.stats.images", count: @dashboard.media_stats.image_count || 0) %></span>
<span class="stat-tag"><%= ShellData.format_bytes(@dashboard.media_stats.total_bytes || 0) %></span>
</div>
</div>
<div class="stat-card">
<div class="stat-number"><%= length(@dashboard_tag_cloud_items) %></div>
<div class="stat-label"><%= translated("dashboard.stats.tags") %></div>
<div class="stat-label"><%= dgettext("ui", "Tags") %></div>
<div class="stat-breakdown">
<span class="stat-tag"><%= translated("dashboard.stats.categories", %{count: length(@dashboard_category_counts)}) %></span>
<span class="stat-tag"><%= dgettext("ui", "dashboard.stats.categories", count: length(@dashboard_category_counts)) %></span>
</div>
</div>
</div>
<%= if Enum.any?(@dashboard_timeline_entries) do %>
<div class="dashboard-section">
<h4><%= translated("dashboard.section.postsOverTime") %></h4>
<h4><%= dgettext("ui", "Posts Over Time") %></h4>
<div class="timeline-chart">
<%= for entry <- @dashboard_timeline_entries do %>
<div class="timeline-bar-container">
@@ -322,7 +322,7 @@
<%= if Enum.any?(@dashboard_tag_cloud_items) do %>
<div class="dashboard-section">
<h4><%= translated("dashboard.section.tags") %></h4>
<h4><%= dgettext("ui", "Tags") %></h4>
<div class="tag-cloud">
<%= for item <- @dashboard_tag_cloud_items do %>
<span class={["dashboard-tag", if(item.color, do: "has-color")]} style={ShellData.render_dashboard_tag_style(item)} title={ShellData.dashboard_post_count_label(item.count)}><%= item.tag %></span>
@@ -333,7 +333,7 @@
<%= if Enum.any?(@dashboard_category_counts) do %>
<div class="dashboard-section">
<h4><%= translated("dashboard.section.categories") %></h4>
<h4><%= dgettext("ui", "Categories") %></h4>
<div class="tag-cloud">
<%= for category <- @dashboard_category_counts do %>
<span class="dashboard-tag dashboard-category" title={ShellData.dashboard_post_count_label(category.count || 0)}>
@@ -347,7 +347,7 @@
<%= if Enum.any?(@dashboard_recent_posts) do %>
<div class="dashboard-section">
<h4><%= translated("dashboard.section.recentlyUpdated") %></h4>
<h4><%= dgettext("ui", "Recently Updated") %></h4>
<div class="recent-posts-list">
<%= for post <- @dashboard_recent_posts do %>
<button
@@ -373,8 +373,8 @@
<div class="dashboard-inspector-meta" hidden>
<%= for item <- @editor_meta do %>
<section class="editor-meta-row">
<strong data-testid="editor-meta-label"><%= translated(item.label) %></strong>
<span><%= translated(item.value) %></span>
<strong data-testid="editor-meta-label"><%= item.label %></strong>
<span><%= item.value %></span>
</section>
<% end %>
</div>
@@ -382,38 +382,47 @@
</div>
<% else %>
<%= cond do %>
<% @current_tab.type == :post and @post_editor -> %>
<PostEditor.post_editor post_editor={@post_editor} toolbar_buttons={BDS.Desktop.ShellLive.PanelRenderer.editor_toolbar_buttons(@current_tab)} />
<% @current_tab.type == :post -> %>
<.live_component module={PostEditor} id={"post-editor-#{@current_tab.id}"} current_tab={@current_tab} offline_mode={@offline_mode} />
<% @current_tab.type == :media and @media_editor -> %>
<MediaEditor.media_editor media_editor={@media_editor} />
<% @current_tab.type == :media -> %>
<.live_component module={MediaEditor} id={"media-editor-#{@current_tab.id}"} current_tab={@current_tab} offline_mode={@offline_mode} />
<% @current_tab.type == :settings and @settings_editor -> %>
<SettingsEditor.settings_editor settings_editor={@settings_editor} />
<% @current_tab.type in [:settings, :style] and @current_project -> %>
<.live_component module={SettingsEditor} id="settings-editor"
project_id={@current_project.id}
current_project={@current_project}
projects={@projects}
workbench={@workbench}
current_tab={@current_tab}
tab_meta={@tab_meta}
offline_mode={@offline_mode}
/>
<% @current_tab.type == :style and @style_editor -> %>
<SettingsEditor.style_editor style_editor={@style_editor} />
<% @current_tab.type == :menu_editor and @current_project -> %>
<.live_component module={MenuEditor} id="menu-editor"
project_id={@current_project.id}
current_tab={@current_tab}
tab_meta={@tab_meta}
/>
<% @current_tab.type == :menu_editor and @menu_editor -> %>
<MenuEditor.menu_editor menu_editor={@menu_editor} />
<% @current_tab.type == :tags and @current_project -> %>
<.live_component module={TagsEditor} id="tags-editor" project_id={@current_project.id} current_tab={@current_tab} tab_meta={@tab_meta} />
<% @current_tab.type == :tags and @tags_editor -> %>
<TagsEditor.tags_editor tags_editor={@tags_editor} />
<% @current_tab.type == :scripts -> %>
<.live_component module={ScriptEditor} id={"script-editor-#{@current_tab.id}"} current_tab={@current_tab} />
<% @current_tab.type == :scripts and @script_editor -> %>
<CodeEntityEditor.script_editor script_editor={@script_editor} />
<% @current_tab.type == :templates -> %>
<.live_component module={TemplateEditor} id={"template-editor-#{@current_tab.id}"} current_tab={@current_tab} />
<% @current_tab.type == :templates and @template_editor -> %>
<CodeEntityEditor.template_editor template_editor={@template_editor} />
<% @current_tab.type == :chat -> %>
<.live_component module={ChatEditor} id={"chat-editor-#{@current_tab.id}"} current_tab={@current_tab} offline_mode={@offline_mode} project_id={@projects.active_project_id} />
<% @current_tab.type == :chat and @chat_editor -> %>
<ChatEditor.chat_editor chat_editor={@chat_editor} />
<% @current_tab.type == :import -> %>
<.live_component module={ImportEditor} id={"import-editor-#{@current_tab.id}"} current_tab={@current_tab} offline_mode={@offline_mode} project_id={@projects.active_project_id} />
<% @current_tab.type == :import and @import_editor -> %>
<ImportEditor.import_editor import_editor={@import_editor} />
<% @current_tab.type in [:site_validation, :metadata_diff, :translation_validation, :find_duplicates, :git_diff] and @misc_editor -> %>
<MiscEditor.misc_editor misc_editor={@misc_editor} />
<% @current_tab.type in [:site_validation, :metadata_diff, :translation_validation, :find_duplicates, :git_diff, :documentation, :api_documentation] -> %>
<.live_component module={MiscEditor} id={"misc-editor-#{@current_tab.type}-#{@current_tab.id}"} current_tab={@current_tab} tab_meta={@tab_meta} project_id={@projects.active_project_id} />
<% true -> %>
<div class="editor-frame">
@@ -433,8 +442,8 @@
<aside class="editor-meta">
<%= for item <- @editor_meta do %>
<section class="editor-meta-row">
<strong data-testid="editor-meta-label"><%= translated(item.label) %></strong>
<span><%= translated(item.value) %></span>
<strong data-testid="editor-meta-label"><%= item.label %></strong>
<span><%= item.value %></span>
</section>
<% end %>
</aside>
@@ -443,12 +452,12 @@
<% end %>
</section>
<section class={["panel-shell", if(not @workbench.panel.visible, do: "is-hidden")]} data-region="panel">
<div class="panel-header">
<div class="panel-tabs">
<section class={["panel-shell flex min-h-0 shrink-0 flex-col overflow-hidden", if(not @workbench.panel.visible, do: "is-hidden")]} data-region="panel">
<div class="panel-header flex items-center justify-between gap-2">
<div class="panel-tabs flex min-w-0 items-center overflow-x-auto">
<%= for tab <- @panel_tabs do %>
<button
class={["panel-tab", if(@workbench.panel.active_tab == tab, do: "active")]}
class={["panel-tab", "ui-tab", if(@workbench.panel.active_tab == tab, do: "ui-tab-active"), "inline-flex h-9 items-center px-3 text-xs uppercase tracking-wide", if(@workbench.panel.active_tab == tab, do: "active")]}
type="button"
phx-click="select_panel_tab"
phx-value-tab={tab}
@@ -458,93 +467,93 @@
<% end %>
</div>
<button
class="panel-close"
class="panel-close ui-button ui-button-secondary inline-flex h-8 w-8 items-center justify-center"
data-testid="panel-close"
type="button"
phx-click="toggle_panel"
aria-label={translated("Close panel")}
title={translated("Close panel")}
aria-label={dgettext("ui", "Close panel")}
title={dgettext("ui", "Close panel")}
>
×
</button>
</div>
<div class="panel-content">
<div class="panel-content min-h-0 flex-1 overflow-auto">
<%= BDS.Desktop.ShellLive.PanelRenderer.render_panel_body(assigns) %>
</div>
</section>
</main>
<section
class={["assistant-sidebar-shell", if(not @workbench.assistant_sidebar_visible, do: "is-hidden")]}
class={["assistant-sidebar-shell flex min-w-0 shrink-0 overflow-hidden", if(not @workbench.assistant_sidebar_visible, do: "is-hidden")]}
data-testid="assistant-shell"
style={"width: #{if(@workbench.assistant_sidebar_visible, do: @workbench.assistant_sidebar_width, else: 0)}px;"}
>
<div class="resizable-panel-divider assistant-divider" data-resize="assistant" data-role="resize-handle"></div>
<aside class="assistant-sidebar" data-region="assistant-sidebar">
<div class="assistant-content">
<header class="assistant-sidebar-header">
<aside class="assistant-sidebar flex min-w-0 flex-1 overflow-hidden" data-region="assistant-sidebar">
<div class="assistant-content flex min-h-0 flex-1 flex-col">
<header class="assistant-sidebar-header flex items-start justify-between gap-3">
<div class="assistant-sidebar-heading">
<strong><%= translated("AI Assistant") %></strong>
<span class="assistant-sidebar-description"><%= translated("AI conversations") %></span>
<strong><%= dgettext("ui", "AI Assistant") %></strong>
<span class="assistant-sidebar-description"><%= dgettext("ui", "AI conversations") %></span>
</div>
<span class={[
"assistant-sidebar-status",
if(@offline_mode, do: "is-offline", else: "is-online")
]}>
<%= if @offline_mode, do: translated("Offline"), else: translated("Chat") %>
<%= if @offline_mode, do: dgettext("ui", "Offline"), else: dgettext("ui", "Chat") %>
</span>
</header>
<section class="assistant-sidebar-context" data-testid="assistant-context">
<div class="assistant-sidebar-context-row">
<span class="assistant-sidebar-context-label"><%= translated("Project") %></span>
<section class="assistant-sidebar-context flex shrink-0 flex-col gap-2" data-testid="assistant-context">
<div class="assistant-sidebar-context-row flex items-center justify-between gap-2">
<span class="assistant-sidebar-context-label"><%= dgettext("ui", "Project") %></span>
<span class="assistant-sidebar-context-value"><%= BDS.Desktop.ShellLive.ChatSurface.assistant_project_name(@current_project) %></span>
</div>
<div class="assistant-sidebar-context-row">
<span class="assistant-sidebar-context-label"><%= translated("Editor") %></span>
<div class="assistant-sidebar-context-row flex items-center justify-between gap-2">
<span class="assistant-sidebar-context-label"><%= dgettext("ui", "Editor") %></span>
<span class="assistant-sidebar-context-value"><%= BDS.Desktop.ShellLive.TabHelpers.tab_title(@current_tab, @tab_meta) %></span>
</div>
<p class="assistant-sidebar-context-text"><%= BDS.Desktop.ShellLive.TabHelpers.tab_subtitle(@current_tab, @tab_meta) %></p>
</section>
<form
class="assistant-sidebar-prompt-form"
class="assistant-sidebar-prompt-form flex shrink-0 flex-col gap-3"
data-testid="assistant-prompt-form"
phx-change="update_assistant_prompt"
phx-submit="submit_assistant_prompt"
>
<textarea
class="assistant-sidebar-prompt"
class="assistant-sidebar-prompt min-h-[8rem] w-full resize-y"
data-testid="assistant-prompt-input"
name="assistant[prompt]"
rows="6"
placeholder={translated("Ask the assistant about the active project or editor.")}
placeholder={dgettext("ui", "Ask the assistant about the active project or editor.")}
><%= @assistant_prompt %></textarea>
<button
class="assistant-sidebar-start-button"
class="assistant-sidebar-start-button ui-button ui-button-primary"
data-testid="assistant-start-button"
type="submit"
disabled={String.trim(@assistant_prompt || "") == ""}
>
<%= translated("Start chat") %>
<%= dgettext("ui", "Start chat") %>
</button>
</form>
<%= if Enum.empty?(@assistant_messages) do %>
<div class="assistant-sidebar-welcome">
<div class="assistant-sidebar-welcome min-h-0 flex-1 overflow-auto">
<%= for card <- @assistant_cards do %>
<section class="assistant-card">
<strong><%= translated(card.label) %></strong>
<span><%= translated(card.text) %></span>
<section class="assistant-card flex flex-col gap-1">
<strong><%= card.label %></strong>
<span><%= card.text %></span>
</section>
<% end %>
</div>
<% else %>
<div class="assistant-sidebar-transcript">
<div class="assistant-sidebar-transcript min-h-0 flex-1 overflow-auto">
<%= for message <- @assistant_messages do %>
<article
class={["assistant-sidebar-message", message.role]}
class={["assistant-sidebar-message flex flex-col gap-1", message.role]}
data-testid={BDS.Desktop.ShellLive.ChatSurface.assistant_message_testid(message.role)}
>
<span class="assistant-sidebar-message-role"><%= BDS.Desktop.ShellLive.ChatSurface.assistant_message_label(message.role) %></span>
@@ -558,41 +567,41 @@
</section>
</div>
<footer class="status-bar" data-region="status-bar" data-testid="status-bar">
<div class="status-bar-left">
<footer class="status-bar flex h-[22px] shrink-0 items-center justify-between gap-2" data-region="status-bar" data-testid="status-bar">
<div class="status-bar-left flex min-w-0 items-center gap-2 overflow-hidden">
<%= if @is_mac_ui do %>
<div class="status-shell-controls" data-testid="status-shell-controls">
<div class="status-shell-controls flex items-center gap-1" data-testid="status-shell-controls">
<button
class="status-shell-toggle-button"
class="status-shell-toggle-button inline-flex h-6 w-6 items-center justify-center"
data-testid="toggle-sidebar"
type="button"
phx-click="toggle_sidebar"
aria-label={translated("Toggle sidebar")}
title={translated("Toggle sidebar")}
aria-label={dgettext("ui", "Toggle sidebar")}
title={dgettext("ui", "Toggle sidebar")}
>
<span class={["window-titlebar-sidebar-icon", if(@workbench.sidebar_visible, do: "is-active", else: "is-inactive")]}>
<span class="window-titlebar-sidebar-pane"></span>
</span>
</button>
<button
class="status-shell-toggle-button"
class="status-shell-toggle-button inline-flex h-6 w-6 items-center justify-center"
data-testid="toggle-panel"
type="button"
phx-click="toggle_panel"
aria-label={translated("Toggle panel")}
title={translated("Toggle panel")}
aria-label={dgettext("ui", "Toggle panel")}
title={dgettext("ui", "Toggle panel")}
>
<span class={["window-titlebar-panel-icon", if(@workbench.panel.visible, do: "is-active", else: "is-inactive")]}>
<span class="window-titlebar-panel-pane"></span>
</span>
</button>
<button
class="status-shell-toggle-button"
class="status-shell-toggle-button inline-flex h-6 w-6 items-center justify-center"
data-testid="toggle-assistant"
type="button"
phx-click="toggle_assistant_sidebar"
aria-label={translated("Toggle assistant")}
title={translated("Toggle assistant")}
aria-label={dgettext("ui", "Toggle assistant")}
title={dgettext("ui", "Toggle assistant")}
>
<span class={["window-titlebar-assistant-icon", if(@workbench.assistant_sidebar_visible, do: "is-active", else: "is-inactive")]}>
<span class="window-titlebar-assistant-pane"></span>
@@ -600,12 +609,12 @@
</button>
</div>
<% end %>
<div class="project-selector">
<div class="project-selector relative shrink-0">
<button
class="project-selector-trigger"
class="project-selector-trigger inline-flex items-center gap-2"
data-testid="project-selector-trigger"
type="button"
title={translated("Switch project")}
title={dgettext("ui", "Switch project")}
phx-click="toggle_project_menu"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" class="project-icon">
@@ -620,7 +629,7 @@
<%= if @project_menu_open do %>
<div class="project-dropdown" data-testid="project-dropdown" phx-click-away="close_project_menu">
<div class="project-dropdown-header">
<span><%= translated("Projects") %></span>
<span><%= dgettext("ui", "Projects") %></span>
</div>
<div class="project-list">
<%= for project <- @projects.projects do %>
@@ -641,32 +650,32 @@
</div>
<div class="project-dropdown-footer">
<button class="existing-project-btn" type="button" phx-click="import_project">
<span><%= translated("Open Existing Blog") %></span>
<span><%= dgettext("ui", "Open Existing Blog") %></span>
</button>
<button class="create-project-btn" type="button" phx-click="create_project">
<span><%= translated("New Project") %></span>
<span><%= dgettext("ui", "New Project") %></span>
</button>
</div>
</div>
<% end %>
</div>
<button class="status-bar-item status-bar-task-button" data-testid="status-task-button" type="button" phx-click="open_tasks_panel">
<button class="status-bar-item status-bar-task-button inline-flex items-center gap-2" data-testid="status-task-button" type="button" phx-click="open_tasks_panel">
<%= if @status.left.running_task_message do %>
<span class="task-spinner"></span>
<% end %>
<span class="task-message-text"><%= @status.left.running_task_message || translated("Idle") %></span>
<span class="task-message-text"><%= @status.left.running_task_message || dgettext("ui", "Idle") %></span>
<%= if (@status.left.running_task_overflow || 0) > 0 do %>
<span class="status-bar-count">+<%= @status.left.running_task_overflow %></span>
<% end %>
</button>
</div>
<div class="status-bar-right">
<div class="status-bar-right flex items-center gap-2 overflow-hidden">
<span class="status-bar-item"><%= @status.right.post_count %></span>
<span class="status-bar-item"><%= @status.right.media_count %></span>
<span class="status-bar-item theme-badge"><%= @status.right.theme_badge %></span>
<button class={["status-bar-item", "offline-badge", if(@status.right.offline_mode, do: "active")]} data-testid="status-offline-button" type="button" phx-click="toggle_offline_mode" title={translated("Toggle offline mode")}>✈</button>
<form class="status-bar-item language-badge" data-testid="status-language-form" phx-change="change_ui_language">
<span><%= translated("UI") %></span>
<button class={["status-bar-item", "offline-badge", if(@status.right.offline_mode, do: "active")]} data-testid="status-offline-button" type="button" phx-click="toggle_offline_mode" title={dgettext("ui", "Toggle offline mode")}>✈</button>
<form class="status-bar-item language-badge flex items-center gap-1" data-testid="status-language-form" phx-change="change_ui_language">
<span><%= dgettext("ui", "UI") %></span>
<select class="status-bar-language-select" name="ui_language" data-testid="status-language-select">
<%= for language <- @supported_ui_languages do %>
<option selected={language.code == @page_language} value={language.code}><%= language.flag %></option>

File diff suppressed because it is too large Load Diff

View File

@@ -1,47 +1,46 @@
<div class="media-editor editor" data-testid="media-editor">
<div class="editor-header">
<div class="editor-tabs">
<div class="media-editor ui-editor-shell flex h-full min-h-0 flex-col" data-testid="media-editor">
<div class="editor-header ui-editor-header flex shrink-0 items-start justify-between gap-3">
<div class="flex min-w-0 flex-1 overflow-hidden">
<div class={[
"editor-tab",
"active",
"ui-tab ui-tab-active ui-editor-tab-current inline-flex max-w-full items-center gap-2 overflow-hidden px-3 py-2",
if(@media_editor.dirty?, do: "dirty")
]}>
<span class="editor-tab-title" data-testid="editor-title"><%= @media_editor.display_title %></span>
<span class="truncate" data-testid="editor-title"><%= @media_editor.display_title %></span>
<%= if @media_editor.dirty? do %>
<span class="editor-tab-dirty" title={translated("Unsaved")}>●</span>
<span class="editor-tab-dirty" title={dgettext("ui", "Unsaved")}>●</span>
<% end %>
</div>
</div>
<div class="editor-actions">
<div class="ui-editor-actions flex flex-wrap items-center justify-end gap-2">
<%= if @media_editor.save_state in [:dirty, :saved] do %>
<span class="auto-save-indicator"><%= media_editor_save_state_label(@media_editor.save_state) %></span>
<% end %>
<div class="quick-actions-wrapper">
<div class="quick-actions-wrapper relative">
<button
class="secondary quick-actions-btn"
class="secondary quick-actions-btn ui-button ui-button-secondary inline-flex items-center gap-2"
type="button"
phx-click="toggle_media_editor_quick_actions"
phx-value-id={@media_editor.id}
phx-target={@myself}
>
<span class="quick-actions-btn-icon">⚡</span>
<span class="quick-actions-btn-label"><%= translated("Quick Actions") %></span>
<span class="quick-actions-btn-label"><%= dgettext("ui", "Quick Actions") %></span>
</button>
<%= if @media_editor.quick_actions_open? do %>
<div class="quick-actions-menu">
<div class="quick-actions-menu ui-dropdown-menu absolute right-0 top-full z-10 mt-2 flex min-w-72 flex-col">
<%= if @media_editor.is_image do %>
<button
class="quick-action-item"
class="quick-action-item ui-dropdown-item flex items-start gap-3 text-left"
data-testid="editor-toolbar-overlay-button"
type="button"
phx-click="open_overlay"
phx-value-kind="ai_suggestions"
>
<span class="quick-action-text">
<strong><%= translated("AI Suggestions") %></strong>
<small><%= translated("Review title, alt text, and caption suggestions") %></small>
<span class="quick-action-text flex min-w-0 flex-1 flex-col">
<strong><%= dgettext("ui", "AI Suggestions") %></strong>
<small><%= dgettext("ui", "Review title, alt text, and caption suggestions") %></small>
</span>
<span class="quick-action-icon">🤖</span>
</button>
@@ -50,15 +49,15 @@
<% end %>
<button
class="quick-action-item"
class="quick-action-item ui-dropdown-item flex items-start gap-3 text-left"
type="button"
phx-click="detect_media_editor_language"
phx-value-id={@media_editor.id}
phx-target={@myself}
disabled={not @media_editor.can_detect_language?}
>
<span class="quick-action-text">
<strong><%= translated("Detect Language") %></strong>
<small><%= translated("Persist the detected language for this media item") %></small>
<span class="quick-action-text flex min-w-0 flex-1 flex-col">
<strong><%= dgettext("ui", "Detect Language") %></strong>
<small><%= dgettext("ui", "Persist the detected language for this media item") %></small>
</span>
<span class="quick-action-icon">🔍</span>
</button>
@@ -66,16 +65,16 @@
<div class="quick-actions-divider"></div>
<button
class="quick-action-item"
class="quick-action-item ui-dropdown-item flex items-start gap-3 text-left"
data-testid="editor-toolbar-overlay-button"
type="button"
phx-click="open_overlay"
phx-value-kind="language_picker"
disabled={not @media_editor.can_translate?}
>
<span class="quick-action-text">
<strong><%= translated("Translate") %></strong>
<small><%= translated("Select a target language for this media item") %></small>
<span class="quick-action-text flex min-w-0 flex-1 flex-col">
<strong><%= dgettext("ui", "Translate") %></strong>
<small><%= dgettext("ui", "Select a target language for this media item") %></small>
</span>
<span class="quick-action-icon">🌍</span>
</button>
@@ -83,32 +82,32 @@
<% end %>
</div>
<button class="secondary" type="button" phx-click="replace_media_editor_file" phx-value-id={@media_editor.id}>
<%= translated("Replace File") %>
<button class="secondary ui-button ui-button-secondary" type="button" phx-click="replace_media_editor_file" phx-target={@myself}>
<%= dgettext("ui", "Replace File") %>
</button>
<button data-testid="media-save-button" type="button" phx-click="save_media_editor" phx-value-id={@media_editor.id}>
<%= translated("Save") %>
<button class="ui-button ui-button-primary" data-testid="media-save-button" type="button" phx-click="save_media_editor" phx-target={@myself}>
<%= dgettext("ui", "Save") %>
</button>
<button
class="secondary danger"
class="secondary danger ui-button ui-button-secondary ui-button-danger"
data-testid="media-delete-button"
type="button"
phx-click="open_overlay"
phx-value-kind="confirm_delete"
>
<%= translated("Delete") %>
<%= dgettext("ui", "Delete") %>
</button>
</div>
</div>
<div class="editor-content media-editor">
<div class="media-preview">
<div class="editor-content media-editor grid min-h-0 flex-1 gap-4 overflow-auto p-4 xl:grid-cols-[minmax(320px,1fr)_minmax(0,1.2fr)]">
<div class="media-preview flex min-h-[16rem] items-center justify-center">
<%= if @media_editor.is_image and @media_editor.preview_url do %>
<div class="media-preview-image">
<img src={@media_editor.preview_url} alt={@media_editor.form["alt"] || @media_editor.original_name} />
</div>
<% else %>
<div class="media-preview-placeholder">
<div class="media-preview-placeholder flex h-full w-full flex-col items-center justify-center gap-3">
<svg width="64" height="64" viewBox="0 0 24 24" fill="currentColor" opacity="0.3">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6z"></path>
</svg>
@@ -117,61 +116,61 @@
<% end %>
</div>
<div class="media-details">
<form class="media-editor-details-form" data-testid="media-editor-form" phx-change="change_media_editor">
<div class="editor-field">
<label><%= translated("File Name") %></label>
<input class="post-editor-input disabled" type="text" value={@media_editor.original_name} disabled />
<div class="media-details min-w-0">
<form class="media-editor-details-form flex flex-col gap-4" data-testid="media-editor-form" phx-change="change_media_editor" phx-target={@myself}>
<div class="editor-field ui-field-stack flex flex-col gap-1.5">
<label><%= dgettext("ui", "File Name") %></label>
<input class="post-editor-input ui-input disabled ui-input-disabled" type="text" value={@media_editor.original_name} disabled />
</div>
<div class="editor-field">
<label><%= translated("MIME Type") %></label>
<input class="post-editor-input disabled" type="text" value={@media_editor.mime_type} disabled />
<div class="editor-field ui-field-stack flex flex-col gap-1.5">
<label><%= dgettext("ui", "MIME Type") %></label>
<input class="post-editor-input ui-input disabled ui-input-disabled" type="text" value={@media_editor.mime_type} disabled />
</div>
<div class="editor-field-row">
<div class="editor-field">
<label><%= translated("Size") %></label>
<input class="post-editor-input disabled" type="text" value={@media_editor.file_size} disabled />
<div class="editor-field-row ui-field-grid-2 grid gap-4 md:grid-cols-2">
<div class="editor-field ui-field-stack flex flex-col gap-1.5">
<label><%= dgettext("ui", "Size") %></label>
<input class="post-editor-input ui-input disabled ui-input-disabled" type="text" value={@media_editor.file_size} disabled />
</div>
<%= if @media_editor.dimensions do %>
<div class="editor-field">
<label><%= translated("Dimensions") %></label>
<input class="post-editor-input disabled" type="text" value={@media_editor.dimensions} disabled />
<div class="editor-field ui-field-stack flex flex-col gap-1.5">
<label><%= dgettext("ui", "Dimensions") %></label>
<input class="post-editor-input ui-input disabled ui-input-disabled" type="text" value={@media_editor.dimensions} disabled />
</div>
<% end %>
</div>
<div class="editor-field">
<label><%= translated("Title") %></label>
<input class="post-editor-input" type="text" name="media_editor[title]" value={@media_editor.form["title"]} />
<div class="editor-field ui-field-stack flex flex-col gap-1.5">
<label><%= dgettext("ui", "Title") %></label>
<input class="post-editor-input ui-input" type="text" name="media_editor[title]" value={@media_editor.form["title"]} />
</div>
<div class="editor-field">
<label><%= translated("Alt Text") %></label>
<input class="post-editor-input" type="text" name="media_editor[alt]" value={@media_editor.form["alt"]} />
<div class="editor-field ui-field-stack flex flex-col gap-1.5">
<label><%= dgettext("ui", "Alt Text") %></label>
<input class="post-editor-input ui-input" type="text" name="media_editor[alt]" value={@media_editor.form["alt"]} />
</div>
<div class="editor-field">
<label><%= translated("Caption") %></label>
<textarea class="post-editor-textarea" name="media_editor[caption]" rows="3"><%= @media_editor.form["caption"] %></textarea>
<div class="editor-field ui-field-stack flex flex-col gap-1.5">
<label><%= dgettext("ui", "Caption") %></label>
<textarea class="post-editor-textarea ui-textarea" name="media_editor[caption]" rows="3"><%= @media_editor.form["caption"] %></textarea>
</div>
<div class="editor-field">
<label><%= translated("Tags") %></label>
<input class="post-editor-input" type="text" name="media_editor[tags]" value={@media_editor.form["tags"]} />
<div class="editor-field ui-field-stack flex flex-col gap-1.5">
<label><%= dgettext("ui", "Tags") %></label>
<input class="post-editor-input ui-input" type="text" name="media_editor[tags]" value={@media_editor.form["tags"]} />
</div>
<div class="editor-field">
<label><%= translated("Author") %></label>
<input class="post-editor-input" type="text" name="media_editor[author]" value={@media_editor.form["author"]} />
<div class="editor-field ui-field-stack flex flex-col gap-1.5">
<label><%= dgettext("ui", "Author") %></label>
<input class="post-editor-input ui-input" type="text" name="media_editor[author]" value={@media_editor.form["author"]} />
</div>
<div class="editor-field">
<label><%= translated("Language") %></label>
<select class="post-editor-input" name="media_editor[language]">
<option value=""><%= translated("None") %></option>
<div class="editor-field ui-field-stack flex flex-col gap-1.5">
<label><%= dgettext("ui", "Language") %></label>
<select class="post-editor-input ui-input" name="media_editor[language]">
<option value=""><%= dgettext("ui", "None") %></option>
<%= for language <- @media_editor.languages do %>
<option value={language} selected={language == @media_editor.form["language"]}><%= language_label(language) %></option>
<% end %>
@@ -180,28 +179,28 @@
</form>
<%= if @media_editor.form["language"] not in [nil, ""] do %>
<div class="editor-field media-translations-section">
<label><%= translated("Translations") %></label>
<div class="editor-field media-translations-section flex flex-col gap-2">
<label><%= dgettext("ui", "Translations") %></label>
<%= if Enum.empty?(@media_editor.translations) do %>
<div class="no-linked-posts"><%= translated("No translations") %></div>
<div class="no-linked-posts"><%= dgettext("ui", "No translations") %></div>
<% else %>
<div class="linked-posts-list">
<div class="linked-posts-list flex flex-col gap-2">
<%= for translation <- @media_editor.translations do %>
<div class="linked-post-item">
<div class="linked-post-item flex items-center justify-between gap-2">
<button
class="linked-post-title linked-post-link"
type="button"
phx-click="edit_media_translation"
phx-value-id={@media_editor.id}
phx-target={@myself}
phx-value-language={translation.language}
>
<%= translation.flag %> <%= language_label(translation.language) %><%= if translation.title, do: " — #{translation.title}" %>
</button>
<button class="secondary compact" type="button" phx-click="refresh_media_translation" phx-value-id={@media_editor.id} phx-value-language={translation.language}>
<%= translated("Refresh") %>
<button class="secondary compact ui-button ui-button-secondary ui-button-compact" type="button" phx-click="refresh_media_translation" phx-target={@myself} phx-value-language={translation.language}>
<%= dgettext("ui", "Refresh") %>
</button>
<button class="unlink-btn" type="button" phx-click="delete_media_translation" phx-value-id={@media_editor.id} phx-value-language={translation.language}>×</button>
<button class="unlink-btn" type="button" phx-click="delete_media_translation" phx-target={@myself} phx-value-language={translation.language}>×</button>
</div>
<% end %>
</div>
@@ -209,38 +208,38 @@
</div>
<% end %>
<div class="editor-field linked-posts-section">
<div class="editor-field linked-posts-section flex flex-col gap-2">
<label>
<%= translated("Linked Posts") %>
<button class="add-link-btn" type="button" phx-click="toggle_media_post_picker" phx-value-id={@media_editor.id}>
<%= translated("Link to Post") %>
<%= dgettext("ui", "Linked Posts") %>
<button class="add-link-btn" type="button" phx-click="toggle_media_post_picker" phx-target={@myself}>
<%= dgettext("ui", "Link to Post") %>
</button>
</label>
<%= if @media_editor.post_picker_open? do %>
<div class="post-picker">
<div class="post-picker flex flex-col gap-3">
<div class="post-picker-search">
<input
type="text"
name="media_post_picker[query]"
value={@media_editor.post_picker_query}
placeholder={translated("Search posts")}
placeholder={dgettext("ui", "Search posts")}
phx-change="change_media_post_picker"
phx-value-id={@media_editor.id}
phx-target={@myself}
/>
</div>
<%= if Enum.empty?(@media_editor.post_picker_results) do %>
<div class="no-posts"><%= translated("No posts to link") %></div>
<div class="no-posts"><%= dgettext("ui", "No posts to link") %></div>
<% else %>
<div class="post-picker-list">
<div class="post-picker-list flex flex-col gap-2">
<%= for result <- @media_editor.post_picker_results do %>
<button class="post-picker-item" type="button" phx-click="link_media_to_post" phx-value-id={@media_editor.id} phx-value-post-id={result.post_id}>
<button class="post-picker-item" type="button" phx-click="link_media_to_post" phx-target={@myself} phx-value-post-id={result.post_id}>
<%= result.title %>
</button>
<% end %>
<%= if @media_editor.post_picker_overflow_count > 0 do %>
<div class="post-picker-more"><%= translated("and %{count} more", %{count: @media_editor.post_picker_overflow_count}) %></div>
<div class="post-picker-more"><%= dgettext("ui", "and %{count} more", count: @media_editor.post_picker_overflow_count) %></div>
<% end %>
</div>
<% end %>
@@ -248,11 +247,11 @@
<% end %>
<%= if Enum.empty?(@media_editor.linked_posts) do %>
<div class="no-linked-posts"><%= translated("Not linked to any posts") %></div>
<div class="no-linked-posts"><%= dgettext("ui", "Not linked to any posts") %></div>
<% else %>
<div class="linked-posts-list">
<div class="linked-posts-list flex flex-col gap-2">
<%= for linked_post <- @media_editor.linked_posts do %>
<div class="linked-post-item">
<div class="linked-post-item flex items-center justify-between gap-2">
<button
class="linked-post-title linked-post-link"
type="button"
@@ -264,7 +263,7 @@
>
📄 <%= linked_post.title %>
</button>
<button class="unlink-btn" type="button" phx-click="unlink_media_from_post" phx-value-id={@media_editor.id} phx-value-post-id={linked_post.post_id}>×</button>
<button class="unlink-btn" type="button" phx-click="unlink_media_from_post" phx-target={@myself} phx-value-post-id={linked_post.post_id}>×</button>
</div>
<% end %>
</div>
@@ -275,29 +274,29 @@
<%= if @media_editor.editing_translation do %>
<div class="translation-modal-backdrop">
<div class="translation-modal">
<div class="translation-modal-header">
<h2><%= translated("Edit Translation") %></h2>
<button class="translation-modal-close" type="button" phx-click="close_media_translation_editor">×</button>
<div class="translation-modal flex max-h-[80vh] w-full max-w-2xl flex-col overflow-hidden">
<div class="translation-modal-header flex items-center justify-between gap-3">
<h2><%= dgettext("ui", "Edit Translation") %></h2>
<button class="translation-modal-close" type="button" phx-click="close_media_translation_editor" phx-target={@myself}>×</button>
</div>
<form class="translation-modal-body" phx-change="change_media_translation">
<form class="translation-modal-body flex flex-col gap-4 overflow-auto" phx-change="change_media_translation" phx-target={@myself}>
<input type="hidden" name="media_translation[language]" value={@media_editor.editing_translation["language"]} />
<div class="editor-field">
<label><%= translated("Title") %></label>
<input class="post-editor-input" type="text" name="media_translation[title]" value={@media_editor.editing_translation["title"]} />
<div class="editor-field ui-field-stack flex flex-col gap-1.5">
<label><%= dgettext("ui", "Title") %></label>
<input class="post-editor-input ui-input" type="text" name="media_translation[title]" value={@media_editor.editing_translation["title"]} />
</div>
<div class="editor-field">
<label><%= translated("Alt Text") %></label>
<input class="post-editor-input" type="text" name="media_translation[alt]" value={@media_editor.editing_translation["alt"]} />
<div class="editor-field ui-field-stack flex flex-col gap-1.5">
<label><%= dgettext("ui", "Alt Text") %></label>
<input class="post-editor-input ui-input" type="text" name="media_translation[alt]" value={@media_editor.editing_translation["alt"]} />
</div>
<div class="editor-field">
<label><%= translated("Caption") %></label>
<textarea class="post-editor-textarea" name="media_translation[caption]" rows="3"><%= @media_editor.editing_translation["caption"] %></textarea>
<div class="editor-field ui-field-stack flex flex-col gap-1.5">
<label><%= dgettext("ui", "Caption") %></label>
<textarea class="post-editor-textarea ui-textarea" name="media_translation[caption]" rows="3"><%= @media_editor.editing_translation["caption"] %></textarea>
</div>
</form>
<div class="translation-modal-footer">
<button class="secondary" type="button" phx-click="close_media_translation_editor"><%= translated("Cancel") %></button>
<button type="button" phx-click="save_media_translation" phx-value-id={@media_editor.id}><%= translated("Save") %></button>
<div class="translation-modal-footer flex items-center justify-end gap-2">
<button class="secondary ui-button ui-button-secondary" type="button" phx-click="close_media_translation_editor" phx-target={@myself}><%= dgettext("ui", "Cancel") %></button>
<button class="ui-button ui-button-primary" type="button" phx-click="save_media_translation" phx-target={@myself}><%= dgettext("ui", "Save") %></button>
</div>
</div>
</div>

View File

@@ -1,9 +1,11 @@
defmodule BDS.Desktop.ShellLive.MenuEditor do
@moduledoc false
use Phoenix.Component
use Phoenix.LiveComponent
alias BDS.Desktop.ShellData
use Gettext, backend: BDS.Gettext
alias BDS.Desktop.ShellLive.Notify
alias BDS.Desktop.ShellLive.MenuEditor.{
DraftManagement,
@@ -15,158 +17,243 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
embed_templates("menu_editor_html/*")
@spec assign_socket(term()) :: term()
def assign_socket(socket) do
case socket.assigns[:current_tab] do
%{type: :menu_editor, id: tab_id} ->
state = State.ensure_state(socket.assigns)
menu_editor = State.build(socket.assigns, state)
@spec update(map(), Phoenix.LiveView.Socket.t()) :: {:ok, Phoenix.LiveView.Socket.t()}
@impl true
def update(%{action: :save} = assigns, socket) do
socket = assign(socket, Map.drop(assigns, [:action]))
socket = do_save(socket)
{:ok, socket}
end
def update(assigns, socket) do
socket =
socket
|> assign(:menu_editor_state, state)
|> assign(:menu_editor, menu_editor)
|> assign(
:tab_meta,
Map.put(socket.assigns.tab_meta, {:menu_editor, tab_id}, %{
title: translated("menuEditor.tabTitle"),
subtitle: translated("menuEditor.description")
})
)
|> assign(assigns)
|> ensure_project_assigns()
|> build_data()
_other ->
assign(socket, :menu_editor, nil)
end
{:ok, socket}
end
@spec select_item(term(), term(), term()) :: term()
def select_item(socket, item_id, reload) do
@spec render(map()) :: Phoenix.LiveView.Rendered.t()
@impl true
def render(assigns) do
menu_editor(assigns)
end
@spec handle_event(String.t(), map(), Phoenix.LiveView.Socket.t()) ::
{:noreply, Phoenix.LiveView.Socket.t()}
@impl true
def handle_event("menu_editor_select_item", %{"item_id" => item_id}, socket) do
socket =
socket
|> State.update_state(fn state -> %{state | selected_id: item_id} end)
|> reload.(socket.assigns.workbench)
|> build_data()
{:noreply, socket}
end
@spec change_entry(term(), term(), term()) :: term()
def change_entry(socket, params, reload) do
def handle_event("change_menu_editor_entry", %{"menu_editor_entry" => params}, socket) do
query = Map.get(params, "query", "")
socket =
socket
|> State.update_state(fn state -> put_in(state, [:draft, :query], query) end)
|> reload.(socket.assigns.workbench)
|> build_data()
{:noreply, socket}
end
@spec submit_entry(term(), term()) :: term()
def submit_entry(socket, reload) do
def handle_event("submit_menu_editor_entry", _params, socket) do
socket =
case DraftManagement.current_draft(socket.assigns) do
%{type: :page} ->
socket
|> State.update_state(&DraftManagement.finalize_submenu_draft/1)
|> reload.(socket.assigns.workbench)
|> build_data()
%{type: :category} ->
socket
|> DraftManagement.confirm_category_draft(&State.update_state/2)
|> reload.(socket.assigns.workbench)
|> build_data()
_other ->
reload.(socket, socket.assigns.workbench)
end
socket
end
@spec cancel_entry(term(), term()) :: term()
def cancel_entry(socket, reload) do
{:noreply, socket}
end
def handle_event("cancel_menu_editor_entry", _params, socket) do
socket =
socket
|> State.update_state(&DraftManagement.cancel_draft/1)
|> reload.(socket.assigns.workbench)
|> build_data()
{:noreply, socket}
end
@spec select_page(term(), term(), term()) :: term()
def select_page(socket, post_id, reload) do
def handle_event("select_menu_editor_page", %{"post_id" => post_id}, socket) do
socket =
case PageCategory.page_post(socket.assigns.projects.active_project_id, post_id) do
nil ->
reload.(socket, socket.assigns.workbench)
socket
post ->
socket
|> State.update_state(&DraftManagement.assign_page_to_draft(&1, post))
|> reload.(socket.assigns.workbench)
end
|> build_data()
end
@spec select_category(term(), term(), term()) :: term()
def select_category(socket, name, reload) do
{:noreply, socket}
end
def handle_event("select_menu_editor_category", %{"name" => name}, socket) do
project_id = socket.assigns.projects.active_project_id
socket =
case Enum.find(PageCategory.category_options(project_id), &(&1.name == name)) do
nil ->
reload.(socket, socket.assigns.workbench)
socket
category ->
socket
|> State.update_state(&DraftManagement.assign_category_to_draft(&1, category))
|> reload.(socket.assigns.workbench)
end
|> build_data()
end
@spec toolbar_action(term(), term(), term(), term()) :: term()
def toolbar_action(socket, action, reload, append_output) do
case action do
"add-entry" ->
socket
|> State.update_state(&DraftManagement.start_page_draft/1)
|> reload.(socket.assigns.workbench)
"add-category-archive" ->
socket
|> State.update_state(&DraftManagement.start_category_draft/1)
|> reload.(socket.assigns.workbench)
"save" ->
State.save(socket, reload, append_output)
"move-up" ->
socket
|> State.update_state(&TreeOps.move_selected(&1, :up))
|> reload.(socket.assigns.workbench)
"move-down" ->
socket
|> State.update_state(&TreeOps.move_selected(&1, :down))
|> reload.(socket.assigns.workbench)
"indent" ->
socket
|> State.update_state(&TreeOps.indent_selected/1)
|> reload.(socket.assigns.workbench)
"unindent" ->
socket
|> State.update_state(&TreeOps.unindent_selected/1)
|> reload.(socket.assigns.workbench)
"delete" ->
socket
|> State.update_state(&TreeOps.delete_selected/1)
|> reload.(socket.assigns.workbench)
_other ->
reload.(socket, socket.assigns.workbench)
end
{:noreply, socket}
end
@spec drop_item(term(), term(), term(), term(), term()) :: term()
def drop_item(socket, drag_item_id, target_item_id, position, reload) do
def handle_event("menu_editor_toolbar_action", %{"action" => action}, socket) do
socket = handle_toolbar_action(socket, action)
{:noreply, socket}
end
def handle_event(
"menu_editor_drop_item",
%{
"drag_item_id" => drag_item_id,
"target_item_id" => target_item_id,
"position" => position
},
socket
) do
socket =
socket
|> State.update_state(&TreeOps.drop_selected(&1, drag_item_id, target_item_id, position))
|> reload.(socket.assigns.workbench)
|> build_data()
{:noreply, socket}
end
@spec handle_keydown(term(), term(), term()) :: term()
def handle_keydown(socket, "Escape", reload) do
cancel_entry(socket, reload)
def handle_event("menu_editor_keydown", %{"key" => "Escape"}, socket) do
socket =
socket
|> State.update_state(&DraftManagement.cancel_draft/1)
|> build_data()
{:noreply, socket}
end
def handle_keydown(socket, _key, reload) do
reload.(socket, socket.assigns.workbench)
def handle_event("menu_editor_keydown", _params, socket) do
{:noreply, socket}
end
defp handle_toolbar_action(socket, "add-entry") do
socket
|> State.update_state(&DraftManagement.start_page_draft/1)
|> build_data()
end
defp handle_toolbar_action(socket, "add-category-archive") do
socket
|> State.update_state(&DraftManagement.start_category_draft/1)
|> build_data()
end
defp handle_toolbar_action(socket, "save") do
do_save(socket)
end
defp handle_toolbar_action(socket, "move-up") do
socket
|> State.update_state(&TreeOps.move_selected(&1, :up))
|> build_data()
end
defp handle_toolbar_action(socket, "move-down") do
socket
|> State.update_state(&TreeOps.move_selected(&1, :down))
|> build_data()
end
defp handle_toolbar_action(socket, "indent") do
socket
|> State.update_state(&TreeOps.indent_selected/1)
|> build_data()
end
defp handle_toolbar_action(socket, "unindent") do
socket
|> State.update_state(&TreeOps.unindent_selected/1)
|> build_data()
end
defp handle_toolbar_action(socket, "delete") do
socket
|> State.update_state(&TreeOps.delete_selected/1)
|> build_data()
end
defp handle_toolbar_action(socket, _other), do: socket
defp do_save(socket) do
state = socket.assigns.menu_editor_state
{:ok, _menu} =
BDS.Menu.update_menu(
state.project_id,
Enum.map(state.items, &TreeOps.persisted_item/1)
)
notify_output(dgettext("ui", "Blog Menu"), dgettext("ui", "Blog menu saved"), "info")
socket |> build_data()
end
defp ensure_project_assigns(socket) do
project_id = socket.assigns.project_id
if Map.has_key?(socket.assigns, :projects) do
socket
else
assign(socket, :projects, %{active_project_id: project_id})
end
end
defp build_data(socket) do
tab_id = socket.assigns.current_tab.id
state = State.ensure_state(socket.assigns)
menu_editor = State.build(socket.assigns, state)
tab_meta =
Map.put(socket.assigns.tab_meta, {:menu_editor, tab_id}, %{
title: dgettext("ui", "Blog Menu"),
subtitle:
dgettext(
"ui",
"Manage the central blog navigation outline and save it to meta/menu.opml."
)
})
socket
|> assign(:menu_editor_state, state)
|> assign(:menu_editor, menu_editor)
|> assign(:tab_meta, tab_meta)
end
defp notify_output(title, message, level) do
Notify.output(title, message, level)
end
attr(:menu_editor, :map, required: true)
@@ -177,6 +264,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
attr(:items, :list, required: true)
attr(:menu_editor, :map, required: true)
attr(:depth, :integer, required: true)
attr(:myself, :any, required: true)
@spec menu_tree_level(term()) :: term()
def menu_tree_level(assigns) do
@@ -199,9 +287,10 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
data-selected={to_string(selected?)}
phx-click={unless(editing?, do: "menu_editor_select_item")}
phx-value-item_id={unless(editing?, do: item.item_id)}
phx-target={@myself}
style={"--menu-editor-depth: #{@depth};"}
>
<span class="menu-editor-row-handle" data-menu-drag-handle="true" title={translated("menuEditor.dragHandle")}>⋮⋮</span>
<span class="menu-editor-row-handle" data-menu-drag-handle="true" title={dgettext("ui", "Drag menu item")}>⋮⋮</span>
<span class="menu-editor-row-kind" title={kind_label(item.kind)} aria-label={kind_label(item.kind)}>
<.kind_icon kind={item.kind} />
</span>
@@ -213,6 +302,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
data-testid="menu-editor-entry-form"
phx-change="change_menu_editor_entry"
phx-submit="submit_menu_editor_entry"
phx-target={@myself}
>
<input
class="menu-editor-inline-input"
@@ -233,16 +323,16 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
<div class="menu-editor-inline-actions">
<%= if @menu_editor.draft.type == :page do %>
<button class="menu-editor-inline-action" data-testid="menu-editor-create-submenu" type="submit">
<%= translated("menuEditor.addSubmenu") %>
<%= dgettext("ui", "Add Submenu") %>
</button>
<% else %>
<button class="menu-editor-inline-action" type="submit">
<%= translated("menuEditor.addCategoryArchive") %>
<%= dgettext("ui", "Add Category Archive") %>
</button>
<% end %>
<button class="menu-editor-inline-action" type="button" phx-click="cancel_menu_editor_entry">
<%= translated("Cancel") %>
<button class="menu-editor-inline-action" type="button" phx-click="cancel_menu_editor_entry" phx-target={@myself}>
<%= dgettext("ui", "Cancel") %>
</button>
</div>
</div>
@@ -250,7 +340,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
<%= if @menu_editor.draft.type == :page do %>
<div class="menu-editor-picker-list">
<%= if @menu_editor.filtered_pages == [] do %>
<div class="menu-editor-picker-state"><%= translated("menuEditor.pagePicker.empty") %></div>
<div class="menu-editor-picker-state"><%= dgettext("ui", "No matching pages found.") %></div>
<% else %>
<%= for post <- @menu_editor.filtered_pages do %>
<button
@@ -258,6 +348,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
type="button"
phx-click="select_menu_editor_page"
phx-value-post_id={post.id}
phx-target={@myself}
>
<span><%= post.title %></span>
<small><%= post.slug %></small>
@@ -268,7 +359,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
<% else %>
<div class="menu-editor-picker-list">
<%= if @menu_editor.filtered_categories == [] do %>
<div class="menu-editor-picker-state"><%= translated("menuEditor.categoryPicker.empty") %></div>
<div class="menu-editor-picker-state"><%= dgettext("ui", "No matching categories found.") %></div>
<% else %>
<%= for category <- @menu_editor.filtered_categories do %>
<button
@@ -276,6 +367,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
type="button"
phx-click="select_menu_editor_category"
phx-value-name={category.name}
phx-target={@myself}
>
<span><%= category.title %></span>
<small><%= category.name %></small>
@@ -294,7 +386,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
<%= if item.children != [] do %>
<ul class="menu-editor-tree-level">
<.menu_tree_level items={item.children} menu_editor={@menu_editor} depth={@depth + 1} />
<.menu_tree_level items={item.children} menu_editor={@menu_editor} depth={@depth + 1} myself={@myself} />
</ul>
<% end %>
</li>
@@ -320,10 +412,6 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
"""
end
@spec translated(term(), term()) :: term()
def translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
@spec row_label(term(), term()) :: term()
def row_label(item, category_titles) do
if item.kind == :category_archive do
@@ -334,24 +422,27 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
end
@spec kind_label(term()) :: term()
def kind_label(:home), do: translated("menuEditor.type.home")
def kind_label(:page), do: translated("menuEditor.type.page")
def kind_label(:category_archive), do: translated("menuEditor.type.categoryArchive")
def kind_label(:submenu), do: translated("menuEditor.type.submenu")
def kind_label(:home), do: dgettext("ui", "Home")
def kind_label(:page), do: dgettext("ui", "Page")
def kind_label(:category_archive), do: dgettext("ui", "Category Archive")
def kind_label(:submenu), do: dgettext("ui", "Submenu")
defdelegate draft_item?(menu_editor, item_id), to: TreePredicates
@spec editing_title(term()) :: term()
def editing_title(%{draft: %{type: :category}}), do: translated("menuEditor.addCategoryArchive")
def editing_title(_menu_editor), do: translated("menuEditor.pagePicker.title")
def editing_title(%{draft: %{type: :category}}), do: dgettext("ui", "Add Category Archive")
def editing_title(_menu_editor), do: dgettext("ui", "Select Page")
@spec editing_hint(term()) :: term()
def editing_hint(%{draft: %{type: :category}}), do: translated("menuEditor.categoryPicker.hint")
def editing_hint(_menu_editor), do: translated("menuEditor.createHint")
def editing_hint(%{draft: %{type: :category}}),
do: dgettext("ui", "Select an existing category or press Enter to create a new archive entry")
def editing_hint(_menu_editor),
do: dgettext("ui", "Select a page below or press Enter to create a submenu")
@spec editing_placeholder(term()) :: term()
def editing_placeholder(%{draft: %{type: :category}}),
do: translated("menuEditor.newCategoryPlaceholder")
do: dgettext("ui", "Type a category name")
def editing_placeholder(_menu_editor), do: translated("menuEditor.newEntryPlaceholder")
def editing_placeholder(_menu_editor), do: dgettext("ui", "Type a page title or submenu label")
end

View File

@@ -1,10 +1,10 @@
defmodule BDS.Desktop.ShellLive.MenuEditor.DraftManagement do
@moduledoc false
alias BDS.Desktop.ShellData
alias BDS.Metadata
alias BDS.Desktop.ShellLive.MenuEditor.PageCategory
alias BDS.Desktop.ShellLive.MenuEditor.TreeOps
use Gettext, backend: BDS.Gettext
@spec current_draft(term()) :: term()
def current_draft(assigns), do: Map.get(assigns.menu_editor_state || %{}, :draft)
@@ -14,7 +14,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.DraftManagement do
item = %{
item_id: Ecto.UUID.generate(),
kind: :page,
label: translated("menuEditor.newPage"),
label: dgettext("ui", "New Page"),
slug: nil,
children: [],
is_home: false
@@ -57,7 +57,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.DraftManagement do
def finalize_submenu_draft(%{draft: %{item_id: item_id, query: query}} = state) do
label =
if(String.trim(query) == "",
do: translated("menuEditor.newSubmenu"),
do: dgettext("ui", "New Submenu"),
else: String.trim(query)
)
@@ -144,7 +144,4 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.DraftManagement do
update_state_fun.(socket, &assign_category_to_draft(&1, category))
end
defp translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
end

View File

@@ -3,9 +3,9 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.State do
use Phoenix.Component
alias BDS.Desktop.ShellData
alias BDS.Menu
alias BDS.Desktop.ShellLive.MenuEditor.{PageCategory, TreeOps, TreePredicates}
use Gettext, backend: BDS.Gettext
@spec ensure_state(term()) :: term()
def ensure_state(assigns) do
@@ -30,8 +30,12 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.State do
draft_query = Map.get(draft || %{}, :query, "")
%{
title: translated("menuEditor.title"),
description: translated("menuEditor.description"),
title: dgettext("ui", "Blog Menu Editor"),
description:
dgettext(
"ui",
"Manage the central blog navigation outline and save it to meta/menu.opml."
),
items: state.items,
selected_id: state.selected_id,
draft: draft,
@@ -66,8 +70,8 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.State do
socket
|> append_output.(
translated("menuEditor.tabTitle"),
translated("menuEditor.saved"),
dgettext("ui", "Blog Menu"),
dgettext("ui", "Blog menu saved"),
nil,
"info"
)
@@ -94,7 +98,4 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.State do
draft: nil
}
end
defp translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
end

View File

@@ -1,53 +1,53 @@
<div class="menu-editor-view" data-testid="menu-editor" phx-window-keydown={if(@menu_editor.draft, do: "menu_editor_keydown")}>
<div class="menu-editor-header">
<div>
<div class="menu-editor-view ui-editor-shell flex h-full min-h-0 flex-col p-4" data-testid="menu-editor" phx-window-keydown={if(@menu_editor.draft, do: "menu_editor_keydown")} phx-target={@myself}>
<div class="menu-editor-header flex shrink-0 items-start justify-between gap-3">
<div class="ui-field-stack">
<h2><%= @menu_editor.title %></h2>
<p><%= @menu_editor.description %></p>
</div>
</div>
<div class="menu-editor-main">
<div class="menu-editor-tree-wrap">
<div class="menu-editor-toolbar" data-testid="menu-editor-toolbar" role="toolbar" aria-label={@menu_editor.title}>
<button class="menu-editor-tool" data-testid="menu-editor-toolbar-button" data-action="add-entry" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="add-entry" title={translated("menuEditor.addEntry")}>
<div class="menu-editor-main min-h-0 flex-1 overflow-hidden">
<div class="menu-editor-tree-wrap ui-section-card flex h-full min-h-0 flex-col">
<div class="menu-editor-toolbar ui-toolbar flex flex-wrap items-center gap-2" data-testid="menu-editor-toolbar" role="toolbar" aria-label={@menu_editor.title}>
<button class="menu-editor-tool inline-flex h-9 min-w-9 items-center justify-center" data-testid="menu-editor-toolbar-button" data-action="add-entry" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="add-entry" phx-target={@myself} title={dgettext("ui", "menuEditor.addEntry")}>
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M7 2h2v5h5v2H9v5H7V9H2V7h5V2z" /></svg>
</button>
<button class="menu-editor-tool" data-testid="menu-editor-toolbar-button" data-action="save" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="save" title={translated("menuEditor.save")}>
<button class="menu-editor-tool inline-flex h-9 min-w-9 items-center justify-center" data-testid="menu-editor-toolbar-button" data-action="save" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="save" phx-target={@myself} title={dgettext("ui", "menuEditor.save")}>
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M2 2h9l3 3v9H2V2zm2 1v3h6V3H4zm0 9h8V7H4v5z" /></svg>
</button>
<button class="menu-editor-tool" data-testid="menu-editor-toolbar-button" data-action="add-category-archive" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="add-category-archive" title={translated("menuEditor.addCategoryArchive")}>
<span aria-hidden="true"><%= translated("menuEditor.addCategoryArchiveShort") %></span>
<button class="menu-editor-tool inline-flex h-9 min-w-9 items-center justify-center" data-testid="menu-editor-toolbar-button" data-action="add-category-archive" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="add-category-archive" phx-target={@myself} title={dgettext("ui", "menuEditor.addCategoryArchive")}>
<span aria-hidden="true"><%= dgettext("ui", "menuEditor.addCategoryArchiveShort") %></span>
</button>
<button class="menu-editor-tool" data-testid="menu-editor-toolbar-button" data-action="move-up" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="move-up" title={translated("menuEditor.moveUp")} disabled={not @menu_editor.can_move_up?}>
<button class="menu-editor-tool inline-flex h-9 min-w-9 items-center justify-center" data-testid="menu-editor-toolbar-button" data-action="move-up" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="move-up" phx-target={@myself} title={dgettext("ui", "menuEditor.moveUp")} disabled={not @menu_editor.can_move_up?}>
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M8 3l4 4H9v6H7V7H4l4-4z" /></svg>
</button>
<button class="menu-editor-tool" data-testid="menu-editor-toolbar-button" data-action="move-down" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="move-down" title={translated("menuEditor.moveDown")} disabled={not @menu_editor.can_move_down?}>
<button class="menu-editor-tool inline-flex h-9 min-w-9 items-center justify-center" data-testid="menu-editor-toolbar-button" data-action="move-down" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="move-down" phx-target={@myself} title={dgettext("ui", "menuEditor.moveDown")} disabled={not @menu_editor.can_move_down?}>
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M7 3h2v6h3l-4 4-4-4h3V3z" /></svg>
</button>
<button class="menu-editor-tool" data-testid="menu-editor-toolbar-button" data-action="indent" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="indent" title={translated("menuEditor.indent")} disabled={not @menu_editor.can_indent?}>
<button class="menu-editor-tool inline-flex h-9 min-w-9 items-center justify-center" data-testid="menu-editor-toolbar-button" data-action="indent" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="indent" phx-target={@myself} title={dgettext("ui", "menuEditor.indent")} disabled={not @menu_editor.can_indent?}>
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M2 4h8v2H2V4zm0 3h4v2H2V7zm0 3h8v2H2v-2zm6-1 3 2-3 2V9z" /></svg>
</button>
<button class="menu-editor-tool" data-testid="menu-editor-toolbar-button" data-action="unindent" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="unindent" title={translated("menuEditor.unindent")} disabled={not @menu_editor.can_unindent?}>
<button class="menu-editor-tool inline-flex h-9 min-w-9 items-center justify-center" data-testid="menu-editor-toolbar-button" data-action="unindent" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="unindent" phx-target={@myself} title={dgettext("ui", "menuEditor.unindent")} disabled={not @menu_editor.can_unindent?}>
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M2 4h8v2H2V4zm0 3h4v2H2V7zm0 3h8v2H2v-2zm3-1-3 2 3 2V9z" /></svg>
</button>
<button class="menu-editor-tool" data-testid="menu-editor-toolbar-button" data-action="delete" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="delete" title={translated("menuEditor.delete")} disabled={not @menu_editor.can_delete?}>
<button class="menu-editor-tool inline-flex h-9 min-w-9 items-center justify-center" data-testid="menu-editor-toolbar-button" data-action="delete" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="delete" phx-target={@myself} title={dgettext("ui", "menuEditor.delete")} disabled={not @menu_editor.can_delete?}>
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M6 2h4l1 1h3v2H2V3h3l1-1zm-1 4h2v6H5V6zm4 0h2v6H9V6z" /></svg>
</button>
</div>
<%= if @menu_editor.items == [] do %>
<div class="menu-editor-empty"><%= translated("menuEditor.empty") %></div>
<div class="menu-editor-empty flex min-h-0 flex-1 items-center justify-center"><%= dgettext("ui", "menuEditor.empty") %></div>
<% else %>
<div id="menu-editor-tree-shell" class="menu-editor-tree-shell" phx-hook="MenuEditorTree">
<div id="menu-editor-tree-shell" class="menu-editor-tree-shell min-h-0 flex-1 overflow-auto" phx-hook="MenuEditorTree">
<ul class="menu-editor-tree-level">
<.menu_tree_level items={@menu_editor.items} menu_editor={@menu_editor} depth={0} />
<.menu_tree_level items={@menu_editor.items} menu_editor={@menu_editor} depth={0} myself={@myself} />
</ul>
</div>
<% end %>

View File

@@ -1,18 +1,22 @@
defmodule BDS.Desktop.ShellLive.MiscEditor do
@moduledoc false
use Phoenix.Component
use Phoenix.LiveComponent
import Phoenix.HTML, only: [raw: 1]
import Ecto.Query
alias BDS.{Embeddings, Generation, Git, Posts, Repo}
alias BDS.Desktop.ShellData
alias BDS.{Embeddings, Generation, Git, HelpDocs, Posts, Repo}
alias BDS.Desktop.ShellLive.Notify
alias BDS.MapUtils
alias BDS.Settings.Setting
use Gettext, backend: BDS.Gettext
embed_templates("misc_editor_html/*")
@misc_routes [
:documentation,
:api_documentation,
:site_validation,
:metadata_diff,
:translation_validation,
@@ -20,30 +24,60 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
:git_diff
]
@spec assign_socket(term()) :: term()
def assign_socket(socket) do
assign(socket, :misc_editor, build(socket.assigns))
# ── LiveComponent lifecycle ────────────────────────────────────────────────
@spec update(map(), Phoenix.LiveView.Socket.t()) :: {:ok, Phoenix.LiveView.Socket.t()}
@impl true
def update(%{current_tab: current_tab, tab_meta: tab_meta, project_id: project_id}, socket) do
socket =
socket
|> assign(:current_tab, current_tab)
|> assign(:project_id, project_id)
|> assign(:tab_meta, tab_meta)
|> ensure_state()
|> build_data()
{:ok, socket}
end
@spec rerun(term()) :: term()
def rerun(socket) do
case meta(socket.assigns) do
%{action: action} when is_binary(action) ->
{:command, action}
_other ->
case misc_route_action(socket.assigns.current_tab.type) do
nil -> {:noop, socket}
action -> {:command, action}
end
end
defp ensure_state(socket) do
socket
|> assign_new(:selected_pairs, fn -> MapSet.new() end)
|> assign_new(:git_selected_file, fn -> nil end)
|> assign_new(:active_tab, fn -> nil end)
|> assign_new(:active_field, fn -> nil end)
end
@spec apply_site_validation(term(), term()) :: term()
def apply_site_validation(socket, append_output) do
defp build_data(socket) do
misc_editor = do_build(socket.assigns)
assign(socket, :misc_editor, misc_editor)
end
@spec render(map()) :: Phoenix.LiveView.Rendered.t()
@impl true
def render(assigns) do
misc_editor(assigns)
end
# ── Event handlers ─────────────────────────────────────────────────────────
@spec handle_event(String.t(), map(), Phoenix.LiveView.Socket.t()) ::
{:noreply, Phoenix.LiveView.Socket.t()}
@impl true
def handle_event("rerun_misc_editor", _params, socket) do
action = rerun_action(socket.assigns)
if action do
notify_command(action)
end
{:noreply, socket}
end
def handle_event("apply_site_validation", _params, socket) do
meta = meta(socket.assigns)
payload = Map.get(meta, :payload, %{})
project_id = Map.get(meta, :project_id, socket.assigns.projects.active_project_id)
project_id = Map.get(meta, :project_id, socket.assigns.project_id)
report = %{
sitemap_path: Map.get(payload, :sitemap_path),
@@ -57,98 +91,22 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
case Generation.apply_validation(project_id, report) do
{:ok, result} ->
{:rerun,
socket
|> append_output.(
translated("Site Validation"),
translated("Validation changes applied"),
notify_output(
dgettext("ui", "Site Validation"),
dgettext("ui", "Validation changes applied"),
inspect(result)
)}
)
notify_command("validate_site")
{:noreply, socket}
end
rescue
error ->
{:socket,
append_output.(socket, translated("Site Validation"), inspect(error), nil, "error")}
notify_output(dgettext("ui", "Site Validation"), inspect(error), nil, "error")
{:noreply, socket}
end
@spec toggle_duplicate(term(), term(), term()) :: term()
def toggle_duplicate(socket, pair_id, reload) do
selected_by_tab = Map.get(socket.assigns, :misc_editor_selected_pairs, %{})
current = Map.get(selected_by_tab, socket.assigns.current_tab.id, MapSet.new())
next =
if MapSet.member?(current, pair_id) do
MapSet.delete(current, pair_id)
else
MapSet.put(current, pair_id)
end
socket
|> assign(
:misc_editor_selected_pairs,
Map.put(selected_by_tab, socket.assigns.current_tab.id, next)
)
|> reload.(socket.assigns.workbench)
end
@spec dismiss_duplicate(term(), term(), term(), term(), term()) :: term()
def dismiss_duplicate(socket, post_id_a, post_id_b, reload, append_output) do
case Embeddings.dismiss_duplicate_pair(post_id_a, post_id_b) do
{:ok, _saved_pair} ->
socket
|> update_payload(fn payload ->
update_in(payload[:pairs], fn pairs ->
Enum.reject(pairs || [], fn pair ->
pair_identity(pair) == pair_id(post_id_a, post_id_b)
end)
end)
end)
|> clear_selected_pair(pair_id(post_id_a, post_id_b))
|> append_output.(translated("Find Duplicates"), translated("Pair dismissed"))
|> reload.(socket.assigns.workbench)
{:error, reason} ->
socket
|> append_output.(translated("Find Duplicates"), inspect(reason), nil, "error")
|> reload.(socket.assigns.workbench)
end
end
@spec dismiss_selected(term(), term(), term()) :: term()
def dismiss_selected(socket, reload, append_output) do
tab_id = socket.assigns.current_tab.id
selected =
socket.assigns.misc_editor_selected_pairs
|> Map.get(tab_id, MapSet.new())
|> MapSet.to_list()
|> Enum.map(&decode_pair_id/1)
|> Enum.reject(&is_nil/1)
case Embeddings.dismiss_duplicate_pairs(selected) do
{:ok, _saved_pairs} ->
socket
|> update_payload(fn payload ->
update_in(payload[:pairs], fn pairs ->
Enum.reject(pairs || [], fn pair -> pair_identity(pair) in selected end)
end)
end)
|> assign(
:misc_editor_selected_pairs,
Map.put(socket.assigns.misc_editor_selected_pairs, tab_id, MapSet.new())
)
|> append_output.(translated("Find Duplicates"), translated("Selected pairs dismissed"))
|> reload.(socket.assigns.workbench)
{:error, reason} ->
socket
|> append_output.(translated("Find Duplicates"), inspect(reason), nil, "error")
|> reload.(socket.assigns.workbench)
end
end
@spec fix_translation_validation(term(), term()) :: term()
def fix_translation_validation(socket, append_output) do
def handle_event("fix_translation_validation", _params, socket) do
report =
socket.assigns
|> meta()
@@ -157,97 +115,282 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
{:ok, result} = Posts.fix_invalid_translations(report)
{:rerun,
socket
|> append_output.(
translated("Translation Validation"),
translated("translationValidation.toast.fixSuccess", %{
notify_output(
dgettext("ui", "Translation Validation"),
dgettext(
"ui",
"Deleted %{dbRows} DB rows and %{files} files, flushed %{flushed} translations to disk",
dbRows: result.deleted_database_rows,
files: result.deleted_files,
flushed: result.flushed_translations
})
)}
)
)
notify_command("validate_translations")
{:noreply, socket}
rescue
error ->
{:socket,
append_output.(socket, translated("Translation Validation"), inspect(error), nil, "error")}
notify_output(dgettext("ui", "Translation Validation"), inspect(error), nil, "error")
{:noreply, socket}
end
@spec select_git_diff_file(term(), term()) :: term()
def select_git_diff_file(socket, file_path) do
assign(
socket,
:misc_editor_git_selected_files,
Map.put(
socket.assigns.misc_editor_git_selected_files,
socket.assigns.current_tab.id,
file_path
)
)
def handle_event("select_git_diff_file", %{"path" => path}, socket) do
{:noreply, assign(socket, :git_selected_file, path) |> build_data()}
end
@spec metadata_diff_repair_request(term(), term(), term()) :: term()
def metadata_diff_repair_request(socket, field, direction) do
meta = meta(socket.assigns)
payload = Map.get(meta, :payload, %{})
items = Enum.map(Map.get(payload, :diff_reports, []), &normalize_metadata_diff_item/1)
tabs = metadata_diff_tabs(items, [])
active_tab = metadata_diff_active_tab(socket.assigns, tabs)
def handle_event("toggle_duplicate_pair", %{"pair-id" => pair_id}, socket) do
current = socket.assigns.selected_pairs
repair_items =
items
|> Enum.filter(&(&1.tab_id == active_tab))
|> Enum.filter(fn item -> Enum.any?(item.differences, &(diff_name(&1) == field)) end)
|> Enum.map(&%{"entity_type" => &1.entity_type, "entity_id" => &1.entity_id})
cond do
not metadata_diff_repairable_tab?(active_tab) ->
{:error, translated("No repair action available")}
repair_items == [] ->
{:error, translated("No metadata diff items selected")}
true ->
{:ok,
%{
"direction" => direction,
"field" => field,
"tab" => active_tab,
"items" => repair_items
}}
end
end
@spec metadata_diff_orphan_import_request(term()) :: term()
def metadata_diff_orphan_import_request(socket) do
meta = meta(socket.assigns)
payload = Map.get(meta, :payload, %{})
items = Enum.map(Map.get(payload, :diff_reports, []), &normalize_metadata_diff_item/1)
orphan_files =
Enum.map(Map.get(payload, :orphan_reports, []), &normalize_metadata_diff_orphan/1)
tabs = metadata_diff_tabs(items, orphan_files)
active_tab = metadata_diff_active_tab(socket.assigns, tabs)
selected_orphans =
orphan_files
|> Enum.filter(&(&1.tab_id == active_tab))
|> Enum.map(&%{"file_path" => &1.file_path})
if selected_orphans == [] do
{:error, translated("No orphan files selected")}
next =
if MapSet.member?(current, pair_id) do
MapSet.delete(current, pair_id)
else
{:ok, %{"tab" => active_tab, "orphans" => selected_orphans}}
MapSet.put(current, pair_id)
end
{:noreply, assign(socket, :selected_pairs, next) |> build_data()}
end
def handle_event(
"dismiss_duplicate_pair",
%{"post-id-a" => post_id_a, "post-id-b" => post_id_b},
socket
) do
case Embeddings.dismiss_duplicate_pair(post_id_a, post_id_b) do
{:ok, _saved_pair} ->
tab_type = socket.assigns.current_tab.type
tab_id = socket.assigns.current_tab.id
pair_id = pair_id(post_id_a, post_id_b)
payload = Map.get(meta(socket.assigns), :payload, %{})
next_pairs =
update_in(payload[:pairs], fn pairs ->
Enum.reject(pairs || [], fn pair ->
pair_identity(pair) == pair_id
end)
end)
next_payload = Map.put(payload, :pairs, next_pairs)
notify_tab_meta(tab_type, tab_id, %{payload: next_payload})
notify_output(dgettext("ui", "Find Duplicates"), dgettext("ui", "Pair dismissed"))
selected = MapSet.delete(socket.assigns.selected_pairs, pair_id)
{:noreply, assign(socket, :selected_pairs, selected) |> build_data()}
{:error, reason} ->
notify_output(dgettext("ui", "Find Duplicates"), inspect(reason), nil, "error")
{:noreply, socket}
end
end
@spec build(term()) :: term()
def build(%{current_tab: %{type: type}} = assigns) when type in @misc_routes do
def handle_event("dismiss_selected_duplicates", _params, socket) do
selected =
socket.assigns.selected_pairs
|> MapSet.to_list()
|> Enum.map(&decode_pair_id/1)
|> Enum.reject(&is_nil/1)
case Embeddings.dismiss_duplicate_pairs(selected) do
{:ok, _saved_pairs} ->
tab_type = socket.assigns.current_tab.type
tab_id = socket.assigns.current_tab.id
payload = Map.get(meta(socket.assigns), :payload, %{})
next_pairs =
update_in(payload[:pairs], fn pairs ->
Enum.reject(pairs || [], fn pair -> pair_identity(pair) in selected end)
end)
next_payload = Map.put(payload, :pairs, next_pairs)
notify_tab_meta(tab_type, tab_id, %{payload: next_payload})
notify_output(
dgettext("ui", "Find Duplicates"),
dgettext("ui", "Selected pairs dismissed")
)
{:noreply, assign(socket, :selected_pairs, MapSet.new()) |> build_data()}
{:error, reason} ->
notify_output(dgettext("ui", "Find Duplicates"), inspect(reason), nil, "error")
{:noreply, socket}
end
end
def handle_event("repair_metadata_diff", %{"field" => field, "direction" => direction}, socket) do
case metadata_diff_repair_request(socket.assigns, field, direction) do
{:ok, params} ->
notify_command("repair_metadata_diff", params)
{:noreply, socket}
{:error, message} ->
notify_output(dgettext("ui", "Metadata Diff"), message, nil, "error")
{:noreply, socket}
end
end
def handle_event("import_metadata_diff_orphans", _params, socket) do
case metadata_diff_orphan_import_request(socket.assigns) do
{:ok, params} ->
notify_command("import_metadata_diff_orphans", params)
{:noreply, socket}
{:error, message} ->
notify_output(dgettext("ui", "Metadata Diff"), message, nil, "error")
{:noreply, socket}
end
end
def handle_event("select_metadata_diff_tab", %{"tab" => tab}, socket) do
{:noreply,
socket
|> assign(:active_tab, tab)
|> assign(:active_field, nil)
|> build_data()}
end
def handle_event("toggle_metadata_diff_field", %{"field" => field}, socket) do
current = socket.assigns.active_field
next = if current == field, do: nil, else: field
{:noreply, assign(socket, :active_field, next) |> build_data()}
end
def handle_event("open_duplicate_post", %{"id" => id, "title" => title}, socket) do
notify_open_sidebar_item(
%{"route" => "post", "id" => id, "title" => title, "subtitle" => "draft"},
:preview
)
{:noreply, socket}
end
# ── Public helper functions (used by template) ─────────────────────────────
@spec misc_class(atom()) :: String.t()
def misc_class(:documentation), do: "help-doc-view"
def misc_class(:api_documentation), do: "help-doc-view"
def misc_class(:site_validation), do: "site-validation-view"
def misc_class(:metadata_diff), do: "metadata-diff-view"
def misc_class(:translation_validation), do: "translation-validation-view"
def misc_class(:find_duplicates), do: "duplicates-view"
def misc_class(:git_diff), do: "git-diff-view"
@spec markdown_html(String.t()) :: Phoenix.HTML.safe()
def markdown_html(content) do
html =
case Earmark.as_html(content || "", escape: true) do
{:ok, rendered, _messages} -> rendered
{:error, rendered, _messages} -> rendered
end
raw(html)
end
@spec refreshable?(atom()) :: boolean()
def refreshable?(kind), do: kind not in [:documentation, :api_documentation]
@spec summary_items(map()) :: [{String.t(), any()}]
def summary_items(%{summary: summary}) when is_map(summary), do: Enum.to_list(summary)
def summary_items(_misc), do: []
@spec duplicate_checked?(map(), String.t()) :: boolean()
def duplicate_checked?(misc, pair_id), do: MapSet.member?(misc.selected_pairs, pair_id)
@spec pair_id_from_pair(map()) :: String.t()
def pair_id_from_pair(pair), do: pair_identity(pair)
@spec translation_issue_label(map()) :: String.t()
def translation_issue_label(issue) do
case issue_value(issue, :issue) do
"same-language-as-canonical" ->
dgettext("ui", "Translation language matches canonical post language")
"do-not-translate-has-translations" ->
dgettext("ui", "Post is marked as do-not-translate but has translations")
"content-in-database" ->
dgettext("ui", "Published translation has content stuck in DB instead of filesystem")
_other ->
dgettext("ui", "Translation points to a missing source post")
end
end
@spec translation_issue_languages(map()) :: String.t()
def translation_issue_languages(issue) do
canonical_language = issue_value(issue, :canonical_language)
translation_language = issue_value(issue, :translation_language)
if canonical_language in [nil, ""] do
translation_language
else
dgettext("ui", "%{canonical} = %{translation}",
canonical: canonical_language,
translation: translation_language
)
end
end
@spec translation_issue_value(map(), atom() | String.t()) :: any()
def translation_issue_value(issue, key), do: issue_value(issue, key)
@spec git_diff_language(String.t() | nil) :: String.t()
def git_diff_language(nil), do: "plaintext"
def git_diff_language(file_path) do
case file_path |> Path.extname() |> String.downcase() do
".md" -> "markdown"
".markdown" -> "markdown"
".mdx" -> "markdown"
".ts" -> "typescript"
".tsx" -> "typescript"
".js" -> "javascript"
".jsx" -> "javascript"
".json" -> "json"
".css" -> "css"
".html" -> "html"
".yml" -> "yaml"
".yaml" -> "yaml"
_other -> "plaintext"
end
end
# ── Private helpers ────────────────────────────────────────────────────────
defp notify_command(action, params \\ %{}) do
Notify.command(action, params)
end
defp notify_output(title, message, detail \\ nil, level \\ "info") do
Notify.output(title, message, detail, level)
end
defp notify_tab_meta(tab_type, tab_id, updates) do
Notify.tab_meta_merge(tab_type, tab_id, updates)
end
defp notify_open_sidebar_item(params, intent) do
Notify.open_sidebar_item(params, intent)
end
defp rerun_action(assigns) do
case meta(assigns) do
%{action: action} when is_binary(action) ->
action
_other ->
misc_route_action(assigns.current_tab.type)
end
end
defp do_build(%{current_tab: %{type: type}} = assigns) when type in @misc_routes do
meta = meta(assigns)
payload = Map.get(meta, :payload, %{})
case type do
:documentation -> build_help_doc(type, meta)
:api_documentation -> build_help_doc(type, meta)
:site_validation -> build_site_validation(meta, payload)
:metadata_diff -> build_metadata_diff(assigns, meta, payload)
:translation_validation -> build_translation_validation(meta, payload)
@@ -256,36 +399,26 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
end
end
@spec build(term()) :: term()
def build(_assigns), do: nil
defp do_build(_assigns), do: nil
@spec translated(term(), term()) :: term()
def translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
defp build_help_doc(type, meta) do
help_doc = HelpDocs.fetch(type)
@spec misc_class(term()) :: term()
def misc_class(:site_validation), do: "site-validation-view"
def misc_class(:metadata_diff), do: "metadata-diff-view"
def misc_class(:translation_validation), do: "translation-validation-view"
def misc_class(:find_duplicates), do: "duplicates-view"
def misc_class(:git_diff), do: "git-diff-view"
def summary_items(%{summary: summary}) when is_map(summary), do: Enum.to_list(summary)
@spec summary_items(term()) :: term()
def summary_items(_misc), do: []
@spec duplicate_checked?(term(), term()) :: term()
def duplicate_checked?(misc, pair_id), do: MapSet.member?(misc.selected_pairs, pair_id)
@spec pair_id_from_pair(term()) :: term()
def pair_id_from_pair(pair), do: pair_identity(pair)
%{
kind: type,
title: Map.get(meta, :title, help_doc.title),
subtitle: Map.get(meta, :subtitle, help_doc.subtitle),
summary: %{},
markdown: help_doc.markdown
}
end
defp build_site_validation(meta, payload) do
summary = Map.get(payload, :summary, %{})
%{
kind: :site_validation,
title: Map.get(meta, :title, translated("Site Validation")),
title: Map.get(meta, :title, dgettext("ui", "Site Validation")),
subtitle: Map.get(meta, :subtitle, ""),
summary: %{
expected: Map.get(summary, :expected_count, 0),
@@ -311,8 +444,8 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
Enum.map(Map.get(payload, :orphan_reports, []), &normalize_metadata_diff_orphan/1)
tabs = metadata_diff_tabs(items, orphan_files)
active_tab = metadata_diff_active_tab(assigns, tabs)
active_field = metadata_diff_active_field(assigns)
active_tab = assigns.active_tab || metadata_diff_active_tab_fallback(tabs)
active_field = assigns.active_field
current_tab =
Enum.find(tabs, &(&1.id == active_tab)) || List.first(tabs) || empty_metadata_diff_tab()
@@ -321,7 +454,7 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
%{
kind: :metadata_diff,
title: Map.get(meta, :title, translated("Metadata Diff")),
title: Map.get(meta, :title, dgettext("ui", "Metadata Diff")),
subtitle: Map.get(meta, :subtitle, ""),
summary: Map.get(payload, :summary, %{}),
tabs:
@@ -332,7 +465,7 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
field_summaries: field_summaries(current_tab.items),
items: filtered_items,
orphan_files: if(is_nil(active_field), do: current_tab.orphan_files, else: []),
empty_message: translated("No items")
empty_message: dgettext("ui", "No items")
}
end
@@ -341,16 +474,18 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
%{
kind: :translation_validation,
title: Map.get(meta, :title, translated("Translation Validation")),
title: Map.get(meta, :title, dgettext("ui", "Translation Validation")),
subtitle: Map.get(meta, :subtitle, ""),
summary: %{},
summary_text:
translated("translationValidation.summary", %{
dgettext(
"ui",
"Checked DB rows: %{dbRows} · Checked files: %{files} · Invalid DB rows: %{invalidDb} · Invalid files: %{invalidFiles}",
dbRows: report.checked_database_row_count,
files: report.checked_filesystem_file_count,
invalidDb: length(report.invalid_database_rows),
invalidFiles: length(report.invalid_filesystem_files)
}),
),
invalid_database_rows: report.invalid_database_rows,
invalid_filesystem_files: report.invalid_filesystem_files,
can_fix?: report.invalid_database_rows != [] or report.invalid_filesystem_files != []
@@ -358,22 +493,19 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
end
defp build_duplicates(assigns, meta, payload) do
selected_pairs =
Map.get(assigns.misc_editor_selected_pairs, assigns.current_tab.id, MapSet.new())
%{
kind: :find_duplicates,
title: Map.get(meta, :title, translated("Find Duplicates")),
title: Map.get(meta, :title, dgettext("ui", "Find Duplicates")),
subtitle: Map.get(meta, :subtitle, ""),
summary: Map.get(payload, :summary, %{}),
pairs: Map.get(payload, :pairs, []),
selected_pairs: selected_pairs
selected_pairs: assigns.selected_pairs
}
end
defp build_git_diff(assigns, meta) do
project_id = assigns.projects.active_project_id
selected_files = Map.get(assigns, :misc_editor_git_selected_files, %{})
project_id = assigns.project_id
selected_file = assigns.git_selected_file
{files, diff, error_message} =
case Git.status(project_id) do
@@ -386,7 +518,11 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
|> Enum.sort()
selected_file_path =
select_git_diff_path(assigns.current_tab.id, file_paths, selected_files)
if selected_file in file_paths do
selected_file
else
List.first(file_paths)
end
diff =
case selected_file_path do
@@ -416,92 +552,71 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
%{
kind: :git_diff,
title: Map.get(meta, :title, translated("Git Diff")),
title: Map.get(meta, :title, dgettext("ui", "Git Diff")),
subtitle: Map.get(meta, :subtitle, ""),
summary: %{},
files: files,
selected_file_path: diff.file_path,
active_diff: Map.put(diff, :language, git_diff_language(diff.file_path)),
preferences: preferences,
empty_message: error_message || translated("No unstaged changes")
empty_message: error_message || dgettext("ui", "No unstaged changes")
}
end
@spec translation_issue_label(term()) :: term()
def translation_issue_label(issue) do
case issue_value(issue, :issue) do
"same-language-as-canonical" ->
translated("translationValidation.issue.sameLanguage")
"do-not-translate-has-translations" ->
translated("translationValidation.issue.doNotTranslate")
"content-in-database" ->
translated("translationValidation.issue.contentInDatabase")
_other ->
translated("translationValidation.issue.missingSource")
end
end
@spec translation_issue_languages(term()) :: term()
def translation_issue_languages(issue) do
canonical_language = issue_value(issue, :canonical_language)
translation_language = issue_value(issue, :translation_language)
if canonical_language in [nil, ""] do
translation_language
else
translated("translationValidation.languagesWithCanonical", %{
canonical: canonical_language,
translation: translation_language
})
end
end
@spec translation_issue_value(term(), term()) :: term()
def translation_issue_value(issue, key), do: issue_value(issue, key)
@spec git_diff_language(term()) :: term()
def git_diff_language(nil), do: "plaintext"
def git_diff_language(file_path) do
case file_path |> Path.extname() |> String.downcase() do
".md" -> "markdown"
".markdown" -> "markdown"
".mdx" -> "markdown"
".ts" -> "typescript"
".tsx" -> "typescript"
".js" -> "javascript"
".jsx" -> "javascript"
".json" -> "json"
".css" -> "css"
".html" -> "html"
".yml" -> "yaml"
".yaml" -> "yaml"
_other -> "plaintext"
end
end
defp meta(assigns) do
Map.get(assigns.tab_meta, {assigns.current_tab.type, assigns.current_tab.id}, %{})
end
defp update_payload(socket, updater) do
key = {socket.assigns.current_tab.type, socket.assigns.current_tab.id}
meta = Map.get(socket.assigns.tab_meta, key, %{})
next_meta = Map.update(meta, :payload, %{}, updater)
assign(socket, :tab_meta, Map.put(socket.assigns.tab_meta, key, next_meta))
defp metadata_diff_repair_request(assigns, field, direction) do
payload = Map.get(meta(assigns), :payload, %{})
items = Enum.map(Map.get(payload, :diff_reports, []), &normalize_metadata_diff_item/1)
tabs = metadata_diff_tabs(items, [])
active_tab = assigns.active_tab || metadata_diff_active_tab_fallback(tabs)
repair_items =
items
|> Enum.filter(&(&1.tab_id == active_tab))
|> Enum.filter(fn item -> Enum.any?(item.differences, &(diff_name(&1) == field)) end)
|> Enum.map(&%{"entity_type" => &1.entity_type, "entity_id" => &1.entity_id})
cond do
not metadata_diff_repairable_tab?(active_tab) ->
{:error, dgettext("ui", "No repair action available")}
repair_items == [] ->
{:error, dgettext("ui", "No metadata diff items selected")}
true ->
{:ok,
%{
"direction" => direction,
"field" => field,
"tab" => active_tab,
"items" => repair_items
}}
end
end
defp clear_selected_pair(socket, pair_id) do
tab_id = socket.assigns.current_tab.id
current = Map.get(socket.assigns.misc_editor_selected_pairs, tab_id, MapSet.new())
defp metadata_diff_orphan_import_request(assigns) do
payload = Map.get(meta(assigns), :payload, %{})
items = Enum.map(Map.get(payload, :diff_reports, []), &normalize_metadata_diff_item/1)
next_pairs =
Map.put(socket.assigns.misc_editor_selected_pairs, tab_id, MapSet.delete(current, pair_id))
orphan_files =
Enum.map(Map.get(payload, :orphan_reports, []), &normalize_metadata_diff_orphan/1)
assign(socket, :misc_editor_selected_pairs, next_pairs)
tabs = metadata_diff_tabs(items, orphan_files)
active_tab = assigns.active_tab || metadata_diff_active_tab_fallback(tabs)
selected_orphans =
orphan_files
|> Enum.filter(&(&1.tab_id == active_tab))
|> Enum.map(&%{"file_path" => &1.file_path})
if selected_orphans == [] do
{:error, dgettext("ui", "No orphan files selected")}
else
{:ok, %{"tab" => active_tab, "orphans" => selected_orphans}}
end
end
defp pair_id(post_id_a, post_id_b), do: Enum.sort([post_id_a, post_id_b]) |> Enum.join("::")
@@ -555,7 +670,7 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
defp empty_metadata_diff_tab do
%{
id: "posts",
label: translated("Posts"),
label: dgettext("ui", "Posts"),
items: [],
orphan_files: [],
diff_count: 0,
@@ -564,19 +679,9 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
}
end
defp metadata_diff_active_tab(assigns, tabs) do
tab_id = Map.get(assigns.metadata_diff_active_tabs || %{}, assigns.current_tab.id)
if Enum.any?(tabs, &(&1.id == tab_id)) do
tab_id
else
defp metadata_diff_active_tab_fallback(tabs) do
tabs |> List.first() |> Map.get(:id)
end
end
defp metadata_diff_active_field(assigns) do
Map.get(assigns.metadata_diff_field_filters || %{}, assigns.current_tab.id)
end
defp metadata_diff_filtered_items(items, nil), do: items
@@ -634,17 +739,17 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
MapUtils.attr(item, :meta_label) || entity_id
end
defp metadata_diff_item_type_label("post"), do: translated("Post")
defp metadata_diff_item_type_label("post_translation"), do: translated("Translations")
defp metadata_diff_item_type_label("media"), do: translated("Media")
defp metadata_diff_item_type_label("media_translation"), do: translated("Translations")
defp metadata_diff_item_type_label("script"), do: translated("Script")
defp metadata_diff_item_type_label("template"), do: translated("Template")
defp metadata_diff_item_type_label("project"), do: translated("Project")
defp metadata_diff_item_type_label("publishing"), do: translated("Publishing")
defp metadata_diff_item_type_label("categories"), do: translated("Categories")
defp metadata_diff_item_type_label("category_meta"), do: translated("Categories")
defp metadata_diff_item_type_label("embedding"), do: translated("Embeddings")
defp metadata_diff_item_type_label("post"), do: dgettext("ui", "Post")
defp metadata_diff_item_type_label("post_translation"), do: dgettext("ui", "Translations")
defp metadata_diff_item_type_label("media"), do: dgettext("ui", "Media")
defp metadata_diff_item_type_label("media_translation"), do: dgettext("ui", "Translations")
defp metadata_diff_item_type_label("script"), do: dgettext("ui", "Script")
defp metadata_diff_item_type_label("template"), do: dgettext("ui", "Template")
defp metadata_diff_item_type_label("project"), do: dgettext("ui", "Project")
defp metadata_diff_item_type_label("publishing"), do: dgettext("ui", "Publishing")
defp metadata_diff_item_type_label("categories"), do: dgettext("ui", "Categories")
defp metadata_diff_item_type_label("category_meta"), do: dgettext("ui", "Categories")
defp metadata_diff_item_type_label("embedding"), do: dgettext("ui", "Embeddings")
defp metadata_diff_item_type_label(entity_type),
do: entity_type |> String.replace("_", " ") |> String.capitalize()
@@ -662,12 +767,12 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
defp metadata_diff_tab_id("embedding"), do: "embeddings"
defp metadata_diff_tab_id(_entity_type), do: "project"
defp metadata_diff_tab_label("posts"), do: translated("Posts")
defp metadata_diff_tab_label("media"), do: translated("Media")
defp metadata_diff_tab_label("scripts"), do: translated("Scripts")
defp metadata_diff_tab_label("templates"), do: translated("Templates")
defp metadata_diff_tab_label("project"), do: translated("Project")
defp metadata_diff_tab_label("embeddings"), do: translated("Embeddings")
defp metadata_diff_tab_label("posts"), do: dgettext("ui", "Posts")
defp metadata_diff_tab_label("media"), do: dgettext("ui", "Media")
defp metadata_diff_tab_label("scripts"), do: dgettext("ui", "Scripts")
defp metadata_diff_tab_label("templates"), do: dgettext("ui", "Templates")
defp metadata_diff_tab_label("project"), do: dgettext("ui", "Project")
defp metadata_diff_tab_label("embeddings"), do: dgettext("ui", "Embeddings")
defp metadata_diff_tab_label(tab_id),
do: tab_id |> String.replace("_", " ") |> String.capitalize()
@@ -737,16 +842,6 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
defp issue_value(_issue, _key), do: nil
defp select_git_diff_path(tab_id, files, selected_files) do
selected = Map.get(selected_files, tab_id)
if selected in files do
selected
else
List.first(files)
end
end
defp empty_git_diff(file_path) do
%{file_path: file_path, original: "", modified: "", error: nil}
end

View File

@@ -1,33 +1,57 @@
<div class={["misc-editor-shell", misc_class(@misc_editor.kind)]} data-testid="misc-editor">
<div class="misc-editor-header">
<div class={["misc-editor-shell flex h-full min-h-0 flex-col overflow-hidden", misc_class(@misc_editor.kind)]} data-testid="misc-editor">
<div class="misc-editor-header flex shrink-0 items-start justify-between gap-3">
<div>
<h2><%= @misc_editor.title %></h2>
<p><%= @misc_editor.subtitle %></p>
</div>
<div class="misc-editor-actions">
<button class="secondary" type="button" phx-click="rerun_misc_editor"><%= translated("Refresh") %></button>
<div class="misc-editor-actions flex flex-wrap items-center justify-end gap-2">
<%= if refreshable?(@misc_editor.kind) do %>
<button class="secondary ui-button ui-button-secondary" type="button" phx-click="rerun_misc_editor" phx-target={@myself}><%= dgettext("ui", "Refresh") %></button>
<% end %>
<%= if @misc_editor.kind == :site_validation do %>
<button class="primary" type="button" phx-click="apply_site_validation" disabled={Enum.empty?(@misc_editor.missing_url_paths) and Enum.empty?(@misc_editor.extra_url_paths) and Enum.empty?(@misc_editor.updated_post_url_paths)}><%= translated("Apply") %></button>
<button class="primary ui-button ui-button-primary" type="button" phx-click="apply_site_validation" phx-target={@myself} disabled={Enum.empty?(@misc_editor.missing_url_paths) and Enum.empty?(@misc_editor.extra_url_paths) and Enum.empty?(@misc_editor.updated_post_url_paths)}><%= dgettext("ui", "Apply") %></button>
<% end %>
<%= if @misc_editor.kind == :find_duplicates do %>
<button class="secondary" type="button" phx-click="dismiss_selected_duplicates" disabled={MapSet.size(@misc_editor.selected_pairs) == 0}><%= translated("Dismiss Checked") %></button>
<button class="secondary ui-button ui-button-secondary" type="button" phx-click="dismiss_selected_duplicates" phx-target={@myself} disabled={MapSet.size(@misc_editor.selected_pairs) == 0}><%= dgettext("ui", "Dismiss Checked") %></button>
<% end %>
</div>
</div>
<div class="misc-editor-summary">
<div class="misc-editor-summary flex flex-wrap gap-2">
<%= for {label, value} <- summary_items(@misc_editor) do %>
<div class="misc-summary-pill"><span><%= label %></span><strong><%= value %></strong></div>
<% end %>
</div>
<div class="misc-editor-content">
<div class="misc-editor-content min-h-0 flex-1 overflow-auto">
<%= case @misc_editor.kind do %>
<% :documentation -> %>
<div class="documentation-view">
<main class="documentation-scroll">
<div class="documentation-content markdown-body">
<article class="documentation-article help-doc-markdown" data-testid="help-documentation">
<%= markdown_html(@misc_editor.markdown) %>
</article>
</div>
</main>
</div>
<% :api_documentation -> %>
<div class="documentation-view">
<main class="documentation-scroll">
<div class="documentation-content markdown-body">
<article class="documentation-article help-doc-markdown" data-testid="help-api-documentation">
<%= markdown_html(@misc_editor.markdown) %>
</article>
</div>
</main>
</div>
<% :site_validation -> %>
<div class="misc-columns">
<section class="misc-card"><h3><%= translated("Missing URLs") %></h3><%= if Enum.empty?(@misc_editor.missing_url_paths) do %><p><%= translated("None found") %></p><% end %><ul><%= for path <- @misc_editor.missing_url_paths do %><li><%= path %></li><% end %></ul></section>
<section class="misc-card"><h3><%= translated("Extra URLs") %></h3><%= if Enum.empty?(@misc_editor.extra_url_paths) do %><p><%= translated("None found") %></p><% end %><ul><%= for path <- @misc_editor.extra_url_paths do %><li><%= path %></li><% end %></ul></section>
<section class="misc-card"><h3><%= translated("Updated URLs") %></h3><%= if Enum.empty?(@misc_editor.updated_post_url_paths) do %><p><%= translated("None found") %></p><% end %><ul><%= for path <- @misc_editor.updated_post_url_paths do %><li><%= path %></li><% end %></ul></section>
<section class="misc-card"><h3><%= dgettext("ui", "Missing URLs") %></h3><%= if Enum.empty?(@misc_editor.missing_url_paths) do %><p><%= dgettext("ui", "None found") %></p><% end %><ul><%= for path <- @misc_editor.missing_url_paths do %><li><%= path %></li><% end %></ul></section>
<section class="misc-card"><h3><%= dgettext("ui", "Extra URLs") %></h3><%= if Enum.empty?(@misc_editor.extra_url_paths) do %><p><%= dgettext("ui", "None found") %></p><% end %><ul><%= for path <- @misc_editor.extra_url_paths do %><li><%= path %></li><% end %></ul></section>
<section class="misc-card"><h3><%= dgettext("ui", "Updated URLs") %></h3><%= if Enum.empty?(@misc_editor.updated_post_url_paths) do %><p><%= dgettext("ui", "None found") %></p><% end %><ul><%= for path <- @misc_editor.updated_post_url_paths do %><li><%= path %></li><% end %></ul></section>
</div>
<% :metadata_diff -> %>
@@ -35,16 +59,17 @@
<div class="metadata-diff-tabs" role="tablist">
<%= for tab <- @misc_editor.tabs do %>
<button
class={["metadata-diff-tab", if(@misc_editor.active_tab == tab.id, do: "active")]}
class={["metadata-diff-tab", "ui-tab", if(@misc_editor.active_tab == tab.id, do: "active ui-tab-active")]}
data-testid="metadata-diff-tab"
data-entity-tab={tab.id}
type="button"
phx-click="select_metadata_diff_tab"
phx-target={@myself}
phx-value-tab={tab.id}
>
<span><%= tab.label %></span>
<%= if tab.badge_count > 0 do %>
<span class="tab-badge"><%= tab.badge_count %></span>
<span class="tab-badge ui-badge"><%= tab.badge_count %></span>
<% end %>
</button>
<% end %>
@@ -60,6 +85,7 @@
data-field={field.field_name}
type="button"
phx-click="toggle_metadata_diff_field"
phx-target={@myself}
phx-value-field={field.field_name}
>
<span class="field-pill-label"><%= field.field_name %></span>
@@ -69,29 +95,31 @@
<%= if @misc_editor.repair_enabled do %>
<div class="metadata-diff-field-pill-actions">
<button
class="secondary metadata-diff-action-button"
class="secondary metadata-diff-action-button ui-button ui-button-secondary"
data-testid="metadata-diff-repair-button"
data-direction="db_to_file"
data-field={field.field_name}
type="button"
phx-click="repair_metadata_diff"
phx-target={@myself}
phx-value-direction="db_to_file"
phx-value-field={field.field_name}
>
<%= translated("DB to File") %>
<%= dgettext("ui", "DB to File") %>
</button>
<button
class="secondary metadata-diff-action-button"
class="secondary metadata-diff-action-button ui-button ui-button-secondary"
data-testid="metadata-diff-repair-button"
data-direction="file_to_db"
data-field={field.field_name}
type="button"
phx-click="repair_metadata_diff"
phx-target={@myself}
phx-value-direction="file_to_db"
phx-value-field={field.field_name}
>
<%= translated("File to DB") %>
<%= dgettext("ui", "File to DB") %>
</button>
</div>
<% end %>
@@ -126,7 +154,7 @@
<span><%= diff.db_value %></span>
</div>
<div class="diff-field-value file-value">
<span class="diff-source-label"><%= translated("File") %></span>
<span class="diff-source-label"><%= dgettext("ui", "File") %></span>
<span><%= diff.file_value %></span>
</div>
</div>
@@ -141,16 +169,17 @@
<%= if @misc_editor.active_field == nil and @misc_editor.orphan_files != [] do %>
<section class="orphan-files-section" data-testid="metadata-diff-orphans">
<div class="orphan-files-header">
<h3><%= translated("Orphan Files") %></h3>
<h3><%= dgettext("ui", "Orphan Files") %></h3>
<div class="orphan-files-actions">
<span class="misc-summary-pill"><%= length(@misc_editor.orphan_files) %></span>
<button
class="secondary metadata-diff-action-button"
class="secondary metadata-diff-action-button ui-button ui-button-secondary"
data-testid="metadata-diff-import-button"
type="button"
phx-click="import_metadata_diff_orphans"
phx-target={@myself}
>
<%= translated("Import") %>
<%= dgettext("ui", "Import") %>
</button>
</div>
</div>
@@ -160,13 +189,13 @@
<header class="diff-item-header">
<div>
<strong><%= orphan.slug %></strong>
<div class="diff-item-meta"><%= translated("Orphan Files") %></div>
<div class="diff-item-meta"><%= dgettext("ui", "Orphan Files") %></div>
</div>
</header>
<div class="diff-item-fields">
<div class="diff-field-row">
<div class="diff-field-name"><%= translated("Path") %></div>
<div class="diff-field-name"><%= dgettext("ui", "Path") %></div>
<div class="diff-field-values">
<div class="diff-field-value file-value orphan-path"><span><%= orphan.file_path %></span></div>
</div>
@@ -187,30 +216,30 @@
</section>
<section class="translation-validation-section">
<h3><%= translated("translationValidation.databaseTitle") %></h3>
<h3><%= dgettext("ui", "translationValidation.databaseTitle") %></h3>
<%= if @misc_editor.invalid_database_rows == [] do %>
<p class="translation-validation-empty"><%= translated("translationValidation.noneDatabase") %></p>
<p class="translation-validation-empty"><%= dgettext("ui", "translationValidation.noneDatabase") %></p>
<% else %>
<div class="translation-validation-list">
<%= for issue <- @misc_editor.invalid_database_rows do %>
<article class="translation-validation-card translation-validation-card-db" data-testid="translation-validation-card">
<p class="translation-validation-card-title"><%= translation_issue_label(issue) %></p>
<dl class="translation-validation-card-meta">
<dt><%= translated("translationValidation.field.translationFor") %></dt>
<dt><%= dgettext("ui", "translationValidation.field.translationFor") %></dt>
<dd><%= translation_issue_value(issue, :translation_for) %></dd>
<%= if translation_issue_value(issue, :translation_id) do %>
<dt><%= translated("translationValidation.field.translationId") %></dt>
<dt><%= dgettext("ui", "translationValidation.field.translationId") %></dt>
<dd><%= translation_issue_value(issue, :translation_id) %></dd>
<% end %>
<%= if translation_issue_value(issue, :title) do %>
<dt><%= translated("translationValidation.field.title") %></dt>
<dt><%= dgettext("ui", "translationValidation.field.title") %></dt>
<dd><%= translation_issue_value(issue, :title) %></dd>
<% end %>
<dt><%= translated("translationValidation.field.languages") %></dt>
<dt><%= dgettext("ui", "translationValidation.field.languages") %></dt>
<dd><%= translation_issue_languages(issue) %></dd>
<%= if translation_issue_value(issue, :file_path) do %>
<dt><%= translated("translationValidation.field.filePath") %></dt>
<dt><%= dgettext("ui", "translationValidation.field.filePath") %></dt>
<dd><%= translation_issue_value(issue, :file_path) %></dd>
<% end %>
</dl>
@@ -221,26 +250,26 @@
</section>
<section class="translation-validation-section">
<h3><%= translated("translationValidation.filesystemTitle") %></h3>
<h3><%= dgettext("ui", "translationValidation.filesystemTitle") %></h3>
<%= if @misc_editor.invalid_filesystem_files == [] do %>
<p class="translation-validation-empty"><%= translated("translationValidation.noneFilesystem") %></p>
<p class="translation-validation-empty"><%= dgettext("ui", "translationValidation.noneFilesystem") %></p>
<% else %>
<div class="translation-validation-list">
<%= for issue <- @misc_editor.invalid_filesystem_files do %>
<article class="translation-validation-card translation-validation-card-file" data-testid="translation-validation-card">
<p class="translation-validation-card-title"><%= translation_issue_label(issue) %></p>
<dl class="translation-validation-card-meta">
<dt><%= translated("translationValidation.field.translationFor") %></dt>
<dt><%= dgettext("ui", "translationValidation.field.translationFor") %></dt>
<dd><%= translation_issue_value(issue, :translation_for) %></dd>
<%= if translation_issue_value(issue, :title) do %>
<dt><%= translated("translationValidation.field.title") %></dt>
<dt><%= dgettext("ui", "translationValidation.field.title") %></dt>
<dd><%= translation_issue_value(issue, :title) %></dd>
<% end %>
<dt><%= translated("translationValidation.field.languages") %></dt>
<dt><%= dgettext("ui", "translationValidation.field.languages") %></dt>
<dd><%= translation_issue_languages(issue) %></dd>
<%= if translation_issue_value(issue, :file_path) do %>
<dt><%= translated("translationValidation.field.filePath") %></dt>
<dt><%= dgettext("ui", "translationValidation.field.filePath") %></dt>
<dd><%= translation_issue_value(issue, :file_path) %></dd>
<% end %>
</dl>
@@ -251,8 +280,8 @@
</section>
<div class="translation-validation-actions">
<button class="secondary" type="button" phx-click="rerun_misc_editor" data-testid="translation-validation-revalidate"><%= translated("translationValidation.revalidate") %></button>
<button class="primary" type="button" phx-click="fix_translation_validation" data-testid="translation-validation-fix" disabled={not @misc_editor.can_fix?}><%= translated("translationValidation.fix") %></button>
<button class="secondary ui-button ui-button-secondary" type="button" phx-click="rerun_misc_editor" phx-target={@myself} data-testid="translation-validation-revalidate"><%= dgettext("ui", "translationValidation.revalidate") %></button>
<button class="primary ui-button ui-button-primary" type="button" phx-click="fix_translation_validation" phx-target={@myself} data-testid="translation-validation-fix" disabled={not @misc_editor.can_fix?}><%= dgettext("ui", "translationValidation.fix") %></button>
</div>
</div>
@@ -260,12 +289,12 @@
<div class="misc-list">
<%= for pair <- @misc_editor.pairs do %>
<article class="misc-list-item duplicate-pair-row">
<label><input type="checkbox" checked={duplicate_checked?(@misc_editor, pair_id_from_pair(pair))} phx-click="toggle_duplicate_pair" phx-value-pair-id={pair_id_from_pair(pair)} /> <span></span></label>
<button class="linkish" type="button" phx-click="open_duplicate_post" phx-value-id={BDS.MapUtils.attr(pair, :post_id_a)} phx-value-title={BDS.MapUtils.attr(pair, :title_a)}><%= BDS.MapUtils.attr(pair, :title_a) %></button>
<label><input type="checkbox" checked={duplicate_checked?(@misc_editor, pair_id_from_pair(pair))} phx-click="toggle_duplicate_pair" phx-target={@myself} phx-value-pair-id={pair_id_from_pair(pair)} /> <span></span></label>
<button class="linkish" type="button" phx-click="open_duplicate_post" phx-target={@myself} phx-value-id={BDS.MapUtils.attr(pair, :post_id_a)} phx-value-title={BDS.MapUtils.attr(pair, :title_a)}><%= BDS.MapUtils.attr(pair, :title_a) %></button>
<span>→</span>
<button class="linkish" type="button" phx-click="open_duplicate_post" phx-value-id={BDS.MapUtils.attr(pair, :post_id_b)} phx-value-title={BDS.MapUtils.attr(pair, :title_b)}><%= BDS.MapUtils.attr(pair, :title_b) %></button>
<span class="misc-summary-pill"><%= if(BDS.MapUtils.attr(pair, :exact_match), do: translated("Exact Match"), else: "#{Float.round((BDS.MapUtils.attr(pair, :similarity) || 0.0) * 100, 1)}%") %></span>
<button class="secondary" type="button" phx-click="dismiss_duplicate_pair" phx-value-post-id-a={BDS.MapUtils.attr(pair, :post_id_a)} phx-value-post-id-b={BDS.MapUtils.attr(pair, :post_id_b)}><%= translated("Dismiss") %></button>
<button class="linkish" type="button" phx-click="open_duplicate_post" phx-target={@myself} phx-value-id={BDS.MapUtils.attr(pair, :post_id_b)} phx-value-title={BDS.MapUtils.attr(pair, :title_b)}><%= BDS.MapUtils.attr(pair, :title_b) %></button>
<span class="misc-summary-pill"><%= if(BDS.MapUtils.attr(pair, :exact_match), do: dgettext("ui", "Exact Match"), else: "#{Float.round((BDS.MapUtils.attr(pair, :similarity) || 0.0) * 100, 1)}%") %></span>
<button class="secondary ui-button ui-button-secondary" type="button" phx-click="dismiss_duplicate_pair" phx-target={@myself} phx-value-post-id-a={BDS.MapUtils.attr(pair, :post_id_a)} phx-value-post-id-b={BDS.MapUtils.attr(pair, :post_id_b)}><%= dgettext("ui", "Dismiss") %></button>
</article>
<% end %>
</div>
@@ -275,9 +304,9 @@
<%= if @misc_editor.files == [] do %>
<p class="git-diff-empty"><%= @misc_editor.empty_message %></p>
<% else %>
<form class="git-diff-toolbar" phx-change="select_git_diff_file">
<label for="git-diff-file-select"><%= translated("gitDiff.changedFiles") %></label>
<select id="git-diff-file-select" data-testid="git-diff-file-select" name="path">
<form class="git-diff-toolbar" phx-change="select_git_diff_file" phx-target={@myself}>
<label for="git-diff-file-select"><%= dgettext("ui", "gitDiff.changedFiles") %></label>
<select class="ui-input" id="git-diff-file-select" data-testid="git-diff-file-select" name="path">
<%= for file_path <- @misc_editor.files do %>
<option value={file_path} selected={file_path == @misc_editor.selected_file_path}><%= file_path %></option>
<% end %>

View File

@@ -0,0 +1,70 @@
defmodule BDS.Desktop.ShellLive.Notify do
@moduledoc """
Standardized parent notification API for LiveComponent editors.
Instead of each editor defining its own `notify_parent/1` and sending
editor-specific message atoms (e.g. `{:post_editor_output, ...}`),
all editors call functions from this module, which sends generic
messages that Bridges handles with a single clause per action type.
"""
@spec output(String.t(), String.t(), String.t()) :: :ok
def output(title, message, level) do
send(self(), {:editor_output, title, message, nil, level})
:ok
end
@spec output(String.t(), String.t(), String.t() | nil, String.t()) :: :ok
def output(title, message, detail, level) do
send(self(), {:editor_output, title, message, detail, level})
:ok
end
@spec tab_meta(atom(), term(), String.t(), String.t()) :: :ok
def tab_meta(type, id, title, subtitle) do
send(self(), {:editor_tab_meta, type, id, %{title: title, subtitle: subtitle || ""}})
:ok
end
@spec tab_meta_merge(atom(), term(), map()) :: :ok
def tab_meta_merge(type, id, updates) when is_map(updates) do
send(self(), {:editor_tab_meta, type, id, updates})
:ok
end
@spec close_tab(atom(), term()) :: :ok
def close_tab(type, id) do
send(self(), {:close_tab, type, id})
:ok
end
@spec reload :: :ok
def reload do
send(self(), :reload_shell)
:ok
end
@spec dirty(atom(), term(), boolean()) :: :ok
def dirty(type, id, dirty?) do
send(self(), {:editor_dirty, type, id, dirty?})
:ok
end
@spec command(atom() | String.t(), map()) :: :ok
def command(action, params \\ %{}) do
send(self(), {:editor_command, action, params})
:ok
end
@spec open_sidebar_item(map(), atom() | nil) :: :ok
def open_sidebar_item(params, intent \\ nil) do
send(self(), {:open_sidebar_item, params, intent})
:ok
end
@spec parent(term()) :: :ok
def parent(message) do
send(self(), message)
:ok
end
end

View File

@@ -2,10 +2,10 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
@moduledoc false
use Phoenix.Component
use Gettext, backend: BDS.Gettext
import Ecto.Query
alias BDS.Desktop.ShellData
alias BDS.{I18n, Media, Metadata, Posts, Repo}
alias BDS.Media.Media, as: MediaRecord
alias BDS.Media.Translation, as: MediaTranslation
@@ -38,10 +38,10 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
language_names: language_names(),
language_flags: language_flags(),
existing_translations: existing_translations(current_tab),
ai_title: ShellData.translate("AI Suggestions", %{}, page_language),
insert_link_title: ShellData.translate("Insert Link", %{}, page_language),
insert_media_title: ShellData.translate("Insert Media", %{}, page_language),
language_picker_title: ShellData.translate("Translate", %{}, page_language),
ai_title: BDS.Gettext.lgettext(page_language, "ui", "AI Suggestions"),
insert_link_title: BDS.Gettext.lgettext(page_language, "ui", "Insert Link"),
insert_media_title: BDS.Gettext.lgettext(page_language, "ui", "Insert Media"),
language_picker_title: BDS.Gettext.lgettext(page_language, "ui", "Translate"),
gallery_title: tab_title,
ai_fields: ai_fields(current_tab, tab_title, tab_subtitle, page_language),
delete_details: delete_details(current_tab, page_language),
@@ -64,9 +64,6 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
def markdown_link(text, url), do: "[#{text}](#{url})"
def translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
def project_metadata(nil), do: %{main_language: "en", blog_languages: []}
def project_metadata(project_id) do
@@ -217,24 +214,27 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
[
%{
key: "title",
label: ShellData.translate("Title", %{}, page_language),
label: BDS.Gettext.lgettext(page_language, "ui", "Title"),
current_value: post.title || title,
suggested_value: refine_title(post.title || title),
locked: false
suggested_value: "",
locked: false,
loading: true
},
%{
key: "excerpt",
label: ShellData.translate("Excerpt", %{}, page_language),
label: BDS.Gettext.lgettext(page_language, "ui", "Excerpt"),
current_value: post.excerpt || subtitle,
suggested_value: refine_excerpt(post.title || title, post.excerpt || subtitle),
locked: false
suggested_value: "",
locked: false,
loading: true
},
%{
key: "slug",
label: ShellData.translate("Slug", %{}, page_language),
label: BDS.Gettext.lgettext(page_language, "ui", "Slug"),
current_value: post.slug || slugify(post.title || title),
suggested_value: refine_slug(post.slug || slugify(post.title || title)),
locked: post.status == :published
suggested_value: "",
locked: post.status == :published,
loading: true
}
]
@@ -251,24 +251,27 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
[
%{
key: "title",
label: ShellData.translate("Title", %{}, page_language),
label: BDS.Gettext.lgettext(page_language, "ui", "Title"),
current_value: media.title || title,
suggested_value: refine_title(media.title || title),
locked: false
suggested_value: "",
locked: false,
loading: true
},
%{
key: "alt",
label: ShellData.translate("Alt Text", %{}, page_language),
label: BDS.Gettext.lgettext(page_language, "ui", "Alt Text"),
current_value: media.alt || "",
suggested_value: media.alt || title,
locked: false
suggested_value: "",
locked: false,
loading: true
},
%{
key: "caption",
label: ShellData.translate("Caption", %{}, page_language),
label: BDS.Gettext.lgettext(page_language, "ui", "Caption"),
current_value: media.caption || "",
suggested_value: refine_excerpt(title, media.caption || title),
locked: false
suggested_value: "",
locked: false,
loading: true
}
]
@@ -300,7 +303,7 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
|> Enum.map(&(&1 || media_id))
%{
title: ShellData.translate("Delete Media", %{}, page_language),
title: BDS.Gettext.lgettext(page_language, "ui", "Delete Media"),
entity_name: entity_name,
entity_type: "media",
reference_list: reference_list
@@ -308,7 +311,7 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
rescue
_error ->
%{
title: ShellData.translate("Delete Media", %{}, page_language),
title: BDS.Gettext.lgettext(page_language, "ui", "Delete Media"),
entity_name: media_id,
entity_type: "media",
reference_list: []
@@ -321,7 +324,7 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
|> Kernel.||("tag")
%{
title: ShellData.translate("Delete Tag", %{}, page_language),
title: BDS.Gettext.lgettext(page_language, "ui", "Delete Tag"),
entity_name: tag_name,
entity_type: "tag",
reference_list: []
@@ -329,7 +332,7 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
rescue
_error ->
%{
title: ShellData.translate("Delete Tag", %{}, page_language),
title: BDS.Gettext.lgettext(page_language, "ui", "Delete Tag"),
entity_name: "tag",
entity_type: "tag",
reference_list: []
@@ -338,7 +341,7 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
defp delete_details(_tab, page_language) do
%{
title: ShellData.translate("Delete", %{}, page_language),
title: BDS.Gettext.lgettext(page_language, "ui", "Delete"),
entity_name: "",
entity_type: "item",
reference_list: []
@@ -360,16 +363,16 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
%{
target: target,
count: max(length(tags), 1),
title: ShellData.translate("Merge Tags", %{}, page_language),
message: ShellData.translate("Cannot be undone.", %{}, page_language)
title: BDS.Gettext.lgettext(page_language, "ui", "Merge Tags"),
message: BDS.Gettext.lgettext(page_language, "ui", "Cannot be undone.")
}
rescue
_error ->
%{
target: "tag",
count: 1,
title: ShellData.translate("Merge Tags", %{}, page_language),
message: ShellData.translate("Cannot be undone.", %{}, page_language)
title: BDS.Gettext.lgettext(page_language, "ui", "Merge Tags"),
message: BDS.Gettext.lgettext(page_language, "ui", "Cannot be undone.")
}
end
@@ -381,17 +384,6 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
defp pad2(value), do: value |> Integer.to_string() |> String.pad_leading(2, "0")
defp refine_title(nil), do: ""
defp refine_title(title), do: String.trim(title <> " Notes")
defp refine_excerpt(title, excerpt) do
base = excerpt |> to_string() |> String.trim()
if base == "", do: "#{title} overview", else: base <> "."
end
defp refine_slug(slug),
do: slug |> to_string() |> String.trim_trailing("-") |> Kernel.<>("-updated")
defp slugify(value) do
value
|> to_string()

Some files were not shown because too many files have changed in this diff Show More