Compare commits
94 Commits
c495a2ed0a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f7a4a9512c | |||
| 141c2bfc89 | |||
| a5ac74db91 | |||
| beca4d992f | |||
| 9e6d93a4b3 | |||
| e29dfb490a | |||
| f2b340ba86 | |||
| d18e0ef7f2 | |||
| 2d796cee83 | |||
| b052d59376 | |||
| 4a089b0856 | |||
| 2632649cdc | |||
| 782511d523 | |||
| 1cb59d7a78 | |||
| 9844f3555a | |||
| 99dc1c2216 | |||
| 71fb99af16 | |||
| 0808b27057 | |||
| ac4f5a3580 | |||
| 43a435f35d | |||
| 7b383d31ab | |||
| 09df925e9b | |||
| a4ecbabc21 | |||
| 2be43ca06d | |||
| d231f42363 | |||
| 7c00279b9d | |||
| b6f9cf58e1 | |||
| 3f77488e33 | |||
| 5c17751d55 | |||
| e4452ca504 | |||
| ce80f28e60 | |||
| f1de11a205 | |||
| ff219fd110 | |||
| de7ea12c9c | |||
| 1beffe6b07 | |||
| 999632dbe7 | |||
| 44b88056e3 | |||
| 35b3818d58 | |||
| 37db52c024 | |||
| f1445120fc | |||
| 14dfbd8829 | |||
| 24e9e9a022 | |||
| 88c689ee55 | |||
| e5429f7265 | |||
| 93a4159c31 | |||
| 06d80e2924 | |||
| 291dff697c | |||
| 9944b70ab1 | |||
| 723b8c6433 | |||
| 92334256cf | |||
| d3f45ba0dd | |||
| 3ce6010b87 | |||
| f704aba288 | |||
| 5282fcd241 | |||
| 7756d9f83c | |||
| 4ab0bc7b4e | |||
| eca89e51d2 | |||
| 8e715eec8b | |||
| 35017f9793 | |||
| b17e9cc3f8 | |||
| 6b6c985187 | |||
| cb46b45cda | |||
| 43a4610ce7 | |||
| 4de8492c4f | |||
| 4bee8cf1db | |||
| dbb93a66f6 | |||
| 483c13aaa3 | |||
| f3d8fbcbdc | |||
| c16afa4c00 | |||
| 0f193929da | |||
| fa76cdf11d | |||
| 98243cbd16 | |||
| 657ed58e80 | |||
| 556f33711f | |||
| 5bc2b4a338 | |||
| 9f17954ce3 | |||
| b9797809aa | |||
| 4fee1a6333 | |||
| 0075f25ef7 | |||
| 8d7e7419d4 | |||
| ce54e973ad | |||
| 6c7fde6b95 | |||
| eb8f5698e3 | |||
| 2be751400d | |||
| 73e066c330 | |||
| a4ea24faa2 | |||
| 45040f9f66 | |||
| 4cf0f5281b | |||
| 24f114c24e | |||
| 07fab7d1ab | |||
| c118412f56 | |||
| e0f13e325b | |||
| 631ceb0521 | |||
| 7db8f6d36b |
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
14
.claude/settings.local.json
Normal file
14
.claude/settings.local.json
Normal 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 *)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
- 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
|
- 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)
|
- 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
|
- 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.
|
- 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
|
- if you add new metadata, add them to publishing, metadata-diff and rebuild-from-database
|
||||||
|
|||||||
474
DOCUMENTATION.md
Normal file
474
DOCUMENTATION.md
Normal 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
166
README.md
@@ -1,52 +1,122 @@
|
|||||||
# bDS2
|
# 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.
|
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.
|
||||||
- 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 following are intentionally not part of the behavioral contract:
|
## Architecture Overview
|
||||||
|
|
||||||
- The implementation language.
|
### Runtime
|
||||||
- Desktop container or UI framework.
|
|
||||||
- ORM choice.
|
|
||||||
- Internal state management, concurrency model, or runtime libraries.
|
|
||||||
|
|
||||||
## 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.
|
### Desktop Shell
|
||||||
- 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.
|
|
||||||
|
|
||||||
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.
|
### Domain Modules
|
||||||
- [config/](/Users/gb/Projects/bDS2/config): Elixir and Ecto configuration.
|
|
||||||
- [lib/](/Users/gb/Projects/bDS2/lib): application bootstrap and shared runtime modules.
|
Most application behavior lives under [lib/bds/](/Users/gb/Projects/bDS2/lib/bds):
|
||||||
- [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.
|
- 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
|
## 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
|
### 1. Install Xcode Command Line Tools
|
||||||
|
|
||||||
@@ -56,8 +126,6 @@ xcode-select --install
|
|||||||
|
|
||||||
### 2. Install Homebrew
|
### 2. Install Homebrew
|
||||||
|
|
||||||
If Homebrew is not already installed:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
/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
|
brew install erlang elixir sqlite
|
||||||
```
|
```
|
||||||
|
|
||||||
Verify the installation:
|
### 4. Fetch Dependencies And Set Up The App
|
||||||
|
|
||||||
```bash
|
|
||||||
elixir --version
|
|
||||||
mix --version
|
|
||||||
sqlite3 --version
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Fetch Dependencies
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /Users/gb/Projects/bDS2
|
cd /Users/gb/Projects/bDS2
|
||||||
mix deps.get
|
mix setup
|
||||||
|
mix assets.setup
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5. Create the Local Database
|
## Development Workflow
|
||||||
|
|
||||||
```bash
|
Useful commands:
|
||||||
mix ecto.create
|
|
||||||
mix ecto.migrate
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6. Run Tests
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
mix compile --warnings-as-errors
|
||||||
mix test
|
mix test
|
||||||
|
mix dialyzer
|
||||||
|
mix assets.build
|
||||||
```
|
```
|
||||||
|
|
||||||
## Development Notes
|
Notes for developers:
|
||||||
|
|
||||||
- Use `mix test` for validation during development.
|
- Specs in [specs/](/Users/gb/Projects/bDS2/specs) define the intended product behavior.
|
||||||
- The application behavior is defined by the Allium specs in [specs/](/Users/gb/Projects/bDS2/specs).
|
- [DOCUMENTATION.md](/Users/gb/Projects/bDS2/DOCUMENTATION.md) is for end users, not implementation details.
|
||||||
- Use [PLAN.md](/Users/gb/Projects/bDS2/PLAN.md) for implementation status and the parity roadmap.
|
- [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
191
SPECAUDIT.md
Normal 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
192
SPECGAPS.md
Normal 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
87
TESTAUDIT.md
Normal 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
19
assets/css/app.css
Normal 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
223
assets/css/assistant.css
Normal 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
956
assets/css/editor.css
Normal 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
141
assets/css/forms.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
689
assets/css/import_editor.css
Normal file
689
assets/css/import_editor.css
Normal 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
325
assets/css/media_editor.css
Normal 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
259
assets/css/menu_editor.css
Normal 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
336
assets/css/overlays.css
Normal 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
541
assets/css/panel.css
Normal 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
807
assets/css/shell.css
Normal 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
1049
assets/css/sidebar.css
Normal file
File diff suppressed because it is too large
Load Diff
189
assets/css/tabs.css
Normal file
189
assets/css/tabs.css
Normal 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
166
assets/css/tokens.css
Normal 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
301
assets/css/utilities.css
Normal 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
29
assets/js/app.js
Normal 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;
|
||||||
|
});
|
||||||
19
assets/js/bridges/document_commands.js
Normal file
19
assets/js/bridges/document_commands.js
Normal 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
58
assets/js/bridges/menu_runtime.js
Normal file
58
assets/js/bridges/menu_runtime.js
Normal 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
39
assets/js/bridges/titlebar_overlay.js
Normal file
39
assets/js/bridges/titlebar_overlay.js
Normal 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
4
assets/js/constants.js
Normal 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-";
|
||||||
232
assets/js/hooks/app_shell.js
Normal file
232
assets/js/hooks/app_shell.js
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
139
assets/js/hooks/chat_surface.js
Normal file
139
assets/js/hooks/chat_surface.js
Normal 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
18
assets/js/hooks/index.js
Normal 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
|
||||||
|
};
|
||||||
134
assets/js/hooks/menu_editor_tree.js
Normal file
134
assets/js/hooks/menu_editor_tree.js
Normal 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
129
assets/js/hooks/monaco_diff_editor.js
Normal file
129
assets/js/hooks/monaco_diff_editor.js
Normal 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();
|
||||||
|
}
|
||||||
|
};
|
||||||
238
assets/js/hooks/monaco_editor.js
Normal file
238
assets/js/hooks/monaco_editor.js
Normal 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();
|
||||||
|
}
|
||||||
|
};
|
||||||
31
assets/js/hooks/section_scroll.js
Normal file
31
assets/js/hooks/section_scroll.js
Normal 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");
|
||||||
24
assets/js/hooks/sidebar_interactions.js
Normal file
24
assets/js/hooks/sidebar_interactions.js
Normal 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
145
assets/js/monaco/languages.js
Normal file
145
assets/js/monaco/languages.js
Normal 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;
|
||||||
|
};
|
||||||
88
assets/js/monaco/services.js
Normal file
88
assets/js/monaco/services.js
Normal 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
62
assets/js/monaco/theme.js
Normal 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
46
assets/js/utils/color.js
Normal 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
34
assets/js/utils/dom.js
Normal 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
43
assets/js/utils/layout.js
Normal 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);
|
||||||
|
};
|
||||||
33
assets/js/utils/script_loader.js
Normal file
33
assets/js/utils/script_loader.js
Normal 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);
|
||||||
|
});
|
||||||
30
assets/js/utils/shortcuts.js
Normal file
30
assets/js/utils/shortcuts.js
Normal 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 [];
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -6,6 +6,7 @@ config :bds,
|
|||||||
config :bds, BDS.Repo,
|
config :bds, BDS.Repo,
|
||||||
database: Path.expand("../priv/data/bds_dev.db", __DIR__),
|
database: Path.expand("../priv/data/bds_dev.db", __DIR__),
|
||||||
pool_size: 5,
|
pool_size: 5,
|
||||||
|
journal_mode: :wal,
|
||||||
busy_timeout: 15_000,
|
busy_timeout: 15_000,
|
||||||
log: false,
|
log: false,
|
||||||
stacktrace: true,
|
stacktrace: true,
|
||||||
@@ -27,6 +28,31 @@ config :bds, BDS.Desktop.Endpoint,
|
|||||||
pubsub_server: BDS.PubSub,
|
pubsub_server: BDS.PubSub,
|
||||||
live_view: [signing_salt: "desktop-live-view"]
|
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,
|
config :bds, :scripting,
|
||||||
runtime: BDS.Scripting.Lua,
|
runtime: BDS.Scripting.Lua,
|
||||||
timeout: 300_000,
|
timeout: 300_000,
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
import Config
|
import Config
|
||||||
|
|
||||||
config :bds, BDS.Repo, pool_size: 5
|
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)]}
|
||||||
|
]
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import Config
|
|||||||
config :bds, BDS.Repo,
|
config :bds, BDS.Repo,
|
||||||
database: Path.expand("../priv/data/bds_test.db", __DIR__),
|
database: Path.expand("../priv/data/bds_test.db", __DIR__),
|
||||||
pool: Ecto.Adapters.SQL.Sandbox,
|
pool: Ecto.Adapters.SQL.Sandbox,
|
||||||
pool_size: 5
|
pool_size: 5,
|
||||||
|
journal_mode: :wal,
|
||||||
|
busy_timeout: 15_000
|
||||||
|
|
||||||
config :logger, level: :warning
|
config :logger, level: :warning
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
defmodule BDS.AI do
|
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.Catalog
|
||||||
alias BDS.AI.Chat
|
alias BDS.AI.Chat
|
||||||
@@ -59,14 +62,12 @@ defmodule BDS.AI do
|
|||||||
model = get_setting("ai.#{kind_key}.model")
|
model = get_setting("ai.#{kind_key}.model")
|
||||||
encrypted_api_key = get_setting(encrypted_key("ai.#{kind_key}.api_key"))
|
encrypted_api_key = get_setting(encrypted_key("ai.#{kind_key}.api_key"))
|
||||||
|
|
||||||
cond do
|
if is_nil(url) and is_nil(model) and is_nil(encrypted_api_key) do
|
||||||
is_nil(url) and is_nil(model) and is_nil(encrypted_api_key) ->
|
{:ok, nil}
|
||||||
{:ok, nil}
|
else
|
||||||
|
with {:ok, api_key} <- get_secret(encrypted_api_key, backend) do
|
||||||
true ->
|
{:ok, %{kind: kind, url: url, api_key: api_key, model: model}}
|
||||||
with {:ok, api_key} <- get_secret(encrypted_api_key, backend) do
|
end
|
||||||
{:ok, %{kind: kind, url: url, api_key: api_key, model: model}}
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -185,4 +186,12 @@ defmodule BDS.AI do
|
|||||||
|
|
||||||
@spec cancel_chat(String.t()) :: :ok
|
@spec cancel_chat(String.t()) :: :ok
|
||||||
defdelegate cancel_chat(conversation_id), to: Chat
|
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
|
end
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ defmodule BDS.AI.Catalog do
|
|||||||
alias BDS.AI.Model
|
alias BDS.AI.Model
|
||||||
alias BDS.AI.ModelModality
|
alias BDS.AI.ModelModality
|
||||||
alias BDS.AI.OpenAICompatibleRuntime
|
alias BDS.AI.OpenAICompatibleRuntime
|
||||||
|
alias BDS.MapUtils
|
||||||
alias BDS.Persistence
|
alias BDS.Persistence
|
||||||
alias BDS.Repo
|
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
|
def put_model_capabilities(model_id, attrs) when is_binary(model_id) and is_map(attrs) do
|
||||||
capabilities = %{
|
capabilities = %{
|
||||||
supports_attachment: truthy?(BDS.MapUtils.attr(attrs, :supports_attachment)),
|
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))
|
put_setting("ai.model_capabilities.#{model_id}", Jason.encode!(capabilities))
|
||||||
@@ -163,7 +165,8 @@ defmodule BDS.AI.Catalog do
|
|||||||
|
|
||||||
@spec model_capabilities(String.t()) :: %{
|
@spec model_capabilities(String.t()) :: %{
|
||||||
supports_attachment: boolean(),
|
supports_attachment: boolean(),
|
||||||
supports_tool_calls: boolean()
|
supports_tool_calls: boolean(),
|
||||||
|
disables_reasoning: boolean()
|
||||||
}
|
}
|
||||||
def model_capabilities(model_id) do
|
def model_capabilities(model_id) do
|
||||||
overrides = decode_model_capabilities_override(model_id)
|
overrides = decode_model_capabilities_override(model_id)
|
||||||
@@ -173,7 +176,8 @@ defmodule BDS.AI.Catalog do
|
|||||||
{:ok, model} ->
|
{:ok, model} ->
|
||||||
%{
|
%{
|
||||||
supports_attachment: model.supports_attachment or "image" in model.input_modalities,
|
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 ->
|
_other ->
|
||||||
@@ -196,7 +200,8 @@ defmodule BDS.AI.Catalog do
|
|||||||
String.contains?(normalized, "llava"),
|
String.contains?(normalized, "llava"),
|
||||||
supports_tool_calls:
|
supports_tool_calls:
|
||||||
String.contains?(normalized, "gpt") or String.contains?(normalized, "claude") or
|
String.contains?(normalized, "gpt") or String.contains?(normalized, "claude") or
|
||||||
String.contains?(normalized, "tool")
|
String.contains?(normalized, "tool"),
|
||||||
|
disables_reasoning: false
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -207,9 +212,7 @@ defmodule BDS.AI.Catalog do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp atomize_map_keys(map) do
|
defp atomize_map_keys(map), do: MapUtils.safe_atomize_keys(map)
|
||||||
Enum.into(map, %{}, fn {key, value} -> {String.to_atom(key), value} end)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp persist_catalog(payload) do
|
defp persist_catalog(payload) do
|
||||||
now = Persistence.now_ms()
|
now = Persistence.now_ms()
|
||||||
@@ -309,7 +312,7 @@ defmodule BDS.AI.Catalog do
|
|||||||
defp parse_modality("audio"), do: :audio
|
defp parse_modality("audio"), do: :audio
|
||||||
defp parse_modality("file"), do: :file
|
defp parse_modality("file"), do: :file
|
||||||
defp parse_modality("tool"), do: :tool
|
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(nil), do: nil
|
||||||
defp encode_nullable(value), do: Jason.encode!(value)
|
defp encode_nullable(value), do: Jason.encode!(value)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ defmodule BDS.AI.Chat do
|
|||||||
@moduledoc false
|
@moduledoc false
|
||||||
|
|
||||||
import Ecto.Query
|
import Ecto.Query
|
||||||
|
require Logger
|
||||||
|
|
||||||
alias BDS.AI
|
alias BDS.AI
|
||||||
alias BDS.AI.Catalog
|
alias BDS.AI.Catalog
|
||||||
@@ -23,7 +24,7 @@ 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_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
|
@default_max_output_tokens 16_384
|
||||||
@title_max_output_tokens 20
|
@title_max_output_tokens 256
|
||||||
@chat_title_max_length 30
|
@chat_title_max_length 30
|
||||||
@chat_max_tool_rounds 10
|
@chat_max_tool_rounds 10
|
||||||
@default_context_window 128_000
|
@default_context_window 128_000
|
||||||
@@ -61,6 +62,42 @@ defmodule BDS.AI.Chat do
|
|||||||
Repo.get(ChatConversation, conversation_id)
|
Repo.get(ChatConversation, conversation_id)
|
||||||
end
|
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()}
|
@spec delete_chat_conversation(String.t()) :: {:ok, :deleted} | {:error, :not_found | term()}
|
||||||
def delete_chat_conversation(conversation_id) when is_binary(conversation_id) do
|
def delete_chat_conversation(conversation_id) when is_binary(conversation_id) do
|
||||||
case Repo.get(ChatConversation, conversation_id) do
|
case Repo.get(ChatConversation, conversation_id) do
|
||||||
@@ -68,7 +105,9 @@ defmodule BDS.AI.Chat do
|
|||||||
{:error, :not_found}
|
{:error, :not_found}
|
||||||
|
|
||||||
%ChatConversation{} = conversation ->
|
%ChatConversation{} = conversation ->
|
||||||
Repo.delete_all(from message in ChatMessage, where: message.conversation_id == ^conversation_id)
|
Repo.delete_all(
|
||||||
|
from message in ChatMessage, where: message.conversation_id == ^conversation_id
|
||||||
|
)
|
||||||
|
|
||||||
case Repo.delete(conversation) do
|
case Repo.delete(conversation) do
|
||||||
{:ok, _conversation} -> {:ok, :deleted}
|
{:ok, _conversation} -> {:ok, :deleted}
|
||||||
@@ -374,7 +413,8 @@ defmodule BDS.AI.Chat do
|
|||||||
opts,
|
opts,
|
||||||
@chat_max_tool_rounds
|
@chat_max_tool_rounds
|
||||||
),
|
),
|
||||||
{:ok, reply} <- maybe_generate_chat_title(conversation.id, user_message.content, reply, opts) do
|
{:ok, reply} <-
|
||||||
|
maybe_generate_chat_title(conversation.id, user_message.content, reply, opts) do
|
||||||
{:ok, reply}
|
{:ok, reply}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -383,13 +423,20 @@ defmodule BDS.AI.Chat do
|
|||||||
conversation = Repo.get!(ChatConversation, conversation_id)
|
conversation = Repo.get!(ChatConversation, conversation_id)
|
||||||
|
|
||||||
cond do
|
cond do
|
||||||
chat_user_message_count(conversation_id) != 1 ->
|
chat_user_message_count(conversation_id) < 1 ->
|
||||||
|
Logger.debug("Chat title generation skipped reason=:no_user_messages")
|
||||||
{:ok, reply}
|
{:ok, reply}
|
||||||
|
|
||||||
not generated_chat_title?(conversation.title, conversation.model) ->
|
not generated_chat_title?(conversation.title, conversation.model) ->
|
||||||
|
Logger.debug(
|
||||||
|
"Chat title generation skipped reason=:conversation_already_titled title=#{inspect(conversation.title)}"
|
||||||
|
)
|
||||||
|
|
||||||
{:ok, reply}
|
{:ok, reply}
|
||||||
|
|
||||||
true ->
|
true ->
|
||||||
|
Logger.debug("Chat title generation requested conversation_id=#{conversation_id}")
|
||||||
|
|
||||||
case generate_chat_title(user_content, opts) do
|
case generate_chat_title(user_content, opts) do
|
||||||
{:ok, title} when is_binary(title) and title != "" ->
|
{:ok, title} when is_binary(title) and title != "" ->
|
||||||
now = Persistence.now_ms()
|
now = Persistence.now_ms()
|
||||||
@@ -417,8 +464,27 @@ defmodule BDS.AI.Chat do
|
|||||||
with {:ok, endpoint, model, mode} <- Runtime.resolve_target(:chat_title, opts),
|
with {:ok, endpoint, model, mode} <- Runtime.resolve_target(:chat_title, opts),
|
||||||
:ok <- Runtime.validate_target(:chat_title, model, mode),
|
:ok <- Runtime.validate_target(:chat_title, model, mode),
|
||||||
request <- build_chat_title_request(user_content, model),
|
request <- build_chat_title_request(user_content, model),
|
||||||
{:ok, response} <- runtime.generate(Runtime.endpoint_with_model(endpoint, model), request, opts) do
|
{:ok, response} <-
|
||||||
{:ok, sanitize_chat_title(Map.get(response, :content))}
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -431,7 +497,7 @@ defmodule BDS.AI.Chat do
|
|||||||
%{
|
%{
|
||||||
"role" => "system",
|
"role" => "system",
|
||||||
"content" =>
|
"content" =>
|
||||||
"Generate an ultra-short title (2-3 words, max 25 characters) for this conversation. Focus ONLY on the topic. Ignore any capability disclaimers. Output ONLY the title text."
|
"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)}"}
|
%{"role" => "user", "content" => "Topic: #{String.slice(user_content, 0, 100)}"}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ defmodule BDS.AI.ChatConversation do
|
|||||||
title: String.t() | nil,
|
title: String.t() | nil,
|
||||||
model: String.t() | nil,
|
model: String.t() | nil,
|
||||||
copilot_session_id: String.t() | nil,
|
copilot_session_id: String.t() | nil,
|
||||||
|
surface_state: map() | nil,
|
||||||
created_at: integer() | nil,
|
created_at: integer() | nil,
|
||||||
updated_at: integer() | nil
|
updated_at: integer() | nil
|
||||||
}
|
}
|
||||||
@@ -19,13 +20,14 @@ defmodule BDS.AI.ChatConversation do
|
|||||||
field :title, :string
|
field :title, :string
|
||||||
field :model, :string
|
field :model, :string
|
||||||
field :copilot_session_id, :string
|
field :copilot_session_id, :string
|
||||||
|
field :surface_state, :map
|
||||||
field :created_at, :integer
|
field :created_at, :integer
|
||||||
field :updated_at, :integer
|
field :updated_at, :integer
|
||||||
end
|
end
|
||||||
|
|
||||||
def changeset(conversation, attrs) do
|
def changeset(conversation, attrs) do
|
||||||
conversation
|
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]
|
empty_values: [nil]
|
||||||
)
|
)
|
||||||
|> validate_required([:id, :title, :created_at, :updated_at])
|
|> validate_required([:id, :title, :created_at, :updated_at])
|
||||||
|
|||||||
@@ -36,7 +36,9 @@ defmodule BDS.AI.ChatMessage do
|
|||||||
:cache_read_tokens,
|
:cache_read_tokens,
|
||||||
:cache_write_tokens,
|
:cache_write_tokens,
|
||||||
:created_at
|
:created_at
|
||||||
], empty_values: [nil])
|
],
|
||||||
|
empty_values: [nil]
|
||||||
|
)
|
||||||
|> validate_required([:conversation_id, :role, :created_at])
|
|> validate_required([:conversation_id, :role, :created_at])
|
||||||
|> assoc_constraint(:conversation)
|
|> assoc_constraint(:conversation)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -909,7 +909,7 @@ defmodule BDS.AI.ChatTools do
|
|||||||
|
|
||||||
defp metadata_attrs(arguments, keys) do
|
defp metadata_attrs(arguments, keys) do
|
||||||
Enum.reduce(keys, %{}, fn key, acc ->
|
Enum.reduce(keys, %{}, fn key, acc ->
|
||||||
maybe_put(acc, String.to_atom(key), arguments[key])
|
maybe_put(acc, BDS.MapUtils.safe_atomize_key(key), arguments[key])
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -60,7 +60,9 @@ defmodule BDS.AI.Model do
|
|||||||
:interleaved,
|
:interleaved,
|
||||||
:status,
|
:status,
|
||||||
:updated_at
|
:updated_at
|
||||||
], empty_values: [nil])
|
],
|
||||||
|
empty_values: [nil]
|
||||||
|
)
|
||||||
|> validate_required([
|
|> validate_required([
|
||||||
:provider,
|
:provider,
|
||||||
:model_id,
|
:model_id,
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
defmodule BDS.AI.OneShot do
|
defmodule BDS.AI.OneShot do
|
||||||
@moduledoc false
|
@moduledoc false
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
alias BDS.AI.Chat
|
alias BDS.AI.Chat
|
||||||
alias BDS.AI.OpenAICompatibleRuntime
|
alias BDS.AI.OpenAICompatibleRuntime
|
||||||
alias BDS.AI.Runtime
|
alias BDS.AI.Runtime
|
||||||
alias BDS.Media.Media
|
alias BDS.Media.Media
|
||||||
alias BDS.MapUtils
|
alias BDS.MapUtils
|
||||||
|
alias BDS.Posts
|
||||||
alias BDS.Posts.Post
|
alias BDS.Posts.Post
|
||||||
|
alias BDS.Projects
|
||||||
alias BDS.Repo
|
alias BDS.Repo
|
||||||
|
|
||||||
@default_max_output_tokens 16_384
|
@default_max_output_tokens 16_384
|
||||||
@@ -162,12 +166,13 @@ defmodule BDS.AI.OneShot do
|
|||||||
end
|
end
|
||||||
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)
|
runtime = Keyword.get(opts, :runtime, OpenAICompatibleRuntime)
|
||||||
|
|
||||||
with {:ok, endpoint, model, mode} <- Runtime.resolve_target(operation, opts),
|
with {:ok, endpoint, model, mode} <- Runtime.resolve_target(operation, opts),
|
||||||
:ok <- Runtime.validate_target(operation, model, mode),
|
: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} <-
|
{:ok, response} <-
|
||||||
runtime.generate(Runtime.endpoint_with_model(endpoint, model), request, opts),
|
runtime.generate(Runtime.endpoint_with_model(endpoint, model), request, opts),
|
||||||
{:ok, json} <- extract_json_response(response),
|
{:ok, json} <- extract_json_response(response),
|
||||||
@@ -177,55 +182,100 @@ defmodule BDS.AI.OneShot do
|
|||||||
end
|
end
|
||||||
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,
|
operation: operation,
|
||||||
model: model,
|
model: model,
|
||||||
max_output_tokens: @default_max_output_tokens,
|
max_output_tokens: @default_max_output_tokens,
|
||||||
messages: [
|
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
|
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."
|
"Return JSON with exactly one key: language_code."
|
||||||
end
|
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."
|
"Return JSON with keys tags and categories, each an array of short strings."
|
||||||
end
|
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."
|
"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
|
end
|
||||||
|
|
||||||
defp one_shot_system_prompt(:analyze_post) do
|
defp one_shot_system_prompt(:analyze_post, nil, _source_language) do
|
||||||
"Return JSON with keys title, excerpt, and slug."
|
"Return JSON with keys title, excerpt, and slug. Each value must be a single string (not an array or object)."
|
||||||
end
|
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."
|
"Return JSON with keys title, excerpt, and content. Preserve Markdown structure."
|
||||||
end
|
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."
|
"Return JSON with keys title, alt, and caption for the provided image."
|
||||||
end
|
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."
|
"Return JSON with keys title, alt, and caption translated to the requested language."
|
||||||
end
|
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}"
|
"Detect the language of this text: #{text}"
|
||||||
end
|
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)}"
|
"Suggest categories and tags for the following post.\nTitle: #{post.title}\nExcerpt: #{post.excerpt}\nContent: #{truncate_text(post.content, 2000)}"
|
||||||
end
|
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.",
|
"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")
|
|> Enum.join("\n")
|
||||||
end
|
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)}"
|
"Suggest an improved title, excerpt, and slug.\nTitle: #{post.title}\nExcerpt: #{post.excerpt}\nContent: #{truncate_text(post.content, 2000)}"
|
||||||
end
|
end
|
||||||
|
|
||||||
defp one_shot_user_content(:translate_post, post) do
|
defp one_shot_user_content(:analyze_post, post, language, _source_language) do
|
||||||
"Translate this post to #{post.target_language}.\nTitle: #{post.title}\nExcerpt: #{post.excerpt}\nContent: #{post.content}"
|
"Suggest an improved title, excerpt, and slug in #{language_name(language)}.\nTitle: #{post.title}\nExcerpt: #{post.excerpt}\nContent: #{truncate_text(post.content, 2000)}"
|
||||||
end
|
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",
|
"type" => "text",
|
||||||
@@ -264,23 +322,64 @@ defmodule BDS.AI.OneShot do
|
|||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
defp one_shot_user_content(:translate_media, media) do
|
defp one_shot_user_content(:analyze_image, media, language, _source_language) do
|
||||||
"Translate this media metadata to #{media.target_language}.\nTitle: #{media.title}\nAlt: #{media.alt}\nCaption: #{media.caption}"
|
[
|
||||||
|
%{
|
||||||
|
"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
|
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(%{json: json}) when is_map(json), do: {:ok, json}
|
||||||
|
|
||||||
defp extract_json_response(%{content: content}) when is_binary(content) do
|
defp extract_json_response(%{content: content}) when is_binary(content) do
|
||||||
case Jason.decode(content) do
|
case Jason.decode(content) do
|
||||||
{:ok, json} when is_map(json) -> {:ok, json}
|
{:ok, json} when is_map(json) ->
|
||||||
_other -> {:error, %{kind: :invalid_json_response}}
|
{: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
|
||||||
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
|
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
|
end
|
||||||
|
|
||||||
defp normalize_post_input(post_id) when is_binary(post_id) do
|
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) || "",
|
title: MapUtils.attr(attrs, :title) || "",
|
||||||
excerpt: MapUtils.attr(attrs, :excerpt) || "",
|
excerpt: MapUtils.attr(attrs, :excerpt) || "",
|
||||||
content: MapUtils.attr(attrs, :content) || ""
|
content: MapUtils.attr(attrs, :content) || "",
|
||||||
|
language: MapUtils.attr(attrs, :language) || ""
|
||||||
}}
|
}}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -306,7 +406,10 @@ defmodule BDS.AI.OneShot do
|
|||||||
title: media.title || "",
|
title: media.title || "",
|
||||||
alt: media.alt || "",
|
alt: media.alt || "",
|
||||||
caption: media.caption || "",
|
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
|
end
|
||||||
|
|
||||||
@@ -324,15 +427,80 @@ defmodule BDS.AI.OneShot do
|
|||||||
title: MapUtils.attr(attrs, :title) || "",
|
title: MapUtils.attr(attrs, :title) || "",
|
||||||
alt: MapUtils.attr(attrs, :alt) || "",
|
alt: MapUtils.attr(attrs, :alt) || "",
|
||||||
caption: MapUtils.attr(attrs, :caption) || "",
|
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
|
end
|
||||||
|
|
||||||
defp ensure_image_media(%{mime_type: "image/" <> _rest}), do: :ok
|
defp ensure_image_media(%{mime_type: "image/" <> _rest}), do: :ok
|
||||||
defp ensure_image_media(_media), do: {:error, %{kind: :invalid_media_type}}
|
defp ensure_image_media(_media), do: {:error, %{kind: :invalid_media_type}}
|
||||||
|
|
||||||
defp media_path_to_file_url(nil), do: nil
|
defp resolve_image_data_url(%{image_url: "data:" <> _} = media) do
|
||||||
defp media_path_to_file_url(path), do: "file://" <> path
|
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
|
defp normalize_string_list(values) do
|
||||||
values
|
values
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
defmodule BDS.AI.OpenAICompatibleRuntime do
|
defmodule BDS.AI.OpenAICompatibleRuntime do
|
||||||
@moduledoc false
|
@moduledoc false
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
alias BDS.AI.HttpClient
|
alias BDS.AI.HttpClient
|
||||||
|
|
||||||
def list_models(endpoint, opts \\ []) when is_map(endpoint) and is_list(opts) do
|
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,
|
"messages" => request.messages,
|
||||||
"max_tokens" => request.max_output_tokens
|
"max_tokens" => request.max_output_tokens
|
||||||
}
|
}
|
||||||
|
|> maybe_disable_thinking(request.model)
|
||||||
|> maybe_put_tools(Map.get(request, :tools, []))
|
|> maybe_put_tools(Map.get(request, :tools, []))
|
||||||
|
|
||||||
with {:ok, response} <- HttpClient.post(url, headers, Jason.encode!(payload)),
|
payload_json = Jason.encode!(payload)
|
||||||
200 <- response.status do
|
|
||||||
normalize_response(response.body)
|
Logger.debug(
|
||||||
else
|
"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)}"
|
||||||
status when is_integer(status) -> {:error, %{kind: :http_error, status: status}}
|
)
|
||||||
{:error, reason} -> {:error, %{kind: :http_error, reason: reason}}
|
|
||||||
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -136,6 +170,18 @@ defmodule BDS.AI.OpenAICompatibleRuntime do
|
|||||||
|> Map.put("tool_choice", "auto")
|
|> Map.put("tool_choice", "auto")
|
||||||
end
|
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
|
defp normalize_tool_calls(tool_calls) do
|
||||||
Enum.map(tool_calls, fn tool_call ->
|
Enum.map(tool_calls, fn tool_call ->
|
||||||
%{
|
%{
|
||||||
|
|||||||
@@ -32,10 +32,18 @@ defmodule BDS.AI.Runtime do
|
|||||||
|
|
||||||
@spec validate_target(atom(), String.t(), :airplane | :online) :: :ok | {:error, term()}
|
@spec validate_target(atom(), String.t(), :airplane | :online) :: :ok | {:error, term()}
|
||||||
def validate_target(:analyze_image, model, _mode) do
|
def validate_target(:analyze_image, model, _mode) do
|
||||||
if Catalog.model_capabilities(model).supports_attachment do
|
capabilities = Catalog.model_capabilities(model)
|
||||||
:ok
|
|
||||||
else
|
cond do
|
||||||
{:error, %{kind: :model_capability_missing, capability: :supports_attachment, model: model}}
|
capabilities.supports_attachment ->
|
||||||
|
:ok
|
||||||
|
|
||||||
|
capabilities.supports_attachment == false ->
|
||||||
|
{:error,
|
||||||
|
%{kind: :model_capability_missing, capability: :supports_attachment, model: model}}
|
||||||
|
|
||||||
|
true ->
|
||||||
|
:ok
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ defmodule BDS.Application do
|
|||||||
BDS.Preview,
|
BDS.Preview,
|
||||||
BDS.Publishing,
|
BDS.Publishing,
|
||||||
{Task.Supervisor, name: BDS.Tasks.TaskSupervisor},
|
{Task.Supervisor, name: BDS.Tasks.TaskSupervisor},
|
||||||
|
{Task.Supervisor, name: BDS.TCP.TaskSupervisor},
|
||||||
BDS.Scripting.JobStore,
|
BDS.Scripting.JobStore,
|
||||||
{Task.Supervisor, name: BDS.Scripting.TaskSupervisor},
|
{Task.Supervisor, name: BDS.Scripting.TaskSupervisor},
|
||||||
BDS.Scripting.JobSupervisor
|
BDS.Scripting.JobSupervisor
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
defmodule BDS.BoundedAtoms do
|
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.Registry
|
||||||
|
alias BDS.UI.MenuBar
|
||||||
|
|
||||||
@panel_tabs [:tasks, :output, :post_links, :git_log]
|
@panel_tabs [:tasks, :output, :post_links, :git_log]
|
||||||
@post_statuses [:draft, :published, :archived]
|
@post_statuses [:draft, :published, :archived]
|
||||||
@@ -37,11 +41,32 @@ defmodule BDS.BoundedAtoms do
|
|||||||
:view_posts,
|
:view_posts,
|
||||||
:view_media,
|
:view_media,
|
||||||
:edit_preferences,
|
:edit_preferences,
|
||||||
|
:open_in_browser,
|
||||||
|
:open_data_folder,
|
||||||
|
:preview_post,
|
||||||
:edit_menu,
|
: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,
|
:documentation,
|
||||||
:api_documentation,
|
:api_documentation,
|
||||||
:close_tab
|
: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)
|
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 ai_endpoint(value, fallback \\ nil), do: atom(value, @ai_endpoints, fallback)
|
||||||
def mcp_agent(value, fallback \\ nil), do: atom(value, @mcp_agents, 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 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
|
defp string_atom(value, allowed, fallback) do
|
||||||
Enum.find(allowed, fallback, &(Atom.to_string(&1) == value))
|
Enum.find(allowed, fallback, &(Atom.to_string(&1) == value))
|
||||||
|
|||||||
@@ -375,13 +375,7 @@ defmodule BDS.Desktop.Automation do
|
|||||||
defp normalize_simple_reply("ok"), do: :ok
|
defp normalize_simple_reply("ok"), do: :ok
|
||||||
defp normalize_simple_reply(reply), do: reply
|
defp normalize_simple_reply(reply), do: reply
|
||||||
|
|
||||||
defp atomize_map(map) when is_map(map) do
|
defp atomize_map(map) when is_map(map), do: BDS.MapUtils.safe_atomize_keys(map)
|
||||||
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 project_root do
|
defp project_root do
|
||||||
Path.expand("../../..", __DIR__)
|
Path.expand("../../..", __DIR__)
|
||||||
|
|||||||
@@ -16,20 +16,14 @@ defmodule BDS.Desktop.Endpoint do
|
|||||||
|
|
||||||
plug(Plug.Static,
|
plug(Plug.Static,
|
||||||
at: "/assets",
|
at: "/assets",
|
||||||
from: {:bds, "priv/ui"},
|
from: {:bds, "priv/static/assets"},
|
||||||
only: ["app.css", "live.js", "monaco"]
|
only: ["app.css", "app.js"]
|
||||||
)
|
)
|
||||||
|
|
||||||
plug(Plug.Static,
|
plug(Plug.Static,
|
||||||
at: "/vendor/phoenix",
|
at: "/monaco",
|
||||||
from: {:phoenix, "priv/static"},
|
from: {:bds, "priv/ui/monaco"},
|
||||||
only: ["phoenix.min.js"]
|
only: ["vs"]
|
||||||
)
|
|
||||||
|
|
||||||
plug(Plug.Static,
|
|
||||||
at: "/vendor/live_view",
|
|
||||||
from: {:phoenix_live_view, "priv/static"},
|
|
||||||
only: ["phoenix_live_view.min.js"]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
plug(BDS.Desktop.Router)
|
plug(BDS.Desktop.Router)
|
||||||
|
|||||||
12
lib/bds/desktop/external_links.ex
Normal file
12
lib/bds/desktop/external_links.ex
Normal 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
|
||||||
@@ -2,9 +2,13 @@ defmodule BDS.Desktop.FilePicker do
|
|||||||
@moduledoc false
|
@moduledoc false
|
||||||
|
|
||||||
def choose_file(prompt) when is_binary(prompt) do
|
def choose_file(prompt) when is_binary(prompt) do
|
||||||
case :os.type() do
|
if System.get_env("BDS_DESKTOP_AUTOMATION") == "1" do
|
||||||
{:unix, :darwin} -> choose_file_macos(prompt)
|
:cancel
|
||||||
_other -> {:error, %{message: "File selection is only supported on macOS desktop"}}
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -16,9 +16,7 @@ defmodule BDS.Desktop.Layouts do
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<%= @inner_content %>
|
<%= @inner_content %>
|
||||||
<script defer phx-track-static src="/vendor/phoenix/phoenix.min.js"></script>
|
<script defer phx-track-static src="/assets/app.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>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ defmodule BDS.Desktop.MenuBar do
|
|||||||
@moduledoc false
|
@moduledoc false
|
||||||
|
|
||||||
use BDS.Desktop.MenuCompat
|
use BDS.Desktop.MenuCompat
|
||||||
alias BDS.Desktop.Shutdown
|
alias BDS.Desktop.{ExternalLinks, ShellData, Shutdown, UILocale}
|
||||||
alias BDS.UI.Commands
|
alias BDS.UI.Commands
|
||||||
alias BDS.UI.MenuBar, as: ShellMenuBar
|
alias BDS.UI.MenuBar, as: ShellMenuBar
|
||||||
alias Desktop.OS
|
alias Desktop.OS
|
||||||
alias Desktop.Window
|
alias Desktop.Window
|
||||||
|
use Gettext, backend: BDS.Gettext
|
||||||
|
|
||||||
def groups(opts \\ []) do
|
def groups(opts \\ []) do
|
||||||
opts
|
opts
|
||||||
@@ -22,6 +23,8 @@ defmodule BDS.Desktop.MenuBar do
|
|||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(menu) do
|
def mount(menu) do
|
||||||
|
UILocale.put(ShellData.ui_language())
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
Desktop.Menu.assign(
|
Desktop.Menu.assign(
|
||||||
menu,
|
menu,
|
||||||
@@ -56,7 +59,7 @@ defmodule BDS.Desktop.MenuBar do
|
|||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("view_on_github", menu) do
|
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}
|
{:noreply, menu}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -75,7 +78,7 @@ defmodule BDS.Desktop.MenuBar do
|
|||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("report_issue", menu) do
|
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}
|
{:noreply, menu}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -85,6 +88,17 @@ defmodule BDS.Desktop.MenuBar do
|
|||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@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
|
def handle_info(_, menu) do
|
||||||
{:noreply, menu}
|
{:noreply, menu}
|
||||||
end
|
end
|
||||||
@@ -127,58 +141,58 @@ defmodule BDS.Desktop.MenuBar do
|
|||||||
defp native_label(label, nil), do: label
|
defp native_label(label, nil), do: label
|
||||||
defp native_label(label, shortcut), do: label <> "\t" <> shortcut
|
defp native_label(label, shortcut), do: label <> "\t" <> shortcut
|
||||||
|
|
||||||
defp group_label(:file), do: "File"
|
defp group_label(:file), do: dgettext("ui", "File")
|
||||||
defp group_label(:edit), do: "Edit"
|
defp group_label(:edit), do: dgettext("ui", "Edit")
|
||||||
defp group_label(:view), do: "View"
|
defp group_label(:view), do: dgettext("ui", "View")
|
||||||
defp group_label(:blog), do: "Blog"
|
defp group_label(:blog), do: dgettext("ui", "Blog")
|
||||||
defp group_label(:help), do: "Help"
|
defp group_label(:help), do: dgettext("ui", "Help")
|
||||||
|
|
||||||
defp item_label(:new_post), do: "New Post"
|
defp item_label(:new_post), do: dgettext("ui", "New Post")
|
||||||
defp item_label(:import_media), do: "Import Media"
|
defp item_label(:import_media), do: dgettext("ui", "Import Media")
|
||||||
defp item_label(:save), do: "Save"
|
defp item_label(:save), do: dgettext("ui", "Save")
|
||||||
defp item_label(:open_in_browser), do: "Open in Browser"
|
defp item_label(:open_in_browser), do: dgettext("ui", "Open in Browser")
|
||||||
defp item_label(:open_data_folder), do: "Open Data Folder"
|
defp item_label(:open_data_folder), do: dgettext("ui", "Open Data Folder")
|
||||||
defp item_label(:close_tab), do: "Close Tab"
|
defp item_label(:close_tab), do: dgettext("ui", "Close Tab")
|
||||||
defp item_label(:quit), do: "Quit"
|
defp item_label(:quit), do: dgettext("ui", "Quit")
|
||||||
defp item_label(:undo), do: "Undo"
|
defp item_label(:undo), do: dgettext("ui", "Undo")
|
||||||
defp item_label(:redo), do: "Redo"
|
defp item_label(:redo), do: dgettext("ui", "Redo")
|
||||||
defp item_label(:cut), do: "Cut"
|
defp item_label(:cut), do: dgettext("ui", "Cut")
|
||||||
defp item_label(:copy), do: "Copy"
|
defp item_label(:copy), do: dgettext("ui", "Copy")
|
||||||
defp item_label(:paste), do: "Paste"
|
defp item_label(:paste), do: dgettext("ui", "Paste")
|
||||||
defp item_label(:delete), do: "Delete"
|
defp item_label(:delete), do: dgettext("ui", "Delete")
|
||||||
defp item_label(:select_all), do: "Select All"
|
defp item_label(:select_all), do: dgettext("ui", "Select All")
|
||||||
defp item_label(:find), do: "Find"
|
defp item_label(:find), do: dgettext("ui", "Find")
|
||||||
defp item_label(:replace), do: "Replace"
|
defp item_label(:replace), do: dgettext("ui", "Replace")
|
||||||
defp item_label(:edit_preferences), do: "Preferences"
|
defp item_label(:edit_preferences), do: dgettext("ui", "Preferences")
|
||||||
defp item_label(:view_posts), do: "Posts"
|
defp item_label(:view_posts), do: dgettext("ui", "Posts")
|
||||||
defp item_label(:view_media), do: "Media"
|
defp item_label(:view_media), do: dgettext("ui", "Media")
|
||||||
defp item_label(:toggle_sidebar), do: "Toggle Sidebar"
|
defp item_label(:toggle_sidebar), do: dgettext("ui", "Toggle Sidebar")
|
||||||
defp item_label(:toggle_panel), do: "Toggle Panel"
|
defp item_label(:toggle_panel), do: dgettext("ui", "Toggle Panel")
|
||||||
defp item_label(:toggle_assistant_sidebar), do: "Toggle Assistant Sidebar"
|
defp item_label(:toggle_assistant_sidebar), do: dgettext("ui", "Toggle Assistant Sidebar")
|
||||||
defp item_label(:toggle_dev_tools), do: "Toggle Dev Tools"
|
defp item_label(:toggle_dev_tools), do: dgettext("ui", "Toggle Dev Tools")
|
||||||
defp item_label(:reload), do: "Reload"
|
defp item_label(:reload), do: dgettext("ui", "Reload")
|
||||||
defp item_label(:force_reload), do: "Force Reload"
|
defp item_label(:force_reload), do: dgettext("ui", "Force Reload")
|
||||||
defp item_label(:reset_zoom), do: "Reset Zoom"
|
defp item_label(:reset_zoom), do: dgettext("ui", "Reset Zoom")
|
||||||
defp item_label(:zoom_in), do: "Zoom In"
|
defp item_label(:zoom_in), do: dgettext("ui", "Zoom In")
|
||||||
defp item_label(:zoom_out), do: "Zoom Out"
|
defp item_label(:zoom_out), do: dgettext("ui", "Zoom Out")
|
||||||
defp item_label(:toggle_full_screen), do: "Toggle Full Screen"
|
defp item_label(:toggle_full_screen), do: dgettext("ui", "Toggle Full Screen")
|
||||||
defp item_label(:publish_selected), do: "Publish Selected"
|
defp item_label(:publish_selected), do: dgettext("ui", "Publish Selected")
|
||||||
defp item_label(:preview_post), do: "Preview Post"
|
defp item_label(:preview_post), do: dgettext("ui", "Preview Post")
|
||||||
defp item_label(:edit_menu), do: "Edit Menu"
|
defp item_label(:edit_menu), do: dgettext("ui", "Edit Menu")
|
||||||
defp item_label(:rebuild_database), do: "Rebuild Database"
|
defp item_label(:rebuild_database), do: dgettext("ui", "Rebuild Database")
|
||||||
defp item_label(:reindex_text), do: "Reindex Text"
|
defp item_label(:reindex_text), do: dgettext("ui", "Reindex Text")
|
||||||
defp item_label(:rebuild_embedding_index), do: "Rebuild Embedding Index"
|
defp item_label(:rebuild_embedding_index), do: dgettext("ui", "Rebuild Embedding Index")
|
||||||
defp item_label(:metadata_diff), do: "Metadata Diff"
|
defp item_label(:metadata_diff), do: dgettext("ui", "Metadata Diff")
|
||||||
defp item_label(:regenerate_calendar), do: "Regenerate Calendar"
|
defp item_label(:regenerate_calendar), do: dgettext("ui", "Regenerate Calendar")
|
||||||
defp item_label(:validate_translations), do: "Validate Translations"
|
defp item_label(:validate_translations), do: dgettext("ui", "Validate Translations")
|
||||||
defp item_label(:fill_missing_translations), do: "Fill Missing Translations"
|
defp item_label(:fill_missing_translations), do: dgettext("ui", "Fill Missing Translations")
|
||||||
defp item_label(:find_duplicates), do: "Find Duplicate Posts"
|
defp item_label(:find_duplicates), do: dgettext("ui", "Find Duplicate Posts")
|
||||||
defp item_label(:generate_sitemap), do: "Generate Site"
|
defp item_label(:generate_sitemap), do: dgettext("ui", "Generate Site")
|
||||||
defp item_label(:validate_site), do: "Validate Site"
|
defp item_label(:validate_site), do: dgettext("ui", "Validate Site")
|
||||||
defp item_label(:upload_site), do: "Upload Site"
|
defp item_label(:upload_site), do: dgettext("ui", "Upload Site")
|
||||||
defp item_label(:about), do: "About"
|
defp item_label(:about), do: dgettext("ui", "About")
|
||||||
defp item_label(:documentation), do: "Documentation"
|
defp item_label(:documentation), do: dgettext("ui", "Documentation")
|
||||||
defp item_label(:api_documentation), do: "API Documentation"
|
defp item_label(:api_documentation), do: dgettext("ui", "API Documentation")
|
||||||
defp item_label(:view_on_github), do: "View on GitHub"
|
defp item_label(:view_on_github), do: dgettext("ui", "View on GitHub")
|
||||||
defp item_label(:report_issue), do: "Report Issue"
|
defp item_label(:report_issue), do: dgettext("ui", "Report Issue")
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -48,29 +48,32 @@ defmodule BDS.Desktop.Overlay do
|
|||||||
end
|
end
|
||||||
|
|
||||||
def open(:media, :confirm_delete, context) do
|
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,
|
kind: :confirm_delete,
|
||||||
title: Map.get(delete_details, :title, "Delete"),
|
title: title,
|
||||||
entity_name: Map.get(delete_details, :entity_name, ""),
|
entity_name: entity_name,
|
||||||
entity_type: Map.get(delete_details, :entity_type, "media"),
|
entity_type: entity_type,
|
||||||
reference_count: length(Map.get(delete_details, :reference_list, [])),
|
reference_count: length(reference_list),
|
||||||
reference_list: Map.get(delete_details, :reference_list, [])
|
reference_list: reference_list
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
def open(:tags, :confirm_delete, context), do: open(:media, :confirm_delete, context)
|
def open(:tags, :confirm_delete, context), do: open(:media, :confirm_delete, context)
|
||||||
|
|
||||||
def open(:tags, :confirm_merge, context) do
|
def open(:tags, :confirm_merge, context) do
|
||||||
merge = Map.get(context, :merge_details, %{})
|
%{title: title, message: message} = context.merge_details
|
||||||
target = Map.get(merge, :target, "")
|
|
||||||
count = Map.get(merge, :count, 0)
|
|
||||||
|
|
||||||
%{
|
%{
|
||||||
kind: :confirm_dialog,
|
kind: :confirm_dialog,
|
||||||
title: Map.get(merge, :title, "Merge #{count} tags into #{target}?"),
|
title: title,
|
||||||
message: Map.get(merge, :message, "Cannot be undone.")
|
message: message
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -115,8 +118,8 @@ defmodule BDS.Desktop.Overlay do
|
|||||||
|> Map.get(:all_media, [])
|
|> Map.get(:all_media, [])
|
||||||
|> Enum.filter(fn media ->
|
|> Enum.filter(fn media ->
|
||||||
normalized == "" or
|
normalized == "" or
|
||||||
search_matches?(Map.get(media, :title, ""), normalized) or
|
search_matches?(media.title, normalized) or
|
||||||
search_matches?(Map.get(media, :original_name, ""), normalized)
|
search_matches?(media.original_name, normalized)
|
||||||
end)
|
end)
|
||||||
|> Enum.map(&to_insert_media_result/1)
|
|> 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
|
def insert_media_result(_overlay, _media_id), do: nil
|
||||||
|
|
||||||
defp language_picker(context, source_language) do
|
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 =
|
targets =
|
||||||
context
|
context
|
||||||
|> Map.get(:blog_languages, [])
|
|> Map.get(:blog_languages, [])
|
||||||
|> Enum.uniq()
|
|> Enum.uniq()
|
||||||
|> Enum.reject(&(&1 == source_language))
|
|> Enum.reject(&(&1 == source_language))
|
||||||
|> Enum.map(fn code ->
|
|> Enum.map(fn code ->
|
||||||
existing_status = Map.get(Map.get(context, :existing_translations, %{}), code)
|
existing_status = Map.get(existing_translations, code)
|
||||||
|
|
||||||
%{
|
%{
|
||||||
code: code,
|
code: code,
|
||||||
name: Map.get(Map.get(context, :language_names, %{}), code, String.upcase(code)),
|
name: Map.get(language_names, code, String.upcase(code)),
|
||||||
flag_emoji: Map.get(Map.get(context, :language_flags, %{}), code, code),
|
flag_emoji: Map.get(language_flags, code, code),
|
||||||
has_existing_translation: not is_nil(existing_status),
|
has_existing_translation: not is_nil(existing_status),
|
||||||
existing_status: existing_status
|
existing_status: existing_status
|
||||||
}
|
}
|
||||||
@@ -228,15 +235,43 @@ defmodule BDS.Desktop.Overlay do
|
|||||||
}
|
}
|
||||||
end
|
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
|
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, "")),
|
key: to_string(key),
|
||||||
label: Map.get(field, :label, ""),
|
label: label,
|
||||||
current_value: Map.get(field, :current_value, ""),
|
current_value: current,
|
||||||
suggested_value: Map.get(field, :suggested_value, ""),
|
suggested_value: suggested,
|
||||||
accepted: not Map.get(field, :locked, false),
|
accepted: not locked,
|
||||||
locked: Map.get(field, :locked, false)
|
locked: locked,
|
||||||
|
loading: Map.get(field, :loading, false)
|
||||||
}
|
}
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
@@ -249,7 +284,7 @@ defmodule BDS.Desktop.Overlay do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp gallery_images(context) do
|
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, [])
|
post_media_ids = Map.get(context, :post_media_ids, [])
|
||||||
|
|
||||||
case Enum.filter(images, &(&1.id in post_media_ids)) do
|
case Enum.filter(images, &(&1.id in post_media_ids)) do
|
||||||
@@ -262,29 +297,29 @@ defmodule BDS.Desktop.Overlay do
|
|||||||
%{
|
%{
|
||||||
post_id: post.id,
|
post_id: post.id,
|
||||||
title: post.title,
|
title: post.title,
|
||||||
status: to_string(Map.get(post, :status, "draft")),
|
status: post.status,
|
||||||
canonical_url: Map.get(post, :canonical_url, "/posts/#{post.id}"),
|
canonical_url: post.canonical_url,
|
||||||
similarity_score: Map.get(post, :similarity_score)
|
similarity_score: nil
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
defp to_insert_media_result(media) do
|
defp to_insert_media_result(media) do
|
||||||
%{
|
%{
|
||||||
media_id: media.id,
|
media_id: media.id,
|
||||||
title: Map.get(media, :title, ""),
|
title: media.title,
|
||||||
original_name: Map.get(media, :original_name, media.id),
|
original_name: media.original_name,
|
||||||
is_image: Map.get(media, :is_image, false),
|
is_image: media.is_image,
|
||||||
thumbnail_url: Map.get(media, :thumbnail_url)
|
thumbnail_url: media.thumbnail_url
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
defp to_gallery_image(media) do
|
defp to_gallery_image(media) do
|
||||||
%{
|
%{
|
||||||
media_id: media.id,
|
media_id: media.id,
|
||||||
thumbnail_url: Map.get(media, :thumbnail_url),
|
thumbnail_url: media.thumbnail_url,
|
||||||
image_url: Map.get(media, :image_url, Map.get(media, :thumbnail_url)),
|
image_url: media.image_url,
|
||||||
alt_text: Map.get(media, :alt_text),
|
alt_text: media.alt_text,
|
||||||
title: Map.get(media, :title, Map.get(media, :original_name, media.id))
|
title: media.title
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -279,6 +279,19 @@ defmodule BDS.Desktop.ShellCommands do
|
|||||||
end)
|
end)
|
||||||
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
|
defp dispatch("repair_metadata_diff", project, params) do
|
||||||
items = normalize_metadata_diff_items(BDS.MapUtils.attr(params, :items, []))
|
items = normalize_metadata_diff_items(BDS.MapUtils.attr(params, :items, []))
|
||||||
direction = BDS.MapUtils.attr(params, :direction)
|
direction = BDS.MapUtils.attr(params, :direction)
|
||||||
@@ -352,6 +365,33 @@ defmodule BDS.Desktop.ShellCommands do
|
|||||||
)
|
)
|
||||||
end
|
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
|
defp dispatch("find_duplicates", project, _params) do
|
||||||
queue_task(project, "find_duplicates", "Find Duplicate Posts", "Embeddings", fn report ->
|
queue_task(project, "find_duplicates", "Find Duplicate Posts", "Embeddings", fn report ->
|
||||||
{:ok, pairs} = Embeddings.find_duplicates(project.id, on_progress: report)
|
{:ok, pairs} = Embeddings.find_duplicates(project.id, on_progress: report)
|
||||||
@@ -408,6 +448,19 @@ defmodule BDS.Desktop.ShellCommands do
|
|||||||
end
|
end
|
||||||
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
|
defp rebuild_database_steps(project) do
|
||||||
[
|
[
|
||||||
%{
|
%{
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
defmodule BDS.Desktop.ShellData do
|
defmodule BDS.Desktop.ShellData do
|
||||||
@moduledoc false
|
@moduledoc false
|
||||||
|
|
||||||
|
use Gettext, backend: BDS.Gettext
|
||||||
|
|
||||||
alias BDS.Git
|
alias BDS.Git
|
||||||
alias BDS.I18n
|
alias BDS.I18n
|
||||||
alias BDS.Projects
|
alias BDS.Projects
|
||||||
|
alias BDS.Repo
|
||||||
alias BDS.UI.Dashboard
|
alias BDS.UI.Dashboard
|
||||||
alias BDS.UI.Sidebar
|
alias BDS.UI.Sidebar
|
||||||
alias BDS.UI.Workbench
|
alias BDS.UI.Workbench
|
||||||
@@ -12,145 +15,6 @@ defmodule BDS.Desktop.ShellData do
|
|||||||
Application.get_env(:bds, :desktop)[:title] || "Blogging Desktop Server"
|
Application.get_env(:bds, :desktop)[:title] || "Blogging Desktop Server"
|
||||||
end
|
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
|
def activity_icon(id) do
|
||||||
case to_string(id) do
|
case to_string(id) do
|
||||||
"posts" ->
|
"posts" ->
|
||||||
@@ -188,22 +52,141 @@ defmodule BDS.Desktop.ShellData do
|
|||||||
end
|
end
|
||||||
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
|
def dashboard_status_label(status) do
|
||||||
case to_string(status) do
|
case to_string(status) do
|
||||||
"draft" -> translate("dashboard.status.draft")
|
"draft" -> dgettext("ui", "Draft")
|
||||||
"published" -> translate("dashboard.status.published")
|
"published" -> dgettext("ui", "Published")
|
||||||
"archived" -> translate("dashboard.status.archived")
|
"archived" -> dgettext("ui", "Archived")
|
||||||
other -> other |> String.replace("_", " ") |> String.capitalize()
|
other -> other |> String.replace("_", " ") |> String.capitalize()
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def dashboard_post_count_label(count) do
|
def dashboard_post_count_label(count) do
|
||||||
normalized_count = count || 0
|
normalized_count = count || 0
|
||||||
|
dngettext("ui", "%{count} post", "%{count} posts", normalized_count, count: normalized_count)
|
||||||
key =
|
|
||||||
if normalized_count == 1, do: "dashboard.postCount.one", else: "dashboard.postCount.other"
|
|
||||||
|
|
||||||
translate(key, %{count: normalized_count})
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def dashboard_tag_cloud_items(items) when is_list(items) do
|
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
|
def route_label(route) do
|
||||||
case to_string(route) do
|
case to_string(route) do
|
||||||
"git_log" ->
|
"git_log" ->
|
||||||
"Git Log"
|
dgettext("ui", "Git Log")
|
||||||
|
|
||||||
"post_links" ->
|
"post_links" ->
|
||||||
"Post Links"
|
dgettext("ui", "Post Links")
|
||||||
|
|
||||||
other ->
|
other ->
|
||||||
other
|
other
|
||||||
@@ -288,11 +271,16 @@ defmodule BDS.Desktop.ShellData do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp effective_ui_language(nil) do
|
defp git_remote_state_provider do
|
||||||
BDS.Desktop.UILocale.current() || ui_language()
|
Application.get_env(:bds, :git_remote_state_provider, &Git.remote_state/2)
|
||||||
end
|
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]
|
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 maybe_add_panel_tab(tabs, _route, _tab), do: tabs
|
||||||
|
|
||||||
defp default_project_snapshot do
|
def default_project_snapshot do
|
||||||
%{
|
%{
|
||||||
active_project_id: "default",
|
active_project_id: "default",
|
||||||
projects: [
|
projects: [
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
224
lib/bds/desktop/shell_live/bridges.ex
Normal file
224
lib/bds/desktop/shell_live/bridges.ex
Normal 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
|
||||||
@@ -1,283 +1,542 @@
|
|||||||
defmodule BDS.Desktop.ShellLive.ChatEditor do
|
defmodule BDS.Desktop.ShellLive.ChatEditor do
|
||||||
@moduledoc false
|
@moduledoc false
|
||||||
|
|
||||||
use Phoenix.Component
|
require Logger
|
||||||
|
|
||||||
|
use Phoenix.LiveComponent
|
||||||
|
|
||||||
import Phoenix.HTML, only: [raw: 1]
|
import Phoenix.HTML, only: [raw: 1]
|
||||||
|
|
||||||
alias BDS.AI
|
alias BDS.{AI, BoundedAtoms, MapUtils, Persistence}
|
||||||
alias BDS.Desktop.ShellData
|
|
||||||
alias BDS.Desktop.ShellLive.ChatEditor.{MessageBuild, ModelSelection, ToolTracking}
|
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/*")
|
embed_templates("chat_editor_html/*")
|
||||||
|
|
||||||
# ── Public API: state assignment ───────────────────────────────────────────
|
# ── LiveComponent lifecycle ────────────────────────────────────────────────
|
||||||
|
|
||||||
@spec assign_socket(term()) :: term()
|
@spec update(map(), Phoenix.LiveView.Socket.t()) :: {:ok, Phoenix.LiveView.Socket.t()}
|
||||||
def assign_socket(socket) do
|
@impl true
|
||||||
assign(socket, :chat_editor, MessageBuild.build(socket.assigns))
|
def update(%{action: :finish_request}, %{assigns: %{request: nil}} = socket) do
|
||||||
|
{:ok, socket}
|
||||||
end
|
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
|
def update(%{action: :note_tool_result, name: name}, socket) do
|
||||||
defdelegate set_model(socket, model_id, reload, append_output), to: ModelSelection
|
{: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(%{action: :persist_surface_state}, socket) do
|
||||||
def update_input(socket, value, reload) do
|
{:ok, persist_surface_state(socket)}
|
||||||
%{id: conversation_id} = socket.assigns.current_tab
|
end
|
||||||
|
|
||||||
socket
|
def update(assigns, socket) do
|
||||||
|> assign(
|
socket =
|
||||||
:chat_editor_inputs,
|
socket
|
||||||
Map.put(socket.assigns.chat_editor_inputs, conversation_id, to_string(value || ""))
|
|> 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(
|
||||||
|
:surface_tabs,
|
||||||
|
Map.put(socket.assigns.surface_tabs, surface_id, parse_integer(index))
|
||||||
|
)
|
||||||
|
|> persist_surface_state()
|
||||||
|
|> build_data()
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("dismiss_chat_surface", %{"surface-id" => surface_id}, socket) do
|
||||||
|
socket =
|
||||||
|
socket
|
||||||
|
|> assign(:dismissed_surfaces, MapSet.put(socket.assigns.dismissed_surfaces, surface_id))
|
||||||
|
|> persist_surface_state()
|
||||||
|
|> build_data()
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
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
|
end
|
||||||
|
|
||||||
@spec update_surface_form(term(), term(), term(), term()) :: term()
|
# ── State initialisation ──────────────────────────────────────────────────
|
||||||
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)
|
|
||||||
|
|
||||||
socket
|
defp ensure_state(socket) do
|
||||||
|> assign(:chat_editor_surface_data, next_data)
|
conversation_id = socket.assigns.current_tab.id
|
||||||
|> reload.(socket.assigns.workbench)
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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
|
end
|
||||||
|
|
||||||
@spec select_surface_tab(term(), term(), term(), term()) :: term()
|
# ── Data builder ──────────────────────────────────────────────────────────
|
||||||
def select_surface_tab(socket, surface_id, index, reload)
|
|
||||||
when is_binary(surface_id) and is_integer(index) and index >= 0 do
|
defp build_data(socket) do
|
||||||
socket
|
conversation_id = socket.assigns.conversation_id
|
||||||
|> assign(
|
request = socket.assigns.request
|
||||||
:chat_editor_surface_tabs,
|
|
||||||
Map.put(socket.assigns.chat_editor_surface_tabs, surface_id, index)
|
fake_assigns = %{
|
||||||
)
|
current_tab: socket.assigns.current_tab,
|
||||||
|> reload.(socket.assigns.workbench)
|
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
|
end
|
||||||
|
|
||||||
@spec dismiss_surface(term(), term(), term()) :: term()
|
# ── Messaging ──────────────────────────────────────────────────────────────
|
||||||
def dismiss_surface(socket, surface_id, reload) when is_binary(surface_id) do
|
|
||||||
socket
|
|
||||||
|> assign(
|
|
||||||
:chat_editor_dismissed_surfaces,
|
|
||||||
MapSet.put(socket.assigns.chat_editor_dismissed_surfaces, surface_id)
|
|
||||||
)
|
|
||||||
|> reload.(socket.assigns.workbench)
|
|
||||||
end
|
|
||||||
|
|
||||||
@spec current_surface_data(term(), term()) :: term()
|
defp do_send_message(socket) do
|
||||||
def current_surface_data(socket, surface_id) when is_binary(surface_id) do
|
conversation_id = socket.assigns.conversation_id
|
||||||
Map.get(socket.assigns.chat_editor_surface_data, surface_id, %{})
|
message = String.trim(socket.assigns.input || "")
|
||||||
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)
|
|
||||||
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)
|
|
||||||
end
|
|
||||||
|
|
||||||
# ── Public API: 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()
|
|
||||||
|
|
||||||
cond do
|
cond do
|
||||||
message == "" ->
|
message == "" ->
|
||||||
reload.(socket, socket.assigns.workbench)
|
build_data(socket)
|
||||||
|
|
||||||
Map.has_key?(socket.assigns.chat_editor_requests, conversation_id) ->
|
not is_nil(socket.assigns.request) ->
|
||||||
reload.(socket, socket.assigns.workbench)
|
build_data(socket)
|
||||||
|
|
||||||
socket.assigns.offline_mode ->
|
socket.assigns.offline_mode ->
|
||||||
socket
|
Notify.output(dgettext("ui", "Chat"),
|
||||||
|> append_output.(
|
dgettext("ui", "Automatic AI actions stay gated by airplane mode."), "info")
|
||||||
translated("Chat"),
|
|
||||||
translated("Automatic AI actions stay gated by airplane mode."),
|
build_data(socket)
|
||||||
nil,
|
|
||||||
"info"
|
|
||||||
)
|
|
||||||
|> reload.(socket.assigns.workbench)
|
|
||||||
|
|
||||||
ModelSelection.needs_api_key?(false) ->
|
ModelSelection.needs_api_key?(false) ->
|
||||||
reload.(socket, socket.assigns.workbench)
|
build_data(socket)
|
||||||
|
|
||||||
true ->
|
true ->
|
||||||
live_view_pid = self()
|
parent = self()
|
||||||
|
started_at = Persistence.now_ms()
|
||||||
|
|
||||||
task =
|
task =
|
||||||
Task.Supervisor.async_nolink(BDS.Tasks.TaskSupervisor, fn ->
|
Task.Supervisor.async_nolink(BDS.Tasks.TaskSupervisor, fn ->
|
||||||
AI.send_chat_message(conversation_id, message,
|
AI.send_chat_message(conversation_id, message,
|
||||||
project_id: socket.assigns.projects.active_project_id,
|
project_id: active_project_id(socket),
|
||||||
event_target: live_view_pid
|
event_target: parent
|
||||||
)
|
)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
:ok = allow_repo_sandbox(task.pid)
|
:ok = allow_repo_sandbox(task.pid)
|
||||||
|
|
||||||
|
Notify.parent({:chat_editor_task_started, conversation_id, task.ref})
|
||||||
|
|
||||||
socket
|
socket
|
||||||
|> assign(
|
|> assign(:input, "")
|
||||||
:chat_editor_inputs,
|
|> assign(:request, %{
|
||||||
Map.put(socket.assigns.chat_editor_inputs, conversation_id, "")
|
ref: task.ref,
|
||||||
)
|
pid: task.pid,
|
||||||
|> assign(
|
started_at: started_at,
|
||||||
:chat_editor_requests,
|
message: message,
|
||||||
Map.put(socket.assigns.chat_editor_requests, conversation_id, %{
|
content: "",
|
||||||
ref: task.ref,
|
tool_events: []
|
||||||
pid: task.pid,
|
})
|
||||||
message: message,
|
|> assign(:action_error, nil)
|
||||||
content: "",
|
|> build_data()
|
||||||
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)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec abort_message(term(), term()) :: term()
|
defp do_abort_message(socket) do
|
||||||
def abort_message(socket, reload) do
|
conversation_id = socket.assigns.conversation_id
|
||||||
%{id: conversation_id} = socket.assigns.current_tab
|
|
||||||
|
|
||||||
case Map.get(socket.assigns.chat_editor_requests, conversation_id) do
|
case socket.assigns.request do
|
||||||
nil ->
|
nil ->
|
||||||
reload.(socket, socket.assigns.workbench)
|
build_data(socket)
|
||||||
|
|
||||||
%{ref: ref} = _request ->
|
%{ref: ref} = _request ->
|
||||||
:ok = AI.cancel_chat(conversation_id)
|
:ok = AI.cancel_chat(conversation_id)
|
||||||
|
|
||||||
|
Notify.parent({:chat_editor_task_cancelled, conversation_id, ref})
|
||||||
|
|
||||||
socket
|
socket
|
||||||
|> assign(
|
|> assign(:request, nil)
|
||||||
:chat_editor_requests,
|
|> clear_streaming_state()
|
||||||
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)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec note_tool_call(term(), term(), term(), term()) :: term()
|
defp clear_streaming_state(socket) do
|
||||||
def note_tool_call(socket, conversation_id, tool_call, reload)
|
input = socket.assigns.input || ""
|
||||||
when is_binary(conversation_id) and is_map(tool_call) do
|
chat_editor = socket.assigns.chat_editor || %{}
|
||||||
update_request(
|
|
||||||
socket,
|
chat_editor =
|
||||||
conversation_id,
|
chat_editor
|
||||||
fn request ->
|
|> Map.put(:is_streaming, false)
|
||||||
update_in(
|
|> Map.put(:pending_user_message, nil)
|
||||||
request.tool_events,
|
|> Map.put(:streaming_content, "")
|
||||||
&(&1 ++
|
|> Map.put(:streaming_tool_markers, [])
|
||||||
[
|
|> Map.put(:streaming_inline_surfaces, [])
|
||||||
%{
|
|> Map.put(:send_disabled?, String.trim(input) == "")
|
||||||
type: :call,
|
|
||||||
name: ToolTracking.tool_call_name(tool_call),
|
assign(socket, :chat_editor, chat_editor)
|
||||||
arguments: ToolTracking.tool_call_arguments(tool_call)
|
|
||||||
}
|
|
||||||
])
|
|
||||||
)
|
|
||||||
end,
|
|
||||||
reload
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec note_tool_result(term(), term(), term(), term()) :: term()
|
defp do_finish_request(socket, result) do
|
||||||
def note_tool_result(socket, conversation_id, name, reload)
|
case result do
|
||||||
when is_binary(conversation_id) and is_binary(name) do
|
{:ok, reply} ->
|
||||||
update_request(
|
|
||||||
socket,
|
|
||||||
conversation_id,
|
|
||||||
fn request ->
|
|
||||||
update_in(request.tool_events, &(&1 ++ [%{type: :result, name: name}]))
|
|
||||||
end,
|
|
||||||
reload
|
|
||||||
)
|
|
||||||
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
|
|
||||||
)
|
|
||||||
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
|
socket
|
||||||
|
|> update_tab_meta_from_reply(reply)
|
||||||
|
|> assign(:request, nil)
|
||||||
|
|> build_data()
|
||||||
|
|
||||||
{conversation_id, remaining_refs} ->
|
{:error, :cancelled} ->
|
||||||
socket =
|
assign(socket, :request, nil) |> build_data()
|
||||||
socket
|
|
||||||
|> assign(:chat_editor_request_refs, remaining_refs)
|
|
||||||
|> assign(
|
|
||||||
:chat_editor_requests,
|
|
||||||
Map.delete(socket.assigns.chat_editor_requests, conversation_id)
|
|
||||||
)
|
|
||||||
|
|
||||||
case result do
|
{:error, %{kind: :endpoint_not_configured}} ->
|
||||||
{:ok, _reply} ->
|
assign(socket, :request, nil) |> build_data()
|
||||||
reload.(socket, socket.assigns.workbench)
|
|
||||||
|
|
||||||
{:error, :cancelled} ->
|
{:error, reason} ->
|
||||||
reload.(socket, socket.assigns.workbench)
|
Notify.output(dgettext("ui", "Chat"), format_error(reason), "error")
|
||||||
|
|
||||||
{:error, %{kind: :endpoint_not_configured}} ->
|
assign(socket, :request, nil) |> build_data()
|
||||||
reload.(socket, socket.assigns.workbench)
|
end
|
||||||
|
end
|
||||||
|
|
||||||
{:error, reason} ->
|
defp do_note_tool_call(socket, tool_call) when is_map(tool_call) do
|
||||||
socket
|
update_request(socket, fn request ->
|
||||||
|> append_output.(translated("Chat"), format_error(reason), nil, "error")
|
update_in(
|
||||||
|> reload.(socket.assigns.workbench)
|
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)
|
||||||
|
end
|
||||||
|
|
||||||
|
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)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp do_note_streaming_content(socket, content) when is_binary(content) do
|
||||||
|
update_request(socket, fn request -> %{request | content: content} end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp update_request(socket, updater) do
|
||||||
|
case socket.assigns.request do
|
||||||
|
nil ->
|
||||||
|
build_data(socket)
|
||||||
|
|
||||||
|
request ->
|
||||||
|
socket
|
||||||
|
|> 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
|
||||||
|
)
|
||||||
|
|
||||||
|
assign(socket, :action_error, nil) |> build_data()
|
||||||
|
|
||||||
|
_other ->
|
||||||
|
set_action_error(socket, "Invalid payload for openPost action")
|
||||||
end
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
# ── HEEx-callable helpers ─────────────────────────────────────────────────
|
# ── HEEx-callable helpers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
@spec message_role_label(term()) :: term()
|
@spec message_role_label(atom()) :: String.t()
|
||||||
def message_role_label(:user), do: translated("chat.role.you")
|
def message_role_label(:user), do: dgettext("ui", "You")
|
||||||
def message_role_label(_role), do: translated("chat.role.assistant")
|
def message_role_label(_role), do: dgettext("ui", "Assistant")
|
||||||
|
|
||||||
defdelegate tool_call_name(tool_call), to: ToolTracking
|
defdelegate tool_call_name(tool_call), to: ToolTracking
|
||||||
defdelegate tool_call_arguments(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")
|
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
|
def markdown_html(content) when is_binary(content) do
|
||||||
html =
|
html =
|
||||||
case Earmark.as_html(content, escape: true) do
|
case Earmark.as_html(content, escape: true) do
|
||||||
@@ -289,13 +548,13 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
|||||||
raw(html)
|
raw(html)
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec markdown_html(term()) :: term()
|
|
||||||
def markdown_html(_content), do: ""
|
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(nil), do: "{}"
|
||||||
def payload_json(payload) when is_map(payload), do: Jason.encode!(payload)
|
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 not is_number(value), do: 0
|
||||||
|
|
||||||
def chart_width(max_value, value) when is_number(max_value) and max_value > 0 do
|
def chart_width(max_value, value) when is_number(max_value) and max_value > 0 do
|
||||||
@@ -307,18 +566,17 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
|||||||
|> Float.round(2)
|
|> Float.round(2)
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec chart_width(term(), term()) :: term()
|
|
||||||
def chart_width(_max_value, _value), do: 0
|
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
|
def truthy?(value) when value in [true, "true", 1, "1", "on"], do: true
|
||||||
@spec truthy?(term()) :: term()
|
|
||||||
def truthy?(_value), do: false
|
def truthy?(_value), do: false
|
||||||
|
|
||||||
# ── HEEx components ───────────────────────────────────────────────────────
|
# ── HEEx components ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
attr(:markers, :list, required: true)
|
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
|
def chat_tool_markers(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<%= if @markers != [] do %>
|
<%= if @markers != [] do %>
|
||||||
@@ -333,10 +591,10 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
|||||||
<% end %>
|
<% end %>
|
||||||
</summary>
|
</summary>
|
||||||
<div class="chat-tool-marker-details" data-testid="chat-tool-marker-details">
|
<div class="chat-tool-marker-details" data-testid="chat-tool-marker-details">
|
||||||
<div class="chat-tool-marker-detail-label"><%= translated("chat.toolArguments") %></div>
|
<div class="chat-tool-marker-detail-label"><%= dgettext("ui", "Arguments") %></div>
|
||||||
<pre><%= Jason.encode!(marker.arguments || %{}, pretty: true) %></pre>
|
<pre><%= Jason.encode!(marker.arguments || %{}, pretty: true) %></pre>
|
||||||
<%= if marker.result not in [nil, ""] do %>
|
<%= if marker.result not in [nil, ""] do %>
|
||||||
<div class="chat-tool-marker-detail-label"><%= translated("chat.toolResult") %></div>
|
<div class="chat-tool-marker-detail-label"><%= dgettext("ui", "Result") %></div>
|
||||||
<pre><%= marker.result %></pre>
|
<pre><%= marker.result %></pre>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
@@ -348,15 +606,16 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
|||||||
end
|
end
|
||||||
|
|
||||||
attr(:surface, :map, required: true)
|
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
|
def chat_surface(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<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)}>
|
<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">
|
<summary class="chat-inline-surface-header">
|
||||||
<span class="chat-inline-surface-icon"><%= surface_icon(@surface.type) %></span>
|
<span class="chat-inline-surface-icon"><%= surface_icon(@surface.type) %></span>
|
||||||
<span class="chat-inline-surface-title"><%= surface_title(@surface) %></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-value-surface-id={@surface.id} aria-label={translated("chat.dismissSurface")} data-testid="chat-inline-surface-dismiss">×</button>
|
<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>
|
</summary>
|
||||||
<div class="chat-inline-surface-body">
|
<div class="chat-inline-surface-body">
|
||||||
<%= case @surface.type do %>
|
<%= case @surface.type do %>
|
||||||
@@ -376,6 +635,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
|||||||
class="chat-surface-action-button"
|
class="chat-surface-action-button"
|
||||||
type="button"
|
type="button"
|
||||||
phx-click="chat_surface_action"
|
phx-click="chat_surface_action"
|
||||||
|
phx-target={@myself}
|
||||||
phx-value-surface-id={@surface.id}
|
phx-value-surface-id={@surface.id}
|
||||||
phx-value-action={action.action}
|
phx-value-action={action.action}
|
||||||
phx-value-payload={payload_json(action.payload)}
|
phx-value-payload={payload_json(action.payload)}
|
||||||
@@ -475,6 +735,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
|||||||
class={["chat-surface-tab-button", if(index == @surface.selected_index, do: "active")]}
|
class={["chat-surface-tab-button", if(index == @surface.selected_index, do: "active")]}
|
||||||
type="button"
|
type="button"
|
||||||
phx-click="select_chat_surface_tab"
|
phx-click="select_chat_surface_tab"
|
||||||
|
phx-target={@myself}
|
||||||
phx-value-surface-id={@surface.id}
|
phx-value-surface-id={@surface.id}
|
||||||
phx-value-index={index}
|
phx-value-index={index}
|
||||||
>
|
>
|
||||||
@@ -498,7 +759,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
|||||||
<%= if present?(@surface.title) do %>
|
<%= if present?(@surface.title) do %>
|
||||||
<h3><%= @surface.title %></h3>
|
<h3><%= @surface.title %></h3>
|
||||||
<% end %>
|
<% 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} />
|
<input type="hidden" name="surface[id]" value={@surface.id} />
|
||||||
|
|
||||||
<%= for field <- @surface.fields do %>
|
<%= for field <- @surface.fields do %>
|
||||||
@@ -535,6 +796,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
|||||||
class="chat-surface-action-button"
|
class="chat-surface-action-button"
|
||||||
type="button"
|
type="button"
|
||||||
phx-click="chat_surface_action"
|
phx-click="chat_surface_action"
|
||||||
|
phx-target={@myself}
|
||||||
phx-value-surface-id={@surface.id}
|
phx-value-surface-id={@surface.id}
|
||||||
phx-value-action={@surface.submit_action}
|
phx-value-action={@surface.submit_action}
|
||||||
phx-value-payload="{}"
|
phx-value-payload="{}"
|
||||||
@@ -580,19 +842,44 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
|||||||
|
|
||||||
# ── Private helpers ───────────────────────────────────────────────────────
|
# ── Private helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
defp update_request(socket, conversation_id, updater, reload) do
|
@surface_state_debounce_ms 500
|
||||||
case Map.get(socket.assigns.chat_editor_requests, conversation_id) do
|
|
||||||
nil ->
|
|
||||||
socket
|
|
||||||
|
|
||||||
request ->
|
defp persist_surface_state(socket) do
|
||||||
socket
|
conversation_id = socket.assigns.conversation_id
|
||||||
|> assign(
|
surface_data = socket.assigns.surface_data
|
||||||
:chat_editor_requests,
|
surface_tabs = socket.assigns.surface_tabs
|
||||||
Map.put(socket.assigns.chat_editor_requests, conversation_id, updater.(request))
|
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
|
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
|
end
|
||||||
|
|
||||||
defp allow_repo_sandbox(pid) when is_pid(pid) do
|
defp allow_repo_sandbox(pid) when is_pid(pid) do
|
||||||
@@ -637,11 +924,16 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
|||||||
defp present?(value), do: not is_nil(value)
|
defp present?(value), do: not is_nil(value)
|
||||||
|
|
||||||
defp format_error(%{kind: :endpoint_not_configured}),
|
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)
|
defp format_error(reason), do: inspect(reason)
|
||||||
|
|
||||||
@spec translated(term(), term()) :: term()
|
defp parse_integer(value) when is_integer(value), do: value
|
||||||
def translated(text, bindings \\ %{}),
|
|
||||||
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
|
defp parse_integer(value) do
|
||||||
|
case Integer.parse(to_string(value)) do
|
||||||
|
{int, _} -> int
|
||||||
|
:error -> 0
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.MessageBuild do
|
|||||||
|
|
||||||
alias BDS.AI
|
alias BDS.AI
|
||||||
alias BDS.AI.ChatConversation
|
alias BDS.AI.ChatConversation
|
||||||
alias BDS.Desktop.ShellData
|
|
||||||
alias BDS.Desktop.ShellLive.ChatEditor.{ModelSelection, ToolSurfaces, ToolTracking}
|
alias BDS.Desktop.ShellLive.ChatEditor.{ModelSelection, ToolSurfaces, ToolTracking}
|
||||||
|
use Gettext, backend: BDS.Gettext
|
||||||
|
|
||||||
@spec build(term()) :: term()
|
@spec build(term()) :: term()
|
||||||
def build(%{current_tab: %{type: :chat, id: conversation_id}} = assigns) do
|
def build(%{current_tab: %{type: :chat, id: conversation_id}} = assigns) do
|
||||||
@@ -17,10 +17,12 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.MessageBuild do
|
|||||||
request = Map.get(assigns.chat_editor_requests, conversation.id)
|
request = Map.get(assigns.chat_editor_requests, conversation.id)
|
||||||
effective_model = AI.effective_chat_model(conversation)
|
effective_model = AI.effective_chat_model(conversation)
|
||||||
available_models = AI.available_chat_models(effective_model)
|
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,
|
id: conversation.id,
|
||||||
title: conversation.title || translated("chat.newChat"),
|
title: conversation.title || dgettext("ui", "New Chat"),
|
||||||
model: conversation.model,
|
model: conversation.model,
|
||||||
effective_model: effective_model,
|
effective_model: effective_model,
|
||||||
available_models: available_models,
|
available_models: available_models,
|
||||||
@@ -31,9 +33,10 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.MessageBuild do
|
|||||||
messages: build_entries(messages, assigns),
|
messages: build_entries(messages, assigns),
|
||||||
pending_user_message: pending_user_message(messages, request),
|
pending_user_message: pending_user_message(messages, request),
|
||||||
is_streaming: not is_nil(request),
|
is_streaming: not is_nil(request),
|
||||||
streaming_content: streaming_content(request),
|
streaming_content: streaming_content,
|
||||||
streaming_tool_markers: ToolTracking.tool_markers_from_events(request),
|
streaming_tool_markers: streaming_tool_markers,
|
||||||
streaming_inline_surfaces: streaming_inline_surfaces(conversation.id, request, assigns),
|
streaming_inline_surfaces:
|
||||||
|
streaming_inline_surfaces(conversation.id, streaming_tool_markers, assigns),
|
||||||
offline?: Map.get(assigns, :offline_mode, true),
|
offline?: Map.get(assigns, :offline_mode, true),
|
||||||
needs_api_key?: ModelSelection.needs_api_key?(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),
|
action_error: Map.get(assigns.chat_editor_action_errors, conversation.id),
|
||||||
@@ -137,28 +140,133 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.MessageBuild do
|
|||||||
|
|
||||||
defp pending_user_message(_messages, nil), do: nil
|
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
|
case messages |> Enum.reverse() |> Enum.find(&(&1.role not in [:system, :tool])) do
|
||||||
%{role: :user, content: ^message} -> nil
|
%{role: :user, content: ^message} -> nil
|
||||||
_other -> message
|
_other -> message
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp pending_user_message(_messages, _request), do: nil
|
|
||||||
|
|
||||||
defp streaming_content(nil), do: ""
|
defp streaming_content(nil), do: ""
|
||||||
defp streaming_content(%{content: content}) when is_binary(content), do: content
|
defp streaming_content(%{content: content}) when is_binary(content), do: content
|
||||||
defp streaming_content(_request), do: ""
|
defp streaming_content(_request), do: ""
|
||||||
|
|
||||||
defp streaming_inline_surfaces(_conversation_id, nil, _assigns), do: []
|
defp streaming_content(messages, request) do
|
||||||
|
content = streaming_content(request)
|
||||||
|
|
||||||
defp streaming_inline_surfaces(conversation_id, request, assigns) do
|
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
|
request
|
||||||
|> ToolTracking.tool_markers_from_events()
|
|> 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)
|
|> ToolSurfaces.build_render_surfaces("streaming-#{conversation_id}", assigns)
|
||||||
|> mark_surfaces_expanded(assigns)
|
|> mark_surfaces_expanded(assigns)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp translated(text, bindings \\ %{}),
|
defp persisted_user_message_for_request?(messages, %{message: message} = request)
|
||||||
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
|
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
|
end
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ModelSelection do
|
|||||||
@moduledoc false
|
@moduledoc false
|
||||||
|
|
||||||
alias BDS.AI
|
alias BDS.AI
|
||||||
alias BDS.Desktop.ShellData
|
|
||||||
|
|
||||||
import Phoenix.Component, only: [assign: 3]
|
import Phoenix.Component, only: [assign: 3]
|
||||||
|
use Gettext, backend: BDS.Gettext
|
||||||
|
|
||||||
@spec toggle_model_selector(term(), term()) :: term()
|
@spec toggle_model_selector(term(), term()) :: term()
|
||||||
def toggle_model_selector(socket, reload) do
|
def toggle_model_selector(socket, reload) do
|
||||||
@@ -34,7 +34,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ModelSelection do
|
|||||||
|
|
||||||
{:error, reason} ->
|
{:error, reason} ->
|
||||||
socket
|
socket
|
||||||
|> append_output.(translated("Chat"), inspect(reason), nil, "error")
|
|> append_output.(dgettext("ui", "Chat"), inspect(reason), nil, "error")
|
||||||
|> reload.(socket.assigns.workbench)
|
|> reload.(socket.assigns.workbench)
|
||||||
end
|
end
|
||||||
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?(value) when is_binary(value), do: String.trim(value) == ""
|
||||||
defp blank?(nil), do: true
|
defp blank?(nil), do: true
|
||||||
|
|
||||||
defp translated(text, bindings \\ %{}),
|
|
||||||
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
defmodule BDS.Desktop.ShellLive.ChatEditor.ToolSurfaces do
|
defmodule BDS.Desktop.ShellLive.ChatEditor.ToolSurfaces do
|
||||||
@moduledoc false
|
@moduledoc false
|
||||||
|
|
||||||
alias BDS.Desktop.ShellData
|
use Gettext, backend: BDS.Gettext
|
||||||
|
|
||||||
@render_tool_names MapSet.new([
|
@render_tool_names MapSet.new([
|
||||||
"render_card",
|
"render_card",
|
||||||
@@ -85,7 +85,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolSurfaces do
|
|||||||
|> List.wrap()
|
|> List.wrap()
|
||||||
|> Enum.map(fn entry ->
|
|> 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)),
|
value: numeric_value(map_value(entry, "value", 0)),
|
||||||
segments: List.wrap(map_value(entry, "segments", []))
|
segments: List.wrap(map_value(entry, "segments", []))
|
||||||
}
|
}
|
||||||
@@ -173,7 +173,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolSurfaces do
|
|||||||
fields: fields,
|
fields: fields,
|
||||||
submit_label:
|
submit_label:
|
||||||
map_value(arguments, "submitLabel") ||
|
map_value(arguments, "submitLabel") ||
|
||||||
map_value(arguments, "submit_label", translated("chat.stop")),
|
map_value(arguments, "submit_label", dgettext("ui", "Stop")),
|
||||||
submit_action:
|
submit_action:
|
||||||
map_value(arguments, "submitAction") ||
|
map_value(arguments, "submitAction") ||
|
||||||
map_value(arguments, "submit_action", "submitForm")
|
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
|
defp decode_surface_actions(actions) when is_list(actions) do
|
||||||
Enum.map(actions, fn action ->
|
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"),
|
action: map_value(action, "action", "openSettings"),
|
||||||
payload: map_value(action, "payload", %{})
|
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 \\ nil)
|
||||||
|
|
||||||
defp map_value(map, key, default) when is_map(map) and is_binary(key) do
|
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
|
rescue
|
||||||
ArgumentError -> Map.get(map, key, default)
|
ArgumentError -> Map.get(map, key, default)
|
||||||
end
|
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) when value in [true, "true", 1, "1", "on"], do: true
|
||||||
defp truthy?(_value), do: false
|
defp truthy?(_value), do: false
|
||||||
|
|
||||||
defp translated(text, bindings \\ %{}),
|
|
||||||
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -8,6 +8,14 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolTracking do
|
|||||||
BDS.MapUtils.attr(tool_call, :name) || "tool"
|
BDS.MapUtils.attr(tool_call, :name) || "tool"
|
||||||
end
|
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()
|
@spec tool_call_arguments(term()) :: term()
|
||||||
def tool_call_arguments(tool_call) when is_map(tool_call) do
|
def tool_call_arguments(tool_call) when is_map(tool_call) do
|
||||||
BDS.MapUtils.attr(tool_call, :arguments) || BDS.MapUtils.attr(tool_call, :args) || %{}
|
BDS.MapUtils.attr(tool_call, :arguments) || BDS.MapUtils.attr(tool_call, :args) || %{}
|
||||||
@@ -72,7 +80,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolTracking do
|
|||||||
markers ++
|
markers ++
|
||||||
[
|
[
|
||||||
%{
|
%{
|
||||||
id: nil,
|
id: Map.get(event, :id),
|
||||||
name: event.name,
|
name: event.name,
|
||||||
arguments: event.arguments,
|
arguments: event.arguments,
|
||||||
args_preview: tool_arguments_preview(event.arguments || %{}),
|
args_preview: tool_arguments_preview(event.arguments || %{}),
|
||||||
|
|||||||
@@ -1,28 +1,29 @@
|
|||||||
<div id={"chat-editor-#{@chat_editor.id}"} class="chat-panel" data-testid="chat-editor" phx-hook="ChatSurface">
|
<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">
|
<div class="chat-panel-header flex shrink-0 items-center justify-between gap-3 px-4 py-3">
|
||||||
<div class="chat-panel-title">
|
<div class="chat-panel-title flex min-w-0 flex-1 items-center justify-between gap-3">
|
||||||
<span class="chat-panel-title-main">
|
<span class="chat-panel-title-main">
|
||||||
<%= if @chat_editor.needs_api_key? do %>
|
<%= if @chat_editor.needs_api_key? do %>
|
||||||
<%= translated("chat.setupTitle") %>
|
<%= dgettext("ui", "AI Chat Setup") %>
|
||||||
<% else %>
|
<% else %>
|
||||||
<%= @chat_editor.title %>
|
<%= @chat_editor.title %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<%= unless @chat_editor.needs_api_key? do %>
|
<%= unless @chat_editor.needs_api_key? do %>
|
||||||
<span class="chat-model-selector-wrap">
|
<span class="chat-model-selector-wrap relative shrink-0">
|
||||||
<button
|
<button
|
||||||
class="chat-model-selector-button chat-model-selector-inline"
|
class="chat-model-selector-button chat-model-selector-inline ui-button ui-button-secondary inline-flex items-center gap-2"
|
||||||
type="button"
|
type="button"
|
||||||
phx-click="toggle_chat_model_selector"
|
phx-click="toggle_chat_model_selector"
|
||||||
|
phx-target={@myself}
|
||||||
data-testid="chat-model-selector-button"
|
data-testid="chat-model-selector-button"
|
||||||
>
|
>
|
||||||
<span><%= @chat_editor.effective_model || translated("chat.modelUnavailable") %></span>
|
<span><%= @chat_editor.effective_model || dgettext("ui", "No model") %></span>
|
||||||
<span class="chat-model-selector-caret">▾</span>
|
<span class="chat-model-selector-caret">▾</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<%= if @chat_editor.model_selector_open? and @chat_editor.available_models != [] do %>
|
<%= 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 %>
|
<%= for group <- @chat_editor.available_model_groups do %>
|
||||||
<section class="chat-model-provider-group" data-testid="chat-model-provider-group" data-provider={group.provider}>
|
<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 %>
|
<%= if length(@chat_editor.available_model_groups) > 1 do %>
|
||||||
@@ -32,11 +33,12 @@
|
|||||||
<%= for model <- group.models do %>
|
<%= for model <- group.models do %>
|
||||||
<button
|
<button
|
||||||
class={[
|
class={[
|
||||||
"chat-model-selector-option",
|
"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")
|
if(model.id == @chat_editor.effective_model, do: "active")
|
||||||
]}
|
]}
|
||||||
type="button"
|
type="button"
|
||||||
phx-click="select_chat_model"
|
phx-click="select_chat_model"
|
||||||
|
phx-target={@myself}
|
||||||
phx-value-model={model.id}
|
phx-value-model={model.id}
|
||||||
data-testid="chat-model-selector-option"
|
data-testid="chat-model-selector-option"
|
||||||
data-provider={group.provider}
|
data-provider={group.provider}
|
||||||
@@ -53,54 +55,42 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 %>
|
<%= 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>
|
<div class="chat-welcome-icon">🔑</div>
|
||||||
<h2><%= translated("chat.apiKeyRequiredTitle") %></h2>
|
<h2><%= dgettext("ui", "API Key Required") %></h2>
|
||||||
<p><%= translated("chat.apiKeyRequiredDescription") %></p>
|
<p><%= dgettext("ui", "Configure an API key in Settings to enable AI chat.") %></p>
|
||||||
<div class="api-key-form">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<% else %>
|
<% else %>
|
||||||
<%= if Enum.empty?(@chat_editor.messages) and not @chat_editor.is_streaming do %>
|
<%= 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>
|
<div class="chat-welcome-icon">🤖</div>
|
||||||
<h2><%= translated("chat.welcomeTitle") %></h2>
|
<h2><%= dgettext("ui", "Welcome to the AI Assistant") %></h2>
|
||||||
<p><%= translated("chat.welcomeDescription") %></p>
|
<p><%= dgettext("ui", "I can help you manage your blog with rich visualizations. Try asking me to:") %></p>
|
||||||
<ul>
|
<ul>
|
||||||
<li><%= translated("chat.welcomeTipSearch") %></li>
|
<li><%= dgettext("ui", "Search for posts about a specific topic") %></li>
|
||||||
<li><%= translated("chat.welcomeTipChart") %></li>
|
<li><%= dgettext("ui", "Show a chart of posts published per month") %></li>
|
||||||
<li><%= translated("chat.welcomeTipTable") %></li>
|
<li><%= dgettext("ui", "Compare my recent posts in a table") %></li>
|
||||||
<li><%= translated("chat.welcomeTipMetadata") %></li>
|
<li><%= dgettext("ui", "Update metadata for posts or media") %></li>
|
||||||
<li><%= translated("chat.welcomeTipTabs") %></li>
|
<li><%= dgettext("ui", "Show post statistics by year in tabs with charts") %></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<% else %>
|
<% 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-user-message-text" data-testid="chat-user-message-text"><%= @chat_editor.pending_user_message %></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<%= for message <- @chat_editor.messages do %>
|
<%= 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-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>
|
<div class="chat-message-header"><span class="chat-message-role"><%= message_role_label(message.role) %></span></div>
|
||||||
<.chat_tool_markers markers={message.tool_markers} />
|
<.chat_tool_markers markers={message.tool_markers} />
|
||||||
|
|
||||||
<%= if message.role == :assistant do %>
|
<%= if message.role == :assistant do %>
|
||||||
<div class="chat-message-text"><%= markdown_html(message.content || "") %></div>
|
<div class="chat-message-text"><%= markdown_html(message.content || "") %></div>
|
||||||
<%= for surface <- message.inline_surfaces do %>
|
<%= for surface <- message.inline_surfaces do %>
|
||||||
<.chat_surface surface={surface} />
|
<.chat_surface surface={surface} myself={@myself} />
|
||||||
<% end %>
|
<% end %>
|
||||||
<% else %>
|
<% else %>
|
||||||
<div class="chat-message-text chat-user-message-text" data-testid="chat-user-message-text"><%= message.content || "" %></div>
|
<div class="chat-message-text chat-user-message-text" data-testid="chat-user-message-text"><%= message.content || "" %></div>
|
||||||
@@ -110,10 +100,22 @@
|
|||||||
|
|
||||||
<% end %>
|
<% 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 %>
|
<%= 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-avatar">🤖</div>
|
||||||
<div class="chat-message-content">
|
<div class="chat-message-content ui-section-card">
|
||||||
<div class="chat-message-header">
|
<div class="chat-message-header">
|
||||||
<span class="chat-message-role"><%= message_role_label(:assistant) %></span>
|
<span class="chat-message-role"><%= message_role_label(:assistant) %></span>
|
||||||
<span class="streaming-indicator">●</span>
|
<span class="streaming-indicator">●</span>
|
||||||
@@ -124,14 +126,14 @@
|
|||||||
<div class="chat-message-text"><%= markdown_html(@chat_editor.streaming_content) %></div>
|
<div class="chat-message-text"><%= markdown_html(@chat_editor.streaming_content) %></div>
|
||||||
<% end %>
|
<% end %>
|
||||||
<%= for surface <- @chat_editor.streaming_inline_surfaces do %>
|
<%= for surface <- @chat_editor.streaming_inline_surfaces do %>
|
||||||
<.chat_surface surface={surface} />
|
<.chat_surface surface={surface} myself={@myself} />
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<%= if @chat_editor.is_streaming and @chat_editor.streaming_content == "" and @chat_editor.streaming_tool_markers == [] do %>
|
<%= 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-avatar">🤖</div>
|
||||||
<div class="chat-message-content">
|
<div class="chat-message-content">
|
||||||
<div class="chat-thinking-indicator">
|
<div class="chat-thinking-indicator">
|
||||||
@@ -145,14 +147,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= unless @chat_editor.needs_api_key? do %>
|
<%= 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 %>
|
<%= 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 %>
|
<% end %>
|
||||||
|
|
||||||
<form class="chat-input-wrapper" phx-change="change_chat_editor_input" phx-submit="send_chat_editor_message">
|
<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" name="message" rows="1" placeholder={translated("chat.inputPlaceholder")} disabled={@chat_editor.is_streaming}><%= @chat_editor.input %></textarea>
|
<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" data-testid="chat-send-button" type="button" phx-click="send_chat_editor_message" disabled={@chat_editor.send_disabled?}>↑</button>
|
<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>
|
</form>
|
||||||
|
|
||||||
<%= if @chat_editor.action_error do %>
|
<%= if @chat_editor.action_error do %>
|
||||||
|
|||||||
@@ -3,144 +3,7 @@ defmodule BDS.Desktop.ShellLive.ChatSurface do
|
|||||||
|
|
||||||
import Phoenix.Component, only: [assign: 3]
|
import Phoenix.Component, only: [assign: 3]
|
||||||
|
|
||||||
alias BDS.BoundedAtoms
|
use Gettext, backend: BDS.Gettext
|
||||||
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
|
|
||||||
|
|
||||||
def assistant_turn(prompt, socket) do
|
def assistant_turn(prompt, socket) do
|
||||||
[
|
[
|
||||||
@@ -149,12 +12,12 @@ defmodule BDS.Desktop.ShellLive.ChatSurface do
|
|||||||
]
|
]
|
||||||
end
|
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_project_name(project), do: project.name
|
||||||
|
|
||||||
def assistant_message_label("assistant"), do: translated("Assistant")
|
def assistant_message_label("assistant"), do: dgettext("ui", "Assistant")
|
||||||
def assistant_message_label("user"), do: translated("You")
|
def assistant_message_label("user"), do: dgettext("ui", "You")
|
||||||
def assistant_message_label(_role), do: translated("Assistant")
|
def assistant_message_label(_role), do: dgettext("ui", "Assistant")
|
||||||
|
|
||||||
def assistant_message_testid(role), do: "assistant-message-#{role}"
|
def assistant_message_testid(role), do: "assistant-message-#{role}"
|
||||||
|
|
||||||
@@ -165,76 +28,19 @@ defmodule BDS.Desktop.ShellLive.ChatSurface do
|
|||||||
end
|
end
|
||||||
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
|
defp assistant_reply(socket) do
|
||||||
if socket.assigns.offline_mode do
|
if socket.assigns.offline_mode do
|
||||||
ShellData.translate(
|
BDS.Gettext.lgettext(
|
||||||
"Automatic AI actions stay gated by airplane mode.",
|
socket.assigns.page_language,
|
||||||
%{},
|
"ui",
|
||||||
socket.assigns.page_language
|
"Automatic AI actions stay gated by airplane mode."
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
ShellData.translate(
|
BDS.Gettext.lgettext(
|
||||||
"The assistant sidebar chat surface is ready, but model execution is not connected yet.",
|
socket.assigns.page_language,
|
||||||
%{},
|
"ui",
|
||||||
socket.assigns.page_language
|
"The assistant sidebar chat surface is ready, but model execution is not connected yet."
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp translated(text), do: ShellData.translate(text, %{}, BDS.Desktop.UILocale.current())
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -46,29 +46,6 @@ defmodule BDS.Desktop.ShellLive.CliSync do
|
|||||||
|> assign(:workbench, workbench)
|
|> assign(:workbench, workbench)
|
||||||
|> assign(:shell_overlay, nil)
|
|> assign(:shell_overlay, nil)
|
||||||
|> assign(:tab_meta, Map.delete(socket.assigns.tab_meta, {:post, post_id}))
|
|> 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}
|
{socket, workbench}
|
||||||
end
|
end
|
||||||
@@ -81,27 +58,6 @@ defmodule BDS.Desktop.ShellLive.CliSync do
|
|||||||
|> assign(:workbench, workbench)
|
|> assign(:workbench, workbench)
|
||||||
|> assign(:shell_overlay, nil)
|
|> assign(:shell_overlay, nil)
|
||||||
|> assign(:tab_meta, Map.delete(socket.assigns.tab_meta, {:media, media_id}))
|
|> 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}
|
{socket, workbench}
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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
@@ -2,7 +2,8 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
|
|||||||
@moduledoc false
|
@moduledoc false
|
||||||
|
|
||||||
alias BDS.{ImportAnalysis, ImportDefinitions, Metadata}
|
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()
|
@spec change_definition(term(), term(), term()) :: term()
|
||||||
def change_definition(socket, params, reload) do
|
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()
|
@spec select_uploads_folder(term(), term(), term()) :: term()
|
||||||
def select_uploads_folder(socket, reload, append_output) do
|
def select_uploads_folder(socket, reload, append_output) do
|
||||||
with %{id: definition_id} <- socket.assigns.current_tab 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, uploads_folder_path} ->
|
||||||
{:ok, _definition} =
|
{:ok, _definition} =
|
||||||
ImportDefinitions.update_definition(definition_id, %{
|
ImportDefinitions.update_definition(definition_id, %{
|
||||||
@@ -32,7 +33,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
|
|||||||
|
|
||||||
{:error, %{message: message}} ->
|
{:error, %{message: message}} ->
|
||||||
socket
|
socket
|
||||||
|> append_output.(translated("activity.import"), message, nil, "error")
|
|> append_output.(dgettext("ui", "Import"), message, nil, "error")
|
||||||
|> reload.(socket.assigns.workbench)
|
|> reload.(socket.assigns.workbench)
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
@@ -44,7 +45,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
|
|||||||
def select_and_analyze(socket, reload, append_output) do
|
def select_and_analyze(socket, reload, append_output) do
|
||||||
with %{id: definition_id} <- socket.assigns.current_tab,
|
with %{id: definition_id} <- socket.assigns.current_tab,
|
||||||
%{} = definition <- ImportDefinitions.get_definition(definition_id) do
|
%{} = 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} ->
|
{:ok, wxr_file_path} ->
|
||||||
project_id = socket.assigns.projects.active_project_id
|
project_id = socket.assigns.projects.active_project_id
|
||||||
|
|
||||||
@@ -78,7 +79,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
|
|||||||
:import_editor_analysis_states,
|
:import_editor_analysis_states,
|
||||||
Map.put(socket.assigns.import_editor_analysis_states, definition_id, %{
|
Map.put(socket.assigns.import_editor_analysis_states, definition_id, %{
|
||||||
loading: true,
|
loading: true,
|
||||||
step: translated("importAnalysis.analyzingWxr"),
|
step: dgettext("ui", "Analyzing WXR file..."),
|
||||||
detail: Path.basename(wxr_file_path),
|
detail: Path.basename(wxr_file_path),
|
||||||
file_path: wxr_file_path,
|
file_path: wxr_file_path,
|
||||||
ref: task.ref
|
ref: task.ref
|
||||||
@@ -99,7 +100,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
|
|||||||
|
|
||||||
{:error, %{message: message}} ->
|
{:error, %{message: message}} ->
|
||||||
socket
|
socket
|
||||||
|> append_output.(translated("activity.import"), message, nil, "error")
|
|> append_output.(dgettext("ui", "Import"), message, nil, "error")
|
||||||
|> reload.(socket.assigns.workbench)
|
|> reload.(socket.assigns.workbench)
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
@@ -167,18 +168,18 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
|
|||||||
|
|
||||||
{:error, reason} ->
|
{:error, reason} ->
|
||||||
socket
|
socket
|
||||||
|> append_output.(translated("activity.import"), inspect(reason), nil, "error")
|
|> append_output.(dgettext("ui", "Import"), inspect(reason), nil, "error")
|
||||||
|> reload.(socket.assigns.workbench)
|
|> reload.(socket.assigns.workbench)
|
||||||
end
|
end
|
||||||
|
|
||||||
{:error, %{message: message}} ->
|
{:error, %{message: message}} ->
|
||||||
socket
|
socket
|
||||||
|> append_output.(translated("activity.import"), message, nil, "error")
|
|> append_output.(dgettext("ui", "Import"), message, nil, "error")
|
||||||
|> reload.(socket.assigns.workbench)
|
|> reload.(socket.assigns.workbench)
|
||||||
|
|
||||||
{:error, reason} ->
|
{:error, reason} ->
|
||||||
socket
|
socket
|
||||||
|> append_output.(translated("activity.import"), inspect(reason), nil, "error")
|
|> append_output.(dgettext("ui", "Import"), inspect(reason), nil, "error")
|
||||||
|> reload.(socket.assigns.workbench)
|
|> reload.(socket.assigns.workbench)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -200,7 +201,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
|
|||||||
:import_editor_analysis_states,
|
:import_editor_analysis_states,
|
||||||
Map.delete(socket.assigns.import_editor_analysis_states, definition_id)
|
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)
|
|> reload.(socket.assigns.workbench)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -265,7 +266,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
|
|||||||
@spec default_author(term()) :: term()
|
@spec default_author(term()) :: term()
|
||||||
def default_author(project_id) do
|
def default_author(project_id) do
|
||||||
{:ok, metadata} = Metadata.get_project_metadata(project_id)
|
{:ok, metadata} = Metadata.get_project_metadata(project_id)
|
||||||
Map.get(metadata, :default_author)
|
metadata.default_author
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec suggested_definition_name(term()) :: term()
|
@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
|
def translate_phase(step) when is_binary(step) do
|
||||||
case step do
|
case step do
|
||||||
"parsing" -> translated("importAnalysis.analysisPhase.parsing")
|
"parsing" -> dgettext("ui", "Parsing WXR file...")
|
||||||
"scanning" -> translated("importAnalysis.analysisPhase.scanning")
|
"scanning" -> dgettext("ui", "Scanning entries...")
|
||||||
"taxonomies" -> translated("importAnalysis.analysisPhase.taxonomies")
|
"taxonomies" -> dgettext("ui", "Analyzing taxonomies...")
|
||||||
"posts" -> translated("importAnalysis.analysisPhase.posts")
|
"posts" -> dgettext("ui", "Analyzing posts...")
|
||||||
"media" -> translated("importAnalysis.analysisPhase.media")
|
"media" -> dgettext("ui", "Analyzing media...")
|
||||||
"complete" -> translated("importAnalysis.analysisPhase.complete")
|
"complete" -> dgettext("ui", "Analysis complete")
|
||||||
other -> other
|
other -> other
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -307,8 +308,5 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
|
|||||||
@spec translate_phase(term()) :: term()
|
@spec translate_phase(term()) :: term()
|
||||||
def translate_phase(other), do: other
|
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, ""]
|
defp present?(value), do: value not in [nil, ""]
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do
|
|||||||
@moduledoc false
|
@moduledoc false
|
||||||
|
|
||||||
alias BDS.{ImportDefinitions, ImportExecution}
|
alias BDS.{ImportDefinitions, ImportExecution}
|
||||||
alias BDS.Desktop.ShellData
|
|
||||||
alias BDS.Desktop.ShellLive.ImportEditor.AnalysisState
|
alias BDS.Desktop.ShellLive.ImportEditor.AnalysisState
|
||||||
|
use Gettext, backend: BDS.Gettext
|
||||||
|
|
||||||
@spec execute_import(term(), term(), term()) :: term()
|
@spec execute_import(term(), term(), term()) :: term()
|
||||||
def execute_import(socket, reload, _append_output) do
|
def execute_import(socket, reload, _append_output) do
|
||||||
@@ -129,8 +129,8 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
|> append_output.(
|
|> append_output.(
|
||||||
translated("activity.import"),
|
dgettext("ui", "Import"),
|
||||||
translated("importAnalysis.importComplete", %{count: previous_state.count}),
|
dgettext("ui", "Import completed successfully!", count: previous_state.count),
|
||||||
nil,
|
nil,
|
||||||
"info"
|
"info"
|
||||||
)
|
)
|
||||||
@@ -148,7 +148,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do
|
|||||||
ref: nil
|
ref: nil
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|> append_output.(translated("activity.import"), message, nil, "error")
|
|> append_output.(dgettext("ui", "Import"), message, nil, "error")
|
||||||
|> reload.(socket.assigns.workbench)
|
|> reload.(socket.assigns.workbench)
|
||||||
|
|
||||||
{:error, reason} ->
|
{:error, reason} ->
|
||||||
@@ -165,7 +165,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do
|
|||||||
ref: nil
|
ref: nil
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|> append_output.(translated("activity.import"), message, nil, "error")
|
|> append_output.(dgettext("ui", "Import"), message, nil, "error")
|
||||||
|> reload.(socket.assigns.workbench)
|
|> reload.(socket.assigns.workbench)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -208,7 +208,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do
|
|||||||
ref: nil
|
ref: nil
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|> append_output.(translated("activity.import"), message, nil, "error")
|
|> append_output.(dgettext("ui", "Import"), message, nil, "error")
|
||||||
|> reload.(socket.assigns.workbench)
|
|> reload.(socket.assigns.workbench)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -265,16 +265,14 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do
|
|||||||
seconds = div(ms, 1000)
|
seconds = div(ms, 1000)
|
||||||
|
|
||||||
if seconds < 60 do
|
if seconds < 60 do
|
||||||
translated("importAnalysis.eta", %{
|
dgettext("ui", "ETA: %{value}", value: dgettext("ui", "%{count}s", count: seconds))
|
||||||
value: translated("importAnalysis.etaSeconds", %{count: seconds})
|
|
||||||
})
|
|
||||||
else
|
else
|
||||||
m = div(seconds, 60)
|
m = div(seconds, 60)
|
||||||
s = rem(seconds, 60)
|
s = rem(seconds, 60)
|
||||||
|
|
||||||
translated("importAnalysis.eta", %{
|
dgettext("ui", "ETA: %{value}",
|
||||||
value: translated("importAnalysis.etaMinutes", %{minutes: m, seconds: s})
|
value: dgettext("ui", "%{minutes}m %{seconds}s", minutes: m, seconds: s)
|
||||||
})
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -282,18 +280,15 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do
|
|||||||
|
|
||||||
def translate_execution_phase(phase) when is_binary(phase) do
|
def translate_execution_phase(phase) when is_binary(phase) do
|
||||||
case phase do
|
case phase do
|
||||||
"tags" -> translated("importAnalysis.phase.tags")
|
"tags" -> dgettext("ui", "Importing tags & categories...")
|
||||||
"posts" -> translated("importAnalysis.phase.posts")
|
"posts" -> dgettext("ui", "Importing posts...")
|
||||||
"media" -> translated("importAnalysis.phase.media")
|
"media" -> dgettext("ui", "Importing media...")
|
||||||
"pages" -> translated("importAnalysis.phase.pages")
|
"pages" -> dgettext("ui", "Importing pages...")
|
||||||
"complete" -> translated("importAnalysis.phase.complete")
|
"complete" -> dgettext("ui", "Import complete")
|
||||||
other -> other
|
other -> other
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec translate_execution_phase(term()) :: term()
|
@spec translate_execution_phase(term()) :: term()
|
||||||
def translate_execution_phase(other), do: other
|
def translate_execution_phase(other), do: other
|
||||||
|
|
||||||
defp translated(text, bindings \\ %{}),
|
|
||||||
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
|
|||||||
@moduledoc false
|
@moduledoc false
|
||||||
|
|
||||||
alias BDS.{AI, ImportDefinitions, Metadata, Tags}
|
alias BDS.{AI, ImportDefinitions, Metadata, Tags}
|
||||||
alias BDS.Desktop.ShellData
|
use Gettext, backend: BDS.Gettext
|
||||||
|
|
||||||
@spec start_taxonomy_edit(term(), term(), term()) :: term()
|
@spec start_taxonomy_edit(term(), term(), term()) :: term()
|
||||||
def start_taxonomy_edit(
|
def start_taxonomy_edit(
|
||||||
@@ -85,11 +85,11 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
|
|||||||
socket.assigns.offline_mode ->
|
socket.assigns.offline_mode ->
|
||||||
socket
|
socket
|
||||||
|> append_output.(
|
|> append_output.(
|
||||||
translated("activity.import"),
|
dgettext("ui", "Import"),
|
||||||
ShellData.translate(
|
BDS.Gettext.lgettext(
|
||||||
"Automatic AI actions stay gated by airplane mode.",
|
socket.assigns.page_language,
|
||||||
%{},
|
"ui",
|
||||||
socket.assigns.page_language
|
"Automatic AI actions stay gated by airplane mode."
|
||||||
),
|
),
|
||||||
nil,
|
nil,
|
||||||
"info"
|
"info"
|
||||||
@@ -124,8 +124,8 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
|
|||||||
|
|
||||||
socket
|
socket
|
||||||
|> append_output.(
|
|> append_output.(
|
||||||
translated("activity.import"),
|
dgettext("ui", "Import"),
|
||||||
translated("importAnalysis.mappedCount", %{count: mapped_count}),
|
dgettext("ui", "%{count} mapped", count: mapped_count),
|
||||||
Map.get(socket.assigns.import_editor_selected_models, definition_id),
|
Map.get(socket.assigns.import_editor_selected_models, definition_id),
|
||||||
"info"
|
"info"
|
||||||
)
|
)
|
||||||
@@ -134,7 +134,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
|
|||||||
{:error, reason} ->
|
{:error, reason} ->
|
||||||
socket
|
socket
|
||||||
|> append_output.(
|
|> append_output.(
|
||||||
translated("activity.import"),
|
dgettext("ui", "Import"),
|
||||||
inspect(reason),
|
inspect(reason),
|
||||||
Map.get(socket.assigns.import_editor_selected_models, definition_id),
|
Map.get(socket.assigns.import_editor_selected_models, definition_id),
|
||||||
"error"
|
"error"
|
||||||
@@ -222,7 +222,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
|
|||||||
{:ok, metadata} = Metadata.get_project_metadata(project_id)
|
{: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()
|
tags: project_id |> Tags.list_tags() |> Enum.map(& &1.name) |> Enum.uniq()
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
@@ -274,19 +274,16 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
|
|||||||
def taxonomy_mapping_tooltip(item) do
|
def taxonomy_mapping_tooltip(item) do
|
||||||
action =
|
action =
|
||||||
if present?(item.mapped_to),
|
if present?(item.mapped_to),
|
||||||
do: translated("importAnalysis.mappingActionEdit"),
|
do: dgettext("ui", "edit"),
|
||||||
else: translated("importAnalysis.mappingActionAdd")
|
else: dgettext("ui", "add")
|
||||||
|
|
||||||
translated("importAnalysis.mappingTooltip", %{action: action})
|
dgettext("ui", "Click to %{action} mapping", action: action)
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec maybe_put_option(term(), term(), term()) :: term()
|
@spec maybe_put_option(term(), term(), term()) :: term()
|
||||||
def maybe_put_option(opts, _key, nil), do: opts
|
def maybe_put_option(opts, _key, nil), do: opts
|
||||||
def maybe_put_option(opts, key, value), do: Keyword.put(opts, key, value)
|
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 present?(value), do: value not in [nil, ""]
|
||||||
defp blank_to_nil(""), do: nil
|
defp blank_to_nil(""), do: nil
|
||||||
defp blank_to_nil(value), do: value
|
defp blank_to_nil(value), do: value
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<div
|
<div
|
||||||
class="app"
|
class="app flex h-full w-full flex-col"
|
||||||
id="bds-shell-app"
|
id="bds-shell-app"
|
||||||
phx-hook="AppShell"
|
phx-hook="AppShell"
|
||||||
data-shortcuts={encoded_shortcuts(@client_shortcuts)}
|
data-shortcuts={encoded_shortcuts(@client_shortcuts)}
|
||||||
@@ -77,8 +77,8 @@
|
|||||||
data-testid="toggle-sidebar"
|
data-testid="toggle-sidebar"
|
||||||
type="button"
|
type="button"
|
||||||
phx-click="toggle_sidebar"
|
phx-click="toggle_sidebar"
|
||||||
aria-label={translated("Toggle sidebar")}
|
aria-label={dgettext("ui", "Toggle sidebar")}
|
||||||
title={translated("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-icon", if(@workbench.sidebar_visible, do: "is-active", else: "is-inactive")]}>
|
||||||
<span class="window-titlebar-sidebar-pane"></span>
|
<span class="window-titlebar-sidebar-pane"></span>
|
||||||
@@ -89,8 +89,8 @@
|
|||||||
data-testid="toggle-panel"
|
data-testid="toggle-panel"
|
||||||
type="button"
|
type="button"
|
||||||
phx-click="toggle_panel"
|
phx-click="toggle_panel"
|
||||||
aria-label={translated("Toggle panel")}
|
aria-label={dgettext("ui", "Toggle panel")}
|
||||||
title={translated("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-icon", if(@workbench.panel.visible, do: "is-active", else: "is-inactive")]}>
|
||||||
<span class="window-titlebar-panel-pane"></span>
|
<span class="window-titlebar-panel-pane"></span>
|
||||||
@@ -101,8 +101,8 @@
|
|||||||
data-testid="toggle-assistant"
|
data-testid="toggle-assistant"
|
||||||
type="button"
|
type="button"
|
||||||
phx-click="toggle_assistant_sidebar"
|
phx-click="toggle_assistant_sidebar"
|
||||||
aria-label={translated("Toggle assistant")}
|
aria-label={dgettext("ui", "Toggle assistant")}
|
||||||
title={translated("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-icon", if(@workbench.assistant_sidebar_visible, do: "is-active", else: "is-inactive")]}>
|
||||||
<span class="window-titlebar-assistant-pane"></span>
|
<span class="window-titlebar-assistant-pane"></span>
|
||||||
@@ -112,12 +112,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<div class="app-main">
|
<div class="app-main flex min-h-0 flex-1 overflow-hidden">
|
||||||
<aside class="activity-bar" data-region="activity-bar">
|
<aside class="activity-bar flex h-full shrink-0 flex-col items-center justify-between" data-region="activity-bar">
|
||||||
<div class="activity-bar-top">
|
<div class="activity-bar-top flex flex-col items-center gap-1">
|
||||||
<%= for button <- Enum.filter(@activity_buttons, &(&1.activity_group == :top)) do %>
|
<%= for button <- Enum.filter(@activity_buttons, &(&1.activity_group == :top)) do %>
|
||||||
<button
|
<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-testid="activity-button"
|
||||||
data-view={button.id}
|
data-view={button.id}
|
||||||
data-active={to_string(button.active)}
|
data-active={to_string(button.active)}
|
||||||
@@ -134,10 +134,10 @@
|
|||||||
</button>
|
</button>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</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 %>
|
<%= for button <- Enum.filter(@activity_buttons, &(&1.activity_group == :bottom)) do %>
|
||||||
<button
|
<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-testid="activity-button"
|
||||||
data-view={button.id}
|
data-view={button.id}
|
||||||
data-active={to_string(button.active)}
|
data-active={to_string(button.active)}
|
||||||
@@ -157,29 +157,29 @@
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<section
|
<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"
|
data-testid="sidebar-shell"
|
||||||
style={"width: #{if(@workbench.sidebar_visible, do: @workbench.sidebar_width, else: 0)}px;"}
|
style={"width: #{if(@workbench.sidebar_visible, do: @workbench.sidebar_width, else: 0)}px;"}
|
||||||
>
|
>
|
||||||
<div class="sidebar" data-region="sidebar">
|
<div class="sidebar flex min-w-0 flex-1 overflow-hidden" data-region="sidebar">
|
||||||
<div id="sidebar-content" class="sidebar-content sidebar-body" phx-hook="SidebarInteractions">
|
<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">
|
<div class="sidebar-section">
|
||||||
<% create_action = sidebar_create_action(@workbench.active_view) %>
|
<% 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>
|
<span><%= String.upcase(sidebar_header_label(@sidebar_header)) %></span>
|
||||||
<%= if create_action || ShellSidebarComponents.filters_enabled?(@sidebar_data) do %>
|
<%= 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 %>
|
<%= if ShellSidebarComponents.filters_enabled?(@sidebar_data) do %>
|
||||||
<button
|
<button
|
||||||
class={[
|
class={[
|
||||||
"sidebar-action",
|
"sidebar-action inline-flex h-8 w-8 items-center justify-center",
|
||||||
if(ShellSidebarComponents.filters_visible?(@sidebar_data), do: "active")
|
if(ShellSidebarComponents.filters_visible?(@sidebar_data), do: "active")
|
||||||
]}
|
]}
|
||||||
data-testid="sidebar-filter-toggle"
|
data-testid="sidebar-filter-toggle"
|
||||||
type="button"
|
type="button"
|
||||||
phx-click="toggle_sidebar_filters"
|
phx-click="toggle_sidebar_filters"
|
||||||
aria-label={translated(Map.get(@sidebar_data.filters, :toggle_filters_label))}
|
aria-label={Map.get(@sidebar_data.filters, :toggle_filters_label)}
|
||||||
title={translated(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">
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||||
<path d="M6 12v-1h4v1H6zM4 8v-1h8v1H4zm-2-4v-1h12v1H2z"/>
|
<path d="M6 12v-1h4v1H6zM4 8v-1h8v1H4zm-2-4v-1h12v1H2z"/>
|
||||||
@@ -188,14 +188,14 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
<%= if create_action do %>
|
<%= if create_action do %>
|
||||||
<button
|
<button
|
||||||
class="sidebar-action"
|
class="sidebar-action inline-flex h-8 w-8 items-center justify-center"
|
||||||
data-testid="sidebar-create-action"
|
data-testid="sidebar-create-action"
|
||||||
data-sidebar-action={create_action.kind}
|
data-sidebar-action={create_action.kind}
|
||||||
type="button"
|
type="button"
|
||||||
phx-click="create_sidebar_item"
|
phx-click="create_sidebar_item"
|
||||||
phx-value-kind={create_action.kind}
|
phx-value-kind={create_action.kind}
|
||||||
aria-label={translated(create_action.label)}
|
aria-label={create_action.label}
|
||||||
title={translated(create_action.label)}
|
title={create_action.label}
|
||||||
>
|
>
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||||
<path d="M14 7v1H8v6H7V8H1V7h6V1h1v6h6z"/>
|
<path d="M14 7v1H8v6H7V8H1V7h6V1h1v6h6z"/>
|
||||||
@@ -212,16 +212,16 @@
|
|||||||
<div class="resizable-panel-divider sidebar-divider" data-resize="sidebar" data-role="resize-handle"></div>
|
<div class="resizable-panel-divider sidebar-divider" data-resize="sidebar" data-role="resize-handle"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<main class="app-content" data-region="content">
|
<main class="app-content flex min-w-0 flex-1 flex-col overflow-hidden" data-region="content">
|
||||||
<div class="tab-bar" data-region="tab-bar">
|
<div class="tab-bar flex h-[35px] shrink-0 items-center overflow-hidden" data-region="tab-bar">
|
||||||
<%= if Enum.empty?(@workbench.tabs) do %>
|
<%= 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 %>
|
<% 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 %>
|
<%= for tab <- @workbench.tabs do %>
|
||||||
<div
|
<div
|
||||||
class={[
|
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(@workbench.active_tab == {tab.type, tab.id}, do: "active"),
|
||||||
if(tab.is_transient, do: "transient"),
|
if(tab.is_transient, do: "transient"),
|
||||||
if(Workbench.dirty?(@workbench, tab.type, tab.id), do: "dirty")
|
if(Workbench.dirty?(@workbench, tab.type, tab.id), do: "dirty")
|
||||||
@@ -231,21 +231,21 @@
|
|||||||
tabindex="0"
|
tabindex="0"
|
||||||
>
|
>
|
||||||
<button
|
<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"
|
type="button"
|
||||||
phx-click="select_tab"
|
phx-click="select_tab"
|
||||||
phx-value-type={tab.type}
|
phx-value-type={tab.type}
|
||||||
phx-value-id={tab.id}
|
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-icon shrink-0"><%= 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-title truncate"><%= BDS.Desktop.ShellLive.TabHelpers.tab_title(tab, @tab_meta) %></span>
|
||||||
</button>
|
</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 %>
|
<%= if Workbench.dirty?(@workbench, tab.type, tab.id) do %>
|
||||||
<span class="tab-dirty-indicator">●</span>
|
<span class="tab-dirty-indicator">●</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
<button
|
<button
|
||||||
class="tab-close"
|
class="tab-close inline-flex h-6 w-6 items-center justify-center"
|
||||||
data-testid="tab-close"
|
data-testid="tab-close"
|
||||||
data-tab-type={tab.type}
|
data-tab-type={tab.type}
|
||||||
data-tab-id={tab.id}
|
data-tab-id={tab.id}
|
||||||
@@ -253,8 +253,8 @@
|
|||||||
phx-click="close_tab"
|
phx-click="close_tab"
|
||||||
phx-value-type={tab.type}
|
phx-value-type={tab.type}
|
||||||
phx-value-id={tab.id}
|
phx-value-id={tab.id}
|
||||||
aria-label={translated("Close tab")}
|
aria-label={dgettext("ui", "Close tab")}
|
||||||
title={translated("Close tab")}
|
title={dgettext("ui", "Close tab")}
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
@@ -265,45 +265,45 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</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 %>
|
<%= if is_nil(@current_tab) do %>
|
||||||
<div class="editor-empty">
|
<div class="editor-empty">
|
||||||
<div class="dashboard-content">
|
<div class="dashboard-content">
|
||||||
<h1 data-testid="editor-title"><%= translated("dashboard.title") %></h1>
|
<h1 data-testid="editor-title"><%= dgettext("ui", "Dashboard") %></h1>
|
||||||
<p class="text-muted"><%= translated("dashboard.subtitle") %></p>
|
<p class="text-muted"><%= dgettext("ui", "Overview of your blog database") %></p>
|
||||||
|
|
||||||
<div class="dashboard-stats">
|
<div class="dashboard-stats">
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-number"><%= @dashboard.post_stats.total_posts || 0 %></div>
|
<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">
|
<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-published"><%= dgettext("ui", "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-draft"><%= dgettext("ui", "dashboard.stats.drafts", count: @dashboard.post_stats.draft_count || 0) %></span>
|
||||||
<%= if (@dashboard.post_stats.archived_count || 0) > 0 do %>
|
<%= 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 %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-number"><%= @dashboard.media_stats.media_count || 0 %></div>
|
<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">
|
<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>
|
<span class="stat-tag"><%= ShellData.format_bytes(@dashboard.media_stats.total_bytes || 0) %></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-number"><%= length(@dashboard_tag_cloud_items) %></div>
|
<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">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= if Enum.any?(@dashboard_timeline_entries) do %>
|
<%= if Enum.any?(@dashboard_timeline_entries) do %>
|
||||||
<div class="dashboard-section">
|
<div class="dashboard-section">
|
||||||
<h4><%= translated("dashboard.section.postsOverTime") %></h4>
|
<h4><%= dgettext("ui", "Posts Over Time") %></h4>
|
||||||
<div class="timeline-chart">
|
<div class="timeline-chart">
|
||||||
<%= for entry <- @dashboard_timeline_entries do %>
|
<%= for entry <- @dashboard_timeline_entries do %>
|
||||||
<div class="timeline-bar-container">
|
<div class="timeline-bar-container">
|
||||||
@@ -322,7 +322,7 @@
|
|||||||
|
|
||||||
<%= if Enum.any?(@dashboard_tag_cloud_items) do %>
|
<%= if Enum.any?(@dashboard_tag_cloud_items) do %>
|
||||||
<div class="dashboard-section">
|
<div class="dashboard-section">
|
||||||
<h4><%= translated("dashboard.section.tags") %></h4>
|
<h4><%= dgettext("ui", "Tags") %></h4>
|
||||||
<div class="tag-cloud">
|
<div class="tag-cloud">
|
||||||
<%= for item <- @dashboard_tag_cloud_items do %>
|
<%= 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>
|
<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 %>
|
<%= if Enum.any?(@dashboard_category_counts) do %>
|
||||||
<div class="dashboard-section">
|
<div class="dashboard-section">
|
||||||
<h4><%= translated("dashboard.section.categories") %></h4>
|
<h4><%= dgettext("ui", "Categories") %></h4>
|
||||||
<div class="tag-cloud">
|
<div class="tag-cloud">
|
||||||
<%= for category <- @dashboard_category_counts do %>
|
<%= for category <- @dashboard_category_counts do %>
|
||||||
<span class="dashboard-tag dashboard-category" title={ShellData.dashboard_post_count_label(category.count || 0)}>
|
<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 %>
|
<%= if Enum.any?(@dashboard_recent_posts) do %>
|
||||||
<div class="dashboard-section">
|
<div class="dashboard-section">
|
||||||
<h4><%= translated("dashboard.section.recentlyUpdated") %></h4>
|
<h4><%= dgettext("ui", "Recently Updated") %></h4>
|
||||||
<div class="recent-posts-list">
|
<div class="recent-posts-list">
|
||||||
<%= for post <- @dashboard_recent_posts do %>
|
<%= for post <- @dashboard_recent_posts do %>
|
||||||
<button
|
<button
|
||||||
@@ -373,8 +373,8 @@
|
|||||||
<div class="dashboard-inspector-meta" hidden>
|
<div class="dashboard-inspector-meta" hidden>
|
||||||
<%= for item <- @editor_meta do %>
|
<%= for item <- @editor_meta do %>
|
||||||
<section class="editor-meta-row">
|
<section class="editor-meta-row">
|
||||||
<strong data-testid="editor-meta-label"><%= translated(item.label) %></strong>
|
<strong data-testid="editor-meta-label"><%= item.label %></strong>
|
||||||
<span><%= translated(item.value) %></span>
|
<span><%= item.value %></span>
|
||||||
</section>
|
</section>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
@@ -382,38 +382,47 @@
|
|||||||
</div>
|
</div>
|
||||||
<% else %>
|
<% else %>
|
||||||
<%= cond do %>
|
<%= cond do %>
|
||||||
<% @current_tab.type == :post and @post_editor -> %>
|
<% @current_tab.type == :post -> %>
|
||||||
<PostEditor.post_editor post_editor={@post_editor} toolbar_buttons={BDS.Desktop.ShellLive.PanelRenderer.editor_toolbar_buttons(@current_tab)} />
|
<.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 -> %>
|
<% @current_tab.type == :media -> %>
|
||||||
<MediaEditor.media_editor media_editor={@media_editor} />
|
<.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 -> %>
|
<% @current_tab.type in [:settings, :style] and @current_project -> %>
|
||||||
<SettingsEditor.settings_editor settings_editor={@settings_editor} />
|
<.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 -> %>
|
<% @current_tab.type == :menu_editor and @current_project -> %>
|
||||||
<SettingsEditor.style_editor style_editor={@style_editor} />
|
<.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 -> %>
|
<% @current_tab.type == :tags and @current_project -> %>
|
||||||
<MenuEditor.menu_editor menu_editor={@menu_editor} />
|
<.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 -> %>
|
<% @current_tab.type == :scripts -> %>
|
||||||
<TagsEditor.tags_editor tags_editor={@tags_editor} />
|
<.live_component module={ScriptEditor} id={"script-editor-#{@current_tab.id}"} current_tab={@current_tab} />
|
||||||
|
|
||||||
<% @current_tab.type == :scripts and @script_editor -> %>
|
<% @current_tab.type == :templates -> %>
|
||||||
<CodeEntityEditor.script_editor script_editor={@script_editor} />
|
<.live_component module={TemplateEditor} id={"template-editor-#{@current_tab.id}"} current_tab={@current_tab} />
|
||||||
|
|
||||||
<% @current_tab.type == :templates and @template_editor -> %>
|
<% @current_tab.type == :chat -> %>
|
||||||
<CodeEntityEditor.template_editor template_editor={@template_editor} />
|
<.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 -> %>
|
<% @current_tab.type == :import -> %>
|
||||||
<ChatEditor.chat_editor chat_editor={@chat_editor} />
|
<.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 -> %>
|
<% @current_tab.type in [:site_validation, :metadata_diff, :translation_validation, :find_duplicates, :git_diff, :documentation, :api_documentation] -> %>
|
||||||
<ImportEditor.import_editor import_editor={@import_editor} />
|
<.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} />
|
||||||
|
|
||||||
<% @current_tab.type in [:site_validation, :metadata_diff, :translation_validation, :find_duplicates, :git_diff] and @misc_editor -> %>
|
|
||||||
<MiscEditor.misc_editor misc_editor={@misc_editor} />
|
|
||||||
|
|
||||||
<% true -> %>
|
<% true -> %>
|
||||||
<div class="editor-frame">
|
<div class="editor-frame">
|
||||||
@@ -433,8 +442,8 @@
|
|||||||
<aside class="editor-meta">
|
<aside class="editor-meta">
|
||||||
<%= for item <- @editor_meta do %>
|
<%= for item <- @editor_meta do %>
|
||||||
<section class="editor-meta-row">
|
<section class="editor-meta-row">
|
||||||
<strong data-testid="editor-meta-label"><%= translated(item.label) %></strong>
|
<strong data-testid="editor-meta-label"><%= item.label %></strong>
|
||||||
<span><%= translated(item.value) %></span>
|
<span><%= item.value %></span>
|
||||||
</section>
|
</section>
|
||||||
<% end %>
|
<% end %>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -443,12 +452,12 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class={["panel-shell", if(not @workbench.panel.visible, do: "is-hidden")]} data-region="panel">
|
<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">
|
<div class="panel-header flex items-center justify-between gap-2">
|
||||||
<div class="panel-tabs">
|
<div class="panel-tabs flex min-w-0 items-center overflow-x-auto">
|
||||||
<%= for tab <- @panel_tabs do %>
|
<%= for tab <- @panel_tabs do %>
|
||||||
<button
|
<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"
|
type="button"
|
||||||
phx-click="select_panel_tab"
|
phx-click="select_panel_tab"
|
||||||
phx-value-tab={tab}
|
phx-value-tab={tab}
|
||||||
@@ -458,93 +467,93 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<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"
|
data-testid="panel-close"
|
||||||
type="button"
|
type="button"
|
||||||
phx-click="toggle_panel"
|
phx-click="toggle_panel"
|
||||||
aria-label={translated("Close panel")}
|
aria-label={dgettext("ui", "Close panel")}
|
||||||
title={translated("Close panel")}
|
title={dgettext("ui", "Close panel")}
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel-content">
|
<div class="panel-content min-h-0 flex-1 overflow-auto">
|
||||||
<%= BDS.Desktop.ShellLive.PanelRenderer.render_panel_body(assigns) %>
|
<%= BDS.Desktop.ShellLive.PanelRenderer.render_panel_body(assigns) %>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<section
|
<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"
|
data-testid="assistant-shell"
|
||||||
style={"width: #{if(@workbench.assistant_sidebar_visible, do: @workbench.assistant_sidebar_width, else: 0)}px;"}
|
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>
|
<div class="resizable-panel-divider assistant-divider" data-resize="assistant" data-role="resize-handle"></div>
|
||||||
<aside class="assistant-sidebar" data-region="assistant-sidebar">
|
<aside class="assistant-sidebar flex min-w-0 flex-1 overflow-hidden" data-region="assistant-sidebar">
|
||||||
<div class="assistant-content">
|
<div class="assistant-content flex min-h-0 flex-1 flex-col">
|
||||||
<header class="assistant-sidebar-header">
|
<header class="assistant-sidebar-header flex items-start justify-between gap-3">
|
||||||
<div class="assistant-sidebar-heading">
|
<div class="assistant-sidebar-heading">
|
||||||
<strong><%= translated("AI Assistant") %></strong>
|
<strong><%= dgettext("ui", "AI Assistant") %></strong>
|
||||||
<span class="assistant-sidebar-description"><%= translated("AI conversations") %></span>
|
<span class="assistant-sidebar-description"><%= dgettext("ui", "AI conversations") %></span>
|
||||||
</div>
|
</div>
|
||||||
<span class={[
|
<span class={[
|
||||||
"assistant-sidebar-status",
|
"assistant-sidebar-status",
|
||||||
if(@offline_mode, do: "is-offline", else: "is-online")
|
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>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="assistant-sidebar-context" data-testid="assistant-context">
|
<section class="assistant-sidebar-context flex shrink-0 flex-col gap-2" data-testid="assistant-context">
|
||||||
<div class="assistant-sidebar-context-row">
|
<div class="assistant-sidebar-context-row flex items-center justify-between gap-2">
|
||||||
<span class="assistant-sidebar-context-label"><%= translated("Project") %></span>
|
<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>
|
<span class="assistant-sidebar-context-value"><%= BDS.Desktop.ShellLive.ChatSurface.assistant_project_name(@current_project) %></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="assistant-sidebar-context-row">
|
<div class="assistant-sidebar-context-row flex items-center justify-between gap-2">
|
||||||
<span class="assistant-sidebar-context-label"><%= translated("Editor") %></span>
|
<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>
|
<span class="assistant-sidebar-context-value"><%= BDS.Desktop.ShellLive.TabHelpers.tab_title(@current_tab, @tab_meta) %></span>
|
||||||
</div>
|
</div>
|
||||||
<p class="assistant-sidebar-context-text"><%= BDS.Desktop.ShellLive.TabHelpers.tab_subtitle(@current_tab, @tab_meta) %></p>
|
<p class="assistant-sidebar-context-text"><%= BDS.Desktop.ShellLive.TabHelpers.tab_subtitle(@current_tab, @tab_meta) %></p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
class="assistant-sidebar-prompt-form"
|
class="assistant-sidebar-prompt-form flex shrink-0 flex-col gap-3"
|
||||||
data-testid="assistant-prompt-form"
|
data-testid="assistant-prompt-form"
|
||||||
phx-change="update_assistant_prompt"
|
phx-change="update_assistant_prompt"
|
||||||
phx-submit="submit_assistant_prompt"
|
phx-submit="submit_assistant_prompt"
|
||||||
>
|
>
|
||||||
<textarea
|
<textarea
|
||||||
class="assistant-sidebar-prompt"
|
class="assistant-sidebar-prompt min-h-[8rem] w-full resize-y"
|
||||||
data-testid="assistant-prompt-input"
|
data-testid="assistant-prompt-input"
|
||||||
name="assistant[prompt]"
|
name="assistant[prompt]"
|
||||||
rows="6"
|
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>
|
><%= @assistant_prompt %></textarea>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="assistant-sidebar-start-button"
|
class="assistant-sidebar-start-button ui-button ui-button-primary"
|
||||||
data-testid="assistant-start-button"
|
data-testid="assistant-start-button"
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={String.trim(@assistant_prompt || "") == ""}
|
disabled={String.trim(@assistant_prompt || "") == ""}
|
||||||
>
|
>
|
||||||
<%= translated("Start chat") %>
|
<%= dgettext("ui", "Start chat") %>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<%= if Enum.empty?(@assistant_messages) do %>
|
<%= 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 %>
|
<%= for card <- @assistant_cards do %>
|
||||||
<section class="assistant-card">
|
<section class="assistant-card flex flex-col gap-1">
|
||||||
<strong><%= translated(card.label) %></strong>
|
<strong><%= card.label %></strong>
|
||||||
<span><%= translated(card.text) %></span>
|
<span><%= card.text %></span>
|
||||||
</section>
|
</section>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% else %>
|
<% else %>
|
||||||
<div class="assistant-sidebar-transcript">
|
<div class="assistant-sidebar-transcript min-h-0 flex-1 overflow-auto">
|
||||||
<%= for message <- @assistant_messages do %>
|
<%= for message <- @assistant_messages do %>
|
||||||
<article
|
<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)}
|
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>
|
<span class="assistant-sidebar-message-role"><%= BDS.Desktop.ShellLive.ChatSurface.assistant_message_label(message.role) %></span>
|
||||||
@@ -558,41 +567,41 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer class="status-bar" data-region="status-bar" data-testid="status-bar">
|
<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">
|
<div class="status-bar-left flex min-w-0 items-center gap-2 overflow-hidden">
|
||||||
<%= if @is_mac_ui do %>
|
<%= 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
|
<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"
|
data-testid="toggle-sidebar"
|
||||||
type="button"
|
type="button"
|
||||||
phx-click="toggle_sidebar"
|
phx-click="toggle_sidebar"
|
||||||
aria-label={translated("Toggle sidebar")}
|
aria-label={dgettext("ui", "Toggle sidebar")}
|
||||||
title={translated("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-icon", if(@workbench.sidebar_visible, do: "is-active", else: "is-inactive")]}>
|
||||||
<span class="window-titlebar-sidebar-pane"></span>
|
<span class="window-titlebar-sidebar-pane"></span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<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"
|
data-testid="toggle-panel"
|
||||||
type="button"
|
type="button"
|
||||||
phx-click="toggle_panel"
|
phx-click="toggle_panel"
|
||||||
aria-label={translated("Toggle panel")}
|
aria-label={dgettext("ui", "Toggle panel")}
|
||||||
title={translated("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-icon", if(@workbench.panel.visible, do: "is-active", else: "is-inactive")]}>
|
||||||
<span class="window-titlebar-panel-pane"></span>
|
<span class="window-titlebar-panel-pane"></span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<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"
|
data-testid="toggle-assistant"
|
||||||
type="button"
|
type="button"
|
||||||
phx-click="toggle_assistant_sidebar"
|
phx-click="toggle_assistant_sidebar"
|
||||||
aria-label={translated("Toggle assistant")}
|
aria-label={dgettext("ui", "Toggle assistant")}
|
||||||
title={translated("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-icon", if(@workbench.assistant_sidebar_visible, do: "is-active", else: "is-inactive")]}>
|
||||||
<span class="window-titlebar-assistant-pane"></span>
|
<span class="window-titlebar-assistant-pane"></span>
|
||||||
@@ -600,12 +609,12 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
<div class="project-selector">
|
<div class="project-selector relative shrink-0">
|
||||||
<button
|
<button
|
||||||
class="project-selector-trigger"
|
class="project-selector-trigger inline-flex items-center gap-2"
|
||||||
data-testid="project-selector-trigger"
|
data-testid="project-selector-trigger"
|
||||||
type="button"
|
type="button"
|
||||||
title={translated("Switch project")}
|
title={dgettext("ui", "Switch project")}
|
||||||
phx-click="toggle_project_menu"
|
phx-click="toggle_project_menu"
|
||||||
>
|
>
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" class="project-icon">
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" class="project-icon">
|
||||||
@@ -620,7 +629,7 @@
|
|||||||
<%= if @project_menu_open do %>
|
<%= if @project_menu_open do %>
|
||||||
<div class="project-dropdown" data-testid="project-dropdown" phx-click-away="close_project_menu">
|
<div class="project-dropdown" data-testid="project-dropdown" phx-click-away="close_project_menu">
|
||||||
<div class="project-dropdown-header">
|
<div class="project-dropdown-header">
|
||||||
<span><%= translated("Projects") %></span>
|
<span><%= dgettext("ui", "Projects") %></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="project-list">
|
<div class="project-list">
|
||||||
<%= for project <- @projects.projects do %>
|
<%= for project <- @projects.projects do %>
|
||||||
@@ -641,32 +650,32 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="project-dropdown-footer">
|
<div class="project-dropdown-footer">
|
||||||
<button class="existing-project-btn" type="button" phx-click="import_project">
|
<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>
|
||||||
<button class="create-project-btn" type="button" phx-click="create_project">
|
<button class="create-project-btn" type="button" phx-click="create_project">
|
||||||
<span><%= translated("New Project") %></span>
|
<span><%= dgettext("ui", "New Project") %></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</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 %>
|
<%= if @status.left.running_task_message do %>
|
||||||
<span class="task-spinner"></span>
|
<span class="task-spinner"></span>
|
||||||
<% end %>
|
<% 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 %>
|
<%= if (@status.left.running_task_overflow || 0) > 0 do %>
|
||||||
<span class="status-bar-count">+<%= @status.left.running_task_overflow %></span>
|
<span class="status-bar-count">+<%= @status.left.running_task_overflow %></span>
|
||||||
<% end %>
|
<% end %>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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.post_count %></span>
|
||||||
<span class="status-bar-item"><%= @status.right.media_count %></span>
|
<span class="status-bar-item"><%= @status.right.media_count %></span>
|
||||||
<span class="status-bar-item theme-badge"><%= @status.right.theme_badge %></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>
|
<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" data-testid="status-language-form" phx-change="change_ui_language">
|
<form class="status-bar-item language-badge flex items-center gap-1" data-testid="status-language-form" phx-change="change_ui_language">
|
||||||
<span><%= translated("UI") %></span>
|
<span><%= dgettext("ui", "UI") %></span>
|
||||||
<select class="status-bar-language-select" name="ui_language" data-testid="status-language-select">
|
<select class="status-bar-language-select" name="ui_language" data-testid="status-language-select">
|
||||||
<%= for language <- @supported_ui_languages do %>
|
<%= for language <- @supported_ui_languages do %>
|
||||||
<option selected={language.code == @page_language} value={language.code}><%= language.flag %></option>
|
<option selected={language.code == @page_language} value={language.code}><%= language.flag %></option>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,47 +1,46 @@
|
|||||||
<div class="media-editor editor" data-testid="media-editor">
|
<div class="media-editor ui-editor-shell flex h-full min-h-0 flex-col" data-testid="media-editor">
|
||||||
<div class="editor-header">
|
<div class="editor-header ui-editor-header flex shrink-0 items-start justify-between gap-3">
|
||||||
<div class="editor-tabs">
|
<div class="flex min-w-0 flex-1 overflow-hidden">
|
||||||
<div class={[
|
<div class={[
|
||||||
"editor-tab",
|
"ui-tab ui-tab-active ui-editor-tab-current inline-flex max-w-full items-center gap-2 overflow-hidden px-3 py-2",
|
||||||
"active",
|
|
||||||
if(@media_editor.dirty?, do: "dirty")
|
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 %>
|
<%= 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 %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</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 %>
|
<%= if @media_editor.save_state in [:dirty, :saved] do %>
|
||||||
<span class="auto-save-indicator"><%= media_editor_save_state_label(@media_editor.save_state) %></span>
|
<span class="auto-save-indicator"><%= media_editor_save_state_label(@media_editor.save_state) %></span>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<div class="quick-actions-wrapper">
|
<div class="quick-actions-wrapper relative">
|
||||||
<button
|
<button
|
||||||
class="secondary quick-actions-btn"
|
class="secondary quick-actions-btn ui-button ui-button-secondary inline-flex items-center gap-2"
|
||||||
type="button"
|
type="button"
|
||||||
phx-click="toggle_media_editor_quick_actions"
|
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-icon">⚡</span>
|
||||||
<span class="quick-actions-btn-label"><%= translated("Quick Actions") %></span>
|
<span class="quick-actions-btn-label"><%= dgettext("ui", "Quick Actions") %></span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<%= if @media_editor.quick_actions_open? do %>
|
<%= 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 %>
|
<%= if @media_editor.is_image do %>
|
||||||
<button
|
<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"
|
data-testid="editor-toolbar-overlay-button"
|
||||||
type="button"
|
type="button"
|
||||||
phx-click="open_overlay"
|
phx-click="open_overlay"
|
||||||
phx-value-kind="ai_suggestions"
|
phx-value-kind="ai_suggestions"
|
||||||
>
|
>
|
||||||
<span class="quick-action-text">
|
<span class="quick-action-text flex min-w-0 flex-1 flex-col">
|
||||||
<strong><%= translated("AI Suggestions") %></strong>
|
<strong><%= dgettext("ui", "AI Suggestions") %></strong>
|
||||||
<small><%= translated("Review title, alt text, and caption suggestions") %></small>
|
<small><%= dgettext("ui", "Review title, alt text, and caption suggestions") %></small>
|
||||||
</span>
|
</span>
|
||||||
<span class="quick-action-icon">🤖</span>
|
<span class="quick-action-icon">🤖</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -50,15 +49,15 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="quick-action-item"
|
class="quick-action-item ui-dropdown-item flex items-start gap-3 text-left"
|
||||||
type="button"
|
type="button"
|
||||||
phx-click="detect_media_editor_language"
|
phx-click="detect_media_editor_language"
|
||||||
phx-value-id={@media_editor.id}
|
phx-target={@myself}
|
||||||
disabled={not @media_editor.can_detect_language?}
|
disabled={not @media_editor.can_detect_language?}
|
||||||
>
|
>
|
||||||
<span class="quick-action-text">
|
<span class="quick-action-text flex min-w-0 flex-1 flex-col">
|
||||||
<strong><%= translated("Detect Language") %></strong>
|
<strong><%= dgettext("ui", "Detect Language") %></strong>
|
||||||
<small><%= translated("Persist the detected language for this media item") %></small>
|
<small><%= dgettext("ui", "Persist the detected language for this media item") %></small>
|
||||||
</span>
|
</span>
|
||||||
<span class="quick-action-icon">🔍</span>
|
<span class="quick-action-icon">🔍</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -66,16 +65,16 @@
|
|||||||
<div class="quick-actions-divider"></div>
|
<div class="quick-actions-divider"></div>
|
||||||
|
|
||||||
<button
|
<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"
|
data-testid="editor-toolbar-overlay-button"
|
||||||
type="button"
|
type="button"
|
||||||
phx-click="open_overlay"
|
phx-click="open_overlay"
|
||||||
phx-value-kind="language_picker"
|
phx-value-kind="language_picker"
|
||||||
disabled={not @media_editor.can_translate?}
|
disabled={not @media_editor.can_translate?}
|
||||||
>
|
>
|
||||||
<span class="quick-action-text">
|
<span class="quick-action-text flex min-w-0 flex-1 flex-col">
|
||||||
<strong><%= translated("Translate") %></strong>
|
<strong><%= dgettext("ui", "Translate") %></strong>
|
||||||
<small><%= translated("Select a target language for this media item") %></small>
|
<small><%= dgettext("ui", "Select a target language for this media item") %></small>
|
||||||
</span>
|
</span>
|
||||||
<span class="quick-action-icon">🌍</span>
|
<span class="quick-action-icon">🌍</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -83,32 +82,32 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="secondary" type="button" phx-click="replace_media_editor_file" phx-value-id={@media_editor.id}>
|
<button class="secondary ui-button ui-button-secondary" type="button" phx-click="replace_media_editor_file" phx-target={@myself}>
|
||||||
<%= translated("Replace File") %>
|
<%= dgettext("ui", "Replace File") %>
|
||||||
</button>
|
</button>
|
||||||
<button data-testid="media-save-button" type="button" phx-click="save_media_editor" phx-value-id={@media_editor.id}>
|
<button class="ui-button ui-button-primary" data-testid="media-save-button" type="button" phx-click="save_media_editor" phx-target={@myself}>
|
||||||
<%= translated("Save") %>
|
<%= dgettext("ui", "Save") %>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="secondary danger"
|
class="secondary danger ui-button ui-button-secondary ui-button-danger"
|
||||||
data-testid="media-delete-button"
|
data-testid="media-delete-button"
|
||||||
type="button"
|
type="button"
|
||||||
phx-click="open_overlay"
|
phx-click="open_overlay"
|
||||||
phx-value-kind="confirm_delete"
|
phx-value-kind="confirm_delete"
|
||||||
>
|
>
|
||||||
<%= translated("Delete") %>
|
<%= dgettext("ui", "Delete") %>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="editor-content media-editor">
|
<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">
|
<div class="media-preview flex min-h-[16rem] items-center justify-center">
|
||||||
<%= if @media_editor.is_image and @media_editor.preview_url do %>
|
<%= if @media_editor.is_image and @media_editor.preview_url do %>
|
||||||
<div class="media-preview-image">
|
<div class="media-preview-image">
|
||||||
<img src={@media_editor.preview_url} alt={@media_editor.form["alt"] || @media_editor.original_name} />
|
<img src={@media_editor.preview_url} alt={@media_editor.form["alt"] || @media_editor.original_name} />
|
||||||
</div>
|
</div>
|
||||||
<% else %>
|
<% 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">
|
<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>
|
<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>
|
</svg>
|
||||||
@@ -117,61 +116,61 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="media-details">
|
<div class="media-details min-w-0">
|
||||||
<form class="media-editor-details-form" data-testid="media-editor-form" phx-change="change_media_editor">
|
<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">
|
<div class="editor-field ui-field-stack flex flex-col gap-1.5">
|
||||||
<label><%= translated("File Name") %></label>
|
<label><%= dgettext("ui", "File Name") %></label>
|
||||||
<input class="post-editor-input disabled" type="text" value={@media_editor.original_name} disabled />
|
<input class="post-editor-input ui-input disabled ui-input-disabled" type="text" value={@media_editor.original_name} disabled />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="editor-field">
|
<div class="editor-field ui-field-stack flex flex-col gap-1.5">
|
||||||
<label><%= translated("MIME Type") %></label>
|
<label><%= dgettext("ui", "MIME Type") %></label>
|
||||||
<input class="post-editor-input disabled" type="text" value={@media_editor.mime_type} disabled />
|
<input class="post-editor-input ui-input disabled ui-input-disabled" type="text" value={@media_editor.mime_type} disabled />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="editor-field-row">
|
<div class="editor-field-row ui-field-grid-2 grid gap-4 md:grid-cols-2">
|
||||||
<div class="editor-field">
|
<div class="editor-field ui-field-stack flex flex-col gap-1.5">
|
||||||
<label><%= translated("Size") %></label>
|
<label><%= dgettext("ui", "Size") %></label>
|
||||||
<input class="post-editor-input disabled" type="text" value={@media_editor.file_size} disabled />
|
<input class="post-editor-input ui-input disabled ui-input-disabled" type="text" value={@media_editor.file_size} disabled />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= if @media_editor.dimensions do %>
|
<%= if @media_editor.dimensions do %>
|
||||||
<div class="editor-field">
|
<div class="editor-field ui-field-stack flex flex-col gap-1.5">
|
||||||
<label><%= translated("Dimensions") %></label>
|
<label><%= dgettext("ui", "Dimensions") %></label>
|
||||||
<input class="post-editor-input disabled" type="text" value={@media_editor.dimensions} disabled />
|
<input class="post-editor-input ui-input disabled ui-input-disabled" type="text" value={@media_editor.dimensions} disabled />
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="editor-field">
|
<div class="editor-field ui-field-stack flex flex-col gap-1.5">
|
||||||
<label><%= translated("Title") %></label>
|
<label><%= dgettext("ui", "Title") %></label>
|
||||||
<input class="post-editor-input" type="text" name="media_editor[title]" value={@media_editor.form["title"]} />
|
<input class="post-editor-input ui-input" type="text" name="media_editor[title]" value={@media_editor.form["title"]} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="editor-field">
|
<div class="editor-field ui-field-stack flex flex-col gap-1.5">
|
||||||
<label><%= translated("Alt Text") %></label>
|
<label><%= dgettext("ui", "Alt Text") %></label>
|
||||||
<input class="post-editor-input" type="text" name="media_editor[alt]" value={@media_editor.form["alt"]} />
|
<input class="post-editor-input ui-input" type="text" name="media_editor[alt]" value={@media_editor.form["alt"]} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="editor-field">
|
<div class="editor-field ui-field-stack flex flex-col gap-1.5">
|
||||||
<label><%= translated("Caption") %></label>
|
<label><%= dgettext("ui", "Caption") %></label>
|
||||||
<textarea class="post-editor-textarea" name="media_editor[caption]" rows="3"><%= @media_editor.form["caption"] %></textarea>
|
<textarea class="post-editor-textarea ui-textarea" name="media_editor[caption]" rows="3"><%= @media_editor.form["caption"] %></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="editor-field">
|
<div class="editor-field ui-field-stack flex flex-col gap-1.5">
|
||||||
<label><%= translated("Tags") %></label>
|
<label><%= dgettext("ui", "Tags") %></label>
|
||||||
<input class="post-editor-input" type="text" name="media_editor[tags]" value={@media_editor.form["tags"]} />
|
<input class="post-editor-input ui-input" type="text" name="media_editor[tags]" value={@media_editor.form["tags"]} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="editor-field">
|
<div class="editor-field ui-field-stack flex flex-col gap-1.5">
|
||||||
<label><%= translated("Author") %></label>
|
<label><%= dgettext("ui", "Author") %></label>
|
||||||
<input class="post-editor-input" type="text" name="media_editor[author]" value={@media_editor.form["author"]} />
|
<input class="post-editor-input ui-input" type="text" name="media_editor[author]" value={@media_editor.form["author"]} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="editor-field">
|
<div class="editor-field ui-field-stack flex flex-col gap-1.5">
|
||||||
<label><%= translated("Language") %></label>
|
<label><%= dgettext("ui", "Language") %></label>
|
||||||
<select class="post-editor-input" name="media_editor[language]">
|
<select class="post-editor-input ui-input" name="media_editor[language]">
|
||||||
<option value=""><%= translated("None") %></option>
|
<option value=""><%= dgettext("ui", "None") %></option>
|
||||||
<%= for language <- @media_editor.languages do %>
|
<%= for language <- @media_editor.languages do %>
|
||||||
<option value={language} selected={language == @media_editor.form["language"]}><%= language_label(language) %></option>
|
<option value={language} selected={language == @media_editor.form["language"]}><%= language_label(language) %></option>
|
||||||
<% end %>
|
<% end %>
|
||||||
@@ -180,28 +179,28 @@
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<%= if @media_editor.form["language"] not in [nil, ""] do %>
|
<%= if @media_editor.form["language"] not in [nil, ""] do %>
|
||||||
<div class="editor-field media-translations-section">
|
<div class="editor-field media-translations-section flex flex-col gap-2">
|
||||||
<label><%= translated("Translations") %></label>
|
<label><%= dgettext("ui", "Translations") %></label>
|
||||||
|
|
||||||
<%= if Enum.empty?(@media_editor.translations) do %>
|
<%= 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 %>
|
<% else %>
|
||||||
<div class="linked-posts-list">
|
<div class="linked-posts-list flex flex-col gap-2">
|
||||||
<%= for translation <- @media_editor.translations do %>
|
<%= for translation <- @media_editor.translations do %>
|
||||||
<div class="linked-post-item">
|
<div class="linked-post-item flex items-center justify-between gap-2">
|
||||||
<button
|
<button
|
||||||
class="linked-post-title linked-post-link"
|
class="linked-post-title linked-post-link"
|
||||||
type="button"
|
type="button"
|
||||||
phx-click="edit_media_translation"
|
phx-click="edit_media_translation"
|
||||||
phx-value-id={@media_editor.id}
|
phx-target={@myself}
|
||||||
phx-value-language={translation.language}
|
phx-value-language={translation.language}
|
||||||
>
|
>
|
||||||
<%= translation.flag %> <%= language_label(translation.language) %><%= if translation.title, do: " — #{translation.title}" %>
|
<%= translation.flag %> <%= language_label(translation.language) %><%= if translation.title, do: " — #{translation.title}" %>
|
||||||
</button>
|
</button>
|
||||||
<button class="secondary compact" type="button" phx-click="refresh_media_translation" phx-value-id={@media_editor.id} phx-value-language={translation.language}>
|
<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}>
|
||||||
<%= translated("Refresh") %>
|
<%= dgettext("ui", "Refresh") %>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
@@ -209,38 +208,38 @@
|
|||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<div class="editor-field linked-posts-section">
|
<div class="editor-field linked-posts-section flex flex-col gap-2">
|
||||||
<label>
|
<label>
|
||||||
<%= translated("Linked Posts") %>
|
<%= dgettext("ui", "Linked Posts") %>
|
||||||
<button class="add-link-btn" type="button" phx-click="toggle_media_post_picker" phx-value-id={@media_editor.id}>
|
<button class="add-link-btn" type="button" phx-click="toggle_media_post_picker" phx-target={@myself}>
|
||||||
<%= translated("Link to Post") %>
|
<%= dgettext("ui", "Link to Post") %>
|
||||||
</button>
|
</button>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<%= if @media_editor.post_picker_open? do %>
|
<%= 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">
|
<div class="post-picker-search">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
name="media_post_picker[query]"
|
name="media_post_picker[query]"
|
||||||
value={@media_editor.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-change="change_media_post_picker"
|
||||||
phx-value-id={@media_editor.id}
|
phx-target={@myself}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= if Enum.empty?(@media_editor.post_picker_results) do %>
|
<%= 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 %>
|
<% else %>
|
||||||
<div class="post-picker-list">
|
<div class="post-picker-list flex flex-col gap-2">
|
||||||
<%= for result <- @media_editor.post_picker_results do %>
|
<%= 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 %>
|
<%= result.title %>
|
||||||
</button>
|
</button>
|
||||||
<% end %>
|
<% end %>
|
||||||
<%= if @media_editor.post_picker_overflow_count > 0 do %>
|
<%= 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 %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
@@ -248,11 +247,11 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<%= if Enum.empty?(@media_editor.linked_posts) do %>
|
<%= 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 %>
|
<% else %>
|
||||||
<div class="linked-posts-list">
|
<div class="linked-posts-list flex flex-col gap-2">
|
||||||
<%= for linked_post <- @media_editor.linked_posts do %>
|
<%= 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
|
<button
|
||||||
class="linked-post-title linked-post-link"
|
class="linked-post-title linked-post-link"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -264,7 +263,7 @@
|
|||||||
>
|
>
|
||||||
📄 <%= linked_post.title %>
|
📄 <%= linked_post.title %>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
@@ -275,31 +274,31 @@
|
|||||||
|
|
||||||
<%= if @media_editor.editing_translation do %>
|
<%= if @media_editor.editing_translation do %>
|
||||||
<div class="translation-modal-backdrop">
|
<div class="translation-modal-backdrop">
|
||||||
<div class="translation-modal">
|
<div class="translation-modal flex max-h-[80vh] w-full max-w-2xl flex-col overflow-hidden">
|
||||||
<div class="translation-modal-header">
|
<div class="translation-modal-header flex items-center justify-between gap-3">
|
||||||
<h2><%= translated("Edit Translation") %></h2>
|
<h2><%= dgettext("ui", "Edit Translation") %></h2>
|
||||||
<button class="translation-modal-close" type="button" phx-click="close_media_translation_editor">×</button>
|
<button class="translation-modal-close" type="button" phx-click="close_media_translation_editor" phx-target={@myself}>×</button>
|
||||||
</div>
|
</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"]} />
|
<input type="hidden" name="media_translation[language]" value={@media_editor.editing_translation["language"]} />
|
||||||
<div class="editor-field">
|
<div class="editor-field ui-field-stack flex flex-col gap-1.5">
|
||||||
<label><%= translated("Title") %></label>
|
<label><%= dgettext("ui", "Title") %></label>
|
||||||
<input class="post-editor-input" type="text" name="media_translation[title]" value={@media_editor.editing_translation["title"]} />
|
<input class="post-editor-input ui-input" type="text" name="media_translation[title]" value={@media_editor.editing_translation["title"]} />
|
||||||
</div>
|
</div>
|
||||||
<div class="editor-field">
|
<div class="editor-field ui-field-stack flex flex-col gap-1.5">
|
||||||
<label><%= translated("Alt Text") %></label>
|
<label><%= dgettext("ui", "Alt Text") %></label>
|
||||||
<input class="post-editor-input" type="text" name="media_translation[alt]" value={@media_editor.editing_translation["alt"]} />
|
<input class="post-editor-input ui-input" type="text" name="media_translation[alt]" value={@media_editor.editing_translation["alt"]} />
|
||||||
</div>
|
</div>
|
||||||
<div class="editor-field">
|
<div class="editor-field ui-field-stack flex flex-col gap-1.5">
|
||||||
<label><%= translated("Caption") %></label>
|
<label><%= dgettext("ui", "Caption") %></label>
|
||||||
<textarea class="post-editor-textarea" name="media_translation[caption]" rows="3"><%= @media_editor.editing_translation["caption"] %></textarea>
|
<textarea class="post-editor-textarea ui-textarea" name="media_translation[caption]" rows="3"><%= @media_editor.editing_translation["caption"] %></textarea>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<div class="translation-modal-footer">
|
<div class="translation-modal-footer flex items-center justify-end gap-2">
|
||||||
<button class="secondary" type="button" phx-click="close_media_translation_editor"><%= translated("Cancel") %></button>
|
<button class="secondary ui-button ui-button-secondary" type="button" phx-click="close_media_translation_editor" phx-target={@myself}><%= dgettext("ui", "Cancel") %></button>
|
||||||
<button type="button" phx-click="save_media_translation" phx-value-id={@media_editor.id}><%= translated("Save") %></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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
defmodule BDS.Desktop.ShellLive.MenuEditor do
|
defmodule BDS.Desktop.ShellLive.MenuEditor do
|
||||||
@moduledoc false
|
@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.{
|
alias BDS.Desktop.ShellLive.MenuEditor.{
|
||||||
DraftManagement,
|
DraftManagement,
|
||||||
@@ -15,158 +17,243 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
|
|||||||
|
|
||||||
embed_templates("menu_editor_html/*")
|
embed_templates("menu_editor_html/*")
|
||||||
|
|
||||||
@spec assign_socket(term()) :: term()
|
@spec update(map(), Phoenix.LiveView.Socket.t()) :: {:ok, Phoenix.LiveView.Socket.t()}
|
||||||
def assign_socket(socket) do
|
@impl true
|
||||||
case socket.assigns[:current_tab] do
|
def update(%{action: :save} = assigns, socket) do
|
||||||
%{type: :menu_editor, id: tab_id} ->
|
socket = assign(socket, Map.drop(assigns, [:action]))
|
||||||
state = State.ensure_state(socket.assigns)
|
socket = do_save(socket)
|
||||||
menu_editor = State.build(socket.assigns, state)
|
{:ok, 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")
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
_other ->
|
|
||||||
assign(socket, :menu_editor, nil)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec select_item(term(), term(), term()) :: term()
|
def update(assigns, socket) do
|
||||||
def select_item(socket, item_id, reload) do
|
socket =
|
||||||
socket
|
socket
|
||||||
|> State.update_state(fn state -> %{state | selected_id: item_id} end)
|
|> assign(assigns)
|
||||||
|> reload.(socket.assigns.workbench)
|
|> ensure_project_assigns()
|
||||||
|
|> build_data()
|
||||||
|
|
||||||
|
{:ok, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec change_entry(term(), term(), term()) :: term()
|
@spec render(map()) :: Phoenix.LiveView.Rendered.t()
|
||||||
def change_entry(socket, params, reload) do
|
@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)
|
||||||
|
|> build_data()
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("change_menu_editor_entry", %{"menu_editor_entry" => params}, socket) do
|
||||||
query = Map.get(params, "query", "")
|
query = Map.get(params, "query", "")
|
||||||
|
|
||||||
socket
|
socket =
|
||||||
|> State.update_state(fn state -> put_in(state, [:draft, :query], query) end)
|
socket
|
||||||
|> reload.(socket.assigns.workbench)
|
|> State.update_state(fn state -> put_in(state, [:draft, :query], query) end)
|
||||||
|
|> build_data()
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec submit_entry(term(), term()) :: term()
|
def handle_event("submit_menu_editor_entry", _params, socket) do
|
||||||
def submit_entry(socket, reload) do
|
socket =
|
||||||
case DraftManagement.current_draft(socket.assigns) do
|
case DraftManagement.current_draft(socket.assigns) do
|
||||||
%{type: :page} ->
|
%{type: :page} ->
|
||||||
socket
|
socket
|
||||||
|> State.update_state(&DraftManagement.finalize_submenu_draft/1)
|
|> State.update_state(&DraftManagement.finalize_submenu_draft/1)
|
||||||
|> reload.(socket.assigns.workbench)
|
|> build_data()
|
||||||
|
|
||||||
%{type: :category} ->
|
%{type: :category} ->
|
||||||
socket
|
socket
|
||||||
|> DraftManagement.confirm_category_draft(&State.update_state/2)
|
|> DraftManagement.confirm_category_draft(&State.update_state/2)
|
||||||
|> reload.(socket.assigns.workbench)
|
|> build_data()
|
||||||
|
|
||||||
_other ->
|
_other ->
|
||||||
reload.(socket, socket.assigns.workbench)
|
socket
|
||||||
end
|
end
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec cancel_entry(term(), term()) :: term()
|
def handle_event("cancel_menu_editor_entry", _params, socket) do
|
||||||
def cancel_entry(socket, reload) do
|
socket =
|
||||||
socket
|
socket
|
||||||
|> State.update_state(&DraftManagement.cancel_draft/1)
|
|> State.update_state(&DraftManagement.cancel_draft/1)
|
||||||
|> reload.(socket.assigns.workbench)
|
|> build_data()
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec select_page(term(), term(), term()) :: term()
|
def handle_event("select_menu_editor_page", %{"post_id" => post_id}, socket) do
|
||||||
def select_page(socket, post_id, reload) do
|
socket =
|
||||||
case PageCategory.page_post(socket.assigns.projects.active_project_id, post_id) do
|
case PageCategory.page_post(socket.assigns.projects.active_project_id, post_id) do
|
||||||
nil ->
|
nil ->
|
||||||
reload.(socket, socket.assigns.workbench)
|
socket
|
||||||
|
|
||||||
post ->
|
post ->
|
||||||
socket
|
socket
|
||||||
|> State.update_state(&DraftManagement.assign_page_to_draft(&1, post))
|
|> State.update_state(&DraftManagement.assign_page_to_draft(&1, post))
|
||||||
|> reload.(socket.assigns.workbench)
|
|> build_data()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec select_category(term(), term(), term()) :: term()
|
def handle_event("select_menu_editor_category", %{"name" => name}, socket) do
|
||||||
def select_category(socket, name, reload) do
|
|
||||||
project_id = socket.assigns.projects.active_project_id
|
project_id = socket.assigns.projects.active_project_id
|
||||||
|
|
||||||
case Enum.find(PageCategory.category_options(project_id), &(&1.name == name)) do
|
socket =
|
||||||
nil ->
|
case Enum.find(PageCategory.category_options(project_id), &(&1.name == name)) do
|
||||||
reload.(socket, socket.assigns.workbench)
|
nil ->
|
||||||
|
socket
|
||||||
|
|
||||||
category ->
|
category ->
|
||||||
socket
|
socket
|
||||||
|> State.update_state(&DraftManagement.assign_category_to_draft(&1, category))
|
|> State.update_state(&DraftManagement.assign_category_to_draft(&1, category))
|
||||||
|> reload.(socket.assigns.workbench)
|
|> build_data()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec toolbar_action(term(), term(), term(), term()) :: term()
|
def handle_event("menu_editor_toolbar_action", %{"action" => action}, socket) do
|
||||||
def toolbar_action(socket, action, reload, append_output) do
|
socket = handle_toolbar_action(socket, action)
|
||||||
case action do
|
{:noreply, socket}
|
||||||
"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
|
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec drop_item(term(), term(), term(), term(), term()) :: term()
|
def handle_event(
|
||||||
def drop_item(socket, drag_item_id, target_item_id, position, reload) do
|
"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))
|
||||||
|
|> build_data()
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
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_event("menu_editor_keydown", _params, socket) do
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp handle_toolbar_action(socket, "add-entry") do
|
||||||
socket
|
socket
|
||||||
|> State.update_state(&TreeOps.drop_selected(&1, drag_item_id, target_item_id, position))
|
|> State.update_state(&DraftManagement.start_page_draft/1)
|
||||||
|> reload.(socket.assigns.workbench)
|
|> build_data()
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec handle_keydown(term(), term(), term()) :: term()
|
defp handle_toolbar_action(socket, "add-category-archive") do
|
||||||
def handle_keydown(socket, "Escape", reload) do
|
socket
|
||||||
cancel_entry(socket, reload)
|
|> State.update_state(&DraftManagement.start_category_draft/1)
|
||||||
|
|> build_data()
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_keydown(socket, _key, reload) do
|
defp handle_toolbar_action(socket, "save") do
|
||||||
reload.(socket, socket.assigns.workbench)
|
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
|
end
|
||||||
|
|
||||||
attr(:menu_editor, :map, required: true)
|
attr(:menu_editor, :map, required: true)
|
||||||
@@ -177,6 +264,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
|
|||||||
attr(:items, :list, required: true)
|
attr(:items, :list, required: true)
|
||||||
attr(:menu_editor, :map, required: true)
|
attr(:menu_editor, :map, required: true)
|
||||||
attr(:depth, :integer, required: true)
|
attr(:depth, :integer, required: true)
|
||||||
|
attr(:myself, :any, required: true)
|
||||||
|
|
||||||
@spec menu_tree_level(term()) :: term()
|
@spec menu_tree_level(term()) :: term()
|
||||||
def menu_tree_level(assigns) do
|
def menu_tree_level(assigns) do
|
||||||
@@ -199,9 +287,10 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
|
|||||||
data-selected={to_string(selected?)}
|
data-selected={to_string(selected?)}
|
||||||
phx-click={unless(editing?, do: "menu_editor_select_item")}
|
phx-click={unless(editing?, do: "menu_editor_select_item")}
|
||||||
phx-value-item_id={unless(editing?, do: item.item_id)}
|
phx-value-item_id={unless(editing?, do: item.item_id)}
|
||||||
|
phx-target={@myself}
|
||||||
style={"--menu-editor-depth: #{@depth};"}
|
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)}>
|
<span class="menu-editor-row-kind" title={kind_label(item.kind)} aria-label={kind_label(item.kind)}>
|
||||||
<.kind_icon kind={item.kind} />
|
<.kind_icon kind={item.kind} />
|
||||||
</span>
|
</span>
|
||||||
@@ -213,6 +302,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
|
|||||||
data-testid="menu-editor-entry-form"
|
data-testid="menu-editor-entry-form"
|
||||||
phx-change="change_menu_editor_entry"
|
phx-change="change_menu_editor_entry"
|
||||||
phx-submit="submit_menu_editor_entry"
|
phx-submit="submit_menu_editor_entry"
|
||||||
|
phx-target={@myself}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
class="menu-editor-inline-input"
|
class="menu-editor-inline-input"
|
||||||
@@ -233,16 +323,16 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
|
|||||||
<div class="menu-editor-inline-actions">
|
<div class="menu-editor-inline-actions">
|
||||||
<%= if @menu_editor.draft.type == :page do %>
|
<%= if @menu_editor.draft.type == :page do %>
|
||||||
<button class="menu-editor-inline-action" data-testid="menu-editor-create-submenu" type="submit">
|
<button class="menu-editor-inline-action" data-testid="menu-editor-create-submenu" type="submit">
|
||||||
<%= translated("menuEditor.addSubmenu") %>
|
<%= dgettext("ui", "Add Submenu") %>
|
||||||
</button>
|
</button>
|
||||||
<% else %>
|
<% else %>
|
||||||
<button class="menu-editor-inline-action" type="submit">
|
<button class="menu-editor-inline-action" type="submit">
|
||||||
<%= translated("menuEditor.addCategoryArchive") %>
|
<%= dgettext("ui", "Add Category Archive") %>
|
||||||
</button>
|
</button>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<button class="menu-editor-inline-action" type="button" phx-click="cancel_menu_editor_entry">
|
<button class="menu-editor-inline-action" type="button" phx-click="cancel_menu_editor_entry" phx-target={@myself}>
|
||||||
<%= translated("Cancel") %>
|
<%= dgettext("ui", "Cancel") %>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -250,7 +340,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
|
|||||||
<%= if @menu_editor.draft.type == :page do %>
|
<%= if @menu_editor.draft.type == :page do %>
|
||||||
<div class="menu-editor-picker-list">
|
<div class="menu-editor-picker-list">
|
||||||
<%= if @menu_editor.filtered_pages == [] do %>
|
<%= 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 %>
|
<% else %>
|
||||||
<%= for post <- @menu_editor.filtered_pages do %>
|
<%= for post <- @menu_editor.filtered_pages do %>
|
||||||
<button
|
<button
|
||||||
@@ -258,6 +348,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
|
|||||||
type="button"
|
type="button"
|
||||||
phx-click="select_menu_editor_page"
|
phx-click="select_menu_editor_page"
|
||||||
phx-value-post_id={post.id}
|
phx-value-post_id={post.id}
|
||||||
|
phx-target={@myself}
|
||||||
>
|
>
|
||||||
<span><%= post.title %></span>
|
<span><%= post.title %></span>
|
||||||
<small><%= post.slug %></small>
|
<small><%= post.slug %></small>
|
||||||
@@ -268,7 +359,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
|
|||||||
<% else %>
|
<% else %>
|
||||||
<div class="menu-editor-picker-list">
|
<div class="menu-editor-picker-list">
|
||||||
<%= if @menu_editor.filtered_categories == [] do %>
|
<%= 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 %>
|
<% else %>
|
||||||
<%= for category <- @menu_editor.filtered_categories do %>
|
<%= for category <- @menu_editor.filtered_categories do %>
|
||||||
<button
|
<button
|
||||||
@@ -276,6 +367,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
|
|||||||
type="button"
|
type="button"
|
||||||
phx-click="select_menu_editor_category"
|
phx-click="select_menu_editor_category"
|
||||||
phx-value-name={category.name}
|
phx-value-name={category.name}
|
||||||
|
phx-target={@myself}
|
||||||
>
|
>
|
||||||
<span><%= category.title %></span>
|
<span><%= category.title %></span>
|
||||||
<small><%= category.name %></small>
|
<small><%= category.name %></small>
|
||||||
@@ -294,7 +386,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
|
|||||||
|
|
||||||
<%= if item.children != [] do %>
|
<%= if item.children != [] do %>
|
||||||
<ul class="menu-editor-tree-level">
|
<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>
|
</ul>
|
||||||
<% end %>
|
<% end %>
|
||||||
</li>
|
</li>
|
||||||
@@ -320,10 +412,6 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
|
|||||||
"""
|
"""
|
||||||
end
|
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()
|
@spec row_label(term(), term()) :: term()
|
||||||
def row_label(item, category_titles) do
|
def row_label(item, category_titles) do
|
||||||
if item.kind == :category_archive do
|
if item.kind == :category_archive do
|
||||||
@@ -334,24 +422,27 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
|
|||||||
end
|
end
|
||||||
|
|
||||||
@spec kind_label(term()) :: term()
|
@spec kind_label(term()) :: term()
|
||||||
def kind_label(:home), do: translated("menuEditor.type.home")
|
def kind_label(:home), do: dgettext("ui", "Home")
|
||||||
def kind_label(:page), do: translated("menuEditor.type.page")
|
def kind_label(:page), do: dgettext("ui", "Page")
|
||||||
def kind_label(:category_archive), do: translated("menuEditor.type.categoryArchive")
|
def kind_label(:category_archive), do: dgettext("ui", "Category Archive")
|
||||||
def kind_label(:submenu), do: translated("menuEditor.type.submenu")
|
def kind_label(:submenu), do: dgettext("ui", "Submenu")
|
||||||
|
|
||||||
defdelegate draft_item?(menu_editor, item_id), to: TreePredicates
|
defdelegate draft_item?(menu_editor, item_id), to: TreePredicates
|
||||||
|
|
||||||
@spec editing_title(term()) :: term()
|
@spec editing_title(term()) :: term()
|
||||||
def editing_title(%{draft: %{type: :category}}), do: translated("menuEditor.addCategoryArchive")
|
def editing_title(%{draft: %{type: :category}}), do: dgettext("ui", "Add Category Archive")
|
||||||
def editing_title(_menu_editor), do: translated("menuEditor.pagePicker.title")
|
def editing_title(_menu_editor), do: dgettext("ui", "Select Page")
|
||||||
|
|
||||||
@spec editing_hint(term()) :: term()
|
@spec editing_hint(term()) :: term()
|
||||||
def editing_hint(%{draft: %{type: :category}}), do: translated("menuEditor.categoryPicker.hint")
|
def editing_hint(%{draft: %{type: :category}}),
|
||||||
def editing_hint(_menu_editor), do: translated("menuEditor.createHint")
|
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()
|
@spec editing_placeholder(term()) :: term()
|
||||||
def editing_placeholder(%{draft: %{type: :category}}),
|
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
|
end
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
defmodule BDS.Desktop.ShellLive.MenuEditor.DraftManagement do
|
defmodule BDS.Desktop.ShellLive.MenuEditor.DraftManagement do
|
||||||
@moduledoc false
|
@moduledoc false
|
||||||
|
|
||||||
alias BDS.Desktop.ShellData
|
|
||||||
alias BDS.Metadata
|
alias BDS.Metadata
|
||||||
alias BDS.Desktop.ShellLive.MenuEditor.PageCategory
|
alias BDS.Desktop.ShellLive.MenuEditor.PageCategory
|
||||||
alias BDS.Desktop.ShellLive.MenuEditor.TreeOps
|
alias BDS.Desktop.ShellLive.MenuEditor.TreeOps
|
||||||
|
use Gettext, backend: BDS.Gettext
|
||||||
|
|
||||||
@spec current_draft(term()) :: term()
|
@spec current_draft(term()) :: term()
|
||||||
def current_draft(assigns), do: Map.get(assigns.menu_editor_state || %{}, :draft)
|
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 = %{
|
||||||
item_id: Ecto.UUID.generate(),
|
item_id: Ecto.UUID.generate(),
|
||||||
kind: :page,
|
kind: :page,
|
||||||
label: translated("menuEditor.newPage"),
|
label: dgettext("ui", "New Page"),
|
||||||
slug: nil,
|
slug: nil,
|
||||||
children: [],
|
children: [],
|
||||||
is_home: false
|
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
|
def finalize_submenu_draft(%{draft: %{item_id: item_id, query: query}} = state) do
|
||||||
label =
|
label =
|
||||||
if(String.trim(query) == "",
|
if(String.trim(query) == "",
|
||||||
do: translated("menuEditor.newSubmenu"),
|
do: dgettext("ui", "New Submenu"),
|
||||||
else: String.trim(query)
|
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))
|
update_state_fun.(socket, &assign_category_to_draft(&1, category))
|
||||||
end
|
end
|
||||||
|
|
||||||
defp translated(text, bindings \\ %{}),
|
|
||||||
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.State do
|
|||||||
|
|
||||||
use Phoenix.Component
|
use Phoenix.Component
|
||||||
|
|
||||||
alias BDS.Desktop.ShellData
|
|
||||||
alias BDS.Menu
|
alias BDS.Menu
|
||||||
alias BDS.Desktop.ShellLive.MenuEditor.{PageCategory, TreeOps, TreePredicates}
|
alias BDS.Desktop.ShellLive.MenuEditor.{PageCategory, TreeOps, TreePredicates}
|
||||||
|
use Gettext, backend: BDS.Gettext
|
||||||
|
|
||||||
@spec ensure_state(term()) :: term()
|
@spec ensure_state(term()) :: term()
|
||||||
def ensure_state(assigns) do
|
def ensure_state(assigns) do
|
||||||
@@ -30,8 +30,12 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.State do
|
|||||||
draft_query = Map.get(draft || %{}, :query, "")
|
draft_query = Map.get(draft || %{}, :query, "")
|
||||||
|
|
||||||
%{
|
%{
|
||||||
title: translated("menuEditor.title"),
|
title: dgettext("ui", "Blog Menu Editor"),
|
||||||
description: translated("menuEditor.description"),
|
description:
|
||||||
|
dgettext(
|
||||||
|
"ui",
|
||||||
|
"Manage the central blog navigation outline and save it to meta/menu.opml."
|
||||||
|
),
|
||||||
items: state.items,
|
items: state.items,
|
||||||
selected_id: state.selected_id,
|
selected_id: state.selected_id,
|
||||||
draft: draft,
|
draft: draft,
|
||||||
@@ -66,8 +70,8 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.State do
|
|||||||
|
|
||||||
socket
|
socket
|
||||||
|> append_output.(
|
|> append_output.(
|
||||||
translated("menuEditor.tabTitle"),
|
dgettext("ui", "Blog Menu"),
|
||||||
translated("menuEditor.saved"),
|
dgettext("ui", "Blog menu saved"),
|
||||||
nil,
|
nil,
|
||||||
"info"
|
"info"
|
||||||
)
|
)
|
||||||
@@ -94,7 +98,4 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.State do
|
|||||||
draft: nil
|
draft: nil
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
defp translated(text, bindings \\ %{}),
|
|
||||||
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,56 +1,56 @@
|
|||||||
<div class="menu-editor-view" data-testid="menu-editor" phx-window-keydown={if(@menu_editor.draft, do: "menu_editor_keydown")}>
|
<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">
|
<div class="menu-editor-header flex shrink-0 items-start justify-between gap-3">
|
||||||
<div>
|
<div class="ui-field-stack">
|
||||||
<h2><%= @menu_editor.title %></h2>
|
<h2><%= @menu_editor.title %></h2>
|
||||||
<p><%= @menu_editor.description %></p>
|
<p><%= @menu_editor.description %></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="menu-editor-main">
|
<div class="menu-editor-main min-h-0 flex-1 overflow-hidden">
|
||||||
<div class="menu-editor-tree-wrap">
|
<div class="menu-editor-tree-wrap ui-section-card flex h-full min-h-0 flex-col">
|
||||||
<div class="menu-editor-toolbar" data-testid="menu-editor-toolbar" role="toolbar" aria-label={@menu_editor.title}>
|
<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" 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")}>
|
<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>
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M7 2h2v5h5v2H9v5H7V9H2V7h5V2z" /></svg>
|
||||||
</button>
|
</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>
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M2 2h9l3 3v9H2V2zm2 1v3h6V3H4zm0 9h8V7H4v5z" /></svg>
|
||||||
</button>
|
</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")}>
|
<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"><%= translated("menuEditor.addCategoryArchiveShort") %></span>
|
<span aria-hidden="true"><%= dgettext("ui", "menuEditor.addCategoryArchiveShort") %></span>
|
||||||
</button>
|
</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>
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M8 3l4 4H9v6H7V7H4l4-4z" /></svg>
|
||||||
</button>
|
</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>
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M7 3h2v6h3l-4 4-4-4h3V3z" /></svg>
|
||||||
</button>
|
</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>
|
<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>
|
||||||
|
|
||||||
<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>
|
<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>
|
||||||
|
|
||||||
<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>
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M6 2h4l1 1h3v2H2V3h3l1-1zm-1 4h2v6H5V6zm4 0h2v6H9V6z" /></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= if @menu_editor.items == [] do %>
|
<%= 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 %>
|
<% 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">
|
<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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,18 +1,22 @@
|
|||||||
defmodule BDS.Desktop.ShellLive.MiscEditor do
|
defmodule BDS.Desktop.ShellLive.MiscEditor do
|
||||||
@moduledoc false
|
@moduledoc false
|
||||||
|
|
||||||
use Phoenix.Component
|
use Phoenix.LiveComponent
|
||||||
|
|
||||||
|
import Phoenix.HTML, only: [raw: 1]
|
||||||
import Ecto.Query
|
import Ecto.Query
|
||||||
|
|
||||||
alias BDS.{Embeddings, Generation, Git, Posts, Repo}
|
alias BDS.{Embeddings, Generation, Git, HelpDocs, Posts, Repo}
|
||||||
alias BDS.Desktop.ShellData
|
alias BDS.Desktop.ShellLive.Notify
|
||||||
alias BDS.MapUtils
|
alias BDS.MapUtils
|
||||||
alias BDS.Settings.Setting
|
alias BDS.Settings.Setting
|
||||||
|
use Gettext, backend: BDS.Gettext
|
||||||
|
|
||||||
embed_templates("misc_editor_html/*")
|
embed_templates("misc_editor_html/*")
|
||||||
|
|
||||||
@misc_routes [
|
@misc_routes [
|
||||||
|
:documentation,
|
||||||
|
:api_documentation,
|
||||||
:site_validation,
|
:site_validation,
|
||||||
:metadata_diff,
|
:metadata_diff,
|
||||||
:translation_validation,
|
:translation_validation,
|
||||||
@@ -20,30 +24,60 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
|
|||||||
:git_diff
|
:git_diff
|
||||||
]
|
]
|
||||||
|
|
||||||
@spec assign_socket(term()) :: term()
|
# ── LiveComponent lifecycle ────────────────────────────────────────────────
|
||||||
def assign_socket(socket) do
|
|
||||||
assign(socket, :misc_editor, build(socket.assigns))
|
@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
|
end
|
||||||
|
|
||||||
@spec rerun(term()) :: term()
|
defp ensure_state(socket) do
|
||||||
def rerun(socket) do
|
socket
|
||||||
case meta(socket.assigns) do
|
|> assign_new(:selected_pairs, fn -> MapSet.new() end)
|
||||||
%{action: action} when is_binary(action) ->
|
|> assign_new(:git_selected_file, fn -> nil end)
|
||||||
{:command, action}
|
|> assign_new(:active_tab, fn -> nil end)
|
||||||
|
|> assign_new(:active_field, fn -> nil end)
|
||||||
|
end
|
||||||
|
|
||||||
_other ->
|
defp build_data(socket) do
|
||||||
case misc_route_action(socket.assigns.current_tab.type) do
|
misc_editor = do_build(socket.assigns)
|
||||||
nil -> {:noop, socket}
|
assign(socket, :misc_editor, misc_editor)
|
||||||
action -> {:command, action}
|
end
|
||||||
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
|
end
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec apply_site_validation(term(), term()) :: term()
|
def handle_event("apply_site_validation", _params, socket) do
|
||||||
def apply_site_validation(socket, append_output) do
|
|
||||||
meta = meta(socket.assigns)
|
meta = meta(socket.assigns)
|
||||||
payload = Map.get(meta, :payload, %{})
|
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 = %{
|
report = %{
|
||||||
sitemap_path: Map.get(payload, :sitemap_path),
|
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
|
case Generation.apply_validation(project_id, report) do
|
||||||
{:ok, result} ->
|
{:ok, result} ->
|
||||||
{:rerun,
|
notify_output(
|
||||||
socket
|
dgettext("ui", "Site Validation"),
|
||||||
|> append_output.(
|
dgettext("ui", "Validation changes applied"),
|
||||||
translated("Site Validation"),
|
inspect(result)
|
||||||
translated("Validation changes applied"),
|
)
|
||||||
inspect(result)
|
|
||||||
)}
|
notify_command("validate_site")
|
||||||
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
rescue
|
rescue
|
||||||
error ->
|
error ->
|
||||||
{:socket,
|
notify_output(dgettext("ui", "Site Validation"), inspect(error), nil, "error")
|
||||||
append_output.(socket, translated("Site Validation"), inspect(error), nil, "error")}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec toggle_duplicate(term(), term(), term()) :: term()
|
def handle_event("fix_translation_validation", _params, socket) do
|
||||||
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
|
|
||||||
report =
|
report =
|
||||||
socket.assigns
|
socket.assigns
|
||||||
|> meta()
|
|> meta()
|
||||||
@@ -157,97 +115,282 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
|
|||||||
|
|
||||||
{:ok, result} = Posts.fix_invalid_translations(report)
|
{:ok, result} = Posts.fix_invalid_translations(report)
|
||||||
|
|
||||||
{:rerun,
|
notify_output(
|
||||||
socket
|
dgettext("ui", "Translation Validation"),
|
||||||
|> append_output.(
|
dgettext(
|
||||||
translated("Translation Validation"),
|
"ui",
|
||||||
translated("translationValidation.toast.fixSuccess", %{
|
"Deleted %{dbRows} DB rows and %{files} files, flushed %{flushed} translations to disk",
|
||||||
dbRows: result.deleted_database_rows,
|
dbRows: result.deleted_database_rows,
|
||||||
files: result.deleted_files,
|
files: result.deleted_files,
|
||||||
flushed: result.flushed_translations
|
flushed: result.flushed_translations
|
||||||
})
|
|
||||||
)}
|
|
||||||
rescue
|
|
||||||
error ->
|
|
||||||
{:socket,
|
|
||||||
append_output.(socket, translated("Translation Validation"), inspect(error), nil, "error")}
|
|
||||||
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
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
notify_command("validate_translations")
|
||||||
|
{:noreply, socket}
|
||||||
|
rescue
|
||||||
|
error ->
|
||||||
|
notify_output(dgettext("ui", "Translation Validation"), inspect(error), nil, "error")
|
||||||
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec metadata_diff_repair_request(term(), term(), term()) :: term()
|
def handle_event("select_git_diff_file", %{"path" => path}, socket) do
|
||||||
def metadata_diff_repair_request(socket, field, direction) do
|
{:noreply, assign(socket, :git_selected_file, path) |> build_data()}
|
||||||
meta = meta(socket.assigns)
|
end
|
||||||
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)
|
|
||||||
|
|
||||||
repair_items =
|
def handle_event("toggle_duplicate_pair", %{"pair-id" => pair_id}, socket) do
|
||||||
items
|
current = socket.assigns.selected_pairs
|
||||||
|> 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
|
next =
|
||||||
not metadata_diff_repairable_tab?(active_tab) ->
|
if MapSet.member?(current, pair_id) do
|
||||||
{:error, translated("No repair action available")}
|
MapSet.delete(current, pair_id)
|
||||||
|
else
|
||||||
|
MapSet.put(current, pair_id)
|
||||||
|
end
|
||||||
|
|
||||||
repair_items == [] ->
|
{:noreply, assign(socket, :selected_pairs, next) |> build_data()}
|
||||||
{:error, translated("No metadata diff items selected")}
|
end
|
||||||
|
|
||||||
true ->
|
def handle_event(
|
||||||
{:ok,
|
"dismiss_duplicate_pair",
|
||||||
%{
|
%{"post-id-a" => post_id_a, "post-id-b" => post_id_b},
|
||||||
"direction" => direction,
|
socket
|
||||||
"field" => field,
|
) do
|
||||||
"tab" => active_tab,
|
case Embeddings.dismiss_duplicate_pair(post_id_a, post_id_b) do
|
||||||
"items" => repair_items
|
{: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
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec metadata_diff_orphan_import_request(term()) :: term()
|
def handle_event("dismiss_selected_duplicates", _params, socket) do
|
||||||
def metadata_diff_orphan_import_request(socket) do
|
selected =
|
||||||
meta = meta(socket.assigns)
|
socket.assigns.selected_pairs
|
||||||
payload = Map.get(meta, :payload, %{})
|
|> MapSet.to_list()
|
||||||
items = Enum.map(Map.get(payload, :diff_reports, []), &normalize_metadata_diff_item/1)
|
|> Enum.map(&decode_pair_id/1)
|
||||||
|
|> Enum.reject(&is_nil/1)
|
||||||
|
|
||||||
orphan_files =
|
case Embeddings.dismiss_duplicate_pairs(selected) do
|
||||||
Enum.map(Map.get(payload, :orphan_reports, []), &normalize_metadata_diff_orphan/1)
|
{:ok, _saved_pairs} ->
|
||||||
|
tab_type = socket.assigns.current_tab.type
|
||||||
|
tab_id = socket.assigns.current_tab.id
|
||||||
|
payload = Map.get(meta(socket.assigns), :payload, %{})
|
||||||
|
|
||||||
tabs = metadata_diff_tabs(items, orphan_files)
|
next_pairs =
|
||||||
active_tab = metadata_diff_active_tab(socket.assigns, tabs)
|
update_in(payload[:pairs], fn pairs ->
|
||||||
|
Enum.reject(pairs || [], fn pair -> pair_identity(pair) in selected end)
|
||||||
|
end)
|
||||||
|
|
||||||
selected_orphans =
|
next_payload = Map.put(payload, :pairs, next_pairs)
|
||||||
orphan_files
|
notify_tab_meta(tab_type, tab_id, %{payload: next_payload})
|
||||||
|> Enum.filter(&(&1.tab_id == active_tab))
|
|
||||||
|> Enum.map(&%{"file_path" => &1.file_path})
|
|
||||||
|
|
||||||
if selected_orphans == [] do
|
notify_output(
|
||||||
{:error, translated("No orphan files selected")}
|
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
|
else
|
||||||
{:ok, %{"tab" => active_tab, "orphans" => selected_orphans}}
|
dgettext("ui", "%{canonical} = %{translation}",
|
||||||
|
canonical: canonical_language,
|
||||||
|
translation: translation_language
|
||||||
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec build(term()) :: term()
|
@spec translation_issue_value(map(), atom() | String.t()) :: any()
|
||||||
def build(%{current_tab: %{type: type}} = assigns) when type in @misc_routes do
|
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)
|
meta = meta(assigns)
|
||||||
payload = Map.get(meta, :payload, %{})
|
payload = Map.get(meta, :payload, %{})
|
||||||
|
|
||||||
case type do
|
case type do
|
||||||
|
:documentation -> build_help_doc(type, meta)
|
||||||
|
:api_documentation -> build_help_doc(type, meta)
|
||||||
:site_validation -> build_site_validation(meta, payload)
|
:site_validation -> build_site_validation(meta, payload)
|
||||||
:metadata_diff -> build_metadata_diff(assigns, meta, payload)
|
:metadata_diff -> build_metadata_diff(assigns, meta, payload)
|
||||||
:translation_validation -> build_translation_validation(meta, payload)
|
:translation_validation -> build_translation_validation(meta, payload)
|
||||||
@@ -256,36 +399,26 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec build(term()) :: term()
|
defp do_build(_assigns), do: nil
|
||||||
def build(_assigns), do: nil
|
|
||||||
|
|
||||||
@spec translated(term(), term()) :: term()
|
defp build_help_doc(type, meta) do
|
||||||
def translated(text, bindings \\ %{}),
|
help_doc = HelpDocs.fetch(type)
|
||||||
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
|
|
||||||
|
|
||||||
@spec misc_class(term()) :: term()
|
%{
|
||||||
def misc_class(:site_validation), do: "site-validation-view"
|
kind: type,
|
||||||
def misc_class(:metadata_diff), do: "metadata-diff-view"
|
title: Map.get(meta, :title, help_doc.title),
|
||||||
def misc_class(:translation_validation), do: "translation-validation-view"
|
subtitle: Map.get(meta, :subtitle, help_doc.subtitle),
|
||||||
def misc_class(:find_duplicates), do: "duplicates-view"
|
summary: %{},
|
||||||
def misc_class(:git_diff), do: "git-diff-view"
|
markdown: help_doc.markdown
|
||||||
|
}
|
||||||
def summary_items(%{summary: summary}) when is_map(summary), do: Enum.to_list(summary)
|
end
|
||||||
@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)
|
|
||||||
|
|
||||||
defp build_site_validation(meta, payload) do
|
defp build_site_validation(meta, payload) do
|
||||||
summary = Map.get(payload, :summary, %{})
|
summary = Map.get(payload, :summary, %{})
|
||||||
|
|
||||||
%{
|
%{
|
||||||
kind: :site_validation,
|
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, ""),
|
subtitle: Map.get(meta, :subtitle, ""),
|
||||||
summary: %{
|
summary: %{
|
||||||
expected: Map.get(summary, :expected_count, 0),
|
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)
|
Enum.map(Map.get(payload, :orphan_reports, []), &normalize_metadata_diff_orphan/1)
|
||||||
|
|
||||||
tabs = metadata_diff_tabs(items, orphan_files)
|
tabs = metadata_diff_tabs(items, orphan_files)
|
||||||
active_tab = metadata_diff_active_tab(assigns, tabs)
|
active_tab = assigns.active_tab || metadata_diff_active_tab_fallback(tabs)
|
||||||
active_field = metadata_diff_active_field(assigns)
|
active_field = assigns.active_field
|
||||||
|
|
||||||
current_tab =
|
current_tab =
|
||||||
Enum.find(tabs, &(&1.id == active_tab)) || List.first(tabs) || empty_metadata_diff_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,
|
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, ""),
|
subtitle: Map.get(meta, :subtitle, ""),
|
||||||
summary: Map.get(payload, :summary, %{}),
|
summary: Map.get(payload, :summary, %{}),
|
||||||
tabs:
|
tabs:
|
||||||
@@ -332,7 +465,7 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
|
|||||||
field_summaries: field_summaries(current_tab.items),
|
field_summaries: field_summaries(current_tab.items),
|
||||||
items: filtered_items,
|
items: filtered_items,
|
||||||
orphan_files: if(is_nil(active_field), do: current_tab.orphan_files, else: []),
|
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
|
end
|
||||||
|
|
||||||
@@ -341,16 +474,18 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
|
|||||||
|
|
||||||
%{
|
%{
|
||||||
kind: :translation_validation,
|
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, ""),
|
subtitle: Map.get(meta, :subtitle, ""),
|
||||||
summary: %{},
|
summary: %{},
|
||||||
summary_text:
|
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,
|
dbRows: report.checked_database_row_count,
|
||||||
files: report.checked_filesystem_file_count,
|
files: report.checked_filesystem_file_count,
|
||||||
invalidDb: length(report.invalid_database_rows),
|
invalidDb: length(report.invalid_database_rows),
|
||||||
invalidFiles: length(report.invalid_filesystem_files)
|
invalidFiles: length(report.invalid_filesystem_files)
|
||||||
}),
|
),
|
||||||
invalid_database_rows: report.invalid_database_rows,
|
invalid_database_rows: report.invalid_database_rows,
|
||||||
invalid_filesystem_files: report.invalid_filesystem_files,
|
invalid_filesystem_files: report.invalid_filesystem_files,
|
||||||
can_fix?: report.invalid_database_rows != [] or 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
|
end
|
||||||
|
|
||||||
defp build_duplicates(assigns, meta, payload) do
|
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,
|
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, ""),
|
subtitle: Map.get(meta, :subtitle, ""),
|
||||||
summary: Map.get(payload, :summary, %{}),
|
summary: Map.get(payload, :summary, %{}),
|
||||||
pairs: Map.get(payload, :pairs, []),
|
pairs: Map.get(payload, :pairs, []),
|
||||||
selected_pairs: selected_pairs
|
selected_pairs: assigns.selected_pairs
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
defp build_git_diff(assigns, meta) do
|
defp build_git_diff(assigns, meta) do
|
||||||
project_id = assigns.projects.active_project_id
|
project_id = assigns.project_id
|
||||||
selected_files = Map.get(assigns, :misc_editor_git_selected_files, %{})
|
selected_file = assigns.git_selected_file
|
||||||
|
|
||||||
{files, diff, error_message} =
|
{files, diff, error_message} =
|
||||||
case Git.status(project_id) do
|
case Git.status(project_id) do
|
||||||
@@ -386,7 +518,11 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
|
|||||||
|> Enum.sort()
|
|> Enum.sort()
|
||||||
|
|
||||||
selected_file_path =
|
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 =
|
diff =
|
||||||
case selected_file_path do
|
case selected_file_path do
|
||||||
@@ -416,92 +552,71 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
|
|||||||
|
|
||||||
%{
|
%{
|
||||||
kind: :git_diff,
|
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, ""),
|
subtitle: Map.get(meta, :subtitle, ""),
|
||||||
summary: %{},
|
summary: %{},
|
||||||
files: files,
|
files: files,
|
||||||
selected_file_path: diff.file_path,
|
selected_file_path: diff.file_path,
|
||||||
active_diff: Map.put(diff, :language, git_diff_language(diff.file_path)),
|
active_diff: Map.put(diff, :language, git_diff_language(diff.file_path)),
|
||||||
preferences: preferences,
|
preferences: preferences,
|
||||||
empty_message: error_message || translated("No unstaged changes")
|
empty_message: error_message || dgettext("ui", "No unstaged changes")
|
||||||
}
|
}
|
||||||
end
|
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
|
defp meta(assigns) do
|
||||||
Map.get(assigns.tab_meta, {assigns.current_tab.type, assigns.current_tab.id}, %{})
|
Map.get(assigns.tab_meta, {assigns.current_tab.type, assigns.current_tab.id}, %{})
|
||||||
end
|
end
|
||||||
|
|
||||||
defp update_payload(socket, updater) do
|
defp metadata_diff_repair_request(assigns, field, direction) do
|
||||||
key = {socket.assigns.current_tab.type, socket.assigns.current_tab.id}
|
payload = Map.get(meta(assigns), :payload, %{})
|
||||||
meta = Map.get(socket.assigns.tab_meta, key, %{})
|
items = Enum.map(Map.get(payload, :diff_reports, []), &normalize_metadata_diff_item/1)
|
||||||
next_meta = Map.update(meta, :payload, %{}, updater)
|
tabs = metadata_diff_tabs(items, [])
|
||||||
assign(socket, :tab_meta, Map.put(socket.assigns.tab_meta, key, next_meta))
|
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
|
end
|
||||||
|
|
||||||
defp clear_selected_pair(socket, pair_id) do
|
defp metadata_diff_orphan_import_request(assigns) do
|
||||||
tab_id = socket.assigns.current_tab.id
|
payload = Map.get(meta(assigns), :payload, %{})
|
||||||
current = Map.get(socket.assigns.misc_editor_selected_pairs, tab_id, MapSet.new())
|
items = Enum.map(Map.get(payload, :diff_reports, []), &normalize_metadata_diff_item/1)
|
||||||
|
|
||||||
next_pairs =
|
orphan_files =
|
||||||
Map.put(socket.assigns.misc_editor_selected_pairs, tab_id, MapSet.delete(current, pair_id))
|
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
|
end
|
||||||
|
|
||||||
defp pair_id(post_id_a, post_id_b), do: Enum.sort([post_id_a, post_id_b]) |> Enum.join("::")
|
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
|
defp empty_metadata_diff_tab do
|
||||||
%{
|
%{
|
||||||
id: "posts",
|
id: "posts",
|
||||||
label: translated("Posts"),
|
label: dgettext("ui", "Posts"),
|
||||||
items: [],
|
items: [],
|
||||||
orphan_files: [],
|
orphan_files: [],
|
||||||
diff_count: 0,
|
diff_count: 0,
|
||||||
@@ -564,18 +679,8 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
defp metadata_diff_active_tab(assigns, tabs) do
|
defp metadata_diff_active_tab_fallback(tabs) do
|
||||||
tab_id = Map.get(assigns.metadata_diff_active_tabs || %{}, assigns.current_tab.id)
|
tabs |> List.first() |> Map.get(:id)
|
||||||
|
|
||||||
if Enum.any?(tabs, &(&1.id == tab_id)) do
|
|
||||||
tab_id
|
|
||||||
else
|
|
||||||
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
|
end
|
||||||
|
|
||||||
defp metadata_diff_filtered_items(items, nil), do: items
|
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
|
MapUtils.attr(item, :meta_label) || entity_id
|
||||||
end
|
end
|
||||||
|
|
||||||
defp metadata_diff_item_type_label("post"), do: translated("Post")
|
defp metadata_diff_item_type_label("post"), do: dgettext("ui", "Post")
|
||||||
defp metadata_diff_item_type_label("post_translation"), do: translated("Translations")
|
defp metadata_diff_item_type_label("post_translation"), do: dgettext("ui", "Translations")
|
||||||
defp metadata_diff_item_type_label("media"), do: translated("Media")
|
defp metadata_diff_item_type_label("media"), do: dgettext("ui", "Media")
|
||||||
defp metadata_diff_item_type_label("media_translation"), do: translated("Translations")
|
defp metadata_diff_item_type_label("media_translation"), do: dgettext("ui", "Translations")
|
||||||
defp metadata_diff_item_type_label("script"), do: translated("Script")
|
defp metadata_diff_item_type_label("script"), do: dgettext("ui", "Script")
|
||||||
defp metadata_diff_item_type_label("template"), do: translated("Template")
|
defp metadata_diff_item_type_label("template"), do: dgettext("ui", "Template")
|
||||||
defp metadata_diff_item_type_label("project"), do: translated("Project")
|
defp metadata_diff_item_type_label("project"), do: dgettext("ui", "Project")
|
||||||
defp metadata_diff_item_type_label("publishing"), do: translated("Publishing")
|
defp metadata_diff_item_type_label("publishing"), do: dgettext("ui", "Publishing")
|
||||||
defp metadata_diff_item_type_label("categories"), do: translated("Categories")
|
defp metadata_diff_item_type_label("categories"), do: dgettext("ui", "Categories")
|
||||||
defp metadata_diff_item_type_label("category_meta"), do: translated("Categories")
|
defp metadata_diff_item_type_label("category_meta"), do: dgettext("ui", "Categories")
|
||||||
defp metadata_diff_item_type_label("embedding"), do: translated("Embeddings")
|
defp metadata_diff_item_type_label("embedding"), do: dgettext("ui", "Embeddings")
|
||||||
|
|
||||||
defp metadata_diff_item_type_label(entity_type),
|
defp metadata_diff_item_type_label(entity_type),
|
||||||
do: entity_type |> String.replace("_", " ") |> String.capitalize()
|
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("embedding"), do: "embeddings"
|
||||||
defp metadata_diff_tab_id(_entity_type), do: "project"
|
defp metadata_diff_tab_id(_entity_type), do: "project"
|
||||||
|
|
||||||
defp metadata_diff_tab_label("posts"), do: translated("Posts")
|
defp metadata_diff_tab_label("posts"), do: dgettext("ui", "Posts")
|
||||||
defp metadata_diff_tab_label("media"), do: translated("Media")
|
defp metadata_diff_tab_label("media"), do: dgettext("ui", "Media")
|
||||||
defp metadata_diff_tab_label("scripts"), do: translated("Scripts")
|
defp metadata_diff_tab_label("scripts"), do: dgettext("ui", "Scripts")
|
||||||
defp metadata_diff_tab_label("templates"), do: translated("Templates")
|
defp metadata_diff_tab_label("templates"), do: dgettext("ui", "Templates")
|
||||||
defp metadata_diff_tab_label("project"), do: translated("Project")
|
defp metadata_diff_tab_label("project"), do: dgettext("ui", "Project")
|
||||||
defp metadata_diff_tab_label("embeddings"), do: translated("Embeddings")
|
defp metadata_diff_tab_label("embeddings"), do: dgettext("ui", "Embeddings")
|
||||||
|
|
||||||
defp metadata_diff_tab_label(tab_id),
|
defp metadata_diff_tab_label(tab_id),
|
||||||
do: tab_id |> String.replace("_", " ") |> String.capitalize()
|
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 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
|
defp empty_git_diff(file_path) do
|
||||||
%{file_path: file_path, original: "", modified: "", error: nil}
|
%{file_path: file_path, original: "", modified: "", error: nil}
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,33 +1,57 @@
|
|||||||
<div class={["misc-editor-shell", misc_class(@misc_editor.kind)]} data-testid="misc-editor">
|
<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">
|
<div class="misc-editor-header flex shrink-0 items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h2><%= @misc_editor.title %></h2>
|
<h2><%= @misc_editor.title %></h2>
|
||||||
<p><%= @misc_editor.subtitle %></p>
|
<p><%= @misc_editor.subtitle %></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="misc-editor-actions">
|
<div class="misc-editor-actions flex flex-wrap items-center justify-end gap-2">
|
||||||
<button class="secondary" type="button" phx-click="rerun_misc_editor"><%= translated("Refresh") %></button>
|
<%= 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 %>
|
<%= 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 %>
|
<% end %>
|
||||||
<%= if @misc_editor.kind == :find_duplicates do %>
|
<%= 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 %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</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 %>
|
<%= for {label, value} <- summary_items(@misc_editor) do %>
|
||||||
<div class="misc-summary-pill"><span><%= label %></span><strong><%= value %></strong></div>
|
<div class="misc-summary-pill"><span><%= label %></span><strong><%= value %></strong></div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="misc-editor-content">
|
<div class="misc-editor-content min-h-0 flex-1 overflow-auto">
|
||||||
<%= case @misc_editor.kind do %>
|
<%= 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 -> %>
|
<% :site_validation -> %>
|
||||||
<div class="misc-columns">
|
<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><%= 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><%= 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><%= 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><%= 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", "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>
|
</div>
|
||||||
|
|
||||||
<% :metadata_diff -> %>
|
<% :metadata_diff -> %>
|
||||||
@@ -35,16 +59,17 @@
|
|||||||
<div class="metadata-diff-tabs" role="tablist">
|
<div class="metadata-diff-tabs" role="tablist">
|
||||||
<%= for tab <- @misc_editor.tabs do %>
|
<%= for tab <- @misc_editor.tabs do %>
|
||||||
<button
|
<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-testid="metadata-diff-tab"
|
||||||
data-entity-tab={tab.id}
|
data-entity-tab={tab.id}
|
||||||
type="button"
|
type="button"
|
||||||
phx-click="select_metadata_diff_tab"
|
phx-click="select_metadata_diff_tab"
|
||||||
|
phx-target={@myself}
|
||||||
phx-value-tab={tab.id}
|
phx-value-tab={tab.id}
|
||||||
>
|
>
|
||||||
<span><%= tab.label %></span>
|
<span><%= tab.label %></span>
|
||||||
<%= if tab.badge_count > 0 do %>
|
<%= 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 %>
|
<% end %>
|
||||||
</button>
|
</button>
|
||||||
<% end %>
|
<% end %>
|
||||||
@@ -60,6 +85,7 @@
|
|||||||
data-field={field.field_name}
|
data-field={field.field_name}
|
||||||
type="button"
|
type="button"
|
||||||
phx-click="toggle_metadata_diff_field"
|
phx-click="toggle_metadata_diff_field"
|
||||||
|
phx-target={@myself}
|
||||||
phx-value-field={field.field_name}
|
phx-value-field={field.field_name}
|
||||||
>
|
>
|
||||||
<span class="field-pill-label"><%= field.field_name %></span>
|
<span class="field-pill-label"><%= field.field_name %></span>
|
||||||
@@ -69,29 +95,31 @@
|
|||||||
<%= if @misc_editor.repair_enabled do %>
|
<%= if @misc_editor.repair_enabled do %>
|
||||||
<div class="metadata-diff-field-pill-actions">
|
<div class="metadata-diff-field-pill-actions">
|
||||||
<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-testid="metadata-diff-repair-button"
|
||||||
data-direction="db_to_file"
|
data-direction="db_to_file"
|
||||||
data-field={field.field_name}
|
data-field={field.field_name}
|
||||||
type="button"
|
type="button"
|
||||||
phx-click="repair_metadata_diff"
|
phx-click="repair_metadata_diff"
|
||||||
|
phx-target={@myself}
|
||||||
phx-value-direction="db_to_file"
|
phx-value-direction="db_to_file"
|
||||||
phx-value-field={field.field_name}
|
phx-value-field={field.field_name}
|
||||||
>
|
>
|
||||||
<%= translated("DB to File") %>
|
<%= dgettext("ui", "DB to File") %>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<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-testid="metadata-diff-repair-button"
|
||||||
data-direction="file_to_db"
|
data-direction="file_to_db"
|
||||||
data-field={field.field_name}
|
data-field={field.field_name}
|
||||||
type="button"
|
type="button"
|
||||||
phx-click="repair_metadata_diff"
|
phx-click="repair_metadata_diff"
|
||||||
|
phx-target={@myself}
|
||||||
phx-value-direction="file_to_db"
|
phx-value-direction="file_to_db"
|
||||||
phx-value-field={field.field_name}
|
phx-value-field={field.field_name}
|
||||||
>
|
>
|
||||||
<%= translated("File to DB") %>
|
<%= dgettext("ui", "File to DB") %>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
@@ -126,7 +154,7 @@
|
|||||||
<span><%= diff.db_value %></span>
|
<span><%= diff.db_value %></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="diff-field-value file-value">
|
<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>
|
<span><%= diff.file_value %></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -141,16 +169,17 @@
|
|||||||
<%= if @misc_editor.active_field == nil and @misc_editor.orphan_files != [] do %>
|
<%= if @misc_editor.active_field == nil and @misc_editor.orphan_files != [] do %>
|
||||||
<section class="orphan-files-section" data-testid="metadata-diff-orphans">
|
<section class="orphan-files-section" data-testid="metadata-diff-orphans">
|
||||||
<div class="orphan-files-header">
|
<div class="orphan-files-header">
|
||||||
<h3><%= translated("Orphan Files") %></h3>
|
<h3><%= dgettext("ui", "Orphan Files") %></h3>
|
||||||
<div class="orphan-files-actions">
|
<div class="orphan-files-actions">
|
||||||
<span class="misc-summary-pill"><%= length(@misc_editor.orphan_files) %></span>
|
<span class="misc-summary-pill"><%= length(@misc_editor.orphan_files) %></span>
|
||||||
<button
|
<button
|
||||||
class="secondary metadata-diff-action-button"
|
class="secondary metadata-diff-action-button ui-button ui-button-secondary"
|
||||||
data-testid="metadata-diff-import-button"
|
data-testid="metadata-diff-import-button"
|
||||||
type="button"
|
type="button"
|
||||||
phx-click="import_metadata_diff_orphans"
|
phx-click="import_metadata_diff_orphans"
|
||||||
|
phx-target={@myself}
|
||||||
>
|
>
|
||||||
<%= translated("Import") %>
|
<%= dgettext("ui", "Import") %>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -160,13 +189,13 @@
|
|||||||
<header class="diff-item-header">
|
<header class="diff-item-header">
|
||||||
<div>
|
<div>
|
||||||
<strong><%= orphan.slug %></strong>
|
<strong><%= orphan.slug %></strong>
|
||||||
<div class="diff-item-meta"><%= translated("Orphan Files") %></div>
|
<div class="diff-item-meta"><%= dgettext("ui", "Orphan Files") %></div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="diff-item-fields">
|
<div class="diff-item-fields">
|
||||||
<div class="diff-field-row">
|
<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-values">
|
||||||
<div class="diff-field-value file-value orphan-path"><span><%= orphan.file_path %></span></div>
|
<div class="diff-field-value file-value orphan-path"><span><%= orphan.file_path %></span></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -187,30 +216,30 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="translation-validation-section">
|
<section class="translation-validation-section">
|
||||||
<h3><%= translated("translationValidation.databaseTitle") %></h3>
|
<h3><%= dgettext("ui", "translationValidation.databaseTitle") %></h3>
|
||||||
|
|
||||||
<%= if @misc_editor.invalid_database_rows == [] do %>
|
<%= 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 %>
|
<% else %>
|
||||||
<div class="translation-validation-list">
|
<div class="translation-validation-list">
|
||||||
<%= for issue <- @misc_editor.invalid_database_rows do %>
|
<%= for issue <- @misc_editor.invalid_database_rows do %>
|
||||||
<article class="translation-validation-card translation-validation-card-db" data-testid="translation-validation-card">
|
<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>
|
<p class="translation-validation-card-title"><%= translation_issue_label(issue) %></p>
|
||||||
<dl class="translation-validation-card-meta">
|
<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>
|
<dd><%= translation_issue_value(issue, :translation_for) %></dd>
|
||||||
<%= if translation_issue_value(issue, :translation_id) do %>
|
<%= 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>
|
<dd><%= translation_issue_value(issue, :translation_id) %></dd>
|
||||||
<% end %>
|
<% end %>
|
||||||
<%= if translation_issue_value(issue, :title) do %>
|
<%= 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>
|
<dd><%= translation_issue_value(issue, :title) %></dd>
|
||||||
<% end %>
|
<% end %>
|
||||||
<dt><%= translated("translationValidation.field.languages") %></dt>
|
<dt><%= dgettext("ui", "translationValidation.field.languages") %></dt>
|
||||||
<dd><%= translation_issue_languages(issue) %></dd>
|
<dd><%= translation_issue_languages(issue) %></dd>
|
||||||
<%= if translation_issue_value(issue, :file_path) do %>
|
<%= 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>
|
<dd><%= translation_issue_value(issue, :file_path) %></dd>
|
||||||
<% end %>
|
<% end %>
|
||||||
</dl>
|
</dl>
|
||||||
@@ -221,26 +250,26 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="translation-validation-section">
|
<section class="translation-validation-section">
|
||||||
<h3><%= translated("translationValidation.filesystemTitle") %></h3>
|
<h3><%= dgettext("ui", "translationValidation.filesystemTitle") %></h3>
|
||||||
|
|
||||||
<%= if @misc_editor.invalid_filesystem_files == [] do %>
|
<%= 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 %>
|
<% else %>
|
||||||
<div class="translation-validation-list">
|
<div class="translation-validation-list">
|
||||||
<%= for issue <- @misc_editor.invalid_filesystem_files do %>
|
<%= for issue <- @misc_editor.invalid_filesystem_files do %>
|
||||||
<article class="translation-validation-card translation-validation-card-file" data-testid="translation-validation-card">
|
<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>
|
<p class="translation-validation-card-title"><%= translation_issue_label(issue) %></p>
|
||||||
<dl class="translation-validation-card-meta">
|
<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>
|
<dd><%= translation_issue_value(issue, :translation_for) %></dd>
|
||||||
<%= if translation_issue_value(issue, :title) do %>
|
<%= 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>
|
<dd><%= translation_issue_value(issue, :title) %></dd>
|
||||||
<% end %>
|
<% end %>
|
||||||
<dt><%= translated("translationValidation.field.languages") %></dt>
|
<dt><%= dgettext("ui", "translationValidation.field.languages") %></dt>
|
||||||
<dd><%= translation_issue_languages(issue) %></dd>
|
<dd><%= translation_issue_languages(issue) %></dd>
|
||||||
<%= if translation_issue_value(issue, :file_path) do %>
|
<%= 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>
|
<dd><%= translation_issue_value(issue, :file_path) %></dd>
|
||||||
<% end %>
|
<% end %>
|
||||||
</dl>
|
</dl>
|
||||||
@@ -251,8 +280,8 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="translation-validation-actions">
|
<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="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" type="button" phx-click="fix_translation_validation" data-testid="translation-validation-fix" disabled={not @misc_editor.can_fix?}><%= translated("translationValidation.fix") %></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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -260,12 +289,12 @@
|
|||||||
<div class="misc-list">
|
<div class="misc-list">
|
||||||
<%= for pair <- @misc_editor.pairs do %>
|
<%= for pair <- @misc_editor.pairs do %>
|
||||||
<article class="misc-list-item duplicate-pair-row">
|
<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>
|
<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-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>
|
<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>
|
<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>
|
<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: translated("Exact Match"), else: "#{Float.round((BDS.MapUtils.attr(pair, :similarity) || 0.0) * 100, 1)}%") %></span>
|
<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" 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="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>
|
</article>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
@@ -275,9 +304,9 @@
|
|||||||
<%= if @misc_editor.files == [] do %>
|
<%= if @misc_editor.files == [] do %>
|
||||||
<p class="git-diff-empty"><%= @misc_editor.empty_message %></p>
|
<p class="git-diff-empty"><%= @misc_editor.empty_message %></p>
|
||||||
<% else %>
|
<% else %>
|
||||||
<form class="git-diff-toolbar" phx-change="select_git_diff_file">
|
<form class="git-diff-toolbar" phx-change="select_git_diff_file" phx-target={@myself}>
|
||||||
<label for="git-diff-file-select"><%= translated("gitDiff.changedFiles") %></label>
|
<label for="git-diff-file-select"><%= dgettext("ui", "gitDiff.changedFiles") %></label>
|
||||||
<select id="git-diff-file-select" data-testid="git-diff-file-select" name="path">
|
<select class="ui-input" id="git-diff-file-select" data-testid="git-diff-file-select" name="path">
|
||||||
<%= for file_path <- @misc_editor.files do %>
|
<%= for file_path <- @misc_editor.files do %>
|
||||||
<option value={file_path} selected={file_path == @misc_editor.selected_file_path}><%= file_path %></option>
|
<option value={file_path} selected={file_path == @misc_editor.selected_file_path}><%= file_path %></option>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
70
lib/bds/desktop/shell_live/notify.ex
Normal file
70
lib/bds/desktop/shell_live/notify.ex
Normal 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
|
||||||
@@ -2,10 +2,10 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
|
|||||||
@moduledoc false
|
@moduledoc false
|
||||||
|
|
||||||
use Phoenix.Component
|
use Phoenix.Component
|
||||||
|
use Gettext, backend: BDS.Gettext
|
||||||
|
|
||||||
import Ecto.Query
|
import Ecto.Query
|
||||||
|
|
||||||
alias BDS.Desktop.ShellData
|
|
||||||
alias BDS.{I18n, Media, Metadata, Posts, Repo}
|
alias BDS.{I18n, Media, Metadata, Posts, Repo}
|
||||||
alias BDS.Media.Media, as: MediaRecord
|
alias BDS.Media.Media, as: MediaRecord
|
||||||
alias BDS.Media.Translation, as: MediaTranslation
|
alias BDS.Media.Translation, as: MediaTranslation
|
||||||
@@ -38,10 +38,10 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
|
|||||||
language_names: language_names(),
|
language_names: language_names(),
|
||||||
language_flags: language_flags(),
|
language_flags: language_flags(),
|
||||||
existing_translations: existing_translations(current_tab),
|
existing_translations: existing_translations(current_tab),
|
||||||
ai_title: ShellData.translate("AI Suggestions", %{}, page_language),
|
ai_title: BDS.Gettext.lgettext(page_language, "ui", "AI Suggestions"),
|
||||||
insert_link_title: ShellData.translate("Insert Link", %{}, page_language),
|
insert_link_title: BDS.Gettext.lgettext(page_language, "ui", "Insert Link"),
|
||||||
insert_media_title: ShellData.translate("Insert Media", %{}, page_language),
|
insert_media_title: BDS.Gettext.lgettext(page_language, "ui", "Insert Media"),
|
||||||
language_picker_title: ShellData.translate("Translate", %{}, page_language),
|
language_picker_title: BDS.Gettext.lgettext(page_language, "ui", "Translate"),
|
||||||
gallery_title: tab_title,
|
gallery_title: tab_title,
|
||||||
ai_fields: ai_fields(current_tab, tab_title, tab_subtitle, page_language),
|
ai_fields: ai_fields(current_tab, tab_title, tab_subtitle, page_language),
|
||||||
delete_details: delete_details(current_tab, 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 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(nil), do: %{main_language: "en", blog_languages: []}
|
||||||
|
|
||||||
def project_metadata(project_id) do
|
def project_metadata(project_id) do
|
||||||
@@ -217,24 +214,27 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
|
|||||||
[
|
[
|
||||||
%{
|
%{
|
||||||
key: "title",
|
key: "title",
|
||||||
label: ShellData.translate("Title", %{}, page_language),
|
label: BDS.Gettext.lgettext(page_language, "ui", "Title"),
|
||||||
current_value: post.title || title,
|
current_value: post.title || title,
|
||||||
suggested_value: refine_title(post.title || title),
|
suggested_value: "",
|
||||||
locked: false
|
locked: false,
|
||||||
|
loading: true
|
||||||
},
|
},
|
||||||
%{
|
%{
|
||||||
key: "excerpt",
|
key: "excerpt",
|
||||||
label: ShellData.translate("Excerpt", %{}, page_language),
|
label: BDS.Gettext.lgettext(page_language, "ui", "Excerpt"),
|
||||||
current_value: post.excerpt || subtitle,
|
current_value: post.excerpt || subtitle,
|
||||||
suggested_value: refine_excerpt(post.title || title, post.excerpt || subtitle),
|
suggested_value: "",
|
||||||
locked: false
|
locked: false,
|
||||||
|
loading: true
|
||||||
},
|
},
|
||||||
%{
|
%{
|
||||||
key: "slug",
|
key: "slug",
|
||||||
label: ShellData.translate("Slug", %{}, page_language),
|
label: BDS.Gettext.lgettext(page_language, "ui", "Slug"),
|
||||||
current_value: post.slug || slugify(post.title || title),
|
current_value: post.slug || slugify(post.title || title),
|
||||||
suggested_value: refine_slug(post.slug || slugify(post.title || title)),
|
suggested_value: "",
|
||||||
locked: post.status == :published
|
locked: post.status == :published,
|
||||||
|
loading: true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -251,24 +251,27 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
|
|||||||
[
|
[
|
||||||
%{
|
%{
|
||||||
key: "title",
|
key: "title",
|
||||||
label: ShellData.translate("Title", %{}, page_language),
|
label: BDS.Gettext.lgettext(page_language, "ui", "Title"),
|
||||||
current_value: media.title || title,
|
current_value: media.title || title,
|
||||||
suggested_value: refine_title(media.title || title),
|
suggested_value: "",
|
||||||
locked: false
|
locked: false,
|
||||||
|
loading: true
|
||||||
},
|
},
|
||||||
%{
|
%{
|
||||||
key: "alt",
|
key: "alt",
|
||||||
label: ShellData.translate("Alt Text", %{}, page_language),
|
label: BDS.Gettext.lgettext(page_language, "ui", "Alt Text"),
|
||||||
current_value: media.alt || "",
|
current_value: media.alt || "",
|
||||||
suggested_value: media.alt || title,
|
suggested_value: "",
|
||||||
locked: false
|
locked: false,
|
||||||
|
loading: true
|
||||||
},
|
},
|
||||||
%{
|
%{
|
||||||
key: "caption",
|
key: "caption",
|
||||||
label: ShellData.translate("Caption", %{}, page_language),
|
label: BDS.Gettext.lgettext(page_language, "ui", "Caption"),
|
||||||
current_value: media.caption || "",
|
current_value: media.caption || "",
|
||||||
suggested_value: refine_excerpt(title, media.caption || title),
|
suggested_value: "",
|
||||||
locked: false
|
locked: false,
|
||||||
|
loading: true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -300,7 +303,7 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
|
|||||||
|> Enum.map(&(&1 || media_id))
|
|> 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_name: entity_name,
|
||||||
entity_type: "media",
|
entity_type: "media",
|
||||||
reference_list: reference_list
|
reference_list: reference_list
|
||||||
@@ -308,7 +311,7 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
|
|||||||
rescue
|
rescue
|
||||||
_error ->
|
_error ->
|
||||||
%{
|
%{
|
||||||
title: ShellData.translate("Delete Media", %{}, page_language),
|
title: BDS.Gettext.lgettext(page_language, "ui", "Delete Media"),
|
||||||
entity_name: media_id,
|
entity_name: media_id,
|
||||||
entity_type: "media",
|
entity_type: "media",
|
||||||
reference_list: []
|
reference_list: []
|
||||||
@@ -321,7 +324,7 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
|
|||||||
|> Kernel.||("tag")
|
|> Kernel.||("tag")
|
||||||
|
|
||||||
%{
|
%{
|
||||||
title: ShellData.translate("Delete Tag", %{}, page_language),
|
title: BDS.Gettext.lgettext(page_language, "ui", "Delete Tag"),
|
||||||
entity_name: tag_name,
|
entity_name: tag_name,
|
||||||
entity_type: "tag",
|
entity_type: "tag",
|
||||||
reference_list: []
|
reference_list: []
|
||||||
@@ -329,7 +332,7 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
|
|||||||
rescue
|
rescue
|
||||||
_error ->
|
_error ->
|
||||||
%{
|
%{
|
||||||
title: ShellData.translate("Delete Tag", %{}, page_language),
|
title: BDS.Gettext.lgettext(page_language, "ui", "Delete Tag"),
|
||||||
entity_name: "tag",
|
entity_name: "tag",
|
||||||
entity_type: "tag",
|
entity_type: "tag",
|
||||||
reference_list: []
|
reference_list: []
|
||||||
@@ -338,7 +341,7 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
|
|||||||
|
|
||||||
defp delete_details(_tab, page_language) 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_name: "",
|
||||||
entity_type: "item",
|
entity_type: "item",
|
||||||
reference_list: []
|
reference_list: []
|
||||||
@@ -360,16 +363,16 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
|
|||||||
%{
|
%{
|
||||||
target: target,
|
target: target,
|
||||||
count: max(length(tags), 1),
|
count: max(length(tags), 1),
|
||||||
title: ShellData.translate("Merge Tags", %{}, page_language),
|
title: BDS.Gettext.lgettext(page_language, "ui", "Merge Tags"),
|
||||||
message: ShellData.translate("Cannot be undone.", %{}, page_language)
|
message: BDS.Gettext.lgettext(page_language, "ui", "Cannot be undone.")
|
||||||
}
|
}
|
||||||
rescue
|
rescue
|
||||||
_error ->
|
_error ->
|
||||||
%{
|
%{
|
||||||
target: "tag",
|
target: "tag",
|
||||||
count: 1,
|
count: 1,
|
||||||
title: ShellData.translate("Merge Tags", %{}, page_language),
|
title: BDS.Gettext.lgettext(page_language, "ui", "Merge Tags"),
|
||||||
message: ShellData.translate("Cannot be undone.", %{}, page_language)
|
message: BDS.Gettext.lgettext(page_language, "ui", "Cannot be undone.")
|
||||||
}
|
}
|
||||||
end
|
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 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
|
defp slugify(value) do
|
||||||
value
|
value
|
||||||
|> to_string()
|
|> to_string()
|
||||||
|
|||||||
@@ -2,13 +2,19 @@
|
|||||||
<%= case @shell_overlay.kind do %>
|
<%= case @shell_overlay.kind do %>
|
||||||
<% :ai_suggestions -> %>
|
<% :ai_suggestions -> %>
|
||||||
<div class="shell-overlay-backdrop ai-suggestions-modal-backdrop" data-testid="shell-overlay-backdrop" phx-window-keydown="overlay_keydown">
|
<div class="shell-overlay-backdrop ai-suggestions-modal-backdrop" data-testid="shell-overlay-backdrop" phx-window-keydown="overlay_keydown">
|
||||||
<button class="shell-overlay-dismiss" type="button" phx-click="close_overlay" aria-label={translated("Cancel")}></button>
|
<button class="shell-overlay-dismiss" type="button" phx-click="close_overlay" aria-label={dgettext("ui", "Cancel")}></button>
|
||||||
<div class="ai-suggestions-modal" role="dialog" aria-modal="true">
|
<div class="ai-suggestions-modal" role="dialog" aria-modal="true">
|
||||||
<div class="ai-suggestions-modal-header">
|
<div class="ai-suggestions-modal-header">
|
||||||
<h2><%= @shell_overlay.title %></h2>
|
<h2><%= @shell_overlay.title %></h2>
|
||||||
<button class="ai-suggestions-modal-close" type="button" phx-click="close_overlay">×</button>
|
<button class="ai-suggestions-modal-close" type="button" phx-click="close_overlay">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="ai-suggestions-modal-body">
|
<div class="ai-suggestions-modal-body">
|
||||||
|
<%= if Map.get(@shell_overlay, :error) do %>
|
||||||
|
<div class="ai-suggestions-error">
|
||||||
|
<strong><%= dgettext("ui", "Error") %></strong>
|
||||||
|
<span><%= @shell_overlay.error %></span>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
<div class="ai-suggestions-list">
|
<div class="ai-suggestions-list">
|
||||||
<%= for field <- @shell_overlay.fields do %>
|
<%= for field <- @shell_overlay.fields do %>
|
||||||
<div class="ai-suggestion-item">
|
<div class="ai-suggestion-item">
|
||||||
@@ -16,7 +22,7 @@
|
|||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={field.accepted}
|
checked={field.accepted}
|
||||||
disabled={field.locked}
|
disabled={field.locked or field.loading}
|
||||||
phx-click="overlay_toggle_ai_field"
|
phx-click="overlay_toggle_ai_field"
|
||||||
phx-value-key={field.key}
|
phx-value-key={field.key}
|
||||||
/>
|
/>
|
||||||
@@ -24,35 +30,36 @@
|
|||||||
</label>
|
</label>
|
||||||
<div class="ai-suggestion-content">
|
<div class="ai-suggestion-content">
|
||||||
<div class="ai-suggestion-label"><%= field.label %></div>
|
<div class="ai-suggestion-label"><%= field.label %></div>
|
||||||
<div class="ai-suggestion-current"><%= field.current_value %></div>
|
<div class={["ai-suggestion-value", if(field.loading, do: "loading")]}>
|
||||||
<div class="ai-suggestion-value"><%= field.suggested_value %></div>
|
<%= if field.loading, do: "Loading…", else: field.suggested_value %>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="ai-suggestions-modal-footer">
|
<div class="ai-suggestions-modal-footer">
|
||||||
<button class="button-cancel" type="button" phx-click="close_overlay"><%= translated("Cancel") %></button>
|
<button class="button-cancel" type="button" phx-click="close_overlay"><%= dgettext("ui", "Cancel") %></button>
|
||||||
<button class="button-apply" type="button" phx-click="overlay_confirm"><%= translated("Apply Selected") %></button>
|
<button class="button-apply" type="button" phx-click="overlay_confirm"><%= dgettext("ui", "Apply Selected") %></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<% :insert_link -> %>
|
<% :insert_link -> %>
|
||||||
<div class="shell-overlay-backdrop insert-modal-backdrop" data-testid="shell-overlay-backdrop" phx-window-keydown="overlay_keydown">
|
<div class="shell-overlay-backdrop insert-modal-backdrop" data-testid="shell-overlay-backdrop" phx-window-keydown="overlay_keydown">
|
||||||
<button class="shell-overlay-dismiss" type="button" phx-click="close_overlay" aria-label={translated("Cancel")}></button>
|
<button class="shell-overlay-dismiss" type="button" phx-click="close_overlay" aria-label={dgettext("ui", "Cancel")}></button>
|
||||||
<div class="insert-modal" role="dialog" aria-modal="true">
|
<div class="insert-modal" role="dialog" aria-modal="true">
|
||||||
<div class="insert-modal-header">
|
<div class="insert-modal-header">
|
||||||
<h2 class="insert-modal-title"><%= @shell_overlay.title %></h2>
|
<h2 class="insert-modal-title"><%= @shell_overlay.title %></h2>
|
||||||
<div class="insert-modal-tabs">
|
<div class="insert-modal-tabs">
|
||||||
<button class={["insert-modal-tab", if(@shell_overlay.active_tab == :internal, do: "active")]} type="button" phx-click="overlay_set_tab" phx-value-tab="internal"><%= translated("Internal") %></button>
|
<button class={["insert-modal-tab", if(@shell_overlay.active_tab == :internal, do: "active")]} type="button" phx-click="overlay_set_tab" phx-value-tab="internal"><%= dgettext("ui", "Internal") %></button>
|
||||||
<button class={["insert-modal-tab", if(@shell_overlay.active_tab == :external, do: "active")]} type="button" phx-click="overlay_set_tab" phx-value-tab="external"><%= translated("External") %></button>
|
<button class={["insert-modal-tab", if(@shell_overlay.active_tab == :external, do: "active")]} type="button" phx-click="overlay_set_tab" phx-value-tab="external"><%= dgettext("ui", "External") %></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= if @shell_overlay.active_tab == :internal do %>
|
<%= if @shell_overlay.active_tab == :internal do %>
|
||||||
<form class="insert-modal-search" phx-change="overlay_set_search">
|
<form class="insert-modal-search" phx-change="overlay_set_search">
|
||||||
<input class="insert-modal-input" type="text" name="overlay[query]" value={@shell_overlay.search_query} placeholder={translated("sidebar.searchPostsPlaceholder")} />
|
<input class="insert-modal-input" type="text" name="overlay[query]" value={@shell_overlay.search_query} placeholder={dgettext("ui", "Search posts...")} />
|
||||||
</form>
|
</form>
|
||||||
<div class="insert-modal-results">
|
<div class="insert-modal-results">
|
||||||
<%= for result <- if(String.length(@shell_overlay.search_query) >= 2, do: @shell_overlay.results, else: @shell_overlay.related_posts) do %>
|
<%= for result <- if(String.length(@shell_overlay.search_query) >= 2, do: @shell_overlay.results, else: @shell_overlay.related_posts) do %>
|
||||||
@@ -62,20 +69,20 @@
|
|||||||
</button>
|
</button>
|
||||||
<% end %>
|
<% end %>
|
||||||
<%= if Enum.empty?(if(String.length(@shell_overlay.search_query) >= 2, do: @shell_overlay.results, else: @shell_overlay.related_posts)) do %>
|
<%= if Enum.empty?(if(String.length(@shell_overlay.search_query) >= 2, do: @shell_overlay.results, else: @shell_overlay.related_posts)) do %>
|
||||||
<div class="insert-modal-status"><%= translated("No items") %></div>
|
<div class="insert-modal-status"><%= dgettext("ui", "No items") %></div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% else %>
|
<% else %>
|
||||||
<form class="insert-modal-external" phx-change="overlay_update_form">
|
<form class="insert-modal-external" phx-change="overlay_update_form">
|
||||||
<label class="insert-modal-field">
|
<label class="insert-modal-field">
|
||||||
<span class="insert-modal-label"><%= translated("URL") %></span>
|
<span class="insert-modal-label"><%= dgettext("ui", "URL") %></span>
|
||||||
<input class="insert-modal-input" type="text" name="overlay[url]" value={@shell_overlay.external_url} />
|
<input class="insert-modal-input" type="text" name="overlay[url]" value={@shell_overlay.external_url} />
|
||||||
</label>
|
</label>
|
||||||
<label class="insert-modal-field">
|
<label class="insert-modal-field">
|
||||||
<span class="insert-modal-label"><%= translated("Display Text") %></span>
|
<span class="insert-modal-label"><%= dgettext("ui", "Display Text") %></span>
|
||||||
<input class="insert-modal-input" type="text" name="overlay[text]" value={@shell_overlay.external_text} />
|
<input class="insert-modal-input" type="text" name="overlay[text]" value={@shell_overlay.external_text} />
|
||||||
</label>
|
</label>
|
||||||
<button class="insert-modal-submit" type="button" phx-click="overlay_insert_external"><%= translated("Insert") %></button>
|
<button class="insert-modal-submit" type="button" phx-click="overlay_insert_external"><%= dgettext("ui", "Insert") %></button>
|
||||||
</form>
|
</form>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
@@ -83,13 +90,13 @@
|
|||||||
|
|
||||||
<% :insert_media -> %>
|
<% :insert_media -> %>
|
||||||
<div class="shell-overlay-backdrop insert-modal-backdrop" data-testid="shell-overlay-backdrop" phx-window-keydown="overlay_keydown">
|
<div class="shell-overlay-backdrop insert-modal-backdrop" data-testid="shell-overlay-backdrop" phx-window-keydown="overlay_keydown">
|
||||||
<button class="shell-overlay-dismiss" type="button" phx-click="close_overlay" aria-label={translated("Cancel")}></button>
|
<button class="shell-overlay-dismiss" type="button" phx-click="close_overlay" aria-label={dgettext("ui", "Cancel")}></button>
|
||||||
<div class="insert-modal" role="dialog" aria-modal="true">
|
<div class="insert-modal" role="dialog" aria-modal="true">
|
||||||
<div class="insert-modal-header">
|
<div class="insert-modal-header">
|
||||||
<h2 class="insert-modal-title"><%= @shell_overlay.title %></h2>
|
<h2 class="insert-modal-title"><%= @shell_overlay.title %></h2>
|
||||||
</div>
|
</div>
|
||||||
<form class="insert-modal-search" phx-change="overlay_set_search">
|
<form class="insert-modal-search" phx-change="overlay_set_search">
|
||||||
<input class="insert-modal-input" type="text" name="overlay[query]" value={@shell_overlay.search_query} placeholder={translated("sidebar.searchMediaPlaceholder")} />
|
<input class="insert-modal-input" type="text" name="overlay[query]" value={@shell_overlay.search_query} placeholder={dgettext("ui", "Search media...")} />
|
||||||
</form>
|
</form>
|
||||||
<div class="insert-modal-results insert-modal-media-grid">
|
<div class="insert-modal-results insert-modal-media-grid">
|
||||||
<%= for result <- @shell_overlay.results do %>
|
<%= for result <- @shell_overlay.results do %>
|
||||||
@@ -108,14 +115,14 @@
|
|||||||
|
|
||||||
<% :language_picker -> %>
|
<% :language_picker -> %>
|
||||||
<div class="shell-overlay-backdrop language-picker-modal-backdrop" data-testid="shell-overlay-backdrop" phx-window-keydown="overlay_keydown">
|
<div class="shell-overlay-backdrop language-picker-modal-backdrop" data-testid="shell-overlay-backdrop" phx-window-keydown="overlay_keydown">
|
||||||
<button class="shell-overlay-dismiss" type="button" phx-click="close_overlay" aria-label={translated("Cancel")}></button>
|
<button class="shell-overlay-dismiss" type="button" phx-click="close_overlay" aria-label={dgettext("ui", "Cancel")}></button>
|
||||||
<div class="language-picker-modal" role="dialog" aria-modal="true">
|
<div class="language-picker-modal" role="dialog" aria-modal="true">
|
||||||
<div class="language-picker-modal-header">
|
<div class="language-picker-modal-header">
|
||||||
<h2><%= @shell_overlay.title %></h2>
|
<h2><%= @shell_overlay.title %></h2>
|
||||||
<button class="language-picker-modal-close" type="button" phx-click="close_overlay">×</button>
|
<button class="language-picker-modal-close" type="button" phx-click="close_overlay">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="language-picker-modal-body">
|
<div class="language-picker-modal-body">
|
||||||
<div class="language-picker-label"><%= translated("Available languages") %></div>
|
<div class="language-picker-label"><%= dgettext("ui", "Available languages") %></div>
|
||||||
<div class="language-picker-options">
|
<div class="language-picker-options">
|
||||||
<%= for target <- @shell_overlay.available_targets do %>
|
<%= for target <- @shell_overlay.available_targets do %>
|
||||||
<button class="language-picker-option" type="button" phx-click="overlay_select_language" phx-value-code={target.code}>
|
<button class="language-picker-option" type="button" phx-click="overlay_select_language" phx-value-code={target.code}>
|
||||||
@@ -133,7 +140,7 @@
|
|||||||
|
|
||||||
<% :confirm_delete -> %>
|
<% :confirm_delete -> %>
|
||||||
<div class="shell-overlay-backdrop confirm-delete-modal-backdrop" data-testid="shell-overlay-backdrop" phx-window-keydown="overlay_keydown">
|
<div class="shell-overlay-backdrop confirm-delete-modal-backdrop" data-testid="shell-overlay-backdrop" phx-window-keydown="overlay_keydown">
|
||||||
<button class="shell-overlay-dismiss" type="button" phx-click="close_overlay" aria-label={translated("Cancel")}></button>
|
<button class="shell-overlay-dismiss" type="button" phx-click="close_overlay" aria-label={dgettext("ui", "Cancel")}></button>
|
||||||
<div class="confirm-delete-modal" role="dialog" aria-modal="true">
|
<div class="confirm-delete-modal" role="dialog" aria-modal="true">
|
||||||
<div class="confirm-delete-modal-header">
|
<div class="confirm-delete-modal-header">
|
||||||
<h2><%= @shell_overlay.title %></h2>
|
<h2><%= @shell_overlay.title %></h2>
|
||||||
@@ -144,7 +151,7 @@
|
|||||||
<%= if @shell_overlay.reference_count > 0 do %>
|
<%= if @shell_overlay.reference_count > 0 do %>
|
||||||
<div class="confirm-delete-warning">
|
<div class="confirm-delete-warning">
|
||||||
<div class="warning-content">
|
<div class="warning-content">
|
||||||
<strong><%= translated("This item is referenced by:") %></strong>
|
<strong><%= dgettext("ui", "This item is referenced by:") %></strong>
|
||||||
<ul class="reference-list">
|
<ul class="reference-list">
|
||||||
<%= for title <- @shell_overlay.reference_list do %>
|
<%= for title <- @shell_overlay.reference_list do %>
|
||||||
<li><span class="reference-title"><%= title %></span></li>
|
<li><span class="reference-title"><%= title %></span></li>
|
||||||
@@ -155,15 +162,15 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<div class="confirm-delete-modal-footer">
|
<div class="confirm-delete-modal-footer">
|
||||||
<button class="button-cancel" type="button" phx-click="close_overlay"><%= translated("Cancel") %></button>
|
<button class="button-cancel" type="button" phx-click="close_overlay"><%= dgettext("ui", "Cancel") %></button>
|
||||||
<button class="button-delete" type="button" phx-click="overlay_confirm"><%= translated("Delete") %></button>
|
<button class="button-delete" type="button" phx-click="overlay_confirm"><%= dgettext("ui", "Delete") %></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<% :confirm_dialog -> %>
|
<% :confirm_dialog -> %>
|
||||||
<div class="shell-overlay-backdrop confirm-delete-modal-backdrop" data-testid="shell-overlay-backdrop" phx-window-keydown="overlay_keydown">
|
<div class="shell-overlay-backdrop confirm-delete-modal-backdrop" data-testid="shell-overlay-backdrop" phx-window-keydown="overlay_keydown">
|
||||||
<button class="shell-overlay-dismiss" type="button" phx-click="close_overlay" aria-label={translated("Cancel")}></button>
|
<button class="shell-overlay-dismiss" type="button" phx-click="close_overlay" aria-label={dgettext("ui", "Cancel")}></button>
|
||||||
<div class="confirm-delete-modal" role="dialog" aria-modal="true">
|
<div class="confirm-delete-modal" role="dialog" aria-modal="true">
|
||||||
<div class="confirm-delete-modal-header">
|
<div class="confirm-delete-modal-header">
|
||||||
<h2><%= @shell_overlay.title %></h2>
|
<h2><%= @shell_overlay.title %></h2>
|
||||||
@@ -173,18 +180,18 @@
|
|||||||
<div class="confirm-delete-message"><%= @shell_overlay.message %></div>
|
<div class="confirm-delete-message"><%= @shell_overlay.message %></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="confirm-delete-modal-footer">
|
<div class="confirm-delete-modal-footer">
|
||||||
<button class="button-cancel" type="button" phx-click="close_overlay"><%= translated("Cancel") %></button>
|
<button class="button-cancel" type="button" phx-click="close_overlay"><%= dgettext("ui", "Cancel") %></button>
|
||||||
<button class="button-apply" type="button" phx-click="overlay_confirm"><%= translated("Confirm") %></button>
|
<button class="button-apply" type="button" phx-click="overlay_confirm"><%= dgettext("ui", "Confirm") %></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<% :gallery -> %>
|
<% :gallery -> %>
|
||||||
<div class="shell-overlay-backdrop gallery-overlay-backdrop" data-testid="shell-overlay-backdrop" phx-window-keydown="overlay_keydown">
|
<div class="shell-overlay-backdrop gallery-overlay-backdrop" data-testid="shell-overlay-backdrop" phx-window-keydown="overlay_keydown">
|
||||||
<button class="shell-overlay-dismiss" type="button" phx-click="close_overlay" aria-label={translated("Cancel")}></button>
|
<button class="shell-overlay-dismiss" type="button" phx-click="close_overlay" aria-label={dgettext("ui", "Cancel")}></button>
|
||||||
<div class="gallery-overlay" role="dialog" aria-modal="true">
|
<div class="gallery-overlay" role="dialog" aria-modal="true">
|
||||||
<div class="gallery-overlay-header">
|
<div class="gallery-overlay-header">
|
||||||
<h2><%= translated("Gallery") %></h2>
|
<h2><%= dgettext("ui", "Gallery") %></h2>
|
||||||
<button class="gallery-overlay-close" type="button" phx-click="close_overlay">×</button>
|
<button class="gallery-overlay-close" type="button" phx-click="close_overlay">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="gallery-overlay-grid">
|
<div class="gallery-overlay-grid">
|
||||||
@@ -198,7 +205,7 @@
|
|||||||
|
|
||||||
<%= if @shell_overlay.lightbox do %>
|
<%= if @shell_overlay.lightbox do %>
|
||||||
<div class="lightbox-overlay">
|
<div class="lightbox-overlay">
|
||||||
<button class="shell-overlay-dismiss" type="button" phx-click="overlay_close_lightbox" aria-label={translated("Cancel")}></button>
|
<button class="shell-overlay-dismiss" type="button" phx-click="overlay_close_lightbox" aria-label={dgettext("ui", "Cancel")}></button>
|
||||||
<div class="lightbox-container">
|
<div class="lightbox-container">
|
||||||
<button class="lightbox-close" type="button" phx-click="overlay_close_lightbox">×</button>
|
<button class="lightbox-close" type="button" phx-click="overlay_close_lightbox">×</button>
|
||||||
<%= if @shell_overlay.lightbox.total_count > 1 do %>
|
<%= if @shell_overlay.lightbox.total_count > 1 do %>
|
||||||
|
|||||||
454
lib/bds/desktop/shell_live/overlay_manager.ex
Normal file
454
lib/bds/desktop/shell_live/overlay_manager.ex
Normal file
@@ -0,0 +1,454 @@
|
|||||||
|
defmodule BDS.Desktop.ShellLive.OverlayManager do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
import Phoenix.Component, only: [assign: 3]
|
||||||
|
import Phoenix.LiveView, only: [send_update: 2]
|
||||||
|
|
||||||
|
alias BDS.{AI, Media, Metadata}
|
||||||
|
alias BDS.Desktop.{Overlay}
|
||||||
|
|
||||||
|
alias BDS.Desktop.ShellLive.{
|
||||||
|
MediaEditor,
|
||||||
|
Notify,
|
||||||
|
PostEditor,
|
||||||
|
TabHelpers
|
||||||
|
}
|
||||||
|
|
||||||
|
alias BDS.Desktop.ShellLive.OverlayComponents, as: ShellOverlayComponents
|
||||||
|
use Gettext, backend: BDS.Gettext
|
||||||
|
|
||||||
|
# ── Event handlers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@spec handle_event(String.t(), map(), Phoenix.LiveView.Socket.t(), map()) ::
|
||||||
|
{:noreply, Phoenix.LiveView.Socket.t()}
|
||||||
|
def handle_event("open_overlay", %{"kind" => kind}, socket, callbacks) do
|
||||||
|
socket =
|
||||||
|
case socket.assigns[:current_tab] do
|
||||||
|
%{type: :post, id: post_id}
|
||||||
|
when kind in ["ai_suggestions", "language_picker"] ->
|
||||||
|
send_update(PostEditor,
|
||||||
|
id: "post-editor-#{post_id}",
|
||||||
|
action: :close_quick_actions
|
||||||
|
)
|
||||||
|
|
||||||
|
socket
|
||||||
|
|
||||||
|
%{type: :media, id: media_id}
|
||||||
|
when kind in ["ai_suggestions", "language_picker", "confirm_delete"] ->
|
||||||
|
send_update(MediaEditor,
|
||||||
|
id: "media-editor-#{media_id}",
|
||||||
|
action: :close_quick_actions
|
||||||
|
)
|
||||||
|
|
||||||
|
socket
|
||||||
|
|
||||||
|
_other ->
|
||||||
|
socket
|
||||||
|
end
|
||||||
|
|
||||||
|
overlay =
|
||||||
|
with overlay_kind when not is_nil(overlay_kind) <- ShellOverlayComponents.kind(kind),
|
||||||
|
%{type: route} <- socket.assigns[:current_tab] do
|
||||||
|
tab = socket.assigns.current_tab
|
||||||
|
title = TabHelpers.tab_title(tab, socket.assigns.tab_meta)
|
||||||
|
subtitle = TabHelpers.tab_subtitle(tab, socket.assigns.tab_meta)
|
||||||
|
|
||||||
|
Overlay.open(
|
||||||
|
route,
|
||||||
|
overlay_kind,
|
||||||
|
ShellOverlayComponents.context(socket.assigns, title, subtitle)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
socket = assign(socket, :shell_overlay, overlay)
|
||||||
|
|
||||||
|
socket =
|
||||||
|
if kind == "ai_suggestions" and not is_nil(overlay) do
|
||||||
|
if socket.assigns.offline_mode do
|
||||||
|
callbacks.append_output.(
|
||||||
|
socket,
|
||||||
|
dgettext("ui", "AI Suggestions"),
|
||||||
|
dgettext("ui", "Automatic AI actions stay gated by airplane mode."),
|
||||||
|
nil,
|
||||||
|
"info"
|
||||||
|
)
|
||||||
|
|> assign(:shell_overlay, nil)
|
||||||
|
else
|
||||||
|
spawn_ai_suggestions_task(socket)
|
||||||
|
end
|
||||||
|
else
|
||||||
|
socket
|
||||||
|
end
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("close_overlay", _params, socket, _callbacks) do
|
||||||
|
{:noreply, assign(socket, :shell_overlay, nil)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("overlay_keydown", %{"key" => key}, socket, _callbacks) do
|
||||||
|
socket =
|
||||||
|
case {socket.assigns[:shell_overlay], key} do
|
||||||
|
{nil, _other} ->
|
||||||
|
socket
|
||||||
|
|
||||||
|
{_overlay, "Escape"} ->
|
||||||
|
assign(socket, :shell_overlay, nil)
|
||||||
|
|
||||||
|
{%{kind: :gallery} = overlay, "ArrowLeft"} ->
|
||||||
|
assign(socket, :shell_overlay, Overlay.lightbox_previous(overlay))
|
||||||
|
|
||||||
|
{%{kind: :gallery} = overlay, "ArrowRight"} ->
|
||||||
|
assign(socket, :shell_overlay, Overlay.lightbox_next(overlay))
|
||||||
|
|
||||||
|
_other ->
|
||||||
|
socket
|
||||||
|
end
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("overlay_toggle_ai_field", %{"key" => key}, socket, _callbacks) do
|
||||||
|
{:noreply, update_shell_overlay(socket, &Overlay.toggle_ai_field(&1, key))}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("overlay_set_search", %{"overlay" => %{"query" => query}}, socket, _callbacks) do
|
||||||
|
{:noreply, update_shell_overlay(socket, &Overlay.set_search_query(&1, query))}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("overlay_set_tab", %{"tab" => tab}, socket, _callbacks) do
|
||||||
|
{:noreply,
|
||||||
|
update_shell_overlay(socket, &Overlay.set_active_tab(&1, ShellOverlayComponents.tab(tab)))}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("overlay_update_form", %{"overlay" => params}, socket, _callbacks) do
|
||||||
|
socket =
|
||||||
|
socket
|
||||||
|
|> update_shell_overlay(
|
||||||
|
&Overlay.update_form_value(&1, :external_url, Map.get(params, "url", ""))
|
||||||
|
)
|
||||||
|
|> update_shell_overlay(
|
||||||
|
&Overlay.update_form_value(&1, :external_text, Map.get(params, "text", ""))
|
||||||
|
)
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("overlay_select_result", %{"id" => id}, socket, _callbacks) do
|
||||||
|
overlay = socket.assigns[:shell_overlay]
|
||||||
|
current_tab = socket.assigns[:current_tab]
|
||||||
|
|
||||||
|
socket =
|
||||||
|
case {overlay, current_tab} do
|
||||||
|
{%{kind: :insert_link}, %{type: :post, id: post_id}} ->
|
||||||
|
case Overlay.insert_link_result(overlay, id) do
|
||||||
|
nil ->
|
||||||
|
socket
|
||||||
|
|
||||||
|
result ->
|
||||||
|
send(
|
||||||
|
self(),
|
||||||
|
{:post_editor_insert_content, post_id,
|
||||||
|
ShellOverlayComponents.markdown_link(result.title, result.canonical_url)}
|
||||||
|
)
|
||||||
|
|
||||||
|
socket
|
||||||
|
end
|
||||||
|
|
||||||
|
{%{kind: :insert_media}, %{type: :post, id: post_id}} ->
|
||||||
|
case Overlay.insert_media_result(overlay, id) do
|
||||||
|
nil ->
|
||||||
|
socket
|
||||||
|
|
||||||
|
result ->
|
||||||
|
syntax =
|
||||||
|
if result.is_image do
|
||||||
|
""
|
||||||
|
else
|
||||||
|
"[#{result.original_name}](bds-media://#{result.media_id})"
|
||||||
|
end
|
||||||
|
|
||||||
|
Notify.parent({:post_editor_insert_content, post_id, syntax})
|
||||||
|
socket
|
||||||
|
end
|
||||||
|
|
||||||
|
_other ->
|
||||||
|
socket
|
||||||
|
end
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("overlay_insert_external", _params, socket, _callbacks) do
|
||||||
|
current_tab = socket.assigns[:current_tab]
|
||||||
|
|
||||||
|
socket =
|
||||||
|
case {socket.assigns[:shell_overlay], current_tab} do
|
||||||
|
{%{kind: :insert_link} = overlay, %{type: :post, id: post_id}} ->
|
||||||
|
details =
|
||||||
|
case {overlay.external_url, String.trim(overlay.external_text || "")} do
|
||||||
|
{"", _text} -> nil
|
||||||
|
{url, ""} -> url
|
||||||
|
{url, text} -> ShellOverlayComponents.markdown_link(text, url)
|
||||||
|
end
|
||||||
|
|
||||||
|
if details do
|
||||||
|
Notify.parent({:post_editor_insert_content, post_id, details})
|
||||||
|
end
|
||||||
|
|
||||||
|
socket
|
||||||
|
|
||||||
|
_other ->
|
||||||
|
socket
|
||||||
|
end
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("overlay_select_language", %{"code" => code}, socket, _callbacks) do
|
||||||
|
current_tab = socket.assigns[:current_tab]
|
||||||
|
|
||||||
|
socket =
|
||||||
|
case {socket.assigns[:shell_overlay], current_tab} do
|
||||||
|
{%{kind: :language_picker}, %{type: :post, id: post_id}} ->
|
||||||
|
Notify.parent({:post_editor_translate, post_id, code})
|
||||||
|
socket
|
||||||
|
|
||||||
|
{%{kind: :language_picker}, %{type: :media, id: media_id}} ->
|
||||||
|
send_update(MediaEditor,
|
||||||
|
id: "media-editor-#{media_id}",
|
||||||
|
action: :translate,
|
||||||
|
language: code
|
||||||
|
)
|
||||||
|
|
||||||
|
socket
|
||||||
|
|
||||||
|
_other ->
|
||||||
|
socket
|
||||||
|
end
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("overlay_confirm", _params, socket, callbacks) do
|
||||||
|
current_tab = socket.assigns[:current_tab]
|
||||||
|
|
||||||
|
socket =
|
||||||
|
case {socket.assigns[:shell_overlay], current_tab} do
|
||||||
|
{%{kind: :confirm_delete, delete_action: %{source: :sidebar, route: route, id: id}}, _tab} ->
|
||||||
|
callbacks.execute_sidebar_delete.(socket, route, id)
|
||||||
|
|
||||||
|
{%{kind: :ai_suggestions} = overlay, %{type: :post, id: post_id}} ->
|
||||||
|
send(
|
||||||
|
self(),
|
||||||
|
{:post_editor_apply_ai_suggestions, post_id, Overlay.selected_ai_fields(overlay)}
|
||||||
|
)
|
||||||
|
|
||||||
|
socket
|
||||||
|
|
||||||
|
{%{kind: :ai_suggestions} = overlay, %{type: :media, id: media_id}} ->
|
||||||
|
send_update(MediaEditor,
|
||||||
|
id: "media-editor-#{media_id}",
|
||||||
|
action: :apply_ai_suggestions,
|
||||||
|
fields: Overlay.selected_ai_fields(overlay)
|
||||||
|
)
|
||||||
|
|
||||||
|
socket
|
||||||
|
|
||||||
|
{%{kind: :confirm_delete}, %{type: :media, id: media_id}} ->
|
||||||
|
case Media.delete_media(media_id) do
|
||||||
|
{:ok, :deleted} ->
|
||||||
|
workbench = BDS.UI.Workbench.close_tab(socket.assigns.workbench, :media, media_id)
|
||||||
|
|
||||||
|
socket
|
||||||
|
|> assign(:shell_overlay, nil)
|
||||||
|
|> assign(
|
||||||
|
:tab_meta,
|
||||||
|
Map.delete(socket.assigns.tab_meta, {:media, media_id})
|
||||||
|
)
|
||||||
|
|> callbacks.reload.(workbench)
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
socket
|
||||||
|
|> assign(:shell_overlay, nil)
|
||||||
|
|> callbacks.append_output.(
|
||||||
|
dgettext("ui", "Delete Media"),
|
||||||
|
inspect(reason),
|
||||||
|
nil,
|
||||||
|
"error"
|
||||||
|
)
|
||||||
|
|> callbacks.reload.(socket.assigns.workbench)
|
||||||
|
end
|
||||||
|
|
||||||
|
{%{kind: :confirm_delete, title: title, entity_name: entity_name}, _tab} ->
|
||||||
|
close_overlay_with_output(socket, callbacks.append_output, title, entity_name)
|
||||||
|
|
||||||
|
{%{kind: :confirm_dialog, title: title, message: message}, _tab} ->
|
||||||
|
close_overlay_with_output(socket, callbacks.append_output, title, message)
|
||||||
|
|
||||||
|
_other ->
|
||||||
|
socket
|
||||||
|
end
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("overlay_select_gallery_image", %{"id" => id}, socket, _callbacks) do
|
||||||
|
{:noreply, update_shell_overlay(socket, &Overlay.select_gallery_image(&1, id))}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("overlay_close_lightbox", _params, socket, _callbacks) do
|
||||||
|
{:noreply, update_shell_overlay(socket, &Overlay.close_lightbox/1)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("overlay_lightbox_previous", _params, socket, _callbacks) do
|
||||||
|
{:noreply, update_shell_overlay(socket, &Overlay.lightbox_previous/1)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("overlay_lightbox_next", _params, socket, _callbacks) do
|
||||||
|
{:noreply, update_shell_overlay(socket, &Overlay.lightbox_next/1)}
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── handle_info for async AI suggestions ───────────────────────────────────
|
||||||
|
|
||||||
|
@spec handle_info(tuple(), Phoenix.LiveView.Socket.t()) ::
|
||||||
|
{:noreply, Phoenix.LiveView.Socket.t()}
|
||||||
|
def handle_info({:ai_suggestions_result, type, id, result}, socket) do
|
||||||
|
socket =
|
||||||
|
case socket.assigns[:shell_overlay] do
|
||||||
|
%{kind: :ai_suggestions} = overlay ->
|
||||||
|
current_tab = socket.assigns.current_tab
|
||||||
|
|
||||||
|
if current_tab && current_tab.type == type && current_tab.id == id do
|
||||||
|
suggestions =
|
||||||
|
case type do
|
||||||
|
:post ->
|
||||||
|
%{
|
||||||
|
"title" => result.title,
|
||||||
|
"excerpt" => result.excerpt,
|
||||||
|
"slug" => result.slug
|
||||||
|
}
|
||||||
|
|
||||||
|
:media ->
|
||||||
|
%{
|
||||||
|
"title" => result.title,
|
||||||
|
"alt" => result.alt,
|
||||||
|
"caption" => result.caption
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
assign(socket, :shell_overlay, Overlay.set_ai_suggestions(overlay, suggestions))
|
||||||
|
else
|
||||||
|
socket
|
||||||
|
end
|
||||||
|
|
||||||
|
_other ->
|
||||||
|
socket
|
||||||
|
end
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_info({:ai_suggestions_error, type, id, reason}, socket) do
|
||||||
|
Logger.error("AI suggestions error type=#{type} id=#{id} reason=#{inspect(reason)}")
|
||||||
|
|
||||||
|
socket =
|
||||||
|
case socket.assigns[:shell_overlay] do
|
||||||
|
%{kind: :ai_suggestions} = overlay ->
|
||||||
|
current_tab = socket.assigns.current_tab
|
||||||
|
|
||||||
|
if current_tab && current_tab.type == type && current_tab.id == id do
|
||||||
|
message =
|
||||||
|
if is_map(reason) and Map.has_key?(reason, :kind) do
|
||||||
|
"#{reason.kind}: #{inspect(Map.drop(reason, [:kind]))}"
|
||||||
|
else
|
||||||
|
inspect(reason)
|
||||||
|
end
|
||||||
|
|
||||||
|
assign(socket, :shell_overlay, Overlay.set_ai_suggestions_error(overlay, message))
|
||||||
|
else
|
||||||
|
socket
|
||||||
|
end
|
||||||
|
|
||||||
|
_other ->
|
||||||
|
socket
|
||||||
|
end
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── Private helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@spec update_shell_overlay(Phoenix.LiveView.Socket.t(), (map() -> map())) ::
|
||||||
|
Phoenix.LiveView.Socket.t()
|
||||||
|
defp update_shell_overlay(socket, updater) do
|
||||||
|
case socket.assigns[:shell_overlay] do
|
||||||
|
nil -> socket
|
||||||
|
overlay -> assign(socket, :shell_overlay, updater.(overlay))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec close_overlay_with_output(
|
||||||
|
Phoenix.LiveView.Socket.t(),
|
||||||
|
(Phoenix.LiveView.Socket.t(), String.t(), String.t(), any(), String.t() ->
|
||||||
|
Phoenix.LiveView.Socket.t()),
|
||||||
|
String.t(),
|
||||||
|
any()
|
||||||
|
) :: Phoenix.LiveView.Socket.t()
|
||||||
|
defp close_overlay_with_output(socket, append_output, title, details) do
|
||||||
|
socket
|
||||||
|
|> append_output.(title, dgettext("ui", "Command completed"), details, "info")
|
||||||
|
|> assign(:shell_overlay, nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec spawn_ai_suggestions_task(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t()
|
||||||
|
defp spawn_ai_suggestions_task(socket) do
|
||||||
|
current_tab = socket.assigns.current_tab
|
||||||
|
language = ai_suggestions_language(socket)
|
||||||
|
|
||||||
|
case current_tab do
|
||||||
|
%{type: :post, id: post_id} ->
|
||||||
|
parent = self()
|
||||||
|
|
||||||
|
Task.Supervisor.start_child(BDS.TCP.TaskSupervisor, fn ->
|
||||||
|
case AI.analyze_post(post_id, language: language) do
|
||||||
|
{:ok, result} ->
|
||||||
|
send(parent, {:ai_suggestions_result, :post, post_id, result})
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
send(parent, {:ai_suggestions_error, :post, post_id, reason})
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
%{type: :media, id: media_id} ->
|
||||||
|
parent = self()
|
||||||
|
|
||||||
|
Task.Supervisor.start_child(BDS.TCP.TaskSupervisor, fn ->
|
||||||
|
case AI.analyze_image(media_id, language: language) do
|
||||||
|
{:ok, result} ->
|
||||||
|
send(parent, {:ai_suggestions_result, :media, media_id, result})
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
send(parent, {:ai_suggestions_error, :media, media_id, reason})
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
_other ->
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
socket
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec ai_suggestions_language(Phoenix.LiveView.Socket.t()) :: String.t()
|
||||||
|
defp ai_suggestions_language(socket) do
|
||||||
|
active_project_id = socket.assigns.projects.active_project_id
|
||||||
|
{:ok, metadata} = Metadata.get_project_metadata(active_project_id)
|
||||||
|
metadata.main_language || "en"
|
||||||
|
rescue
|
||||||
|
_error -> "en"
|
||||||
|
end
|
||||||
|
end
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user