Compare commits
82 Commits
4de8492c4f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d7e30b94cb | |||
| f1265ee326 | |||
| c5e09e7316 | |||
| 1ae6152da7 | |||
| 0305d80051 | |||
| a021fc45cd | |||
| fceb995c7c | |||
| e58d68e73e | |||
| 0f30221907 | |||
| d423b6db98 | |||
| 3adb4407a0 | |||
| 05923f255b | |||
| ff89d78ab4 | |||
| e2c92cb90d | |||
| 82ce445c44 | |||
| f99e139fa5 | |||
| 1914b05f39 | |||
| b09b14cc03 | |||
| 721b1ae626 | |||
| 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 |
11
.claude/launch.json
Normal file
11
.claude/launch.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "phoenix",
|
||||
"runtimeExecutable": "mix",
|
||||
"runtimeArgs": ["phx.server"],
|
||||
"port": 4000
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
22
.claude/settings.local.json
Normal file
22
.claude/settings.local.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"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 *)",
|
||||
"Bash(mix assets.deploy)",
|
||||
"Bash(mix phx.server)",
|
||||
"mcp__Claude_Preview__preview_start",
|
||||
"mcp__Claude_in_Chrome__navigate",
|
||||
"mcp__Claude_in_Chrome__computer",
|
||||
"mcp__Claude_in_Chrome__browser_batch",
|
||||
"mcp__Claude_in_Chrome__javascript_tool",
|
||||
"Bash(allium check *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -3,10 +3,11 @@
|
||||
/deps/
|
||||
/dist/
|
||||
/doc/
|
||||
/tmp/
|
||||
/.elixir_ls/
|
||||
/erl_crash.dump
|
||||
/node_modules/
|
||||
/priv/data/*.db
|
||||
/priv/data/*.db-shm
|
||||
/priv/data/*.db-wal
|
||||
*.ez
|
||||
*.eztmp/
|
||||
|
||||
@@ -20,6 +20,7 @@ This document provides context and best practices for GitHub Copilot when workin
|
||||
- you must use ecto to generate migrations and snapshots
|
||||
- on MacOS we use native menus and you have to hook them into the intercept for new menu items
|
||||
- there are two areas of localization, you sometimes need both (menus for example)
|
||||
- localization is done with elixier gettext and you need mix gettext.extract to update translation files
|
||||
- all automatic AI activities must be gated by airplane (offline) mode of the app and either use the local model or inform the user via toast
|
||||
- metadata needs to be flushed to the filesystem and needs to be included in metadata diff tool and in rebuild from filesystem. All three aspects have to be in sync with each other.
|
||||
- if you add new metadata, add them to publishing, metadata-diff and rebuild-from-database
|
||||
@@ -112,6 +113,7 @@ This document provides context and best practices for GitHub Copilot when workin
|
||||
- For supported locales, translations MUST come from that locale file only; do not copy English terms into supported locale files as fallback
|
||||
- English fallback is allowed only when the requested locale is unsupported by available locale files
|
||||
- The project `mainLanguage` selector must expose exactly all supported render languages and no unsupported extras
|
||||
- When adding new `msgid` entries, you MUST provide translations for ALL supported locales (de, fr, it, es) — empty `msgstr` values are not acceptable
|
||||
|
||||
> **No hardcoded user-facing text. No exceptions.**
|
||||
|
||||
|
||||
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 is the Elixir rewrite of bDS, the offline-first desktop blogging workspace in [../bDS](/Users/gb/Projects/bDS). The repository now contains a substantial BEAM application: Ecto persistence, filesystem-backed content workflows, rendering/generation/publishing pipelines, AI and MCP integrations, and a bundled desktop shell served by the Elixir runtime.
|
||||
bDS2 is the Elixir rewrite of bDS, the offline-first desktop blogging workspace. It is no longer just a rewrite scaffold: the repository now contains the main desktop runtime, Ecto persistence, filesystem-backed content workflows, rendering and publishing pipelines, Lua scripting, AI and MCP integration, and a Phoenix LiveView shell embedded in a native desktop window.
|
||||
|
||||
The Allium specifications in [specs/](/Users/gb/Projects/bDS2/specs) remain the behavioral contract for the rewrite. For current implementation status and the parity roadmap, see [PLAN.md](/Users/gb/Projects/bDS2/PLAN.md).
|
||||
The Allium specs in [specs/](/Users/gb/Projects/bDS2/specs) remain the behavioral contract for the rewrite. For end-user operation, see [DOCUMENTATION.md](/Users/gb/Projects/bDS2/DOCUMENTATION.md). For the scripting surface, see [API.md](/Users/gb/Projects/bDS2/API.md).
|
||||
|
||||
## Scope
|
||||
## Current Status
|
||||
|
||||
The rewrite aims to preserve the product behavior of bDS while replacing the technical stack.
|
||||
The major architectural rework is in place.
|
||||
|
||||
Behaviour that should remain stable includes:
|
||||
- The desktop UI is served by Phoenix LiveView inside the desktop shell rather than by a separate handwritten frontend runtime.
|
||||
- Assets use Phoenix-default Tailwind and esbuild tooling from [assets/](/Users/gb/Projects/bDS2/assets) into [priv/static/](/Users/gb/Projects/bDS2/priv/static).
|
||||
- Core editorial flows are implemented in the main application: posts, media, tags, templates, scripts, imports, preview, generation, publishing, maintenance, AI, and MCP.
|
||||
- Localization is now a first-class architectural concern rather than an afterthought: UI chrome and rendered site output have separate locale flows, and post/media translation workflows are built into the domain model.
|
||||
|
||||
- Offline-first editorial workflows.
|
||||
- Filesystem-backed content with stable frontmatter, media sidecars, templates, scripts, and menu formats.
|
||||
- Project, post, media, translation, tag, template, generation, preview, publishing, AI, and MCP workflows.
|
||||
- Generated site output, search behavior, metadata synchronization, and rebuild behavior where those are part of the product contract.
|
||||
The rewrite still aims to preserve the product behavior of bDS while replacing the technical stack. The contract is product behavior, not the old implementation language or framework choices.
|
||||
|
||||
The following are intentionally not part of the behavioral contract:
|
||||
## Architecture Overview
|
||||
|
||||
- The implementation language.
|
||||
- Desktop container or UI framework.
|
||||
- ORM choice.
|
||||
- Internal state management, concurrency model, or runtime libraries.
|
||||
### Runtime
|
||||
|
||||
## Scripting Direction
|
||||
[BDS.Application](/Users/gb/Projects/bDS2/lib/bds/application.ex) is the supervision root. It starts the Phoenix endpoint, database, preview and publishing workers, task supervisors, scripting jobs, and the desktop server/window adapters.
|
||||
|
||||
bDS2 should use Lua as its user-facing scripting language.
|
||||
At a high level, the stack is:
|
||||
|
||||
The reason is host fit, not language fashion: Lua has a better embedding story for the BEAM than Python does, while still being small, expressive, and suitable for user-authored macros, transforms, and utility scripts. The current direction is:
|
||||
- Native windowing through the `:desktop` integration.
|
||||
- Phoenix endpoint and LiveView shell for the actual app UI.
|
||||
- Ecto + SQLite for indexed state, editor state, and app data.
|
||||
- Filesystem-backed project data for published content, media, sidecars, scripts, templates, generated output, and rebuild workflows.
|
||||
|
||||
- Lua script files as the persisted user script format.
|
||||
- A BEAM-hosted execution boundary with explicit host capabilities instead of unrestricted runtime access.
|
||||
- Bounded but long-running script execution for user-authored code, with explicit progress reporting through host APIs.
|
||||
### Desktop Shell
|
||||
|
||||
The initial runtime baseline in this repository uses a dedicated Elixir scripting boundary with a Luerl-backed Lua adapter. The goal is to keep scripting integration native to the BEAM while making sandboxing and host capability exposure explicit at the application boundary.
|
||||
The desktop workbench lives under [lib/bds/desktop/](/Users/gb/Projects/bDS2/lib/bds/desktop). The main screen is [BDS.Desktop.ShellLive](/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live.ex), with feature-specific editors and sidebar logic under [lib/bds/desktop/shell_live/](/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live).
|
||||
|
||||
This keeps the scripting surface lightweight and aligned with the Elixir host application. Python remains a possible integration boundary for specialized tasks, but it is no longer the default scripting model for the rewrite.
|
||||
If you are tracing UI behavior, start there first:
|
||||
|
||||
## Repository Layout
|
||||
- LiveView event routing, workbench state, overlays, and menu handling live in the desktop shell modules.
|
||||
- HEEx templates under the same tree now own most common layout and state styling.
|
||||
- Monaco remains a vendor drop under [priv/ui/monaco/](/Users/gb/Projects/bDS2/priv/ui/monaco).
|
||||
|
||||
- [mix.exs](/Users/gb/Projects/bDS2/mix.exs): Mix project definition.
|
||||
- [config/](/Users/gb/Projects/bDS2/config): Elixir and Ecto configuration.
|
||||
- [lib/](/Users/gb/Projects/bDS2/lib): application bootstrap and shared runtime modules.
|
||||
- [priv/repo/](/Users/gb/Projects/bDS2/priv/repo): Ecto migrations.
|
||||
- [specs/](/Users/gb/Projects/bDS2/specs): Allium specs distilled from the existing bDS product and being normalized for implementation-agnostic use.
|
||||
### Domain Modules
|
||||
|
||||
Most application behavior lives under [lib/bds/](/Users/gb/Projects/bDS2/lib/bds):
|
||||
|
||||
- posts, media, tags, templates, scripts, and project settings
|
||||
- metadata, frontmatter, sidecars, rebuild, and maintenance
|
||||
- rendering, generation, preview, and publishing
|
||||
- AI runtimes, chat tooling, embeddings, and MCP
|
||||
- scripting capabilities and generated API docs
|
||||
|
||||
The repo has been pushed toward smaller feature-focused modules rather than one large mixed runtime. For new work, prefer finding the owning feature module instead of adding more behavior to broad catch-all files.
|
||||
|
||||
### Storage Model
|
||||
|
||||
The database is important, but it is not the whole source of truth.
|
||||
|
||||
- Ecto models hold app state, indexes, editor state, and workflow data.
|
||||
- The filesystem holds published content artifacts and sidecar metadata that must stay stable and reviewable.
|
||||
- Rebuild and metadata-diff flows exist because database state and filesystem state are expected to stay in sync.
|
||||
|
||||
When you change persisted behavior, think in both directions: database writes and filesystem writes/readback.
|
||||
|
||||
## Localization And i18n
|
||||
|
||||
Localization now has two separate layers, and confusing them causes bugs.
|
||||
|
||||
### 1. UI Localization
|
||||
|
||||
UI chrome, menus, dashboard text, editor labels, and toasts use Gettext through [BDS.Gettext](/Users/gb/Projects/bDS2/lib/bds/gettext.ex) and the `ui` domain. Locale normalization lives in [BDS.I18n](/Users/gb/Projects/bDS2/lib/bds/i18n.ex), and the desktop shell binds the active UI locale through [BDS.Desktop.UILocale](/Users/gb/Projects/bDS2/lib/bds/desktop/ui_locale.ex).
|
||||
|
||||
In practice, this is the language of the app itself.
|
||||
|
||||
### 2. Render Localization
|
||||
|
||||
Rendered site output uses a separate locale flow. Archive labels, pagination text, template-facing render strings, and generated site language handling use the `render` Gettext domain and the project's `main_language` and `blog_languages` settings.
|
||||
|
||||
In practice, this is the language of the blog output, not necessarily the UI.
|
||||
|
||||
### 3. Content Translation
|
||||
|
||||
Posts and media have translation-aware workflows. Post translations and media metadata translations are modeled explicitly, and generation/preview/publishing use the project's configured languages when building output.
|
||||
|
||||
Relevant translation resources live under:
|
||||
|
||||
- [priv/gettext/](/Users/gb/Projects/bDS2/priv/gettext) for Gettext catalogs
|
||||
- [priv/i18n/](/Users/gb/Projects/bDS2/priv/i18n) for additional locale data used by the app
|
||||
|
||||
If you touch i18n-sensitive behavior, check whether the change belongs to UI locale, render locale, or content translation. They are related, but they are not interchangeable.
|
||||
|
||||
## Frontend And Assets
|
||||
|
||||
Frontend source now follows the Phoenix asset layout:
|
||||
|
||||
- [assets/css/](/Users/gb/Projects/bDS2/assets/css) for Tailwind-based CSS modules
|
||||
- [assets/js/](/Users/gb/Projects/bDS2/assets/js) for LiveView hooks, bridges, Monaco integration, and UI helpers
|
||||
- [priv/static/assets/](/Users/gb/Projects/bDS2/priv/static/assets) for generated outputs
|
||||
|
||||
The rule of thumb is simple:
|
||||
|
||||
- common layout, spacing, state, and typography belong in HEEx and small shared UI primitives
|
||||
- authored CSS stays for tokens and desktop-specific selectors
|
||||
- JavaScript stays focused on LiveView hooks, editor integration, drag/drop, and browser APIs
|
||||
|
||||
## Repository Map
|
||||
|
||||
- [mix.exs](/Users/gb/Projects/bDS2/mix.exs): Mix project definition, aliases, releases, and dependencies
|
||||
- [config/](/Users/gb/Projects/bDS2/config): runtime, dev, test, and asset configuration
|
||||
- [lib/bds/](/Users/gb/Projects/bDS2/lib/bds): core application modules
|
||||
- [lib/bds/desktop/](/Users/gb/Projects/bDS2/lib/bds/desktop): desktop endpoint, shell, menus, controllers, and window integration
|
||||
- [assets/](/Users/gb/Projects/bDS2/assets): Tailwind and esbuild source
|
||||
- [priv/repo/](/Users/gb/Projects/bDS2/priv/repo): Ecto migrations and snapshots
|
||||
- [priv/gettext/](/Users/gb/Projects/bDS2/priv/gettext): UI and render translation catalogs
|
||||
- [specs/](/Users/gb/Projects/bDS2/specs): Allium behavior specs
|
||||
- [DOCUMENTATION.md](/Users/gb/Projects/bDS2/DOCUMENTATION.md): end-user guide
|
||||
- [API.md](/Users/gb/Projects/bDS2/API.md): generated scripting API reference
|
||||
|
||||
## macOS Development Setup
|
||||
|
||||
If you are setting up a new macOS machine, start with the toolchain.
|
||||
If you are setting up a new macOS machine, install the toolchain first.
|
||||
|
||||
### 1. Install Xcode Command Line Tools
|
||||
|
||||
@@ -56,8 +126,6 @@ xcode-select --install
|
||||
|
||||
### 2. Install Homebrew
|
||||
|
||||
If Homebrew is not already installed:
|
||||
|
||||
```bash
|
||||
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
||||
```
|
||||
@@ -69,36 +137,28 @@ brew update
|
||||
brew install erlang elixir sqlite
|
||||
```
|
||||
|
||||
Verify the installation:
|
||||
|
||||
```bash
|
||||
elixir --version
|
||||
mix --version
|
||||
sqlite3 --version
|
||||
```
|
||||
|
||||
### 4. Fetch Dependencies
|
||||
### 4. Fetch Dependencies And Set Up The App
|
||||
|
||||
```bash
|
||||
cd /Users/gb/Projects/bDS2
|
||||
mix deps.get
|
||||
mix setup
|
||||
mix assets.setup
|
||||
```
|
||||
|
||||
### 5. Create the Local Database
|
||||
|
||||
```bash
|
||||
mix ecto.create
|
||||
mix ecto.migrate
|
||||
```
|
||||
|
||||
### 6. Run Tests
|
||||
## Development Workflow
|
||||
|
||||
Useful commands:
|
||||
|
||||
```bash
|
||||
mix compile --warnings-as-errors
|
||||
mix test
|
||||
mix dialyzer
|
||||
mix assets.build
|
||||
```
|
||||
|
||||
## Development Notes
|
||||
Notes for developers:
|
||||
|
||||
- Use `mix test` for validation during development.
|
||||
- The application behavior is defined by the Allium specs in [specs/](/Users/gb/Projects/bDS2/specs).
|
||||
- Use [PLAN.md](/Users/gb/Projects/bDS2/PLAN.md) for implementation status and the parity roadmap.
|
||||
- Specs in [specs/](/Users/gb/Projects/bDS2/specs) define the intended product behavior.
|
||||
- [DOCUMENTATION.md](/Users/gb/Projects/bDS2/DOCUMENTATION.md) is for end users, not implementation details.
|
||||
- [API.md](/Users/gb/Projects/bDS2/API.md) is generated from the live scripting capability map and should stay in sync with runtime changes.
|
||||
- When changing persistence or localization behavior, check both the database side and the filesystem/render side before assuming the change is complete.
|
||||
|
||||
191
SPECAUDIT.md
Normal file
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
|
||||
195
SPECGAPS.md
Normal file
195
SPECGAPS.md
Normal file
@@ -0,0 +1,195 @@
|
||||
# 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 | `unarchive_post/1` implemented, `publish_post` already handled archived→published | **Resolved:** `unarchive_post/1` in posts.ex restores content from disk, UI wired via quick actions, 4 tests added |
|
||||
| A1-2 | ~~`DeletePost` must delete translations + translation files~~ | post.allium:209-212 | `delete_post/1` now fetches translations before cascade-delete and removes their files from disk | **Resolved:** translation file cleanup added to `delete_post/1` in posts.ex, test added |
|
||||
| A1-3 | ~~Publish must delete old file when path changes~~ | engine_side_effects.allium:73-74 | `publish_post` now deletes old file when `file_path` changes | **Resolved:** old file deletion added to `publish_post/1` in posts.ex, test added |
|
||||
| A1-4 | ~~`doNotTranslate: false` written to frontmatter despite "only when true"~~ | frontmatter.allium:398 | `file_sync.ex:78` now converts false→nil so serializer omits the key | **Resolved:** doNotTranslate omitted from frontmatter when false, test added |
|
||||
| A1-5 | ~~Auto-save after 3000ms idle~~ | editor_post.allium:183-188 | PostEditor schedules auto-save via parent timer on dirty change | **Resolved:** 3000ms idle auto-save timer in Bridges, tab-switch save in ShellLive, cancel on manual save, 3 tests added |
|
||||
| A1-6 | ~~On-demand rendering in preview server~~ | preview.allium:53-93 | `Preview.Router` matches post/archive/home/language routes and renders on-demand via `Rendering` | **Resolved:** `Preview.Router` implements on-demand template rendering for post, archive, home, date, tag, category, page, and language-prefixed routes; static file fallback retained for non-HTML assets (pagefind, feeds); 6 tests added |
|
||||
| A1-7 | ~~Template lookup must use all 4 levels (post→tag→category→default)~~ | template_context.allium:267-277 | `resolve_post_template_slug/3` implements tag→category cascade; all callers (preview, generation) updated | **Resolved:** `resolve_post_template_slug/3` in template_selection.ex, callers in preview.ex, router.ex, outputs.ex updated, 8 tests added |
|
||||
| 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 |
|
||||
| A1-13 | Git sidebar shows only "Working tree" placeholder | sidebar_views.allium:651-770 | `sidebar.ex:782-798` returns single entity_list item; `BDS.Git` has full status/diff/commit/history/fetch/pull/push/prune_lfs but sidebar doesn't use it | Fix code: wire sidebar `git_view/0` to `BDS.Git` — render branch, ahead/behind, status file list, commit input, history entries, action buttons per spec |
|
||||
| A1-14 | Embedding uses TF-IDF hash projection instead of real neural model | embedding.allium:44-53, invariants ModelCaching/VectorCacheInDb | `backends/in_app.ex` hashes terms into sparse vectors via `:erlang.phash2`; no ONNX model, no `"query: "` prefix, no mean pooling, vectors stored as JSON text not Float32Array BLOB, snapshot-based neighbor lookup instead of USearch HNSW index | Fix code: (1) add Bumblebee + ONNX runtime deps to run `Xenova/multilingual-e5-small`, (2) implement lazy model download + cache in app data dir, (3) `"query: "` prefix + mean pooling + L2 norm in backend, (4) store vectors as binary BLOB (1536 bytes), (5) replace JSON snapshot with USearch HNSW index (cosine, M=16, ef=128/64, 5s debounce), (6) cross-language semantic similarity must work |
|
||||
| A1-15 | ~~Preview vs generation content source strategy undocumented~~ | preview.allium (no invariant), generation.allium (no invariant) | Generation uses only published .md file content (`Generation.Data` snapshots set `content: nil`); preview includes published+draft posts and prefers DB content over file (`Preview.Router` queries `:published`/`:draft`, uses `editor_body`) | **Resolved:** added `PreviewDraftOverlay` invariant to preview.allium and `GenerationPublishedOnly` invariant to generation.allium; both cross-reference each other; code already correct, 3 tests added for draft-in-preview behavior |
|
||||
|
||||
### 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 | **Resolved:** spec updated to 2 modes (markdown/preview), visual/WYSIWYG dropped |
|
||||
| A2-2 | ~~Template/Script are global entities~~ | template.allium, script.allium | Both have `project_id`, per-project uniqueness | **Resolved:** spec updated — added `project_id` to entities, scoped uniqueness invariants and create rules per project |
|
||||
| A2-3 | ~~TagsFile uses `{tags: [...]}` wrapper~~ | frontmatter.allium:255-273 | Code writes bare array `[...]` | **Resolved:** spec updated — removed wrapper object, TagEntry is now the top-level value, bare array in invariant, camelCase keys |
|
||||
| A2-4 | ~~Sidecar is "YAML-like, not gray-matter"~~ | frontmatter.allium:174 | Code wraps with `---` delimiters | **Resolved:** spec updated — format comment now says gray-matter style with --- delimiters |
|
||||
| A2-5 | ~~Translation frontmatter omits status/timestamps~~ | frontmatter.allium:107-117 | Code writes status, createdAt, updatedAt, publishedAt | **Resolved:** spec updated — TranslationFrontmatter now includes status, created_at, updated_at, published_at; TranslationFilesInheritCanonicalMetadata renamed to TranslationFrontmatterRoundtrip; translation.allium invariant updated to TranslationFilesCarryFullMetadata |
|
||||
| A2-6 | ~~Search index has single `stemmed_content`~~ | search.allium:40-54 | FTS5 per-field stemmed columns | **Resolved:** spec updated — PostSearchIndex has title/excerpt/content/tags/categories; MediaSearchIndex has title/alt/caption/original_name/tags; SearchMedia now accepts filters; index rules use delete-and-reinsert with per-field stemming |
|
||||
| A2-7 | ~~Tag archives are single-page~~ | generation.allium:142-147 | Code paginates | **Resolved:** spec updated — GenerateTagPages now paginated like categories, using max_posts_per_page |
|
||||
| A2-8 | ~~Date archives year+month only~~ | generation.allium:151-159 | Code also generates day-level | **Resolved:** spec updated — GenerateDateArchivePages now includes day-level archives, all three levels paginated |
|
||||
| A2-9 | ~~Menu is DB entity~~ | menu.allium:20-26 | Purely file-based OPML, no DB table | **Resolved:** spec updated — `entity Menu` changed to `value Menu`, file-only model with OPML persistence, added LoadMenu/SyncMenuFromFilesystem rules |
|
||||
| A2-10 | ~~Panel tabs: problems, terminal~~ | layout.allium:235-240 | `[:tasks, :output, :post_links, :git_log]` | **Resolved:** spec already lists tasks/output/post_links/git_log with availability and fallback rules matching code |
|
||||
| A2-11 | ~~Git sidebar: commit input, history, push/pull~~ | sidebar_views.allium | Only "Working tree" item | **Moved to A1-13:** backend code exists in BDS.Git, sidebar must wire it up |
|
||||
| A2-12 | ~~Slug timestamp fallback after 999~~ | post.allium:21 | Unbounded numeric suffix | **Resolved:** spec updated — uniqueness comment now says unbounded numeric suffix, no 999 cap or timestamp fallback |
|
||||
| A2-13 | ~~Thumbnail generation is async~~ | engine_side_effects.allium:117 | Synchronous | **Resolved:** spec updated — import thumbnail generation now says synchronous (awaited, logged on error), matching code; summary table changed from `async` to `sync` |
|
||||
| A2-14 | ~~AiModelModality: :video vs :file/:tool~~ | schema.allium:291 | Code has `:file`, `:tool` instead of `:video` | **Resolved:** spec updated — modality enum now lists "text" \| "image" \| "audio" \| "file" \| "tool", matching code |
|
||||
| A2-15 | ~~JSON key convention: snake_case vs camelCase~~ | frontmatter.allium values | Code uses camelCase for all metadata JSON | **Resolved:** all value types in frontmatter.allium updated to camelCase field names; added CamelCaseKeys invariant; surfaces updated; also added linkedPostIds to MediaSidecar (C-2) and projectId to TemplateFrontmatter/ScriptFrontmatter (B1-9) |
|
||||
| A2-16 | ~~Snowball stemmer language list~~ | search.allium:26-31 | Library determines which have algorithms vs passthrough | **Resolved:** spec updated — StemmerLanguage comment now says "Snowball stemmers via library (Stemex); languages with algorithm get real stemming, others pass through" |
|
||||
| A2-17 | ~~`provider_package_ref` on AiModel~~ | schema.allium:282 | Not in code; legacy field not needed | **Resolved:** dropped from AiModel entity and AiModelRecordSurface in schema.allium; DB column retained (migration artifact) |
|
||||
|
||||
---
|
||||
|
||||
## 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` | **Resolved:** added to MediaSidecar value in frontmatter.allium (with A2-15) |
|
||||
| B1-9 | ~~`projectId` in template/script frontmatter~~ | `templates.ex:337`, `scripts.ex:268` | **Resolved:** added projectId to TemplateFrontmatter and ScriptFrontmatter in frontmatter.allium (with A2-15) |
|
||||
| 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 | **Resolved:** linkedPostIds added to MediaSidecar in frontmatter.allium (with A2-15) |
|
||||
| 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 | **Resolved:** both specs updated (see A2-5) |
|
||||
|
||||
---
|
||||
|
||||
## 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-14** — code must follow spec (includes auto-save, on-demand preview, template lookup, validation gates, real Pagefind, graceful shutdown, real embedding model)
|
||||
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.
|
||||
20
assets/css/app.css
Normal file
20
assets/css/app.css
Normal file
@@ -0,0 +1,20 @@
|
||||
@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 "./misc_editor.css";
|
||||
@import "./utilities.css";
|
||||
557
assets/css/assistant.css
Normal file
557
assets/css/assistant.css
Normal file
@@ -0,0 +1,557 @@
|
||||
.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%;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.chat-message.user {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.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: var(--vscode-button-background, var(--accent-color, #007acc));
|
||||
color: var(--vscode-button-foreground, var(--vscode-list-activeSelectionForeground, #ffffff));
|
||||
border: 1px solid var(--vscode-button-background, var(--accent-color, #007acc));
|
||||
border-radius: 6px;
|
||||
padding: 12px 14px;
|
||||
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);
|
||||
}
|
||||
|
||||
/* ── Inline surfaces (<details> wrappers) ──────────────────────────── */
|
||||
|
||||
.chat-inline-surface {
|
||||
margin: 10px 0;
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
border-radius: 6px;
|
||||
background: var(--vscode-sideBar-background);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-inline-surface-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
list-style: none;
|
||||
font-size: 12px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
.chat-inline-surface-header::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-inline-surface-header::marker {
|
||||
content: "";
|
||||
}
|
||||
|
||||
.chat-inline-surface-icon {
|
||||
flex: 0 0 auto;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.chat-inline-surface-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
color: var(--vscode-editor-foreground);
|
||||
}
|
||||
|
||||
.chat-inline-surface-dismiss {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.chat-inline-surface:hover .chat-inline-surface-dismiss {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.chat-inline-surface-dismiss:hover {
|
||||
background: var(--vscode-toolbar-hoverBackground);
|
||||
color: var(--vscode-editor-foreground);
|
||||
}
|
||||
|
||||
.chat-inline-surface-body {
|
||||
padding: 0 12px 12px;
|
||||
}
|
||||
|
||||
.chat-inline-surface-body h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--vscode-editor-foreground);
|
||||
}
|
||||
|
||||
/* ── Chart surface ─────────────────────────────────────────────────── */
|
||||
|
||||
.chat-surface-chart-type {
|
||||
margin: 0 0 8px;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-surface-chart-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chat-surface-chart-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.chat-surface-chart-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.chat-surface-chart-meta span:first-child {
|
||||
color: var(--vscode-editor-foreground);
|
||||
}
|
||||
|
||||
.chat-surface-chart-meta span:last-child {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.chat-surface-chart-bar {
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-surface-chart-bar span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
background: var(--accent-color);
|
||||
min-width: 0;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
/* ── Card surface ──────────────────────────────────────────────────── */
|
||||
|
||||
.chat-surface-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chat-surface-subtitle {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
.chat-surface-body {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.chat-surface-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.chat-surface-action-button {
|
||||
padding: 4px 12px;
|
||||
border: 1px solid var(--vscode-input-border);
|
||||
border-radius: 4px;
|
||||
background: var(--vscode-input-background);
|
||||
color: var(--vscode-editor-foreground);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chat-surface-action-button:hover {
|
||||
background: var(--vscode-list-hoverBackground);
|
||||
}
|
||||
|
||||
/* ── Metric surface ────────────────────────────────────────────────── */
|
||||
|
||||
.chat-surface-metric {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.chat-surface-metric-label {
|
||||
font-size: 12px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
.chat-surface-metric-value {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--vscode-editor-foreground);
|
||||
}
|
||||
|
||||
/* ── List surface ──────────────────────────────────────────────────── */
|
||||
|
||||
.chat-surface-list {
|
||||
margin: 0;
|
||||
padding: 0 0 0 18px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── Mindmap surface ───────────────────────────────────────────────── */
|
||||
|
||||
.chat-surface-mindmap {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.chat-surface-mindmap li {
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px solid var(--vscode-panel-border);
|
||||
}
|
||||
|
||||
.chat-surface-mindmap li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.chat-surface-mindmap strong {
|
||||
display: block;
|
||||
color: var(--vscode-editor-foreground);
|
||||
}
|
||||
|
||||
.chat-surface-mindmap-children {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
/* ── Tabs surface ──────────────────────────────────────────────────── */
|
||||
|
||||
.chat-surface-tabs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chat-surface-tab-list {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 1px solid var(--vscode-panel-border);
|
||||
}
|
||||
|
||||
.chat-surface-tab-button {
|
||||
padding: 6px 12px;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chat-surface-tab-button.active {
|
||||
color: var(--vscode-editor-foreground);
|
||||
border-bottom-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.chat-surface-tab-button:hover:not(.active) {
|
||||
color: var(--vscode-editor-foreground);
|
||||
}
|
||||
|
||||
.chat-surface-tab-panel {
|
||||
padding: 10px 0 0;
|
||||
}
|
||||
|
||||
/* ── Form surface ──────────────────────────────────────────────────── */
|
||||
|
||||
.chat-surface-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.chat-surface-form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
.chat-surface-form-field input,
|
||||
.chat-surface-form-field textarea,
|
||||
.chat-surface-form-field select {
|
||||
padding: 5px 8px;
|
||||
border: 1px solid var(--vscode-input-border);
|
||||
border-radius: 4px;
|
||||
background: var(--vscode-input-background);
|
||||
color: var(--vscode-input-foreground);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.chat-surface-form-field textarea {
|
||||
min-height: 60px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.chat-surface-form-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* ── Text surface ──────────────────────────────────────────────────── */
|
||||
|
||||
.chat-surface-text {
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* ── Table surface wrapper ─────────────────────────────────────────── */
|
||||
|
||||
.chat-tool-surface-table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.chat-panel .chat-input-container {
|
||||
--chat-input-line-height: 22px;
|
||||
--chat-input-min-height: 24px;
|
||||
border-top: 1px solid var(--vscode-panel-border);
|
||||
padding: 12px 16px;
|
||||
background: var(--vscode-sideBar-background);
|
||||
}
|
||||
|
||||
.chat-panel .chat-input-wrapper {
|
||||
min-height: 40px;
|
||||
border: 1px solid var(--vscode-input-border);
|
||||
border-radius: 8px;
|
||||
padding: 6px 8px;
|
||||
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;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--vscode-input-foreground);
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.chat-panel .chat-input:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
539
assets/css/misc_editor.css
Normal file
539
assets/css/misc_editor.css
Normal file
@@ -0,0 +1,539 @@
|
||||
/* ── Misc-editor shell (shared by all misc tabs) ──────────────────────── */
|
||||
|
||||
.misc-editor-shell {
|
||||
background: var(--vscode-editor-background);
|
||||
}
|
||||
|
||||
.misc-editor-header {
|
||||
padding: 12px 16px 8px;
|
||||
border-bottom: 1px solid var(--vscode-panel-border);
|
||||
background: var(--vscode-tab-activeBackground);
|
||||
}
|
||||
|
||||
.misc-editor-header h2 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.misc-editor-header p {
|
||||
margin: 2px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
.misc-editor-actions {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.misc-editor-summary {
|
||||
padding: 8px 16px;
|
||||
border-bottom: 1px solid var(--vscode-panel-border);
|
||||
}
|
||||
|
||||
.misc-editor-content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* ── Summary pills ───────────────────────────────────────────────────── */
|
||||
|
||||
.misc-summary-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
background: var(--vscode-input-background);
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
color: var(--vscode-foreground);
|
||||
}
|
||||
|
||||
.misc-summary-pill span {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
.misc-summary-pill strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── Misc card (used by site-validation, empty states) ───────────────── */
|
||||
|
||||
.misc-card {
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--vscode-editor-background) 84%, var(--vscode-input-background));
|
||||
}
|
||||
|
||||
.misc-card h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.misc-card p {
|
||||
margin: 0;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.misc-card ul {
|
||||
margin: 6px 0 0;
|
||||
padding-left: 18px;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ── Misc columns (site-validation 3-column layout) ──────────────────── */
|
||||
|
||||
.misc-columns {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* ── Misc list (find-duplicates) ─────────────────────────────────────── */
|
||||
|
||||
.misc-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.misc-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.misc-list-item:hover {
|
||||
background: var(--vscode-list-hoverBackground);
|
||||
}
|
||||
|
||||
.duplicate-pair-row label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.duplicate-pair-row .linkish {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--vscode-textLink-foreground, #3794ff);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
text-decoration: underline;
|
||||
text-decoration-thickness: 1px;
|
||||
text-underline-offset: 0.14em;
|
||||
}
|
||||
|
||||
.duplicate-pair-row .linkish:hover {
|
||||
color: var(--vscode-foreground);
|
||||
}
|
||||
|
||||
/* ── Metadata-diff: tab bar ──────────────────────────────────────────── */
|
||||
|
||||
.metadata-diff-tool {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.metadata-diff-tabs {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
border-bottom: 1px solid var(--vscode-panel-border);
|
||||
}
|
||||
|
||||
.metadata-diff-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 14px;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--vscode-tab-inactiveForeground, var(--vscode-descriptionForeground));
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: color 0.12s, border-color 0.12s;
|
||||
}
|
||||
|
||||
.metadata-diff-tab:hover {
|
||||
color: var(--vscode-tab-activeForeground, var(--vscode-foreground));
|
||||
}
|
||||
|
||||
.metadata-diff-tab.active {
|
||||
color: var(--vscode-tab-activeForeground, var(--vscode-foreground));
|
||||
border-bottom-color: var(--vscode-focusBorder, #007fd4);
|
||||
}
|
||||
|
||||
.tab-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
background: var(--vscode-activityBarBadge-background, #007acc);
|
||||
color: var(--vscode-activityBarBadge-foreground, #ffffff);
|
||||
}
|
||||
|
||||
/* ── Metadata-diff: field pills ──────────────────────────────────────── */
|
||||
|
||||
.metadata-diff-field-pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.metadata-diff-field-pill {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
background: var(--vscode-input-background);
|
||||
overflow: hidden;
|
||||
transition: border-color 0.12s;
|
||||
}
|
||||
|
||||
.metadata-diff-field-pill.active {
|
||||
border-color: var(--vscode-focusBorder, #007fd4);
|
||||
background: color-mix(in srgb, var(--vscode-focusBorder, #007fd4) 12%, transparent);
|
||||
}
|
||||
|
||||
.metadata-diff-field-pill-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--vscode-foreground);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.metadata-diff-field-pill-toggle:hover {
|
||||
background: var(--vscode-list-hoverBackground);
|
||||
}
|
||||
|
||||
.field-pill-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.field-pill-count {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
.metadata-diff-field-pill-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 2px 4px;
|
||||
border-left: 1px solid var(--vscode-panel-border);
|
||||
}
|
||||
|
||||
.metadata-diff-action-button {
|
||||
font-size: 11px !important;
|
||||
padding: 2px 8px !important;
|
||||
min-height: 22px !important;
|
||||
}
|
||||
|
||||
/* ── Metadata-diff: results area ─────────────────────────────────────── */
|
||||
|
||||
.metadata-diff-results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.metadata-diff-empty p {
|
||||
text-align: center;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
/* ── Diff item cards (shared by metadata-diff and orphan sections) ──── */
|
||||
|
||||
.diff-item-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.diff-item-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));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.diff-item-card.orphan-file {
|
||||
border-left: 3px solid var(--vscode-editorWarning-foreground, #cca700);
|
||||
}
|
||||
|
||||
.diff-item-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
background: color-mix(in srgb, var(--vscode-sideBar-background) 50%, transparent);
|
||||
border-bottom: 1px solid var(--vscode-panel-border);
|
||||
}
|
||||
|
||||
.diff-item-header strong {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--vscode-foreground);
|
||||
}
|
||||
|
||||
.diff-item-meta {
|
||||
font-size: 11px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.diff-item-fields {
|
||||
padding: 8px 14px;
|
||||
}
|
||||
|
||||
.diff-field-row {
|
||||
display: grid;
|
||||
grid-template-columns: 120px 1fr;
|
||||
gap: 8px;
|
||||
padding: 5px 0;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--vscode-panel-border) 50%, transparent);
|
||||
}
|
||||
|
||||
.diff-field-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.diff-field-name {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding-top: 3px;
|
||||
}
|
||||
|
||||
.diff-field-values {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.diff-field-value {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.diff-field-value.db-value {
|
||||
color: var(--vscode-foreground);
|
||||
}
|
||||
|
||||
.diff-field-value.file-value {
|
||||
color: var(--vscode-foreground);
|
||||
opacity: 0.82;
|
||||
}
|
||||
|
||||
.diff-source-label {
|
||||
flex-shrink: 0;
|
||||
min-width: 28px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.db-value .diff-source-label {
|
||||
background: color-mix(in srgb, var(--vscode-focusBorder, #007fd4) 22%, transparent);
|
||||
color: var(--vscode-focusBorder, #007fd4);
|
||||
}
|
||||
|
||||
.file-value .diff-source-label {
|
||||
background: color-mix(in srgb, var(--vscode-testing-iconPassed, #73c991) 22%, transparent);
|
||||
color: var(--vscode-testing-iconPassed, #73c991);
|
||||
}
|
||||
|
||||
/* ── Orphan files section ────────────────────────────────────────────── */
|
||||
|
||||
.orphan-files-section {
|
||||
border: 1px solid color-mix(in srgb, var(--vscode-editorWarning-foreground, #cca700) 35%, transparent);
|
||||
border-radius: 8px;
|
||||
padding: 14px 16px;
|
||||
background: color-mix(in srgb, var(--vscode-editorWarning-foreground, #cca700) 5%, var(--vscode-editor-background));
|
||||
}
|
||||
|
||||
.orphan-files-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.orphan-files-header h3 {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.orphan-files-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.orphan-path span {
|
||||
font-family: "SFMono-Regular", Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
/* ── Translation validation ──────────────────────────────────────────── */
|
||||
|
||||
.translation-validation-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.translation-validation-summary {
|
||||
padding: 10px 14px;
|
||||
border-radius: 6px;
|
||||
background: var(--vscode-input-background);
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
font-size: 12px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
.translation-validation-summary p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.translation-validation-section h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.translation-validation-empty {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.translation-validation-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.translation-validation-card {
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--vscode-editor-background) 84%, var(--vscode-input-background));
|
||||
}
|
||||
|
||||
.translation-validation-card-db {
|
||||
border-left: 3px solid var(--vscode-focusBorder, #007fd4);
|
||||
}
|
||||
|
||||
.translation-validation-card-file {
|
||||
border-left: 3px solid var(--vscode-testing-iconPassed, #73c991);
|
||||
}
|
||||
|
||||
.translation-validation-card-title {
|
||||
margin: 0 0 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.translation-validation-card-meta {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 3px 12px;
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.translation-validation-card-meta dt {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.translation-validation-card-meta dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.translation-validation-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--vscode-panel-border);
|
||||
}
|
||||
|
||||
/* ── Git diff ────────────────────────────────────────────────────────── */
|
||||
|
||||
.git-diff-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.git-diff-empty {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
text-align: center;
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
.git-diff-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.git-diff-toolbar label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.git-diff-toolbar select {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.git-diff-editor {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
338
assets/css/overlays.css
Normal file
338
assets/css/overlays.css
Normal file
@@ -0,0 +1,338 @@
|
||||
.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 {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
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,
|
||||
database: Path.expand("../priv/data/bds_dev.db", __DIR__),
|
||||
pool_size: 5,
|
||||
journal_mode: :wal,
|
||||
busy_timeout: 15_000,
|
||||
log: false,
|
||||
stacktrace: true,
|
||||
@@ -27,6 +28,31 @@ config :bds, BDS.Desktop.Endpoint,
|
||||
pubsub_server: BDS.PubSub,
|
||||
live_view: [signing_salt: "desktop-live-view"]
|
||||
|
||||
config :tailwind,
|
||||
version: "4.1.14",
|
||||
default: [
|
||||
cd: Path.expand("..", __DIR__),
|
||||
args: ~w(
|
||||
--input=assets/css/app.css
|
||||
--output=priv/static/assets/app.css
|
||||
)
|
||||
]
|
||||
|
||||
config :esbuild,
|
||||
version: "0.25.4",
|
||||
default: [
|
||||
cd: Path.expand("../assets", __DIR__),
|
||||
args: ~w(
|
||||
js/app.js
|
||||
--bundle
|
||||
--target=es2022
|
||||
--outdir=../priv/static/assets
|
||||
--external:/fonts/*
|
||||
--external:/images/*
|
||||
),
|
||||
env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
|
||||
]
|
||||
|
||||
config :bds, :scripting,
|
||||
runtime: BDS.Scripting.Lua,
|
||||
timeout: 300_000,
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import Config
|
||||
|
||||
config :bds, BDS.Repo, pool_size: 5
|
||||
|
||||
config :bds, BDS.Desktop.Endpoint,
|
||||
watchers: [
|
||||
tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]},
|
||||
esbuild: {Esbuild, :install_and_run, [:default, ~w(--watch)]}
|
||||
]
|
||||
|
||||
@@ -3,6 +3,8 @@ import Config
|
||||
config :bds, BDS.Repo,
|
||||
database: Path.expand("../priv/data/bds_test.db", __DIR__),
|
||||
pool: Ecto.Adapters.SQL.Sandbox,
|
||||
pool_size: 5
|
||||
pool_size: 5,
|
||||
journal_mode: :wal,
|
||||
busy_timeout: 15_000
|
||||
|
||||
config :logger, level: :warning
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
defmodule BDS.AI do
|
||||
@moduledoc false
|
||||
@moduledoc """
|
||||
Public interface for AI features — endpoint configuration, secret management,
|
||||
model catalog access, and dispatching chat and one-shot inference requests.
|
||||
"""
|
||||
|
||||
alias BDS.AI.Catalog
|
||||
alias BDS.AI.Chat
|
||||
@@ -59,14 +62,12 @@ defmodule BDS.AI do
|
||||
model = get_setting("ai.#{kind_key}.model")
|
||||
encrypted_api_key = get_setting(encrypted_key("ai.#{kind_key}.api_key"))
|
||||
|
||||
cond do
|
||||
is_nil(url) and is_nil(model) and is_nil(encrypted_api_key) ->
|
||||
{:ok, nil}
|
||||
|
||||
true ->
|
||||
with {:ok, api_key} <- get_secret(encrypted_api_key, backend) do
|
||||
{:ok, %{kind: kind, url: url, api_key: api_key, model: model}}
|
||||
end
|
||||
if is_nil(url) and is_nil(model) and is_nil(encrypted_api_key) do
|
||||
{:ok, nil}
|
||||
else
|
||||
with {:ok, api_key} <- get_secret(encrypted_api_key, backend) do
|
||||
{:ok, %{kind: kind, url: url, api_key: api_key, model: model}}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -185,4 +186,12 @@ defmodule BDS.AI do
|
||||
|
||||
@spec cancel_chat(String.t()) :: :ok
|
||||
defdelegate cancel_chat(conversation_id), to: Chat
|
||||
|
||||
@spec get_surface_state(String.t()) :: map()
|
||||
defdelegate get_surface_state(conversation_id), to: Chat
|
||||
|
||||
@spec put_surface_state(String.t(), map(), map(), MapSet.t()) ::
|
||||
{:ok, map()} | {:error, term()}
|
||||
defdelegate put_surface_state(conversation_id, surface_data, surface_tabs, dismissed_surfaces),
|
||||
to: Chat
|
||||
end
|
||||
|
||||
@@ -15,6 +15,7 @@ defmodule BDS.AI.Catalog do
|
||||
alias BDS.AI.Model
|
||||
alias BDS.AI.ModelModality
|
||||
alias BDS.AI.OpenAICompatibleRuntime
|
||||
alias BDS.MapUtils
|
||||
alias BDS.Persistence
|
||||
alias BDS.Repo
|
||||
|
||||
@@ -211,9 +212,7 @@ defmodule BDS.AI.Catalog do
|
||||
end
|
||||
end
|
||||
|
||||
defp atomize_map_keys(map) do
|
||||
Enum.into(map, %{}, fn {key, value} -> {String.to_atom(key), value} end)
|
||||
end
|
||||
defp atomize_map_keys(map), do: MapUtils.safe_atomize_keys(map)
|
||||
|
||||
defp persist_catalog(payload) do
|
||||
now = Persistence.now_ms()
|
||||
@@ -313,7 +312,7 @@ defmodule BDS.AI.Catalog do
|
||||
defp parse_modality("audio"), do: :audio
|
||||
defp parse_modality("file"), do: :file
|
||||
defp parse_modality("tool"), do: :tool
|
||||
defp parse_modality(other) when is_binary(other), do: String.to_atom(other)
|
||||
defp parse_modality(other) when is_binary(other), do: MapUtils.safe_atomize_key(other)
|
||||
|
||||
defp encode_nullable(nil), do: nil
|
||||
defp encode_nullable(value), do: Jason.encode!(value)
|
||||
|
||||
@@ -62,6 +62,42 @@ defmodule BDS.AI.Chat do
|
||||
Repo.get(ChatConversation, conversation_id)
|
||||
end
|
||||
|
||||
@spec get_surface_state(String.t()) :: map()
|
||||
def get_surface_state(conversation_id) when is_binary(conversation_id) do
|
||||
case Repo.get(ChatConversation, conversation_id) do
|
||||
%ChatConversation{surface_state: state} when is_map(state) -> state
|
||||
_other -> %{}
|
||||
end
|
||||
end
|
||||
|
||||
@spec put_surface_state(String.t(), map(), map(), MapSet.t()) ::
|
||||
{:ok, map()} | {:error, term()}
|
||||
def put_surface_state(conversation_id, surface_data, surface_tabs, dismissed_surfaces)
|
||||
when is_binary(conversation_id) do
|
||||
case Repo.get(ChatConversation, conversation_id) do
|
||||
nil ->
|
||||
{:error, :not_found}
|
||||
|
||||
%ChatConversation{} = conversation ->
|
||||
state = %{
|
||||
"surface_data" => surface_data,
|
||||
"surface_tabs" => surface_tabs,
|
||||
"dismissed_surfaces" => MapSet.to_list(dismissed_surfaces)
|
||||
}
|
||||
|
||||
conversation
|
||||
|> ChatConversation.changeset(%{
|
||||
surface_state: state,
|
||||
updated_at: Persistence.now_ms()
|
||||
})
|
||||
|> Repo.update()
|
||||
|> case do
|
||||
{:ok, _updated} -> {:ok, state}
|
||||
error -> error
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@spec delete_chat_conversation(String.t()) :: {:ok, :deleted} | {:error, :not_found | term()}
|
||||
def delete_chat_conversation(conversation_id) when is_binary(conversation_id) do
|
||||
case Repo.get(ChatConversation, conversation_id) do
|
||||
@@ -69,7 +105,9 @@ defmodule BDS.AI.Chat do
|
||||
{:error, :not_found}
|
||||
|
||||
%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
|
||||
{:ok, _conversation} -> {:ok, :deleted}
|
||||
@@ -375,7 +413,8 @@ defmodule BDS.AI.Chat do
|
||||
opts,
|
||||
@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}
|
||||
end
|
||||
end
|
||||
@@ -425,7 +464,8 @@ defmodule BDS.AI.Chat do
|
||||
with {:ok, endpoint, model, mode} <- Runtime.resolve_target(:chat_title, opts),
|
||||
:ok <- Runtime.validate_target(:chat_title, model, mode),
|
||||
request <- build_chat_title_request(user_content, model),
|
||||
{:ok, response} <- runtime.generate(Runtime.endpoint_with_model(endpoint, model), request, opts) do
|
||||
{:ok, response} <-
|
||||
runtime.generate(Runtime.endpoint_with_model(endpoint, model), request, opts) do
|
||||
title = sanitize_chat_title(Map.get(response, :content))
|
||||
|
||||
if title == "" do
|
||||
|
||||
@@ -11,6 +11,7 @@ defmodule BDS.AI.ChatConversation do
|
||||
title: String.t() | nil,
|
||||
model: String.t() | nil,
|
||||
copilot_session_id: String.t() | nil,
|
||||
surface_state: map() | nil,
|
||||
created_at: integer() | nil,
|
||||
updated_at: integer() | nil
|
||||
}
|
||||
@@ -19,13 +20,14 @@ defmodule BDS.AI.ChatConversation do
|
||||
field :title, :string
|
||||
field :model, :string
|
||||
field :copilot_session_id, :string
|
||||
field :surface_state, :map
|
||||
field :created_at, :integer
|
||||
field :updated_at, :integer
|
||||
end
|
||||
|
||||
def changeset(conversation, attrs) do
|
||||
conversation
|
||||
|> cast(attrs, [:id, :title, :model, :copilot_session_id, :created_at, :updated_at],
|
||||
|> cast(attrs, [:id, :title, :model, :copilot_session_id, :surface_state, :created_at, :updated_at],
|
||||
empty_values: [nil]
|
||||
)
|
||||
|> validate_required([:id, :title, :created_at, :updated_at])
|
||||
|
||||
@@ -36,7 +36,9 @@ defmodule BDS.AI.ChatMessage do
|
||||
:cache_read_tokens,
|
||||
:cache_write_tokens,
|
||||
:created_at
|
||||
], empty_values: [nil])
|
||||
],
|
||||
empty_values: [nil]
|
||||
)
|
||||
|> validate_required([:conversation_id, :role, :created_at])
|
||||
|> assoc_constraint(:conversation)
|
||||
end
|
||||
|
||||
@@ -909,7 +909,7 @@ defmodule BDS.AI.ChatTools do
|
||||
|
||||
defp metadata_attrs(arguments, keys) do
|
||||
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
|
||||
|
||||
|
||||
@@ -60,7 +60,9 @@ defmodule BDS.AI.Model do
|
||||
:interleaved,
|
||||
:status,
|
||||
:updated_at
|
||||
], empty_values: [nil])
|
||||
],
|
||||
empty_values: [nil]
|
||||
)
|
||||
|> validate_required([
|
||||
:provider,
|
||||
:model_id,
|
||||
|
||||
@@ -211,8 +211,14 @@ defmodule BDS.AI.OneShot do
|
||||
model: model,
|
||||
max_output_tokens: @default_max_output_tokens,
|
||||
messages: [
|
||||
%{"role" => "system", "content" => one_shot_system_prompt(operation, language, source_language)},
|
||||
%{"role" => "user", "content" => one_shot_user_content(operation, payload, language, source_language)}
|
||||
%{
|
||||
"role" => "system",
|
||||
"content" => one_shot_system_prompt(operation, language, source_language)
|
||||
},
|
||||
%{
|
||||
"role" => "user",
|
||||
"content" => one_shot_user_content(operation, payload, language, source_language)
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
@@ -320,7 +326,8 @@ defmodule BDS.AI.OneShot do
|
||||
[
|
||||
%{
|
||||
"type" => "text",
|
||||
"text" => "Analyze this image and return title, alt text, and caption in #{language_name(language)}."
|
||||
"text" =>
|
||||
"Analyze this image and return title, alt text, and caption in #{language_name(language)}."
|
||||
},
|
||||
%{"type" => "image_url", "image_url" => %{"url" => media.image_url}}
|
||||
]
|
||||
@@ -443,7 +450,11 @@ defmodule BDS.AI.OneShot do
|
||||
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)")
|
||||
|
||||
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} ->
|
||||
@@ -452,7 +463,9 @@ defmodule BDS.AI.OneShot do
|
||||
end
|
||||
end
|
||||
|
||||
defp resolve_image_data_url(%{file_path: file_path, project_id: project_id, mime_type: mime_type} = media)
|
||||
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 ->
|
||||
@@ -465,7 +478,11 @@ defmodule BDS.AI.OneShot do
|
||||
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)")
|
||||
|
||||
Logger.debug(
|
||||
"AI analyze_image converted #{absolute_path} to data URL (#{byte_size(data_url)} chars)"
|
||||
)
|
||||
|
||||
{:ok, %{media | image_url: data_url}}
|
||||
|
||||
{:error, reason} ->
|
||||
|
||||
@@ -53,7 +53,7 @@ defmodule BDS.AI.OpenAICompatibleRuntime do
|
||||
|
||||
case result do
|
||||
{:ok, %{json: nil, content: content}} when is_binary(content) ->
|
||||
Logger.warning(
|
||||
Logger.debug(
|
||||
"AI OpenAI-compatible response parsed but content is not valid JSON. Content: #{String.slice(content, 0, 500)}"
|
||||
)
|
||||
|
||||
|
||||
@@ -39,7 +39,8 @@ defmodule BDS.AI.Runtime do
|
||||
:ok
|
||||
|
||||
capabilities.supports_attachment == false ->
|
||||
{:error, %{kind: :model_capability_missing, capability: :supports_attachment, model: model}}
|
||||
{:error,
|
||||
%{kind: :model_capability_missing, capability: :supports_attachment, model: model}}
|
||||
|
||||
true ->
|
||||
:ok
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
defmodule BDS.BoundedAtoms do
|
||||
@moduledoc false
|
||||
@moduledoc """
|
||||
Safe conversion of dynamic values to atoms from pre-defined allow-lists,
|
||||
preventing atom table exhaustion from untrusted input.
|
||||
"""
|
||||
|
||||
alias BDS.UI.Registry
|
||||
alias BDS.UI.MenuBar
|
||||
|
||||
@@ -375,13 +375,7 @@ defmodule BDS.Desktop.Automation do
|
||||
defp normalize_simple_reply("ok"), do: :ok
|
||||
defp normalize_simple_reply(reply), do: reply
|
||||
|
||||
defp atomize_map(map) when is_map(map) do
|
||||
Enum.into(map, %{}, fn {key, value} ->
|
||||
normalized_key = if is_binary(key), do: String.to_atom(key), else: key
|
||||
normalized_value = if is_map(value), do: atomize_map(value), else: value
|
||||
{normalized_key, normalized_value}
|
||||
end)
|
||||
end
|
||||
defp atomize_map(map) when is_map(map), do: BDS.MapUtils.safe_atomize_keys(map)
|
||||
|
||||
defp project_root do
|
||||
Path.expand("../../..", __DIR__)
|
||||
|
||||
@@ -16,20 +16,14 @@ defmodule BDS.Desktop.Endpoint do
|
||||
|
||||
plug(Plug.Static,
|
||||
at: "/assets",
|
||||
from: {:bds, "priv/ui"},
|
||||
only: ["app.css", "live.js", "monaco"]
|
||||
from: {:bds, "priv/static/assets"},
|
||||
only: ["app.css", "app.js"]
|
||||
)
|
||||
|
||||
plug(Plug.Static,
|
||||
at: "/vendor/phoenix",
|
||||
from: {:phoenix, "priv/static"},
|
||||
only: ["phoenix.min.js"]
|
||||
)
|
||||
|
||||
plug(Plug.Static,
|
||||
at: "/vendor/live_view",
|
||||
from: {:phoenix_live_view, "priv/static"},
|
||||
only: ["phoenix_live_view.min.js"]
|
||||
at: "/monaco",
|
||||
from: {:bds, "priv/ui/monaco"},
|
||||
only: ["vs"]
|
||||
)
|
||||
|
||||
plug(BDS.Desktop.Router)
|
||||
|
||||
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,24 @@ defmodule BDS.Desktop.FilePicker do
|
||||
@moduledoc false
|
||||
|
||||
def choose_file(prompt) when is_binary(prompt) do
|
||||
case :os.type() do
|
||||
{:unix, :darwin} -> choose_file_macos(prompt)
|
||||
_other -> {:error, %{message: "File selection is only supported on macOS desktop"}}
|
||||
if System.get_env("BDS_DESKTOP_AUTOMATION") == "1" do
|
||||
:cancel
|
||||
else
|
||||
case :os.type() do
|
||||
{:unix, :darwin} -> choose_file_macos(prompt)
|
||||
_other -> {:error, %{message: "File selection is only supported on macOS desktop"}}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def choose_files(prompt, opts \\ []) when is_binary(prompt) do
|
||||
if System.get_env("BDS_DESKTOP_AUTOMATION") == "1" do
|
||||
:cancel
|
||||
else
|
||||
case :os.type() do
|
||||
{:unix, :darwin} -> choose_files_macos(prompt, opts)
|
||||
_other -> {:error, %{message: "File selection is only supported on macOS desktop"}}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -17,6 +32,50 @@ defmodule BDS.Desktop.FilePicker do
|
||||
end
|
||||
end
|
||||
|
||||
defp choose_files_macos(prompt, opts) do
|
||||
multiple = Keyword.get(opts, :multiple, false)
|
||||
image_only = Keyword.get(opts, :image_only, false)
|
||||
|
||||
script_parts = ["POSIX path of (choose file with prompt \"#{escape_applescript(prompt)}\""]
|
||||
|
||||
script_parts =
|
||||
if image_only do
|
||||
script_parts ++ [" of type {\"public.image\"}"]
|
||||
else
|
||||
script_parts
|
||||
end
|
||||
|
||||
script_parts =
|
||||
if multiple do
|
||||
script_parts ++ [" with multiple selections allowed"]
|
||||
else
|
||||
script_parts
|
||||
end
|
||||
|
||||
script = Enum.join(script_parts, "") <> ")"
|
||||
|
||||
case System.cmd("osascript", ["-e", script], stderr_to_stdout: true) do
|
||||
{output, 0} -> parse_choose_files_result(String.trim(output), multiple)
|
||||
{output, _status} -> normalize_picker_failure(output)
|
||||
end
|
||||
end
|
||||
|
||||
@doc false
|
||||
def parse_choose_files_result(output, true = _multiple) do
|
||||
paths =
|
||||
output
|
||||
|> String.split("\n")
|
||||
|> Enum.map(&String.trim/1)
|
||||
|> Enum.reject(&(&1 == ""))
|
||||
|
||||
{:ok, paths}
|
||||
end
|
||||
|
||||
@doc false
|
||||
def parse_choose_files_result(output, false = _multiple) do
|
||||
{:ok, output}
|
||||
end
|
||||
|
||||
defp normalize_picker_failure(output) do
|
||||
message = String.trim(output)
|
||||
|
||||
|
||||
@@ -16,9 +16,7 @@ defmodule BDS.Desktop.Layouts do
|
||||
</head>
|
||||
<body>
|
||||
<%= @inner_content %>
|
||||
<script defer phx-track-static src="/vendor/phoenix/phoenix.min.js"></script>
|
||||
<script defer phx-track-static src="/vendor/live_view/phoenix_live_view.min.js"></script>
|
||||
<script defer phx-track-static src="/assets/live.js"></script>
|
||||
<script defer phx-track-static src="/assets/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
@@ -2,7 +2,7 @@ defmodule BDS.Desktop.MenuBar do
|
||||
@moduledoc false
|
||||
|
||||
use BDS.Desktop.MenuCompat
|
||||
alias BDS.Desktop.{ShellData, Shutdown, UILocale}
|
||||
alias BDS.Desktop.{ExternalLinks, ShellData, Shutdown, UILocale}
|
||||
alias BDS.UI.Commands
|
||||
alias BDS.UI.MenuBar, as: ShellMenuBar
|
||||
alias Desktop.OS
|
||||
@@ -59,7 +59,7 @@ defmodule BDS.Desktop.MenuBar do
|
||||
end
|
||||
|
||||
def handle_event("view_on_github", menu) do
|
||||
OS.launch_default_browser("https://github.com/rfc1437/bDS")
|
||||
OS.launch_default_browser(ExternalLinks.github_url())
|
||||
{:noreply, menu}
|
||||
end
|
||||
|
||||
@@ -78,7 +78,7 @@ defmodule BDS.Desktop.MenuBar do
|
||||
end
|
||||
|
||||
def handle_event("report_issue", menu) do
|
||||
OS.launch_default_browser("https://github.com/rfc1437/bDS/issues")
|
||||
OS.launch_default_browser(ExternalLinks.github_issues_url())
|
||||
{:noreply, menu}
|
||||
end
|
||||
|
||||
|
||||
@@ -35,7 +35,8 @@ defmodule BDS.Desktop.Overlay do
|
||||
title: Map.get(context, :insert_media_title, "Insert Media"),
|
||||
search_query: "",
|
||||
results: Enum.map(media, &to_insert_media_result/1),
|
||||
all_media: media
|
||||
all_media: media,
|
||||
post_id: current_id(context)
|
||||
}
|
||||
end
|
||||
|
||||
@@ -48,29 +49,32 @@ defmodule BDS.Desktop.Overlay do
|
||||
end
|
||||
|
||||
def open(:media, :confirm_delete, context) do
|
||||
delete_details = Map.get(context, :delete_details, %{})
|
||||
%{
|
||||
title: title,
|
||||
entity_name: entity_name,
|
||||
entity_type: entity_type,
|
||||
reference_list: reference_list
|
||||
} = context.delete_details
|
||||
|
||||
%{
|
||||
kind: :confirm_delete,
|
||||
title: Map.get(delete_details, :title, "Delete"),
|
||||
entity_name: Map.get(delete_details, :entity_name, ""),
|
||||
entity_type: Map.get(delete_details, :entity_type, "media"),
|
||||
reference_count: length(Map.get(delete_details, :reference_list, [])),
|
||||
reference_list: Map.get(delete_details, :reference_list, [])
|
||||
title: title,
|
||||
entity_name: entity_name,
|
||||
entity_type: entity_type,
|
||||
reference_count: length(reference_list),
|
||||
reference_list: reference_list
|
||||
}
|
||||
end
|
||||
|
||||
def open(:tags, :confirm_delete, context), do: open(:media, :confirm_delete, context)
|
||||
|
||||
def open(:tags, :confirm_merge, context) do
|
||||
merge = Map.get(context, :merge_details, %{})
|
||||
target = Map.get(merge, :target, "")
|
||||
count = Map.get(merge, :count, 0)
|
||||
%{title: title, message: message} = context.merge_details
|
||||
|
||||
%{
|
||||
kind: :confirm_dialog,
|
||||
title: Map.get(merge, :title, "Merge #{count} tags into #{target}?"),
|
||||
message: Map.get(merge, :message, "Cannot be undone.")
|
||||
title: title,
|
||||
message: message
|
||||
}
|
||||
end
|
||||
|
||||
@@ -115,8 +119,8 @@ defmodule BDS.Desktop.Overlay do
|
||||
|> Map.get(:all_media, [])
|
||||
|> Enum.filter(fn media ->
|
||||
normalized == "" or
|
||||
search_matches?(Map.get(media, :title, ""), normalized) or
|
||||
search_matches?(Map.get(media, :original_name, ""), normalized)
|
||||
search_matches?(media.title, normalized) or
|
||||
search_matches?(media.original_name, normalized)
|
||||
end)
|
||||
|> Enum.map(&to_insert_media_result/1)
|
||||
|
||||
@@ -203,18 +207,22 @@ defmodule BDS.Desktop.Overlay do
|
||||
def insert_media_result(_overlay, _media_id), do: nil
|
||||
|
||||
defp language_picker(context, source_language) do
|
||||
existing_translations = Map.get(context, :existing_translations, %{})
|
||||
language_names = Map.get(context, :language_names, %{})
|
||||
language_flags = Map.get(context, :language_flags, %{})
|
||||
|
||||
targets =
|
||||
context
|
||||
|> Map.get(:blog_languages, [])
|
||||
|> Enum.uniq()
|
||||
|> Enum.reject(&(&1 == source_language))
|
||||
|> Enum.map(fn code ->
|
||||
existing_status = Map.get(Map.get(context, :existing_translations, %{}), code)
|
||||
existing_status = Map.get(existing_translations, code)
|
||||
|
||||
%{
|
||||
code: code,
|
||||
name: Map.get(Map.get(context, :language_names, %{}), code, String.upcase(code)),
|
||||
flag_emoji: Map.get(Map.get(context, :language_flags, %{}), code, code),
|
||||
name: Map.get(language_names, code, String.upcase(code)),
|
||||
flag_emoji: Map.get(language_flags, code, code),
|
||||
has_existing_translation: not is_nil(existing_status),
|
||||
existing_status: existing_status
|
||||
}
|
||||
@@ -255,14 +263,15 @@ defmodule BDS.Desktop.Overlay do
|
||||
def set_ai_suggestions_error(overlay, _error_message), do: overlay
|
||||
|
||||
defp normalize_ai_fields(fields) do
|
||||
Enum.map(fields, fn field ->
|
||||
Enum.map(fields, fn %{key: key, label: label, current_value: current,
|
||||
suggested_value: suggested, locked: locked} = field ->
|
||||
%{
|
||||
key: to_string(Map.get(field, :key, "")),
|
||||
label: Map.get(field, :label, ""),
|
||||
current_value: Map.get(field, :current_value, ""),
|
||||
suggested_value: Map.get(field, :suggested_value, ""),
|
||||
accepted: not Map.get(field, :locked, false),
|
||||
locked: Map.get(field, :locked, false),
|
||||
key: to_string(key),
|
||||
label: label,
|
||||
current_value: current,
|
||||
suggested_value: suggested,
|
||||
accepted: not locked,
|
||||
locked: locked,
|
||||
loading: Map.get(field, :loading, false)
|
||||
}
|
||||
end)
|
||||
@@ -276,7 +285,7 @@ defmodule BDS.Desktop.Overlay do
|
||||
end
|
||||
|
||||
defp gallery_images(context) do
|
||||
images = Enum.filter(Map.get(context, :media, []), &Map.get(&1, :is_image, false))
|
||||
images = Enum.filter(Map.get(context, :media, []), & &1.is_image)
|
||||
post_media_ids = Map.get(context, :post_media_ids, [])
|
||||
|
||||
case Enum.filter(images, &(&1.id in post_media_ids)) do
|
||||
@@ -289,29 +298,29 @@ defmodule BDS.Desktop.Overlay do
|
||||
%{
|
||||
post_id: post.id,
|
||||
title: post.title,
|
||||
status: to_string(Map.get(post, :status, "draft")),
|
||||
canonical_url: Map.get(post, :canonical_url, "/posts/#{post.id}"),
|
||||
similarity_score: Map.get(post, :similarity_score)
|
||||
status: post.status,
|
||||
canonical_url: post.canonical_url,
|
||||
similarity_score: nil
|
||||
}
|
||||
end
|
||||
|
||||
defp to_insert_media_result(media) do
|
||||
%{
|
||||
media_id: media.id,
|
||||
title: Map.get(media, :title, ""),
|
||||
original_name: Map.get(media, :original_name, media.id),
|
||||
is_image: Map.get(media, :is_image, false),
|
||||
thumbnail_url: Map.get(media, :thumbnail_url)
|
||||
title: media.title,
|
||||
original_name: media.original_name,
|
||||
is_image: media.is_image,
|
||||
thumbnail_url: media.thumbnail_url
|
||||
}
|
||||
end
|
||||
|
||||
defp to_gallery_image(media) do
|
||||
%{
|
||||
media_id: media.id,
|
||||
thumbnail_url: Map.get(media, :thumbnail_url),
|
||||
image_url: Map.get(media, :image_url, Map.get(media, :thumbnail_url)),
|
||||
alt_text: Map.get(media, :alt_text),
|
||||
title: Map.get(media, :title, Map.get(media, :original_name, media.id))
|
||||
thumbnail_url: media.thumbnail_url,
|
||||
image_url: media.image_url,
|
||||
alt_text: media.alt_text,
|
||||
title: media.title
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
@@ -449,7 +449,7 @@ defmodule BDS.Desktop.ShellCommands do
|
||||
end
|
||||
|
||||
defp translation_fill_enabled?(metadata) do
|
||||
([Map.get(metadata, :main_language)] ++ Map.get(metadata, :blog_languages, []))
|
||||
([metadata.main_language] ++ metadata.blog_languages)
|
||||
|> Enum.map(fn language ->
|
||||
language
|
||||
|> to_string()
|
||||
|
||||
@@ -6,6 +6,7 @@ defmodule BDS.Desktop.ShellData do
|
||||
alias BDS.Git
|
||||
alias BDS.I18n
|
||||
alias BDS.Projects
|
||||
alias BDS.Repo
|
||||
alias BDS.UI.Dashboard
|
||||
alias BDS.UI.Sidebar
|
||||
alias BDS.UI.Workbench
|
||||
@@ -16,17 +17,38 @@ defmodule BDS.Desktop.ShellData do
|
||||
|
||||
def activity_icon(id) do
|
||||
case to_string(id) do
|
||||
"posts" -> ~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zM6 20V4h7v5h5v11H6z"></path><path d="M8 12h8v2H8zm0 4h8v2H8z"></path></svg>)
|
||||
"pages" -> ~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M4 4h10v4h6v12H4V4zm10 1.5V9h4.5L14 5.5zM7 12h10v1.5H7V12zm0 3h10v1.5H7V15z"></path></svg>)
|
||||
"media" -> ~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"></path></svg>)
|
||||
"scripts" -> ~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M20 3H4a1 1 0 0 0-1 1v11a1 1 0 0 0 1 1h7v2H8v2h8v-2h-3v-2h7a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1zM5 14V5h14v9H5zm2-7.5L9.5 9 7 11.5l1.4 1.4L12.3 9 8.4 5.1 7 6.5zm6.5 5.5h4v-2h-4v2z"></path></svg>)
|
||||
"templates" -> ~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M4 4h7v7H4V4zm9 0h7v7h-7V4zM4 13h7v7H4v-7zm9 0h7v7h-7v-7zM5.5 5.5v4h4v-4h-4zm9 0v4h4v-4h-4zm-9 9v4h4v-4h-4zm9 0v4h4v-4h-4z"></path></svg>)
|
||||
"tags" -> ~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M21.41 11.58l-9-9C12.05 2.22 11.55 2 11 2H4c-1.1 0-2 .9-2 2v7c0 .55.22 1.05.59 1.42l9 9c.36.36.86.58 1.41.58s1.05-.22 1.41-.59l7-7c.37-.36.59-.86.59-1.41s-.23-1.06-.59-1.42zM5.5 7C4.67 7 4 6.33 4 5.5S4.67 4 5.5 4 7 4.67 7 5.5 6.33 7 5.5 7z"></path></svg>)
|
||||
"chat" -> ~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H6l-2 2V4h16v12z"></path><circle cx="8" cy="10" r="1.5"></circle><circle cx="12" cy="10" r="1.5"></circle><circle cx="16" cy="10" r="1.5"></circle></svg>)
|
||||
"import" -> ~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"></path></svg>)
|
||||
"git" -> ~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M22 11.73L12.27 2a1 1 0 0 0-1.41 0L8.84 4.02l2.56 2.56a1.2 1.2 0 0 1 1.52 1.53l2.47 2.47a1.2 1.2 0 1 1-.72.67l-2.3-2.3v6.06a1.2 1.2 0 1 1-.85 0V8.9a1.2 1.2 0 0 1-.66-1.59L8.35 4.8 2 11.16a1 1 0 0 0 0 1.41L11.73 22a1 1 0 0 0 1.41 0L22 13.14a1 1 0 0 0 0-1.41z"></path></svg>)
|
||||
"settings" -> ~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M19.14 12.94c.04-.31.06-.63.06-.94 0-.31-.02-.63-.06-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.04.31-.06.63-.06.94s.02.63.06.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"></path></svg>)
|
||||
_other -> activity_icon("posts")
|
||||
"posts" ->
|
||||
~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zM6 20V4h7v5h5v11H6z"></path><path d="M8 12h8v2H8zm0 4h8v2H8z"></path></svg>)
|
||||
|
||||
"pages" ->
|
||||
~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M4 4h10v4h6v12H4V4zm10 1.5V9h4.5L14 5.5zM7 12h10v1.5H7V12zm0 3h10v1.5H7V15z"></path></svg>)
|
||||
|
||||
"media" ->
|
||||
~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"></path></svg>)
|
||||
|
||||
"scripts" ->
|
||||
~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M20 3H4a1 1 0 0 0-1 1v11a1 1 0 0 0 1 1h7v2H8v2h8v-2h-3v-2h7a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1zM5 14V5h14v9H5zm2-7.5L9.5 9 7 11.5l1.4 1.4L12.3 9 8.4 5.1 7 6.5zm6.5 5.5h4v-2h-4v2z"></path></svg>)
|
||||
|
||||
"templates" ->
|
||||
~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M4 4h7v7H4V4zm9 0h7v7h-7V4zM4 13h7v7H4v-7zm9 0h7v7h-7v-7zM5.5 5.5v4h4v-4h-4zm9 0v4h4v-4h-4zm-9 9v4h4v-4h-4zm9 0v4h4v-4h-4z"></path></svg>)
|
||||
|
||||
"tags" ->
|
||||
~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M21.41 11.58l-9-9C12.05 2.22 11.55 2 11 2H4c-1.1 0-2 .9-2 2v7c0 .55.22 1.05.59 1.42l9 9c.36.36.86.58 1.41.58s1.05-.22 1.41-.59l7-7c.37-.36.59-.86.59-1.41s-.23-1.06-.59-1.42zM5.5 7C4.67 7 4 6.33 4 5.5S4.67 4 5.5 4 7 4.67 7 5.5 6.33 7 5.5 7z"></path></svg>)
|
||||
|
||||
"chat" ->
|
||||
~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H6l-2 2V4h16v12z"></path><circle cx="8" cy="10" r="1.5"></circle><circle cx="12" cy="10" r="1.5"></circle><circle cx="16" cy="10" r="1.5"></circle></svg>)
|
||||
|
||||
"import" ->
|
||||
~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"></path></svg>)
|
||||
|
||||
"git" ->
|
||||
~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M22 11.73L12.27 2a1 1 0 0 0-1.41 0L8.84 4.02l2.56 2.56a1.2 1.2 0 0 1 1.52 1.53l2.47 2.47a1.2 1.2 0 1 1-.72.67l-2.3-2.3v6.06a1.2 1.2 0 1 1-.85 0V8.9a1.2 1.2 0 0 1-.66-1.59L8.35 4.8 2 11.16a1 1 0 0 0 0 1.41L11.73 22a1 1 0 0 0 1.41 0L22 13.14a1 1 0 0 0 0-1.41z"></path></svg>)
|
||||
|
||||
"settings" ->
|
||||
~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M19.14 12.94c.04-.31.06-.63.06-.94 0-.31-.02-.63-.06-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.04.31-.06.63-.06.94s.02.63.06.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"></path></svg>)
|
||||
|
||||
_other ->
|
||||
activity_icon("posts")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -41,15 +63,11 @@ defmodule BDS.Desktop.ShellData do
|
||||
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()
|
||||
if Repo.ready?() do
|
||||
{:ok, Projects.shell_snapshot()}
|
||||
else
|
||||
{:error, :not_ready}
|
||||
end
|
||||
end
|
||||
|
||||
def current_project(projects_snapshot) do
|
||||
@@ -58,43 +76,45 @@ defmodule BDS.Desktop.ShellData do
|
||||
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()
|
||||
if Repo.ready?() do
|
||||
{:ok, Dashboard.snapshot(project_id)}
|
||||
else
|
||||
{:error, :not_ready}
|
||||
end
|
||||
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)
|
||||
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", "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.")
|
||||
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.")}
|
||||
%{
|
||||
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", "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()}
|
||||
]
|
||||
@@ -115,26 +135,35 @@ defmodule BDS.Desktop.ShellData do
|
||||
|
||||
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(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
|
||||
provider = Keyword.get(opts, :provider, git_remote_state_provider())
|
||||
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)
|
||||
|
||||
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__
|
||||
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
|
||||
|
||||
0
|
||||
{:ok, count}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -260,7 +289,7 @@ defmodule BDS.Desktop.ShellData do
|
||||
|
||||
defp maybe_add_panel_tab(tabs, _route, _tab), do: tabs
|
||||
|
||||
defp default_project_snapshot do
|
||||
def default_project_snapshot do
|
||||
%{
|
||||
active_project_id: "default",
|
||||
projects: [
|
||||
|
||||
@@ -5,13 +5,14 @@ defmodule BDS.Desktop.ShellLive do
|
||||
|
||||
import Phoenix.HTML
|
||||
|
||||
alias BDS.{AI, BoundedAtoms}
|
||||
alias BDS.{AI, BoundedAtoms, Metadata}
|
||||
alias BDS.CliSync.Watcher
|
||||
alias BDS.Desktop.{FolderPicker, ShellData, UILocale}
|
||||
alias BDS.Desktop.{ExternalLinks, FilePicker, FolderPicker, ShellData, UILocale}
|
||||
|
||||
alias BDS.Desktop.ShellLive.{
|
||||
Bridges,
|
||||
ChatEditor,
|
||||
GalleryImport,
|
||||
ImportEditor,
|
||||
MediaEditor,
|
||||
MenuEditor,
|
||||
@@ -33,6 +34,7 @@ defmodule BDS.Desktop.ShellLive do
|
||||
alias BDS.Desktop.ShellLive.{
|
||||
ChatSurface,
|
||||
Layout,
|
||||
PanelRenderer,
|
||||
SessionUtil,
|
||||
ShellCommandRunner,
|
||||
SidebarCreate,
|
||||
@@ -60,6 +62,9 @@ defmodule BDS.Desktop.ShellLive do
|
||||
use Gettext, backend: BDS.Gettext
|
||||
|
||||
@refresh_interval 1_500
|
||||
|
||||
def refresh_interval, do: @refresh_interval
|
||||
|
||||
@output_entry_limit 20
|
||||
@sidebar_filter_events [
|
||||
"toggle_sidebar_filters",
|
||||
@@ -79,18 +84,21 @@ defmodule BDS.Desktop.ShellLive do
|
||||
"load_more_sidebar"
|
||||
]
|
||||
|
||||
@local_menu_actions MapSet.new([
|
||||
:toggle_sidebar,
|
||||
:toggle_panel,
|
||||
:toggle_assistant_sidebar,
|
||||
:view_posts,
|
||||
:view_media,
|
||||
:edit_preferences,
|
||||
:edit_menu,
|
||||
:documentation,
|
||||
:api_documentation,
|
||||
:close_tab
|
||||
])
|
||||
@layout_menu_actions MapSet.new([
|
||||
:toggle_sidebar,
|
||||
:toggle_panel,
|
||||
:toggle_assistant_sidebar,
|
||||
:close_tab
|
||||
])
|
||||
@sidebar_menu_actions MapSet.new([
|
||||
:view_posts,
|
||||
:view_media,
|
||||
:edit_preferences,
|
||||
:edit_menu,
|
||||
:documentation,
|
||||
:api_documentation
|
||||
])
|
||||
@local_menu_actions MapSet.union(@layout_menu_actions, @sidebar_menu_actions)
|
||||
@socket_menu_actions MapSet.new([
|
||||
:new_post,
|
||||
:import_media,
|
||||
@@ -126,19 +134,21 @@ defmodule BDS.Desktop.ShellLive do
|
||||
|> MapSet.union(MapSet.new([:open_in_browser, :open_data_folder]))
|
||||
|> MapSet.union(MapSet.new([:preview_post, :rebuild_database, :reindex_text]))
|
||||
|> MapSet.union(MapSet.new([:rebuild_embedding_index, :metadata_diff, :regenerate_calendar]))
|
||||
|> MapSet.union(MapSet.new([:validate_translations, :fill_missing_translations, :find_duplicates]))
|
||||
|> MapSet.union(
|
||||
MapSet.new([:validate_translations, :fill_missing_translations, :find_duplicates])
|
||||
)
|
||||
|> MapSet.union(MapSet.new([:generate_sitemap, :validate_site, :upload_site]))
|
||||
end
|
||||
|
||||
embed_templates("shell_live/*")
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
def mount(params, _session, socket) do
|
||||
connected = connected?(socket)
|
||||
|
||||
if connected do
|
||||
Phoenix.PubSub.subscribe(BDS.PubSub, Watcher.topic())
|
||||
:timer.send_interval(@refresh_interval, :refresh_task_status)
|
||||
Process.send_after(self(), :refresh_task_status, @refresh_interval)
|
||||
end
|
||||
|
||||
workbench = Workbench.new()
|
||||
@@ -158,26 +168,31 @@ defmodule BDS.Desktop.ShellLive do
|
||||
|> assign(:titlebar_menu_item_index, nil)
|
||||
|> assign(:tab_meta, %{})
|
||||
|> assign(:project_menu_open, false)
|
||||
|> assign(:sidebar_filters_by_view, %{})
|
||||
|> assign(:sidebar_filter_panels, %{})
|
||||
|> assign(:chat_editor_request_refs, %{})
|
||||
|> assign(:shell_overlay, nil)
|
||||
|> assign(:output_entries, [])
|
||||
|> reload_shell(workbench)
|
||||
|> tap(&sync_menu_bar_locale/1)}
|
||||
|> assign(:sidebar_filters_by_view, %{})
|
||||
|> assign(:sidebar_filter_panels, %{})
|
||||
|> assign(:chat_editor_request_refs, %{})
|
||||
|> assign(:file_picker_task, nil)
|
||||
|> assign(:shell_overlay, nil)
|
||||
|> assign(:output_entries, [])
|
||||
|> assign(:panel_post_links, %{backlinks: [], outlinks: []})
|
||||
|> assign(:panel_git_entries, [])
|
||||
|> assign(:auto_save_timers, %{})
|
||||
|> reload_shell(workbench)
|
||||
|> apply_url_params(params)
|
||||
|> tap(&sync_menu_bar_locale/1)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("toggle_sidebar", _params, socket) do
|
||||
{:noreply, reload_shell(socket, Workbench.toggle_sidebar(socket.assigns.workbench))}
|
||||
{:noreply, refresh_layout(socket, Workbench.toggle_sidebar(socket.assigns.workbench))}
|
||||
end
|
||||
|
||||
def handle_event("toggle_panel", _params, socket) do
|
||||
{:noreply, reload_shell(socket, Workbench.toggle_panel(socket.assigns.workbench))}
|
||||
{:noreply, refresh_layout(socket, Workbench.toggle_panel(socket.assigns.workbench))}
|
||||
end
|
||||
|
||||
def handle_event("toggle_assistant_sidebar", _params, socket) do
|
||||
{:noreply, reload_shell(socket, Workbench.toggle_assistant_sidebar(socket.assigns.workbench))}
|
||||
{:noreply, refresh_layout(socket, Workbench.toggle_assistant_sidebar(socket.assigns.workbench))}
|
||||
end
|
||||
|
||||
def handle_event("select_view", %{"view" => view_id}, socket) do
|
||||
@@ -187,7 +202,10 @@ defmodule BDS.Desktop.ShellLive do
|
||||
BoundedAtoms.sidebar_view(view_id, :posts)
|
||||
)
|
||||
|
||||
{:noreply, reload_shell(socket, workbench)}
|
||||
{:noreply,
|
||||
socket
|
||||
|> refresh_sidebar(workbench)
|
||||
|> push_url_state()}
|
||||
end
|
||||
|
||||
def handle_event("select_panel_tab", %{"tab" => tab}, socket) do
|
||||
@@ -196,7 +214,7 @@ defmodule BDS.Desktop.ShellLive do
|
||||
|> Workbench.set_panel_visible(true)
|
||||
|> Workbench.set_panel_tab(BoundedAtoms.panel_tab(tab, :tasks))
|
||||
|
||||
{:noreply, reload_shell(socket, workbench)}
|
||||
{:noreply, refresh_layout(socket, workbench)}
|
||||
end
|
||||
|
||||
def handle_event("open_sidebar_item", %{"route" => _route, "id" => _id} = params, socket) do
|
||||
@@ -208,15 +226,15 @@ defmodule BDS.Desktop.ShellLive do
|
||||
end
|
||||
|
||||
def handle_event("sync_layout", params, socket) do
|
||||
{:noreply, reload_shell(socket, Layout.sync(socket.assigns.workbench, params))}
|
||||
{:noreply, refresh_layout(socket, Layout.sync(socket.assigns.workbench, params))}
|
||||
end
|
||||
|
||||
def handle_event("resize_panel", %{"target" => target, "width" => width}, socket) do
|
||||
{:noreply, reload_shell(socket, Layout.resize(socket.assigns.workbench, target, width))}
|
||||
{:noreply, refresh_layout(socket, Layout.resize(socket.assigns.workbench, target, width))}
|
||||
end
|
||||
|
||||
def handle_event(event, params, socket) when event in @sidebar_filter_events do
|
||||
SidebarEvents.handle(socket, event, params, &reload_shell/2)
|
||||
SidebarEvents.handle(socket, event, params, &refresh_sidebar/2)
|
||||
end
|
||||
|
||||
def handle_event("create_sidebar_item", %{"kind" => kind}, socket) do
|
||||
@@ -235,6 +253,8 @@ defmodule BDS.Desktop.ShellLive do
|
||||
end
|
||||
|
||||
def handle_event("select_tab", %{"type" => type, "id" => id}, socket) do
|
||||
socket = auto_save_current_post(socket)
|
||||
|
||||
workbench =
|
||||
Workbench.open_tab(
|
||||
socket.assigns.workbench,
|
||||
@@ -243,10 +263,18 @@ defmodule BDS.Desktop.ShellLive do
|
||||
:preview
|
||||
)
|
||||
|
||||
{:noreply, reload_shell(socket, workbench)}
|
||||
tab_meta = TabHelpers.sync_tab_meta(workbench, socket.assigns[:tab_meta] || %{})
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:tab_meta, tab_meta)
|
||||
|> refresh_layout(workbench)
|
||||
|> push_url_state()}
|
||||
end
|
||||
|
||||
def handle_event("close_tab", %{"type" => type, "id" => id}, socket) do
|
||||
socket = auto_save_current_post(socket)
|
||||
|
||||
type_atom = BoundedAtoms.editor_route(type, :post)
|
||||
workbench = Workbench.close_tab(socket.assigns.workbench, type_atom, id)
|
||||
tab_meta = Map.delete(socket.assigns.tab_meta, {type_atom, id})
|
||||
@@ -254,7 +282,8 @@ defmodule BDS.Desktop.ShellLive do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:tab_meta, tab_meta)
|
||||
|> reload_shell(workbench)}
|
||||
|> refresh_layout(workbench)
|
||||
|> push_url_state()}
|
||||
end
|
||||
|
||||
def handle_event(
|
||||
@@ -262,7 +291,14 @@ defmodule BDS.Desktop.ShellLive do
|
||||
%{"route" => route, "id" => id} = params,
|
||||
socket
|
||||
) do
|
||||
{:noreply, SidebarDelete.request_delete(socket, route, id, Map.get(params, "title"), sidebar_delete_callbacks())}
|
||||
{:noreply,
|
||||
SidebarDelete.request_delete(
|
||||
socket,
|
||||
route,
|
||||
id,
|
||||
Map.get(params, "title"),
|
||||
sidebar_delete_callbacks()
|
||||
)}
|
||||
end
|
||||
|
||||
def handle_event("toggle_offline_mode", _params, socket) do
|
||||
@@ -271,7 +307,7 @@ defmodule BDS.Desktop.ShellLive do
|
||||
:ok = AI.set_airplane_mode(next_mode)
|
||||
socket = assign(socket, :offline_mode, next_mode)
|
||||
|
||||
{:noreply, reload_shell(socket, socket.assigns.workbench)}
|
||||
{:noreply, refresh_layout(socket, socket.assigns.workbench)}
|
||||
end
|
||||
|
||||
def handle_event("update_assistant_prompt", %{"assistant" => %{"prompt" => prompt}}, socket) do
|
||||
@@ -302,7 +338,7 @@ defmodule BDS.Desktop.ShellLive do
|
||||
|> Workbench.set_panel_visible(true)
|
||||
|> Workbench.set_panel_tab(:tasks)
|
||||
|
||||
{:noreply, reload_shell(socket, workbench)}
|
||||
{:noreply, refresh_layout(socket, workbench)}
|
||||
end
|
||||
|
||||
def handle_event("settings_shell_command", %{"action" => action}, socket) do
|
||||
@@ -319,7 +355,8 @@ defmodule BDS.Desktop.ShellLive do
|
||||
do: OverlayManager.handle_event("overlay_keydown", params, socket, overlay_callbacks())
|
||||
|
||||
def handle_event("overlay_toggle_ai_field", params, socket),
|
||||
do: OverlayManager.handle_event("overlay_toggle_ai_field", params, socket, overlay_callbacks())
|
||||
do:
|
||||
OverlayManager.handle_event("overlay_toggle_ai_field", params, socket, overlay_callbacks())
|
||||
|
||||
def handle_event("overlay_set_search", params, socket),
|
||||
do: OverlayManager.handle_event("overlay_set_search", params, socket, overlay_callbacks())
|
||||
@@ -334,26 +371,75 @@ defmodule BDS.Desktop.ShellLive do
|
||||
do: OverlayManager.handle_event("overlay_select_result", params, socket, overlay_callbacks())
|
||||
|
||||
def handle_event("overlay_insert_external", params, socket),
|
||||
do: OverlayManager.handle_event("overlay_insert_external", params, socket, overlay_callbacks())
|
||||
do:
|
||||
OverlayManager.handle_event("overlay_insert_external", params, socket, overlay_callbacks())
|
||||
|
||||
def handle_event("overlay_select_language", params, socket),
|
||||
do: OverlayManager.handle_event("overlay_select_language", params, socket, overlay_callbacks())
|
||||
do:
|
||||
OverlayManager.handle_event("overlay_select_language", params, socket, overlay_callbacks())
|
||||
|
||||
def handle_event("overlay_confirm", params, socket),
|
||||
do: OverlayManager.handle_event("overlay_confirm", params, socket, overlay_callbacks())
|
||||
|
||||
def handle_event("overlay_select_gallery_image", params, socket),
|
||||
do: OverlayManager.handle_event("overlay_select_gallery_image", params, socket, overlay_callbacks())
|
||||
do:
|
||||
OverlayManager.handle_event(
|
||||
"overlay_select_gallery_image",
|
||||
params,
|
||||
socket,
|
||||
overlay_callbacks()
|
||||
)
|
||||
|
||||
def handle_event("overlay_close_lightbox", params, socket),
|
||||
do: OverlayManager.handle_event("overlay_close_lightbox", params, socket, overlay_callbacks())
|
||||
|
||||
def handle_event("overlay_lightbox_previous", params, socket),
|
||||
do: OverlayManager.handle_event("overlay_lightbox_previous", params, socket, overlay_callbacks())
|
||||
do:
|
||||
OverlayManager.handle_event(
|
||||
"overlay_lightbox_previous",
|
||||
params,
|
||||
socket,
|
||||
overlay_callbacks()
|
||||
)
|
||||
|
||||
def handle_event("overlay_lightbox_next", params, socket),
|
||||
do: OverlayManager.handle_event("overlay_lightbox_next", params, socket, overlay_callbacks())
|
||||
|
||||
def handle_event("add_gallery_images", %{"post-id" => post_id}, socket) do
|
||||
if socket.assigns.offline_mode do
|
||||
{:noreply,
|
||||
append_output_entry(
|
||||
socket,
|
||||
dgettext("ui", "Add Gallery Images"),
|
||||
dgettext("ui", "Automatic AI actions stay gated by airplane mode."),
|
||||
nil,
|
||||
"info"
|
||||
)}
|
||||
else
|
||||
project_id = socket.assigns.projects.active_project_id
|
||||
{:ok, metadata} = Metadata.get_project_metadata(project_id)
|
||||
concurrency_limit = metadata.image_import_concurrency
|
||||
language = metadata.main_language || "en"
|
||||
parent = self()
|
||||
|
||||
Task.Supervisor.start_child(BDS.TCP.TaskSupervisor, fn ->
|
||||
case FilePicker.choose_files(dgettext("ui", "Add Gallery Images"),
|
||||
image_only: true, multiple: true) do
|
||||
{:ok, paths} when is_list(paths) and paths != [] ->
|
||||
GalleryImport.start(paths, project_id, post_id, language, concurrency_limit, parent)
|
||||
|
||||
:cancel ->
|
||||
send(parent, {:add_images_cancelled})
|
||||
|
||||
{:error, reason} ->
|
||||
send(parent, {:add_images_error, reason})
|
||||
end
|
||||
end)
|
||||
|
||||
{:noreply, assign(socket, :gallery_import_post_id, post_id)}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("toggle_project_menu", _params, socket) do
|
||||
{:noreply, assign(socket, :project_menu_open, not socket.assigns.project_menu_open)}
|
||||
end
|
||||
@@ -464,6 +550,12 @@ defmodule BDS.Desktop.ShellLive do
|
||||
Process.demonitor(ref, [:flush])
|
||||
|
||||
cond do
|
||||
socket.assigns.file_picker_task == ref ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:file_picker_task, nil)
|
||||
|> handle_file_picker_result(result)}
|
||||
|
||||
Map.has_key?(socket.assigns.chat_editor_request_refs, ref) ->
|
||||
{conversation_id, remaining_refs} = Map.pop(socket.assigns.chat_editor_request_refs, ref)
|
||||
|
||||
@@ -483,8 +575,24 @@ defmodule BDS.Desktop.ShellLive do
|
||||
def handle_info({:DOWN, ref, :process, _pid, reason}, socket) when is_reference(ref) do
|
||||
next_socket =
|
||||
cond do
|
||||
socket.assigns.file_picker_task == ref ->
|
||||
if reason == :normal do
|
||||
assign(socket, :file_picker_task, nil)
|
||||
else
|
||||
socket
|
||||
|> assign(:file_picker_task, nil)
|
||||
|> append_output_entry(
|
||||
dgettext("ui", "Import media"),
|
||||
inspect(reason),
|
||||
nil,
|
||||
"error"
|
||||
)
|
||||
|> refresh_content(socket.assigns.workbench)
|
||||
end
|
||||
|
||||
Map.has_key?(socket.assigns.chat_editor_request_refs, ref) ->
|
||||
{conversation_id, remaining_refs} = Map.pop(socket.assigns.chat_editor_request_refs, ref)
|
||||
{conversation_id, remaining_refs} =
|
||||
Map.pop(socket.assigns.chat_editor_request_refs, ref)
|
||||
|
||||
if reason == :normal do
|
||||
assign(socket, :chat_editor_request_refs, remaining_refs)
|
||||
@@ -513,6 +621,68 @@ defmodule BDS.Desktop.ShellLive do
|
||||
OverlayManager.handle_info({:ai_suggestions_error, type, id, reason}, socket)
|
||||
end
|
||||
|
||||
def handle_info({:add_image_processed, title}, socket) do
|
||||
{:noreply,
|
||||
append_output_entry(socket, dgettext("ui", "Add Gallery Images"), dgettext("ui", "Added %{title}", title: title), nil, "info")}
|
||||
end
|
||||
|
||||
def handle_info({:add_images_complete, count}, socket) do
|
||||
post_id = socket.assigns[:gallery_import_post_id]
|
||||
|
||||
socket =
|
||||
if is_binary(post_id) do
|
||||
send_update(PostEditor,
|
||||
id: "post-editor-#{post_id}",
|
||||
action: :insert_content,
|
||||
content: "\n[[gallery]]\n"
|
||||
)
|
||||
|
||||
send_update(PostEditor,
|
||||
id: "post-editor-#{post_id}",
|
||||
action: :refresh
|
||||
)
|
||||
|
||||
socket
|
||||
|> assign(:gallery_import_post_id, nil)
|
||||
else
|
||||
socket
|
||||
end
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> append_output_entry(
|
||||
dgettext("ui", "Add Gallery Images"),
|
||||
dgettext("ui", "Added %{count} images to post", count: count),
|
||||
nil,
|
||||
"info"
|
||||
)}
|
||||
end
|
||||
|
||||
def handle_info({:add_images_error, reason}, socket) do
|
||||
{:noreply,
|
||||
append_output_entry(socket, dgettext("ui", "Add Gallery Images"), inspect(reason), nil, "error")}
|
||||
end
|
||||
|
||||
def handle_info({:add_image_error, path, reason}, socket) do
|
||||
{:noreply,
|
||||
append_output_entry(
|
||||
socket,
|
||||
dgettext("ui", "Add Gallery Images"),
|
||||
dgettext("ui", "Failed to process %{path}: %{reason}", path: Path.basename(path), reason: inspect(reason)),
|
||||
nil,
|
||||
"error"
|
||||
)}
|
||||
end
|
||||
|
||||
def handle_info({:add_images_cancelled}, socket) do
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_info({:test_ping, caller, ref}, socket) do
|
||||
send(caller, {:test_pong, ref})
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_info(message, socket) do
|
||||
Bridges.handle_info(message, socket, bridges_callbacks())
|
||||
end
|
||||
@@ -523,23 +693,216 @@ defmodule BDS.Desktop.ShellLive do
|
||||
index(assigns)
|
||||
end
|
||||
|
||||
defp reload_shell(socket, workbench) do
|
||||
projects = ShellData.project_snapshot()
|
||||
dashboard = ShellData.dashboard(projects.active_project_id)
|
||||
git_badge_count = ShellData.git_badge_count(projects.active_project_id)
|
||||
active_view_id = Atom.to_string(workbench.active_view)
|
||||
tab_meta = TabHelpers.sync_tab_meta(workbench, socket.assigns[:tab_meta] || %{})
|
||||
defp refresh_layout(socket, workbench) do
|
||||
git_badge_count = socket.assigns[:git_badge_count] || 0
|
||||
activity_buttons = Workbench.activity_buttons(workbench, git_badge_count)
|
||||
task_status = socket.assigns[:task_status] || %{running_task_message: nil, running_task_overflow: nil}
|
||||
dashboard = socket.assigns[:dashboard] || BDS.UI.Dashboard.empty_snapshot()
|
||||
page_language = socket.assigns[:page_language] || ShellData.ui_language()
|
||||
offline_mode = Map.get(socket.assigns, :offline_mode, true)
|
||||
sidebar_data = socket.assigns[:sidebar_data] || %{}
|
||||
current_tab = current_tab(workbench)
|
||||
prev_tab = socket.assigns[:current_tab]
|
||||
prev_panel_tab =
|
||||
case socket.assigns[:workbench] do
|
||||
%Workbench{panel: %{active_tab: tab}} -> tab
|
||||
_ -> nil
|
||||
end
|
||||
|
||||
sidebar_data =
|
||||
ShellData.sidebar_view(
|
||||
projects.active_project_id,
|
||||
active_view_id,
|
||||
ShellSidebarState.current_filters(socket, active_view_id)
|
||||
socket =
|
||||
socket
|
||||
|> assign(:workbench, workbench)
|
||||
|> assign(:activity_buttons, activity_buttons)
|
||||
|> assign(
|
||||
:sidebar_header,
|
||||
active_sidebar_label(activity_buttons, workbench.active_view, sidebar_data)
|
||||
)
|
||||
|> assign(:panel_tabs, ShellData.panel_tabs(workbench))
|
||||
|> assign(:current_tab, current_tab)
|
||||
|> assign(:editor_meta, ShellData.editor_meta(task_status))
|
||||
|> assign(
|
||||
:status,
|
||||
ShellData.status_bar(workbench, task_status, dashboard,
|
||||
ui_language: page_language,
|
||||
offline_mode: offline_mode
|
||||
)
|
||||
)
|
||||
|
||||
if panel_data_stale?(current_tab, prev_tab, workbench.panel.active_tab, prev_panel_tab) do
|
||||
refresh_panel_data(socket)
|
||||
else
|
||||
socket
|
||||
end
|
||||
end
|
||||
|
||||
defp panel_data_stale?(current_tab, prev_tab, panel_tab, prev_panel_tab) do
|
||||
current_tab != prev_tab or panel_tab != prev_panel_tab
|
||||
end
|
||||
|
||||
defp refresh_panel_data(socket) do
|
||||
panel_tab = socket.assigns.workbench.panel.active_tab
|
||||
|
||||
socket
|
||||
|> assign(
|
||||
:panel_post_links,
|
||||
if(panel_tab == :post_links,
|
||||
do: PanelRenderer.fetch_post_link_entries(socket.assigns),
|
||||
else: socket.assigns[:panel_post_links] || %{backlinks: [], outlinks: []}
|
||||
)
|
||||
)
|
||||
|> assign(
|
||||
:panel_git_entries,
|
||||
if(panel_tab == :git_log,
|
||||
do: PanelRenderer.fetch_git_log_entries(socket.assigns),
|
||||
else: socket.assigns[:panel_git_entries] || []
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
defp push_url_state(socket) do
|
||||
workbench = socket.assigns.workbench
|
||||
|
||||
params =
|
||||
%{}
|
||||
|> put_url_view(workbench.active_view)
|
||||
|> put_url_tab(workbench.active_tab)
|
||||
|
||||
query = URI.encode_query(params)
|
||||
path = if query == "", do: "/", else: "/?" <> query
|
||||
|
||||
push_event(socket, "url-state", %{path: path})
|
||||
end
|
||||
|
||||
defp put_url_view(params, :posts), do: params
|
||||
defp put_url_view(params, view), do: Map.put(params, "view", Atom.to_string(view))
|
||||
|
||||
defp put_url_tab(params, nil), do: params
|
||||
|
||||
defp put_url_tab(params, {type, id}),
|
||||
do: Map.put(params, "tab", Atom.to_string(type) <> ":" <> id)
|
||||
|
||||
defp apply_url_params(socket, params) when is_map(params) and map_size(params) > 0 do
|
||||
workbench = socket.assigns.workbench
|
||||
|
||||
workbench = apply_url_view(workbench, Map.get(params, "view"))
|
||||
workbench = apply_url_tab(workbench, Map.get(params, "tab"))
|
||||
|
||||
if workbench == socket.assigns.workbench do
|
||||
socket
|
||||
else
|
||||
tab_meta = TabHelpers.sync_tab_meta(workbench, socket.assigns[:tab_meta] || %{})
|
||||
|
||||
socket
|
||||
|> assign(:tab_meta, tab_meta)
|
||||
|> refresh_sidebar(workbench)
|
||||
end
|
||||
end
|
||||
|
||||
defp apply_url_params(socket, _params), do: socket
|
||||
|
||||
defp apply_url_view(workbench, nil), do: workbench
|
||||
|
||||
defp apply_url_view(workbench, view_str) do
|
||||
view = BoundedAtoms.sidebar_view(view_str, nil)
|
||||
|
||||
if view && view != workbench.active_view do
|
||||
Workbench.click_activity(workbench, view)
|
||||
else
|
||||
workbench
|
||||
end
|
||||
end
|
||||
|
||||
defp apply_url_tab(workbench, nil), do: workbench
|
||||
|
||||
defp apply_url_tab(workbench, tab_str) do
|
||||
case String.split(tab_str, ":", parts: 2) do
|
||||
[type_str, id] ->
|
||||
type = BoundedAtoms.editor_route(type_str, nil)
|
||||
|
||||
if type && workbench.active_tab != {type, id} do
|
||||
Workbench.open_tab(workbench, type, id, :preview)
|
||||
else
|
||||
workbench
|
||||
end
|
||||
|
||||
_ ->
|
||||
workbench
|
||||
end
|
||||
end
|
||||
|
||||
defp refresh_sidebar(socket, workbench) do
|
||||
project_id = (socket.assigns[:projects] || %{})[:active_project_id]
|
||||
active_view_id = Atom.to_string(workbench.active_view)
|
||||
|
||||
sidebar_data =
|
||||
case ShellData.sidebar_view(
|
||||
project_id,
|
||||
active_view_id,
|
||||
ShellSidebarState.current_filters(socket, active_view_id)
|
||||
) do
|
||||
{:ok, data} -> data
|
||||
{:error, :not_ready} -> BDS.UI.Sidebar.view(nil, active_view_id, %{})
|
||||
end
|
||||
|
||||
sidebar_data = ShellSidebarState.merge_ui_state(socket, active_view_id, sidebar_data)
|
||||
|
||||
socket
|
||||
|> assign(:sidebar_data, sidebar_data)
|
||||
|> refresh_layout(workbench)
|
||||
end
|
||||
|
||||
defp refresh_content(socket, workbench) do
|
||||
projects =
|
||||
case ShellData.project_snapshot() do
|
||||
{:ok, data} -> data
|
||||
{:error, :not_ready} -> ShellData.default_project_snapshot()
|
||||
end
|
||||
|
||||
dashboard =
|
||||
case ShellData.dashboard(projects.active_project_id) do
|
||||
{:ok, data} -> data
|
||||
{:error, :not_ready} -> BDS.UI.Dashboard.empty_snapshot()
|
||||
end
|
||||
|
||||
git_badge_count =
|
||||
case ShellData.git_badge_count(projects.active_project_id) do
|
||||
{:ok, count} -> count
|
||||
{:error, :not_ready} -> 0
|
||||
end
|
||||
|
||||
active_view_id = Atom.to_string(workbench.active_view)
|
||||
|
||||
sidebar_data =
|
||||
case ShellData.sidebar_view(
|
||||
projects.active_project_id,
|
||||
active_view_id,
|
||||
ShellSidebarState.current_filters(socket, active_view_id)
|
||||
) do
|
||||
{:ok, data} -> data
|
||||
{:error, :not_ready} -> BDS.UI.Sidebar.view(nil, active_view_id, %{})
|
||||
end
|
||||
|
||||
sidebar_data = ShellSidebarState.merge_ui_state(socket, active_view_id, sidebar_data)
|
||||
|
||||
socket
|
||||
|> assign(:projects, projects)
|
||||
|> assign(:current_project, ShellData.current_project(projects))
|
||||
|> assign(:dashboard, dashboard)
|
||||
|> assign(:dashboard_timeline_entries, Map.get(dashboard, :timeline_entries, []))
|
||||
|> assign(:dashboard_category_counts, Map.get(dashboard, :category_counts, []))
|
||||
|> assign(:dashboard_recent_posts, Map.get(dashboard, :recent_posts, []))
|
||||
|> assign(
|
||||
:dashboard_tag_cloud_items,
|
||||
ShellData.dashboard_tag_cloud_items(Map.get(dashboard, :tag_cloud_items, []))
|
||||
)
|
||||
|> assign(:git_badge_count, git_badge_count)
|
||||
|> assign(:sidebar_data, sidebar_data)
|
||||
|> refresh_layout(workbench)
|
||||
end
|
||||
|
||||
defp reload_shell(socket, workbench) do
|
||||
tab_meta = TabHelpers.sync_tab_meta(workbench, socket.assigns[:tab_meta] || %{})
|
||||
raw_task_status = BDS.Tasks.status_snapshot()
|
||||
activity_buttons = Workbench.activity_buttons(workbench, git_badge_count)
|
||||
page_language = socket.assigns[:page_language] || ShellData.ui_language()
|
||||
|
||||
offline_mode =
|
||||
@@ -552,39 +915,14 @@ defmodule BDS.Desktop.ShellLive do
|
||||
task_status = localize_task_status(raw_task_status, page_language)
|
||||
|
||||
socket
|
||||
|> assign(:tab_meta, tab_meta)
|
||||
|> assign(:workbench, workbench)
|
||||
|> assign(:projects, projects)
|
||||
|> assign(:current_project, ShellData.current_project(projects))
|
||||
|> assign(:dashboard, dashboard)
|
||||
|> assign(:dashboard_timeline_entries, Map.get(dashboard, :timeline_entries, []))
|
||||
|> assign(:dashboard_category_counts, Map.get(dashboard, :category_counts, []))
|
||||
|> assign(:dashboard_recent_posts, Map.get(dashboard, :recent_posts, []))
|
||||
|> assign(
|
||||
:dashboard_tag_cloud_items,
|
||||
ShellData.dashboard_tag_cloud_items(Map.get(dashboard, :tag_cloud_items, []))
|
||||
)
|
||||
|> assign(:sidebar_data, sidebar_data)
|
||||
|> assign(
|
||||
:sidebar_header,
|
||||
active_sidebar_label(activity_buttons, workbench.active_view, sidebar_data)
|
||||
)
|
||||
|> assign(:assistant_cards, ShellData.assistant_cards())
|
||||
|> assign(:editor_meta, ShellData.editor_meta(task_status))
|
||||
|> assign(:tab_meta, tab_meta)
|
||||
|> assign(:task_status, task_status)
|
||||
|> assign(
|
||||
:status,
|
||||
ShellData.status_bar(workbench, task_status, dashboard,
|
||||
ui_language: page_language,
|
||||
offline_mode: offline_mode
|
||||
)
|
||||
)
|
||||
|> assign(:activity_buttons, activity_buttons)
|
||||
|> assign(:panel_tabs, ShellData.panel_tabs(workbench))
|
||||
|> assign(:offline_mode, offline_mode)
|
||||
|> assign(:assistant_cards, ShellData.assistant_cards())
|
||||
|> assign(:supported_ui_languages, ShellData.supported_ui_languages())
|
||||
|> assign(:menu_groups, socket.assigns[:menu_groups] || TitlebarMenu.groups())
|
||||
|> assign(:titlebar_menu_item_index, socket.assigns[:titlebar_menu_item_index])
|
||||
|> assign(:current_tab, current_tab(workbench))
|
||||
|> refresh_content(workbench)
|
||||
end
|
||||
|
||||
defp encoded_shortcuts(shortcuts), do: Jason.encode!(shortcuts)
|
||||
@@ -626,9 +964,27 @@ defmodule BDS.Desktop.ShellLive do
|
||||
defp create_sidebar_item(socket, kind),
|
||||
do: SidebarCreate.create(socket, kind, sidebar_create_callbacks())
|
||||
|
||||
defp handle_file_picker_result(socket, {:ok, _media}),
|
||||
do: refresh_content(socket, socket.assigns.workbench)
|
||||
|
||||
defp handle_file_picker_result(socket, :cancel), do: socket
|
||||
|
||||
defp handle_file_picker_result(socket, {:error, %{message: message}}),
|
||||
do:
|
||||
socket
|
||||
|> append_output_entry(dgettext("ui", "Import media"), message, nil, "error")
|
||||
|> refresh_content(socket.assigns.workbench)
|
||||
|
||||
defp handle_file_picker_result(socket, {:error, reason}),
|
||||
do:
|
||||
socket
|
||||
|> append_output_entry(dgettext("ui", "Import media"), inspect(reason), nil, "error")
|
||||
|> refresh_content(socket.assigns.workbench)
|
||||
|
||||
defp sidebar_create_callbacks do
|
||||
%{
|
||||
reload: &reload_shell/2,
|
||||
refresh_content: &refresh_content/2,
|
||||
open_sidebar: &open_sidebar_item/3,
|
||||
append_output: &append_output_entry/5
|
||||
}
|
||||
@@ -653,9 +1009,12 @@ defmodule BDS.Desktop.ShellLive do
|
||||
subtitle: Map.get(params, "subtitle", "")
|
||||
})
|
||||
|
||||
tab_meta = TabHelpers.sync_tab_meta(workbench, tab_meta)
|
||||
|
||||
socket
|
||||
|> assign(:tab_meta, tab_meta)
|
||||
|> reload_shell(workbench)
|
||||
|> refresh_layout(workbench)
|
||||
|> push_url_state()
|
||||
end
|
||||
|
||||
defp sidebar_create_action(view), do: SidebarCreate.action(view)
|
||||
@@ -700,6 +1059,7 @@ defmodule BDS.Desktop.ShellLive do
|
||||
|> assign(:sidebar_filters_by_view, %{})
|
||||
|> append_output_entry(title, message_fun.(project))
|
||||
|> reload_shell(Workbench.new())
|
||||
|> push_url_state()
|
||||
|
||||
{:error, reason} ->
|
||||
socket
|
||||
@@ -724,8 +1084,17 @@ defmodule BDS.Desktop.ShellLive do
|
||||
|
||||
defp handle_menu_action(socket, action) when is_atom(action) do
|
||||
cond do
|
||||
MapSet.member?(@local_menu_actions, action) ->
|
||||
reload_shell(socket, MenuBar.execute(socket.assigns.workbench, action))
|
||||
MapSet.member?(@layout_menu_actions, action) ->
|
||||
refresh_layout(socket, MenuBar.execute(socket.assigns.workbench, action))
|
||||
|
||||
MapSet.member?(@sidebar_menu_actions, action) ->
|
||||
workbench = MenuBar.execute(socket.assigns.workbench, action)
|
||||
tab_meta = TabHelpers.sync_tab_meta(workbench, socket.assigns[:tab_meta] || %{})
|
||||
|
||||
socket
|
||||
|> assign(:tab_meta, tab_meta)
|
||||
|> refresh_sidebar(workbench)
|
||||
|> push_url_state()
|
||||
|
||||
MapSet.member?(@socket_menu_actions, action) ->
|
||||
handle_socket_menu_action(socket, action)
|
||||
@@ -737,7 +1106,13 @@ defmodule BDS.Desktop.ShellLive do
|
||||
apply_shell_command(socket, Atom.to_string(action))
|
||||
|
||||
true ->
|
||||
append_output_entry(socket, "Menu", "Unsupported shell command", Atom.to_string(action), "error")
|
||||
append_output_entry(
|
||||
socket,
|
||||
"Menu",
|
||||
"Unsupported shell command",
|
||||
Atom.to_string(action),
|
||||
"error"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -752,12 +1127,12 @@ defmodule BDS.Desktop.ShellLive do
|
||||
end
|
||||
|
||||
defp handle_socket_menu_action(socket, :view_on_github) do
|
||||
OS.launch_default_browser("https://github.com/rfc1437/bDS")
|
||||
OS.launch_default_browser(ExternalLinks.github_url())
|
||||
socket
|
||||
end
|
||||
|
||||
defp handle_socket_menu_action(socket, :report_issue) do
|
||||
OS.launch_default_browser("https://github.com/rfc1437/bDS/issues")
|
||||
OS.launch_default_browser(ExternalLinks.github_issues_url())
|
||||
socket
|
||||
end
|
||||
|
||||
@@ -773,6 +1148,18 @@ defmodule BDS.Desktop.ShellLive do
|
||||
|
||||
defp shell_command?(action), do: not is_nil(shell_command_atom(action))
|
||||
|
||||
defp auto_save_current_post(
|
||||
%{assigns: %{current_tab: %{type: :post, id: post_id}, workbench: workbench}} = socket
|
||||
) do
|
||||
if Workbench.dirty?(workbench, :post, post_id) do
|
||||
send_update(PostEditor, id: "post-editor-#{post_id}", action: :save)
|
||||
end
|
||||
|
||||
socket
|
||||
end
|
||||
|
||||
defp auto_save_current_post(socket), do: socket
|
||||
|
||||
defp save_current_tab(%{assigns: %{current_tab: %{type: :post, id: post_id}}} = socket) do
|
||||
send_update(PostEditor, id: "post-editor-#{post_id}", action: :save)
|
||||
socket
|
||||
@@ -808,14 +1195,14 @@ defmodule BDS.Desktop.ShellLive do
|
||||
socket
|
||||
end
|
||||
|
||||
defp save_current_tab(socket), do: reload_shell(socket, socket.assigns.workbench)
|
||||
defp save_current_tab(socket), do: refresh_layout(socket, socket.assigns.workbench)
|
||||
|
||||
defp publish_current_tab(%{assigns: %{current_tab: %{type: :post, id: post_id}}} = socket) do
|
||||
send_update(PostEditor, id: "post-editor-#{post_id}", action: :publish)
|
||||
socket
|
||||
end
|
||||
|
||||
defp publish_current_tab(socket), do: reload_shell(socket, socket.assigns.workbench)
|
||||
defp publish_current_tab(socket), do: refresh_layout(socket, socket.assigns.workbench)
|
||||
|
||||
defp apply_shell_command(socket, action, params \\ %{}),
|
||||
do: ShellCommandRunner.execute(socket, action, params, shell_command_callbacks())
|
||||
@@ -826,6 +1213,7 @@ defmodule BDS.Desktop.ShellLive do
|
||||
defp shell_command_callbacks do
|
||||
%{
|
||||
reload: &reload_shell/2,
|
||||
refresh_content: &refresh_content/2,
|
||||
append_output: &append_output_entry/5
|
||||
}
|
||||
end
|
||||
@@ -842,6 +1230,7 @@ defmodule BDS.Desktop.ShellLive do
|
||||
defp overlay_callbacks,
|
||||
do: %{
|
||||
reload: &reload_shell/2,
|
||||
refresh_content: &refresh_content/2,
|
||||
append_output: &append_output_entry/5,
|
||||
execute_sidebar_delete: fn socket, route, id ->
|
||||
SidebarDelete.execute_delete(socket, route, id, sidebar_delete_callbacks())
|
||||
@@ -851,12 +1240,16 @@ defmodule BDS.Desktop.ShellLive do
|
||||
defp sidebar_delete_callbacks,
|
||||
do: %{
|
||||
reload: &reload_shell/2,
|
||||
refresh_content: &refresh_content/2,
|
||||
append_output: &append_output_entry/5
|
||||
}
|
||||
|
||||
defp bridges_callbacks,
|
||||
do: %{
|
||||
reload: &reload_shell/2,
|
||||
refresh_layout: &refresh_layout/2,
|
||||
refresh_sidebar: &refresh_sidebar/2,
|
||||
refresh_content: &refresh_content/2,
|
||||
append_output: &append_output_entry/5,
|
||||
open_sidebar: &open_sidebar_item/3,
|
||||
apply_shell_command: &apply_shell_command/3,
|
||||
@@ -871,5 +1264,4 @@ defmodule BDS.Desktop.ShellLive do
|
||||
pid -> send(pid, {:set_ui_locale, locale})
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
@@ -2,32 +2,114 @@ defmodule BDS.Desktop.ShellLive.Bridges do
|
||||
@moduledoc false
|
||||
|
||||
import Phoenix.Component, only: [assign: 3]
|
||||
import Phoenix.LiveView, only: [send_update: 2]
|
||||
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()}
|
||||
def handle_info({:import_editor_output, title, message, level}, socket, callbacks) do
|
||||
{:noreply, callbacks.append_output.(socket, title, message, nil, level)}
|
||||
|
||||
# ── 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({:import_editor_tab_meta, definition_id, title, subtitle}, socket, callbacks) do
|
||||
tab_meta =
|
||||
Map.put(socket.assigns.tab_meta, {:import, definition_id}, %{
|
||||
title: title,
|
||||
subtitle: subtitle || ""
|
||||
})
|
||||
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
|
||||
|
||||
@default_auto_save_delay 3000
|
||||
|
||||
def handle_info({:schedule_auto_save, type, id}, socket, _callbacks) do
|
||||
timers = socket.assigns[:auto_save_timers] || %{}
|
||||
key = {type, id}
|
||||
|
||||
case Map.get(timers, key) do
|
||||
nil -> :ok
|
||||
old_ref -> Process.cancel_timer(old_ref)
|
||||
end
|
||||
|
||||
delay = Application.get_env(:bds, :auto_save_delay, @default_auto_save_delay)
|
||||
ref = Process.send_after(self(), {:auto_save_fire, type, id}, delay)
|
||||
{:noreply, assign(socket, :auto_save_timers, Map.put(timers, key, ref))}
|
||||
end
|
||||
|
||||
def handle_info({:cancel_auto_save, type, id}, socket, _callbacks) do
|
||||
timers = socket.assigns[:auto_save_timers] || %{}
|
||||
key = {type, id}
|
||||
|
||||
case Map.get(timers, key) do
|
||||
nil -> :ok
|
||||
old_ref -> Process.cancel_timer(old_ref)
|
||||
end
|
||||
|
||||
{:noreply, assign(socket, :auto_save_timers, Map.delete(timers, key))}
|
||||
end
|
||||
|
||||
def handle_info({:auto_save_fire, :post, post_id}, socket, _callbacks) do
|
||||
timers = socket.assigns[:auto_save_timers] || %{}
|
||||
send_update(PostEditor, id: "post-editor-#{post_id}", action: :save)
|
||||
{:noreply, assign(socket, :auto_save_timers, Map.delete(timers, {:post, post_id}))}
|
||||
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,
|
||||
socket
|
||||
|> assign(:tab_meta, tab_meta)
|
||||
|> callbacks.reload.(socket.assigns.workbench)}
|
||||
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}",
|
||||
@@ -68,149 +150,47 @@ defmodule BDS.Desktop.ShellLive.Bridges do
|
||||
{:noreply, assign(socket, :chat_editor_request_refs, refs)}
|
||||
end
|
||||
|
||||
def handle_info({:chat_editor_output, title, message, level}, socket, callbacks) do
|
||||
{:noreply, callbacks.append_output.(socket, title, message, nil, level)}
|
||||
end
|
||||
def handle_info({:persist_surface_state, conversation_id}, socket, _callbacks) do
|
||||
send_update(ChatEditor,
|
||||
id: "chat-editor-#{conversation_id}",
|
||||
action: :persist_surface_state
|
||||
)
|
||||
|
||||
def handle_info({:chat_editor_tab_meta, conversation_id, title, subtitle}, socket, callbacks) do
|
||||
tab_meta =
|
||||
Map.put(socket.assigns.tab_meta, {:chat, conversation_id}, %{
|
||||
title: title,
|
||||
subtitle: subtitle || ""
|
||||
})
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:tab_meta, tab_meta)
|
||||
|> callbacks.reload.(socket.assigns.workbench)}
|
||||
end
|
||||
|
||||
def handle_info({:open_sidebar_item, params, intent}, socket, callbacks) do
|
||||
{:noreply, callbacks.open_sidebar.(socket, params, intent)}
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_info({:chat_editor_toggle_sidebar}, socket, callbacks) do
|
||||
{:noreply, callbacks.reload.(socket, Workbench.toggle_sidebar(socket.assigns.workbench))}
|
||||
{:noreply,
|
||||
callbacks.refresh_layout.(socket, Workbench.toggle_sidebar(socket.assigns.workbench))}
|
||||
end
|
||||
|
||||
def handle_info({:chat_editor_toggle_panel}, socket, callbacks) do
|
||||
{:noreply, callbacks.reload.(socket, Workbench.toggle_panel(socket.assigns.workbench))}
|
||||
{: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.reload.(socket, Workbench.toggle_assistant_sidebar(socket.assigns.workbench))}
|
||||
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.reload.(socket, Workbench.click_activity(socket.assigns.workbench, view))}
|
||||
{:noreply,
|
||||
callbacks.refresh_sidebar.(socket, Workbench.click_activity(socket.assigns.workbench, view))}
|
||||
end
|
||||
|
||||
def handle_info({:entity_changed, payload}, socket, callbacks) when is_map(payload) do
|
||||
{:noreply, CliSync.apply_entity_change(socket, payload, callbacks.reload)}
|
||||
end
|
||||
|
||||
def handle_info(:refresh_task_status, socket, callbacks) do
|
||||
raw_task_status = BDS.Tasks.status_snapshot()
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
{:noreply,
|
||||
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 ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> SessionUtil.mark_task_result_handled(task.id)
|
||||
|> callbacks.apply_shell_command_result.(task.result)}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_info({:tags_editor_output, title, message, level}, socket, callbacks) do
|
||||
{:noreply, callbacks.append_output.(socket, title, message, nil, level)}
|
||||
end
|
||||
|
||||
def handle_info(:tags_changed, socket, callbacks) do
|
||||
{:noreply, callbacks.reload.(socket, socket.assigns.workbench)}
|
||||
end
|
||||
|
||||
def handle_info({:settings_output, title, message, level}, socket, callbacks) do
|
||||
{:noreply, callbacks.append_output.(socket, title, message, nil, level)}
|
||||
end
|
||||
|
||||
def handle_info(:settings_changed, socket, callbacks) do
|
||||
{:noreply, callbacks.reload.(socket, socket.assigns.workbench)}
|
||||
end
|
||||
|
||||
def handle_info({:menu_editor_output, title, message, level}, socket, callbacks) do
|
||||
{:noreply, callbacks.append_output.(socket, title, message, nil, level)}
|
||||
end
|
||||
|
||||
def handle_info({:script_editor_output, title, message, level}, socket, callbacks) do
|
||||
{:noreply, callbacks.append_output.(socket, title, message, nil, level)}
|
||||
end
|
||||
|
||||
def handle_info({:template_editor_output, title, message, level}, socket, callbacks) do
|
||||
{:noreply, callbacks.append_output.(socket, title, message, nil, level)}
|
||||
end
|
||||
|
||||
def handle_info({:misc_editor_output, title, message, _detail, level}, socket, callbacks) do
|
||||
{:noreply, callbacks.append_output.(socket, title, message, nil, level)}
|
||||
end
|
||||
|
||||
def handle_info({:misc_editor_command, action, params}, socket, callbacks) do
|
||||
{:noreply, callbacks.apply_shell_command.(socket, action, params)}
|
||||
end
|
||||
|
||||
def handle_info({:misc_editor_tab_meta, tab_type, tab_id, updates}, socket, _callbacks) do
|
||||
key = {tab_type, tab_id}
|
||||
current_meta = Map.get(socket.assigns.tab_meta, key, %{})
|
||||
next_meta = Map.merge(current_meta, updates)
|
||||
{:noreply, assign(socket, :tab_meta, Map.put(socket.assigns.tab_meta, key, next_meta))}
|
||||
end
|
||||
|
||||
def handle_info({:post_editor_output, title, message, level}, socket, callbacks) do
|
||||
{:noreply, callbacks.append_output.(socket, title, message, nil, level)}
|
||||
end
|
||||
|
||||
def handle_info({:post_editor_dirty, post_id, dirty?}, socket, _callbacks) do
|
||||
workbench =
|
||||
if dirty? do
|
||||
Workbench.mark_dirty(socket.assigns.workbench, :post, post_id)
|
||||
else
|
||||
Workbench.clear_dirty(socket.assigns.workbench, :post, post_id)
|
||||
end
|
||||
|
||||
{:noreply, assign(socket, :workbench, workbench)}
|
||||
end
|
||||
|
||||
def handle_info({:post_editor_tab_meta, post_id, title, subtitle}, socket, _callbacks) do
|
||||
tab_meta =
|
||||
Map.put(socket.assigns.tab_meta, {:post, post_id}, %{title: title, subtitle: subtitle})
|
||||
|
||||
{:noreply, assign(socket, :tab_meta, tab_meta)}
|
||||
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)
|
||||
send_update(PostEditor,
|
||||
id: "post-editor-#{post_id}",
|
||||
action: :insert_content,
|
||||
content: content
|
||||
)
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@@ -220,7 +200,8 @@ defmodule BDS.Desktop.ShellLive.Bridges do
|
||||
end
|
||||
|
||||
def handle_info({:post_editor_apply_ai_suggestions, post_id, fields}, socket, _callbacks) do
|
||||
send_update(PostEditor, id: "post-editor-#{post_id}",
|
||||
send_update(PostEditor,
|
||||
id: "post-editor-#{post_id}",
|
||||
action: :apply_ai_suggestions,
|
||||
fields: fields
|
||||
)
|
||||
@@ -228,34 +209,49 @@ defmodule BDS.Desktop.ShellLive.Bridges do
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_info({:media_editor_output, title, message, level}, socket, callbacks) do
|
||||
{:noreply, callbacks.append_output.(socket, title, message, nil, level)}
|
||||
# ── 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({:media_editor_dirty, media_id, dirty?}, socket, _callbacks) do
|
||||
workbench =
|
||||
if dirty? do
|
||||
Workbench.mark_dirty(socket.assigns.workbench, :media, media_id)
|
||||
else
|
||||
Workbench.clear_dirty(socket.assigns.workbench, :media, media_id)
|
||||
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
|
||||
|
||||
{:noreply, assign(socket, :workbench, workbench)}
|
||||
end
|
||||
if connected?(socket) do
|
||||
Process.send_after(self(), :refresh_task_status, BDS.Desktop.ShellLive.refresh_interval())
|
||||
end
|
||||
|
||||
def handle_info({:media_editor_tab_meta, media_id, title, subtitle}, socket, _callbacks) do
|
||||
tab_meta =
|
||||
Map.put(socket.assigns.tab_meta, {:media, media_id}, %{title: title, subtitle: subtitle})
|
||||
|
||||
{:noreply, assign(socket, :tab_meta, tab_meta)}
|
||||
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.reload.(socket, Workbench.close_tab(socket.assigns.workbench, type, id))}
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_info(_message, socket, _callbacks), do: {:noreply, socket}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
defmodule BDS.Desktop.ShellLive.ChatEditor do
|
||||
@moduledoc false
|
||||
|
||||
require Logger
|
||||
|
||||
use Phoenix.LiveComponent
|
||||
|
||||
import Phoenix.HTML, only: [raw: 1]
|
||||
|
||||
alias BDS.{AI, BoundedAtoms, MapUtils, Persistence}
|
||||
alias BDS.Desktop.ShellLive.ChatEditor.{MessageBuild, ModelSelection, ToolTracking}
|
||||
alias BDS.Desktop.ShellLive.Notify
|
||||
alias BDS.Desktop.ShellLive.TabHelpers
|
||||
use Gettext, backend: BDS.Gettext
|
||||
|
||||
@@ -16,6 +19,10 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
||||
|
||||
@spec update(map(), Phoenix.LiveView.Socket.t()) :: {:ok, Phoenix.LiveView.Socket.t()}
|
||||
@impl true
|
||||
def update(%{action: :finish_request}, %{assigns: %{request: nil}} = socket) do
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
def update(%{action: :finish_request, result: result}, socket) do
|
||||
{:ok, do_finish_request(socket, result)}
|
||||
end
|
||||
@@ -32,6 +39,10 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
||||
{:ok, do_note_streaming_content(socket, content)}
|
||||
end
|
||||
|
||||
def update(%{action: :persist_surface_state}, socket) do
|
||||
{:ok, persist_surface_state(socket)}
|
||||
end
|
||||
|
||||
def update(assigns, socket) do
|
||||
socket =
|
||||
socket
|
||||
@@ -61,7 +72,8 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
||||
|
||||
def handle_event("toggle_chat_model_selector", _params, socket) do
|
||||
{:noreply,
|
||||
assign(socket, :model_selector_open?, not socket.assigns.model_selector_open?) |> build_data()}
|
||||
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
|
||||
@@ -72,7 +84,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
||||
{:noreply, assign(socket, :model_selector_open?, false) |> build_data()}
|
||||
|
||||
{:error, reason} ->
|
||||
notify_parent({:chat_editor_output, dgettext("ui", "Chat"), inspect(reason), "error"})
|
||||
Notify.output(dgettext("ui", "Chat"), inspect(reason), "error")
|
||||
{:noreply, assign(socket, :model_selector_open?, false) |> build_data()}
|
||||
end
|
||||
end
|
||||
@@ -91,7 +103,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
||||
socket
|
||||
) do
|
||||
next_data = Map.put(socket.assigns.surface_data, surface_id, fields)
|
||||
{:noreply, assign(socket, :surface_data, next_data) |> build_data()}
|
||||
{:noreply, assign(socket, :surface_data, next_data) |> schedule_surface_state_persist() |> build_data()}
|
||||
end
|
||||
|
||||
def handle_event(
|
||||
@@ -101,7 +113,11 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
||||
) do
|
||||
socket =
|
||||
socket
|
||||
|> assign(:surface_tabs, Map.put(socket.assigns.surface_tabs, surface_id, parse_integer(index)))
|
||||
|> assign(
|
||||
:surface_tabs,
|
||||
Map.put(socket.assigns.surface_tabs, surface_id, parse_integer(index))
|
||||
)
|
||||
|> persist_surface_state()
|
||||
|> build_data()
|
||||
|
||||
{:noreply, socket}
|
||||
@@ -111,6 +127,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
||||
socket =
|
||||
socket
|
||||
|> assign(:dismissed_surfaces, MapSet.put(socket.assigns.dismissed_surfaces, surface_id))
|
||||
|> persist_surface_state()
|
||||
|> build_data()
|
||||
|
||||
{:noreply, socket}
|
||||
@@ -121,14 +138,14 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
||||
end
|
||||
|
||||
def handle_event("open_chat_settings", _params, socket) do
|
||||
notify_parent(
|
||||
{:open_sidebar_item,
|
||||
%{
|
||||
"route" => "settings",
|
||||
"id" => "settings-ai",
|
||||
"title" => "Settings",
|
||||
"subtitle" => "AI"
|
||||
}, :pin}
|
||||
Notify.open_sidebar_item(
|
||||
%{
|
||||
"route" => "settings",
|
||||
"id" => "settings-ai",
|
||||
"title" => "Settings",
|
||||
"subtitle" => "AI"
|
||||
},
|
||||
:pin
|
||||
)
|
||||
|
||||
{:noreply, socket}
|
||||
@@ -139,14 +156,29 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
||||
defp ensure_state(socket) do
|
||||
conversation_id = socket.assigns.current_tab.id
|
||||
|
||||
persisted = AI.get_surface_state(conversation_id)
|
||||
|
||||
{surface_data, surface_tabs, dismissed_surfaces} =
|
||||
case persisted do
|
||||
state when is_map(state) and map_size(state) > 0 ->
|
||||
{
|
||||
state["surface_data"] || %{},
|
||||
state["surface_tabs"] || %{},
|
||||
MapSet.new(state["dismissed_surfaces"] || [])
|
||||
}
|
||||
|
||||
_other ->
|
||||
{%{}, %{}, MapSet.new()}
|
||||
end
|
||||
|
||||
defaults = %{
|
||||
conversation_id: conversation_id,
|
||||
input: "",
|
||||
model_selector_open?: false,
|
||||
request: nil,
|
||||
surface_data: %{},
|
||||
surface_tabs: %{},
|
||||
dismissed_surfaces: MapSet.new(),
|
||||
surface_data: surface_data,
|
||||
surface_tabs: surface_tabs,
|
||||
dismissed_surfaces: dismissed_surfaces,
|
||||
action_error: nil
|
||||
}
|
||||
|
||||
@@ -195,10 +227,8 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
||||
build_data(socket)
|
||||
|
||||
socket.assigns.offline_mode ->
|
||||
notify_parent(
|
||||
{:chat_editor_output, dgettext("ui", "Chat"),
|
||||
dgettext("ui", "Automatic AI actions stay gated by airplane mode."), "info"}
|
||||
)
|
||||
Notify.output(dgettext("ui", "Chat"),
|
||||
dgettext("ui", "Automatic AI actions stay gated by airplane mode."), "info")
|
||||
|
||||
build_data(socket)
|
||||
|
||||
@@ -219,7 +249,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
||||
|
||||
:ok = allow_repo_sandbox(task.pid)
|
||||
|
||||
notify_parent({:chat_editor_task_started, conversation_id, task.ref})
|
||||
Notify.parent({:chat_editor_task_started, conversation_id, task.ref})
|
||||
|
||||
socket
|
||||
|> assign(:input, "")
|
||||
@@ -246,17 +276,30 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
||||
%{ref: ref} = _request ->
|
||||
:ok = AI.cancel_chat(conversation_id)
|
||||
|
||||
notify_parent({:chat_editor_task_cancelled, conversation_id, ref})
|
||||
|
||||
# Allow the terminated task's DB connection to be cleaned up before rebuilding.
|
||||
Process.sleep(20)
|
||||
Notify.parent({:chat_editor_task_cancelled, conversation_id, ref})
|
||||
|
||||
socket
|
||||
|> assign(:request, nil)
|
||||
|> build_data()
|
||||
|> clear_streaming_state()
|
||||
end
|
||||
end
|
||||
|
||||
defp clear_streaming_state(socket) do
|
||||
input = socket.assigns.input || ""
|
||||
chat_editor = socket.assigns.chat_editor || %{}
|
||||
|
||||
chat_editor =
|
||||
chat_editor
|
||||
|> Map.put(:is_streaming, false)
|
||||
|> Map.put(:pending_user_message, nil)
|
||||
|> Map.put(:streaming_content, "")
|
||||
|> Map.put(:streaming_tool_markers, [])
|
||||
|> Map.put(:streaming_inline_surfaces, [])
|
||||
|> Map.put(:send_disabled?, String.trim(input) == "")
|
||||
|
||||
assign(socket, :chat_editor, chat_editor)
|
||||
end
|
||||
|
||||
defp do_finish_request(socket, result) do
|
||||
case result do
|
||||
{:ok, reply} ->
|
||||
@@ -272,7 +315,8 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
||||
assign(socket, :request, nil) |> build_data()
|
||||
|
||||
{:error, reason} ->
|
||||
notify_parent({:chat_editor_output, dgettext("ui", "Chat"), format_error(reason), "error"})
|
||||
Notify.output(dgettext("ui", "Chat"), format_error(reason), "error")
|
||||
|
||||
assign(socket, :request, nil) |> build_data()
|
||||
end
|
||||
end
|
||||
@@ -307,7 +351,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
||||
defp update_request(socket, updater) do
|
||||
case socket.assigns.request do
|
||||
nil ->
|
||||
socket
|
||||
build_data(socket)
|
||||
|
||||
request ->
|
||||
socket
|
||||
@@ -323,7 +367,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
||||
|> MapUtils.attr(:title)
|
||||
|
||||
if is_binary(title) and String.trim(title) != "" do
|
||||
notify_parent({:chat_editor_tab_meta, socket.assigns.conversation_id, title, ""})
|
||||
Notify.tab_meta(:chat, socket.assigns.conversation_id, title, "")
|
||||
end
|
||||
|
||||
socket
|
||||
@@ -344,14 +388,14 @@ defmodule BDS.Desktop.ShellLive.ChatEditor 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_parent(
|
||||
{:open_sidebar_item,
|
||||
%{
|
||||
"route" => "post",
|
||||
"id" => post_id,
|
||||
"title" => TabHelpers.post_title(post_id),
|
||||
"subtitle" => TabHelpers.post_subtitle(post_id)
|
||||
}, :pin}
|
||||
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()
|
||||
@@ -363,14 +407,14 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
||||
:open_media ->
|
||||
case Map.get(payload, "mediaId") || Map.get(payload, "media_id") do
|
||||
media_id when is_binary(media_id) and media_id != "" ->
|
||||
notify_parent(
|
||||
{:open_sidebar_item,
|
||||
%{
|
||||
"route" => "media",
|
||||
"id" => media_id,
|
||||
"title" => TabHelpers.media_title(media_id),
|
||||
"subtitle" => TabHelpers.media_subtitle(media_id)
|
||||
}, :pin}
|
||||
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()
|
||||
@@ -380,14 +424,14 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
||||
end
|
||||
|
||||
:open_settings ->
|
||||
notify_parent(
|
||||
{:open_sidebar_item,
|
||||
%{
|
||||
"route" => "settings",
|
||||
"id" => "settings-ai",
|
||||
"title" => "Settings",
|
||||
"subtitle" => "AI"
|
||||
}, :pin}
|
||||
Notify.open_sidebar_item(
|
||||
%{
|
||||
"route" => "settings",
|
||||
"id" => "settings-ai",
|
||||
"title" => "Settings",
|
||||
"subtitle" => "AI"
|
||||
},
|
||||
:pin
|
||||
)
|
||||
|
||||
assign(socket, :action_error, nil) |> build_data()
|
||||
@@ -397,14 +441,14 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
||||
Map.get(payload, "conversationId") || Map.get(payload, "conversation_id") ||
|
||||
socket.assigns.conversation_id
|
||||
|
||||
notify_parent(
|
||||
{:open_sidebar_item,
|
||||
%{
|
||||
"route" => "chat",
|
||||
"id" => chat_id,
|
||||
"title" => Map.get(payload, "title", "Chat"),
|
||||
"subtitle" => Map.get(payload, "subtitle", "")
|
||||
}, :pin}
|
||||
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()
|
||||
@@ -415,20 +459,20 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
||||
set_action_error(socket, "Invalid payload for switchView action")
|
||||
|
||||
view ->
|
||||
notify_parent({:chat_editor_switch_view, view})
|
||||
Notify.parent({:chat_editor_switch_view, view})
|
||||
assign(socket, :action_error, nil) |> build_data()
|
||||
end
|
||||
|
||||
:toggle_sidebar ->
|
||||
notify_parent({:chat_editor_toggle_sidebar})
|
||||
Notify.parent({:chat_editor_toggle_sidebar})
|
||||
assign(socket, :action_error, nil) |> build_data()
|
||||
|
||||
:toggle_panel ->
|
||||
notify_parent({:chat_editor_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})
|
||||
Notify.parent({:chat_editor_toggle_assistant_sidebar})
|
||||
assign(socket, :action_error, nil) |> build_data()
|
||||
|
||||
:unknown ->
|
||||
@@ -798,8 +842,40 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
||||
|
||||
# ── Private helpers ───────────────────────────────────────────────────────
|
||||
|
||||
defp notify_parent(message) do
|
||||
send(self(), message)
|
||||
@surface_state_debounce_ms 500
|
||||
|
||||
defp persist_surface_state(socket) do
|
||||
conversation_id = socket.assigns.conversation_id
|
||||
surface_data = socket.assigns.surface_data
|
||||
surface_tabs = socket.assigns.surface_tabs
|
||||
dismissed_surfaces = socket.assigns.dismissed_surfaces
|
||||
|
||||
case AI.put_surface_state(conversation_id, surface_data, surface_tabs, dismissed_surfaces) do
|
||||
{:ok, _state} ->
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("Failed to persist surface state for conversation #{conversation_id}",
|
||||
reason: inspect(reason)
|
||||
)
|
||||
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
|
||||
|
||||
@@ -215,7 +215,8 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.MessageBuild do
|
||||
persisted_markers = persisted_tool_markers_for_request(messages, request)
|
||||
|
||||
{remaining, _persisted_markers} =
|
||||
Enum.reduce(tool_markers, {[], persisted_markers}, fn marker, {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}
|
||||
|
||||
@@ -287,7 +287,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolSurfaces do
|
||||
defp map_value(map, key, default \\ nil)
|
||||
|
||||
defp map_value(map, key, default) when is_map(map) and is_binary(key) do
|
||||
Map.get(map, key, Map.get(map, String.to_atom(key), default))
|
||||
Map.get(map, key, Map.get(map, String.to_existing_atom(key), default))
|
||||
rescue
|
||||
ArgumentError -> Map.get(map, key, default)
|
||||
end
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<div id={"chat-editor-#{@chat_editor.id}"} class="chat-panel" data-testid="chat-editor" phx-hook="ChatSurface">
|
||||
<div class="chat-panel-header">
|
||||
<div class="chat-panel-title">
|
||||
<div id={"chat-editor-#{@chat_editor.id}"} class="chat-panel ui-editor-shell flex h-full min-h-0 flex-col" data-testid="chat-editor" phx-hook="ChatSurface">
|
||||
<div class="chat-panel-header flex shrink-0 items-center justify-between gap-3 px-4 py-3">
|
||||
<div class="chat-panel-title flex min-w-0 flex-1 items-center justify-between gap-3">
|
||||
<span class="chat-panel-title-main">
|
||||
<%= if @chat_editor.needs_api_key? do %>
|
||||
<%= dgettext("ui", "AI Chat Setup") %>
|
||||
@@ -10,9 +10,9 @@
|
||||
</span>
|
||||
|
||||
<%= unless @chat_editor.needs_api_key? do %>
|
||||
<span class="chat-model-selector-wrap">
|
||||
<span class="chat-model-selector-wrap relative shrink-0">
|
||||
<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"
|
||||
phx-click="toggle_chat_model_selector"
|
||||
phx-target={@myself}
|
||||
@@ -23,7 +23,7 @@
|
||||
</button>
|
||||
|
||||
<%= if @chat_editor.model_selector_open? and @chat_editor.available_models != [] do %>
|
||||
<div class="chat-model-selector-menu">
|
||||
<div class="chat-model-selector-menu ui-dropdown-menu absolute right-0 top-full z-10 mt-2 flex min-w-56 flex-col">
|
||||
<%= for group <- @chat_editor.available_model_groups do %>
|
||||
<section class="chat-model-provider-group" data-testid="chat-model-provider-group" data-provider={group.provider}>
|
||||
<%= if length(@chat_editor.available_model_groups) > 1 do %>
|
||||
@@ -33,7 +33,7 @@
|
||||
<%= for model <- group.models do %>
|
||||
<button
|
||||
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")
|
||||
]}
|
||||
type="button"
|
||||
@@ -55,19 +55,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chat-messages chat-surface-scroll">
|
||||
<div class="chat-messages chat-surface-scroll min-h-0 flex-1 overflow-auto">
|
||||
<%= if @chat_editor.needs_api_key? do %>
|
||||
<div class="chat-welcome chat-api-key-state" data-testid="chat-api-key-required">
|
||||
<div class="chat-welcome chat-api-key-state ui-section-card flex flex-col items-start gap-3 p-4" data-testid="chat-api-key-required">
|
||||
<div class="chat-welcome-icon">🔑</div>
|
||||
<h2><%= dgettext("ui", "API Key Required") %></h2>
|
||||
<p><%= dgettext("ui", "Configure an API key in Settings to enable AI chat.") %></p>
|
||||
<div class="api-key-form">
|
||||
<button class="api-key-submit" type="button" phx-click="open_chat_settings" phx-target={@myself}><%= dgettext("ui", "Open Settings") %></button>
|
||||
<button class="api-key-submit ui-button ui-button-primary" type="button" phx-click="open_chat_settings" phx-target={@myself}><%= dgettext("ui", "Open Settings") %></button>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<%= if Enum.empty?(@chat_editor.messages) and not @chat_editor.is_streaming do %>
|
||||
<div class="chat-welcome">
|
||||
<div class="chat-welcome ui-section-card flex flex-col items-start gap-3 p-4">
|
||||
<div class="chat-welcome-icon">🤖</div>
|
||||
<h2><%= dgettext("ui", "Welcome to the AI Assistant") %></h2>
|
||||
<p><%= dgettext("ui", "I can help you manage your blog with rich visualizations. Try asking me to:") %></p>
|
||||
@@ -80,22 +80,10 @@
|
||||
</ul>
|
||||
</div>
|
||||
<% else %>
|
||||
<%= if @chat_editor.pending_user_message do %>
|
||||
<div class="chat-message user pending" data-testid="chat-pending-user-message">
|
||||
<div class="chat-message-avatar">👤</div>
|
||||
<div class="chat-message-content">
|
||||
<div class="chat-message-header">
|
||||
<span class="chat-message-role"><%= message_role_label(:user) %></span>
|
||||
</div>
|
||||
<div class="chat-message-text chat-user-message-text" data-testid="chat-user-message-text"><%= @chat_editor.pending_user_message %></div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= for message <- @chat_editor.messages do %>
|
||||
<div class={["chat-message", to_string(message.role || "assistant")]}>
|
||||
<div class={["chat-message flex items-start gap-3", to_string(message.role || "assistant")]}>
|
||||
<div class="chat-message-avatar"><%= if message.role == :user, do: "👤", else: "🤖" %></div>
|
||||
<div class="chat-message-content">
|
||||
<div class="chat-message-content ui-section-card">
|
||||
<div class="chat-message-header"><span class="chat-message-role"><%= message_role_label(message.role) %></span></div>
|
||||
<.chat_tool_markers markers={message.tool_markers} />
|
||||
|
||||
@@ -112,10 +100,22 @@
|
||||
|
||||
<% end %>
|
||||
|
||||
<%= if @chat_editor.pending_user_message do %>
|
||||
<div class="chat-message user pending flex items-start gap-3" data-testid="chat-pending-user-message">
|
||||
<div class="chat-message-avatar">👤</div>
|
||||
<div class="chat-message-content ui-section-card">
|
||||
<div class="chat-message-header">
|
||||
<span class="chat-message-role"><%= message_role_label(:user) %></span>
|
||||
</div>
|
||||
<div class="chat-message-text chat-user-message-text" data-testid="chat-user-message-text"><%= @chat_editor.pending_user_message %></div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if @chat_editor.is_streaming and (@chat_editor.streaming_content != "" or @chat_editor.streaming_tool_markers != []) do %>
|
||||
<div class="chat-message assistant streaming" data-testid="chat-streaming-message">
|
||||
<div class="chat-message assistant streaming flex items-start gap-3" data-testid="chat-streaming-message">
|
||||
<div class="chat-message-avatar">🤖</div>
|
||||
<div class="chat-message-content">
|
||||
<div class="chat-message-content ui-section-card">
|
||||
<div class="chat-message-header">
|
||||
<span class="chat-message-role"><%= message_role_label(:assistant) %></span>
|
||||
<span class="streaming-indicator">●</span>
|
||||
@@ -133,7 +133,7 @@
|
||||
<% end %>
|
||||
|
||||
<%= if @chat_editor.is_streaming and @chat_editor.streaming_content == "" and @chat_editor.streaming_tool_markers == [] do %>
|
||||
<div class="chat-message assistant thinking" data-testid="chat-streaming-thinking">
|
||||
<div class="chat-message assistant thinking flex items-start gap-3" data-testid="chat-streaming-thinking">
|
||||
<div class="chat-message-avatar">🤖</div>
|
||||
<div class="chat-message-content">
|
||||
<div class="chat-thinking-indicator">
|
||||
@@ -147,14 +147,14 @@
|
||||
</div>
|
||||
|
||||
<%= unless @chat_editor.needs_api_key? do %>
|
||||
<div class="chat-input-container" data-testid="chat-input-container">
|
||||
<div class="chat-input-container ui-field-stack flex shrink-0 flex-col gap-3" data-testid="chat-input-container">
|
||||
<%= if @chat_editor.is_streaming do %>
|
||||
<button class="chat-abort-button" data-testid="chat-abort-button" type="button" phx-click="abort_chat_editor_message" phx-target={@myself}>◼ <%= dgettext("ui", "Stop") %></button>
|
||||
<button class="chat-abort-button ui-button ui-button-secondary" data-testid="chat-abort-button" type="button" phx-click="abort_chat_editor_message" phx-target={@myself}>◼ <%= dgettext("ui", "Stop") %></button>
|
||||
<% end %>
|
||||
|
||||
<form class="chat-input-wrapper" phx-change="change_chat_editor_input" phx-submit="send_chat_editor_message" phx-target={@myself}>
|
||||
<textarea class="chat-input chat-surface-input" 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" phx-target={@myself} disabled={@chat_editor.send_disabled?}>↑</button>
|
||||
<form class="chat-input-wrapper flex items-end gap-2" phx-change="change_chat_editor_input" phx-submit="send_chat_editor_message" phx-target={@myself}>
|
||||
<textarea class="chat-input chat-surface-input ui-textarea" name="message" rows="1" placeholder={dgettext("ui", "Type a message...")} disabled={@chat_editor.is_streaming}><%= @chat_editor.input %></textarea>
|
||||
<button class="chat-send-button ui-button ui-button-primary" data-testid="chat-send-button" type="button" phx-click="send_chat_editor_message" phx-target={@myself} disabled={@chat_editor.send_disabled?}>↑</button>
|
||||
</form>
|
||||
|
||||
<%= if @chat_editor.action_error do %>
|
||||
|
||||
@@ -30,9 +30,17 @@ defmodule BDS.Desktop.ShellLive.ChatSurface do
|
||||
|
||||
defp assistant_reply(socket) do
|
||||
if socket.assigns.offline_mode do
|
||||
BDS.Gettext.lgettext(socket.assigns.page_language, "ui", "Automatic AI actions stay gated by airplane mode.")
|
||||
BDS.Gettext.lgettext(
|
||||
socket.assigns.page_language,
|
||||
"ui",
|
||||
"Automatic AI actions stay gated by airplane mode."
|
||||
)
|
||||
else
|
||||
BDS.Gettext.lgettext(socket.assigns.page_language, "ui", "The assistant sidebar chat surface is ready, but model execution is not connected yet.")
|
||||
BDS.Gettext.lgettext(
|
||||
socket.assigns.page_language,
|
||||
"ui",
|
||||
"The assistant sidebar chat surface is ready, but model execution is not connected yet."
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
172
lib/bds/desktop/shell_live/gallery_import.ex
Normal file
172
lib/bds/desktop/shell_live/gallery_import.ex
Normal file
@@ -0,0 +1,172 @@
|
||||
defmodule BDS.Desktop.ShellLive.GalleryImport do
|
||||
@moduledoc false
|
||||
|
||||
require Logger
|
||||
|
||||
alias BDS.{AI, Media, Metadata}
|
||||
|
||||
@doc """
|
||||
Starts the image import pipeline: for each selected path, imports the file,
|
||||
runs AI analysis, updates metadata, links to the post, and translates to
|
||||
all configured blog languages.
|
||||
|
||||
Processes images with a concurrency cap via a sliding window.
|
||||
"""
|
||||
@spec start(list(String.t()), String.t(), String.t(), String.t(), integer(), pid()) :: :ok
|
||||
def start(paths, project_id, post_id, language, concurrency_limit, parent) do
|
||||
{:ok, metadata} = Metadata.get_project_metadata(project_id)
|
||||
main_language = metadata.main_language || language
|
||||
blog_languages = metadata.blog_languages || []
|
||||
|
||||
translate_targets =
|
||||
[main_language | blog_languages]
|
||||
|> Enum.reject(&(&1 == language or is_nil(&1)))
|
||||
|> Enum.uniq()
|
||||
|
||||
{in_flight, remaining} = Enum.split(paths, concurrency_limit)
|
||||
|
||||
tasks =
|
||||
Enum.map(in_flight, fn path ->
|
||||
Task.async(fn ->
|
||||
process_single_image(path, project_id, post_id, language, translate_targets, parent)
|
||||
end)
|
||||
end)
|
||||
|
||||
known_refs = MapSet.new(tasks, & &1.ref)
|
||||
|
||||
drain_tasks(
|
||||
remaining, tasks, known_refs, project_id, post_id, language, translate_targets, parent
|
||||
)
|
||||
|
||||
send(parent, {:add_images_complete, length(paths)})
|
||||
end
|
||||
|
||||
defp drain_tasks(
|
||||
[], tasks, _known_refs, _project_id, _post_id, _language, _translate_targets, _parent
|
||||
) do
|
||||
Enum.each(tasks, fn task -> Task.await(task, :infinity) end)
|
||||
end
|
||||
|
||||
defp drain_tasks(
|
||||
[next_path | rest],
|
||||
tasks,
|
||||
known_refs,
|
||||
project_id,
|
||||
post_id,
|
||||
language,
|
||||
translate_targets,
|
||||
parent
|
||||
) do
|
||||
receive do
|
||||
{ref, _result} when is_reference(ref) ->
|
||||
if MapSet.member?(known_refs, ref) do
|
||||
{_completed_task, remaining_tasks} = pop_task_by_ref(tasks, ref)
|
||||
|
||||
new_task =
|
||||
Task.async(fn ->
|
||||
process_single_image(
|
||||
next_path, project_id, post_id, language, translate_targets, parent
|
||||
)
|
||||
end)
|
||||
|
||||
drain_tasks(
|
||||
rest,
|
||||
[new_task | remaining_tasks],
|
||||
MapSet.put(MapSet.delete(known_refs, ref), new_task.ref),
|
||||
project_id,
|
||||
post_id,
|
||||
language,
|
||||
translate_targets,
|
||||
parent
|
||||
)
|
||||
else
|
||||
drain_tasks(
|
||||
[next_path | rest], tasks, known_refs,
|
||||
project_id, post_id, language, translate_targets, parent
|
||||
)
|
||||
end
|
||||
|
||||
{:DOWN, ref, :process, _pid, _reason} when is_reference(ref) ->
|
||||
if MapSet.member?(known_refs, ref) do
|
||||
{_completed_task, remaining_tasks} = pop_task_by_ref(tasks, ref)
|
||||
|
||||
new_task =
|
||||
Task.async(fn ->
|
||||
process_single_image(
|
||||
next_path, project_id, post_id, language, translate_targets, parent
|
||||
)
|
||||
end)
|
||||
|
||||
drain_tasks(
|
||||
rest,
|
||||
[new_task | remaining_tasks],
|
||||
MapSet.put(MapSet.delete(known_refs, ref), new_task.ref),
|
||||
project_id,
|
||||
post_id,
|
||||
language,
|
||||
translate_targets,
|
||||
parent
|
||||
)
|
||||
else
|
||||
drain_tasks(
|
||||
[next_path | rest], tasks, known_refs,
|
||||
project_id, post_id, language, translate_targets, parent
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp pop_task_by_ref(tasks, ref) do
|
||||
Enum.reduce(tasks, {nil, []}, fn
|
||||
%{ref: ^ref} = task, {nil, rest} -> {task, rest}
|
||||
task, {found, rest} -> {found, [task | rest]}
|
||||
end)
|
||||
end
|
||||
|
||||
defp process_single_image(
|
||||
path, project_id, post_id, language, translate_targets, parent
|
||||
) do
|
||||
with {:ok, media} <- Media.import_media(%{project_id: project_id, source_path: path}),
|
||||
true <- String.starts_with?(media.mime_type || "", "image/"),
|
||||
{:ok, result} <- AI.analyze_image(media.id, language: language),
|
||||
{:ok, _updated} <- Media.update_media(media.id, %{
|
||||
title: result.title,
|
||||
alt: result.alt,
|
||||
caption: result.caption
|
||||
}),
|
||||
{:ok, _link} <- Media.link_media_to_post(media.id, post_id) do
|
||||
translate_media_translations(media.id, translate_targets)
|
||||
title = result.title || media.original_name
|
||||
send(parent, {:add_image_processed, title})
|
||||
else
|
||||
false ->
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("Image pipeline error for #{path}: #{inspect(reason)}")
|
||||
send(parent, {:add_image_error, path, reason})
|
||||
end
|
||||
end
|
||||
|
||||
defp translate_media_translations(_media_id, []), do: :ok
|
||||
|
||||
defp translate_media_translations(media_id, [target | rest]) do
|
||||
case AI.translate_media(media_id, target) do
|
||||
{:ok, translation} ->
|
||||
Media.upsert_media_translation(media_id, target, %{
|
||||
title: translation.title,
|
||||
alt: translation.alt,
|
||||
caption: translation.caption
|
||||
})
|
||||
|
||||
translate_media_translations(media_id, rest)
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning(
|
||||
"Media translation failed for #{media_id} -> #{target}: #{inspect(reason)}"
|
||||
)
|
||||
|
||||
translate_media_translations(media_id, rest)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -5,6 +5,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
|
||||
|
||||
alias BDS.{AI, ImportAnalysis, ImportDefinitions, ImportExecution}
|
||||
alias BDS.Desktop.{FilePicker, FolderPicker, ShellData}
|
||||
alias BDS.Desktop.ShellLive.Notify
|
||||
|
||||
alias BDS.Desktop.ShellLive.ImportEditor.{
|
||||
AnalysisState,
|
||||
@@ -31,6 +32,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
|
||||
]
|
||||
|
||||
use Gettext, backend: BDS.Gettext
|
||||
|
||||
import TaxonomyEditing,
|
||||
only: [
|
||||
existing_taxonomy_terms: 1,
|
||||
@@ -344,7 +346,8 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
|
||||
mapped_to <- Map.get(params, "mapped_to"),
|
||||
normalized_value <-
|
||||
TaxonomyEditing.normalize_taxonomy_mapping_value(project_id, type, mapped_to),
|
||||
updated_report <- TaxonomyEditing.update_taxonomy_mapping(report, type, name, normalized_value),
|
||||
updated_report <-
|
||||
TaxonomyEditing.update_taxonomy_mapping(report, type, name, normalized_value),
|
||||
{:ok, _definition} <-
|
||||
ImportDefinitions.update_definition(definition_id, %{
|
||||
last_analysis_result: updated_report
|
||||
@@ -375,7 +378,8 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
|
||||
%{} = report <- ImportDefinitions.decode_analysis_result(definition),
|
||||
normalized_value <-
|
||||
TaxonomyEditing.normalize_taxonomy_mapping_value(project_id, type, ""),
|
||||
updated_report <- TaxonomyEditing.update_taxonomy_mapping(report, type, name, normalized_value),
|
||||
updated_report <-
|
||||
TaxonomyEditing.update_taxonomy_mapping(report, type, name, normalized_value),
|
||||
{:ok, _definition} <-
|
||||
ImportDefinitions.update_definition(definition_id, %{
|
||||
last_analysis_result: updated_report
|
||||
@@ -409,7 +413,8 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
|
||||
|
||||
def handle_event("toggle_import_ai_model_selector", _params, socket) do
|
||||
{:noreply,
|
||||
assign(socket, :model_selector_open?, not socket.assigns.model_selector_open?) |> build_data()}
|
||||
assign(socket, :model_selector_open?, not socket.assigns.model_selector_open?)
|
||||
|> build_data()}
|
||||
end
|
||||
|
||||
def handle_event("select_import_ai_model", %{"model" => model_id}, socket) do
|
||||
@@ -432,7 +437,11 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
|
||||
if socket.assigns.offline_mode? do
|
||||
notify_output(
|
||||
dgettext("ui", "Import"),
|
||||
BDS.Gettext.lgettext(socket.assigns[:page_language] || ShellData.ui_language(), "ui", "Automatic AI actions stay gated by airplane mode."),
|
||||
BDS.Gettext.lgettext(
|
||||
socket.assigns[:page_language] || ShellData.ui_language(),
|
||||
"ui",
|
||||
"Automatic AI actions stay gated by airplane mode."
|
||||
),
|
||||
"info"
|
||||
)
|
||||
|
||||
@@ -485,7 +494,10 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
|
||||
|
||||
# ── handle_info for async tasks ────────────────────────────────────────────
|
||||
|
||||
@spec handle_info({:import_analysis_progress, atom() | String.t(), String.t()}, Phoenix.LiveView.Socket.t()) ::
|
||||
@spec handle_info(
|
||||
{:import_analysis_progress, atom() | String.t(), String.t()},
|
||||
Phoenix.LiveView.Socket.t()
|
||||
) ::
|
||||
{:noreply, Phoenix.LiveView.Socket.t()}
|
||||
def handle_info({:import_analysis_progress, step, detail}, socket) do
|
||||
socket =
|
||||
@@ -551,7 +563,8 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
|
||||
|> assign(:analysis_state, default_analysis_state())
|
||||
|> notify_output(dgettext("ui", "Import"), message, "error")
|
||||
|
||||
match?(%{ref: ^ref}, socket.assigns.execution_state) and reason not in [:normal, :shutdown] ->
|
||||
match?(%{ref: ^ref}, socket.assigns.execution_state) and
|
||||
reason not in [:normal, :shutdown] ->
|
||||
message = if is_binary(reason), do: reason, else: inspect(reason)
|
||||
|
||||
socket
|
||||
@@ -629,9 +642,11 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
|
||||
defp maybe_update_tab_meta(socket, name) do
|
||||
title = name || dgettext("ui", "Untitled Import")
|
||||
|
||||
notify_parent(
|
||||
{:import_editor_tab_meta, socket.assigns.definition_id, title,
|
||||
dgettext("ui", "Select a WordPress export file (WXR) and an uploads folder to analyze what would be imported.")}
|
||||
Notify.tab_meta(:import, socket.assigns.definition_id, title,
|
||||
dgettext(
|
||||
"ui",
|
||||
"Select a WordPress export file (WXR) and an uploads folder to analyze what would be imported."
|
||||
)
|
||||
)
|
||||
|
||||
socket
|
||||
@@ -1389,12 +1404,8 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
|
||||
|
||||
# ── Private helpers ───────────────────────────────────────────────────────
|
||||
|
||||
defp notify_parent(message) do
|
||||
send(self(), message)
|
||||
end
|
||||
|
||||
defp notify_output(socket, title, message, level \\ "info") do
|
||||
notify_parent({:import_editor_output, title, message, level})
|
||||
Notify.output(title, message, level)
|
||||
socket
|
||||
end
|
||||
|
||||
|
||||
@@ -266,7 +266,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
|
||||
@spec default_author(term()) :: term()
|
||||
def default_author(project_id) do
|
||||
{:ok, metadata} = Metadata.get_project_metadata(project_id)
|
||||
Map.get(metadata, :default_author)
|
||||
metadata.default_author
|
||||
end
|
||||
|
||||
@spec suggested_definition_name(term()) :: term()
|
||||
|
||||
@@ -130,7 +130,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do
|
||||
)
|
||||
|> append_output.(
|
||||
dgettext("ui", "Import"),
|
||||
dgettext("ui", "Import completed successfully!", count: previous_state.count),
|
||||
dgettext("ui", "Import completed successfully!", count: previous_state.count),
|
||||
nil,
|
||||
"info"
|
||||
)
|
||||
@@ -265,9 +265,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do
|
||||
seconds = div(ms, 1000)
|
||||
|
||||
if seconds < 60 do
|
||||
dgettext("ui", "ETA: %{value}",
|
||||
value: dgettext("ui", "%{count}s", count: seconds)
|
||||
)
|
||||
dgettext("ui", "ETA: %{value}", value: dgettext("ui", "%{count}s", count: seconds))
|
||||
else
|
||||
m = div(seconds, 60)
|
||||
s = rem(seconds, 60)
|
||||
|
||||
@@ -86,7 +86,11 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
|
||||
socket
|
||||
|> append_output.(
|
||||
dgettext("ui", "Import"),
|
||||
BDS.Gettext.lgettext(socket.assigns.page_language, "ui", "Automatic AI actions stay gated by airplane mode."),
|
||||
BDS.Gettext.lgettext(
|
||||
socket.assigns.page_language,
|
||||
"ui",
|
||||
"Automatic AI actions stay gated by airplane mode."
|
||||
),
|
||||
nil,
|
||||
"info"
|
||||
)
|
||||
@@ -218,7 +222,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
|
||||
{:ok, metadata} = Metadata.get_project_metadata(project_id)
|
||||
|
||||
%{
|
||||
categories: Enum.uniq(Map.get(metadata, :categories, []) || []),
|
||||
categories: Enum.uniq(metadata.categories || []),
|
||||
tags: project_id |> Tags.list_tags() |> Enum.map(& &1.name) |> Enum.uniq()
|
||||
}
|
||||
end
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<div
|
||||
class="app"
|
||||
class="app flex h-full w-full flex-col"
|
||||
id="bds-shell-app"
|
||||
phx-hook="AppShell"
|
||||
data-shortcuts={encoded_shortcuts(@client_shortcuts)}
|
||||
@@ -112,12 +112,12 @@
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="app-main">
|
||||
<aside class="activity-bar" data-region="activity-bar">
|
||||
<div class="activity-bar-top">
|
||||
<div class="app-main flex min-h-0 flex-1 overflow-hidden">
|
||||
<aside class="activity-bar flex h-full shrink-0 flex-col items-center justify-between" data-region="activity-bar">
|
||||
<div class="activity-bar-top flex flex-col items-center gap-1">
|
||||
<%= for button <- Enum.filter(@activity_buttons, &(&1.activity_group == :top)) do %>
|
||||
<button
|
||||
class={["activity-bar-item", if(button.active, do: "active")]}
|
||||
class={["activity-bar-item inline-flex h-12 w-12 items-center justify-center", if(button.active, do: "active")]}
|
||||
data-testid="activity-button"
|
||||
data-view={button.id}
|
||||
data-active={to_string(button.active)}
|
||||
@@ -134,10 +134,10 @@
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="activity-bar-bottom">
|
||||
<div class="activity-bar-bottom flex flex-col items-center gap-1">
|
||||
<%= for button <- Enum.filter(@activity_buttons, &(&1.activity_group == :bottom)) do %>
|
||||
<button
|
||||
class={["activity-bar-item", if(button.active, do: "active")]}
|
||||
class={["activity-bar-item inline-flex h-12 w-12 items-center justify-center", if(button.active, do: "active")]}
|
||||
data-testid="activity-button"
|
||||
data-view={button.id}
|
||||
data-active={to_string(button.active)}
|
||||
@@ -157,22 +157,22 @@
|
||||
</aside>
|
||||
|
||||
<section
|
||||
class={["sidebar-shell", if(not @workbench.sidebar_visible, do: "is-hidden")]}
|
||||
class={["sidebar-shell flex min-w-0 shrink-0 overflow-hidden", if(not @workbench.sidebar_visible, do: "is-hidden")]}
|
||||
data-testid="sidebar-shell"
|
||||
style={"width: #{if(@workbench.sidebar_visible, do: @workbench.sidebar_width, else: 0)}px;"}
|
||||
>
|
||||
<div class="sidebar" data-region="sidebar">
|
||||
<div id="sidebar-content" class="sidebar-content sidebar-body" phx-hook="SidebarInteractions">
|
||||
<div class="sidebar flex min-w-0 flex-1 overflow-hidden" data-region="sidebar">
|
||||
<div id="sidebar-content" class="sidebar-content sidebar-body flex min-h-0 flex-1 flex-col overflow-y-auto" phx-hook="SidebarInteractions">
|
||||
<div class="sidebar-section">
|
||||
<% create_action = sidebar_create_action(@workbench.active_view) %>
|
||||
<div class="sidebar-section-header">
|
||||
<div class="sidebar-section-header flex items-center justify-between gap-2">
|
||||
<span><%= String.upcase(sidebar_header_label(@sidebar_header)) %></span>
|
||||
<%= if create_action || ShellSidebarComponents.filters_enabled?(@sidebar_data) do %>
|
||||
<div class="sidebar-actions">
|
||||
<div class="sidebar-actions flex items-center gap-1">
|
||||
<%= if ShellSidebarComponents.filters_enabled?(@sidebar_data) do %>
|
||||
<button
|
||||
class={[
|
||||
"sidebar-action",
|
||||
"sidebar-action inline-flex h-8 w-8 items-center justify-center",
|
||||
if(ShellSidebarComponents.filters_visible?(@sidebar_data), do: "active")
|
||||
]}
|
||||
data-testid="sidebar-filter-toggle"
|
||||
@@ -188,7 +188,7 @@
|
||||
<% end %>
|
||||
<%= if create_action do %>
|
||||
<button
|
||||
class="sidebar-action"
|
||||
class="sidebar-action inline-flex h-8 w-8 items-center justify-center"
|
||||
data-testid="sidebar-create-action"
|
||||
data-sidebar-action={create_action.kind}
|
||||
type="button"
|
||||
@@ -212,16 +212,16 @@
|
||||
<div class="resizable-panel-divider sidebar-divider" data-resize="sidebar" data-role="resize-handle"></div>
|
||||
</section>
|
||||
|
||||
<main class="app-content" data-region="content">
|
||||
<div class="tab-bar" data-region="tab-bar">
|
||||
<main class="app-content flex min-w-0 flex-1 flex-col overflow-hidden" data-region="content">
|
||||
<div class="tab-bar flex h-[35px] shrink-0 items-center overflow-hidden" data-region="tab-bar">
|
||||
<%= if Enum.empty?(@workbench.tabs) do %>
|
||||
<div class="tab-bar-empty"><%= dgettext("ui", "Dashboard") %></div>
|
||||
<div class="tab-bar-empty flex h-full items-center px-3 text-sm"><%= dgettext("ui", "Dashboard") %></div>
|
||||
<% else %>
|
||||
<div class="tab-bar-tabs">
|
||||
<div class="tab-bar-tabs flex min-w-0 flex-1 items-stretch overflow-x-auto">
|
||||
<%= for tab <- @workbench.tabs do %>
|
||||
<div
|
||||
class={[
|
||||
"tab",
|
||||
"tab flex min-w-0 max-w-[240px] shrink-0 items-stretch",
|
||||
if(@workbench.active_tab == {tab.type, tab.id}, do: "active"),
|
||||
if(tab.is_transient, do: "transient"),
|
||||
if(Workbench.dirty?(@workbench, tab.type, tab.id), do: "dirty")
|
||||
@@ -231,21 +231,21 @@
|
||||
tabindex="0"
|
||||
>
|
||||
<button
|
||||
class="tab-select"
|
||||
class="tab-select flex min-w-0 flex-1 items-center gap-2 overflow-hidden px-3 text-sm"
|
||||
type="button"
|
||||
phx-click="select_tab"
|
||||
phx-value-type={tab.type}
|
||||
phx-value-id={tab.id}
|
||||
>
|
||||
<span class="tab-icon"><%= raw(ShellData.activity_icon(BDS.Desktop.ShellLive.TabHelpers.tab_icon_id(tab))) %></span>
|
||||
<span class="tab-title"><%= BDS.Desktop.ShellLive.TabHelpers.tab_title(tab, @tab_meta) %></span>
|
||||
<span class="tab-icon shrink-0"><%= raw(ShellData.activity_icon(BDS.Desktop.ShellLive.TabHelpers.tab_icon_id(tab))) %></span>
|
||||
<span class="tab-title truncate"><%= BDS.Desktop.ShellLive.TabHelpers.tab_title(tab, @tab_meta) %></span>
|
||||
</button>
|
||||
<div class="tab-actions">
|
||||
<div class="tab-actions flex items-center gap-1 pr-2">
|
||||
<%= if Workbench.dirty?(@workbench, tab.type, tab.id) do %>
|
||||
<span class="tab-dirty-indicator">●</span>
|
||||
<% end %>
|
||||
<button
|
||||
class="tab-close"
|
||||
class="tab-close inline-flex h-6 w-6 items-center justify-center"
|
||||
data-testid="tab-close"
|
||||
data-tab-type={tab.type}
|
||||
data-tab-id={tab.id}
|
||||
@@ -265,7 +265,7 @@
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<section class="editor-shell" data-region="editor">
|
||||
<section class="editor-shell flex min-h-0 flex-1 flex-col overflow-hidden" data-region="editor">
|
||||
<%= if is_nil(@current_tab) do %>
|
||||
<div class="editor-empty">
|
||||
<div class="dashboard-content">
|
||||
@@ -421,7 +421,7 @@
|
||||
<% @current_tab.type == :import -> %>
|
||||
<.live_component module={ImportEditor} id={"import-editor-#{@current_tab.id}"} current_tab={@current_tab} offline_mode={@offline_mode} project_id={@projects.active_project_id} />
|
||||
|
||||
<% @current_tab.type in [:site_validation, :metadata_diff, :translation_validation, :find_duplicates, :git_diff] -> %>
|
||||
<% @current_tab.type in [:site_validation, :metadata_diff, :translation_validation, :find_duplicates, :git_diff, :documentation, :api_documentation] -> %>
|
||||
<.live_component module={MiscEditor} id={"misc-editor-#{@current_tab.type}-#{@current_tab.id}"} current_tab={@current_tab} tab_meta={@tab_meta} project_id={@projects.active_project_id} />
|
||||
|
||||
<% true -> %>
|
||||
@@ -452,12 +452,12 @@
|
||||
<% end %>
|
||||
</section>
|
||||
|
||||
<section class={["panel-shell", if(not @workbench.panel.visible, do: "is-hidden")]} data-region="panel">
|
||||
<div class="panel-header">
|
||||
<div class="panel-tabs">
|
||||
<section class={["panel-shell flex min-h-0 shrink-0 flex-col overflow-hidden", if(not @workbench.panel.visible, do: "is-hidden")]} data-region="panel">
|
||||
<div class="panel-header flex items-center justify-between gap-2">
|
||||
<div class="panel-tabs flex min-w-0 items-center overflow-x-auto">
|
||||
<%= for tab <- @panel_tabs do %>
|
||||
<button
|
||||
class={["panel-tab", if(@workbench.panel.active_tab == tab, do: "active")]}
|
||||
class={["panel-tab", "ui-tab", if(@workbench.panel.active_tab == tab, do: "ui-tab-active"), "inline-flex h-9 items-center px-3 text-xs uppercase tracking-wide", if(@workbench.panel.active_tab == tab, do: "active")]}
|
||||
type="button"
|
||||
phx-click="select_panel_tab"
|
||||
phx-value-tab={tab}
|
||||
@@ -467,7 +467,7 @@
|
||||
<% end %>
|
||||
</div>
|
||||
<button
|
||||
class="panel-close"
|
||||
class="panel-close ui-button ui-button-secondary inline-flex h-8 w-8 items-center justify-center"
|
||||
data-testid="panel-close"
|
||||
type="button"
|
||||
phx-click="toggle_panel"
|
||||
@@ -477,21 +477,21 @@
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div class="panel-content">
|
||||
<div class="panel-content min-h-0 flex-1 overflow-auto">
|
||||
<%= BDS.Desktop.ShellLive.PanelRenderer.render_panel_body(assigns) %>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<section
|
||||
class={["assistant-sidebar-shell", if(not @workbench.assistant_sidebar_visible, do: "is-hidden")]}
|
||||
class={["assistant-sidebar-shell flex min-w-0 shrink-0 overflow-hidden", if(not @workbench.assistant_sidebar_visible, do: "is-hidden")]}
|
||||
data-testid="assistant-shell"
|
||||
style={"width: #{if(@workbench.assistant_sidebar_visible, do: @workbench.assistant_sidebar_width, else: 0)}px;"}
|
||||
>
|
||||
<div class="resizable-panel-divider assistant-divider" data-resize="assistant" data-role="resize-handle"></div>
|
||||
<aside class="assistant-sidebar" data-region="assistant-sidebar">
|
||||
<div class="assistant-content">
|
||||
<header class="assistant-sidebar-header">
|
||||
<aside class="assistant-sidebar flex min-w-0 flex-1 overflow-hidden" data-region="assistant-sidebar">
|
||||
<div class="assistant-content flex min-h-0 flex-1 flex-col">
|
||||
<header class="assistant-sidebar-header flex items-start justify-between gap-3">
|
||||
<div class="assistant-sidebar-heading">
|
||||
<strong><%= dgettext("ui", "AI Assistant") %></strong>
|
||||
<span class="assistant-sidebar-description"><%= dgettext("ui", "AI conversations") %></span>
|
||||
@@ -504,12 +504,12 @@
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<section class="assistant-sidebar-context" data-testid="assistant-context">
|
||||
<div class="assistant-sidebar-context-row">
|
||||
<section class="assistant-sidebar-context flex shrink-0 flex-col gap-2" data-testid="assistant-context">
|
||||
<div class="assistant-sidebar-context-row flex items-center justify-between gap-2">
|
||||
<span class="assistant-sidebar-context-label"><%= dgettext("ui", "Project") %></span>
|
||||
<span class="assistant-sidebar-context-value"><%= BDS.Desktop.ShellLive.ChatSurface.assistant_project_name(@current_project) %></span>
|
||||
</div>
|
||||
<div class="assistant-sidebar-context-row">
|
||||
<div class="assistant-sidebar-context-row flex items-center justify-between gap-2">
|
||||
<span class="assistant-sidebar-context-label"><%= dgettext("ui", "Editor") %></span>
|
||||
<span class="assistant-sidebar-context-value"><%= BDS.Desktop.ShellLive.TabHelpers.tab_title(@current_tab, @tab_meta) %></span>
|
||||
</div>
|
||||
@@ -517,13 +517,13 @@
|
||||
</section>
|
||||
|
||||
<form
|
||||
class="assistant-sidebar-prompt-form"
|
||||
class="assistant-sidebar-prompt-form flex shrink-0 flex-col gap-3"
|
||||
data-testid="assistant-prompt-form"
|
||||
phx-change="update_assistant_prompt"
|
||||
phx-submit="submit_assistant_prompt"
|
||||
>
|
||||
<textarea
|
||||
class="assistant-sidebar-prompt"
|
||||
class="assistant-sidebar-prompt min-h-[8rem] w-full resize-y"
|
||||
data-testid="assistant-prompt-input"
|
||||
name="assistant[prompt]"
|
||||
rows="6"
|
||||
@@ -531,7 +531,7 @@
|
||||
><%= @assistant_prompt %></textarea>
|
||||
|
||||
<button
|
||||
class="assistant-sidebar-start-button"
|
||||
class="assistant-sidebar-start-button ui-button ui-button-primary"
|
||||
data-testid="assistant-start-button"
|
||||
type="submit"
|
||||
disabled={String.trim(@assistant_prompt || "") == ""}
|
||||
@@ -541,19 +541,19 @@
|
||||
</form>
|
||||
|
||||
<%= if Enum.empty?(@assistant_messages) do %>
|
||||
<div class="assistant-sidebar-welcome">
|
||||
<div class="assistant-sidebar-welcome min-h-0 flex-1 overflow-auto">
|
||||
<%= for card <- @assistant_cards do %>
|
||||
<section class="assistant-card">
|
||||
<section class="assistant-card flex flex-col gap-1">
|
||||
<strong><%= card.label %></strong>
|
||||
<span><%= card.text %></span>
|
||||
</section>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="assistant-sidebar-transcript">
|
||||
<div class="assistant-sidebar-transcript min-h-0 flex-1 overflow-auto">
|
||||
<%= for message <- @assistant_messages do %>
|
||||
<article
|
||||
class={["assistant-sidebar-message", message.role]}
|
||||
class={["assistant-sidebar-message flex flex-col gap-1", message.role]}
|
||||
data-testid={BDS.Desktop.ShellLive.ChatSurface.assistant_message_testid(message.role)}
|
||||
>
|
||||
<span class="assistant-sidebar-message-role"><%= BDS.Desktop.ShellLive.ChatSurface.assistant_message_label(message.role) %></span>
|
||||
@@ -567,12 +567,12 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<footer class="status-bar" data-region="status-bar" data-testid="status-bar">
|
||||
<div class="status-bar-left">
|
||||
<footer class="status-bar flex h-[22px] shrink-0 items-center justify-between gap-2" data-region="status-bar" data-testid="status-bar">
|
||||
<div class="status-bar-left flex min-w-0 items-center gap-2 overflow-hidden">
|
||||
<%= if @is_mac_ui do %>
|
||||
<div class="status-shell-controls" data-testid="status-shell-controls">
|
||||
<div class="status-shell-controls flex items-center gap-1" data-testid="status-shell-controls">
|
||||
<button
|
||||
class="status-shell-toggle-button"
|
||||
class="status-shell-toggle-button inline-flex h-6 w-6 items-center justify-center"
|
||||
data-testid="toggle-sidebar"
|
||||
type="button"
|
||||
phx-click="toggle_sidebar"
|
||||
@@ -584,7 +584,7 @@
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
class="status-shell-toggle-button"
|
||||
class="status-shell-toggle-button inline-flex h-6 w-6 items-center justify-center"
|
||||
data-testid="toggle-panel"
|
||||
type="button"
|
||||
phx-click="toggle_panel"
|
||||
@@ -596,7 +596,7 @@
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
class="status-shell-toggle-button"
|
||||
class="status-shell-toggle-button inline-flex h-6 w-6 items-center justify-center"
|
||||
data-testid="toggle-assistant"
|
||||
type="button"
|
||||
phx-click="toggle_assistant_sidebar"
|
||||
@@ -609,9 +609,9 @@
|
||||
</button>
|
||||
</div>
|
||||
<% end %>
|
||||
<div class="project-selector">
|
||||
<div class="project-selector relative shrink-0">
|
||||
<button
|
||||
class="project-selector-trigger"
|
||||
class="project-selector-trigger inline-flex items-center gap-2"
|
||||
data-testid="project-selector-trigger"
|
||||
type="button"
|
||||
title={dgettext("ui", "Switch project")}
|
||||
@@ -659,7 +659,7 @@
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<button class="status-bar-item status-bar-task-button" data-testid="status-task-button" type="button" phx-click="open_tasks_panel">
|
||||
<button class="status-bar-item status-bar-task-button inline-flex items-center gap-2" data-testid="status-task-button" type="button" phx-click="open_tasks_panel">
|
||||
<%= if @status.left.running_task_message do %>
|
||||
<span class="task-spinner"></span>
|
||||
<% end %>
|
||||
@@ -669,12 +669,12 @@
|
||||
<% end %>
|
||||
</button>
|
||||
</div>
|
||||
<div class="status-bar-right">
|
||||
<div class="status-bar-right flex items-center gap-2 overflow-hidden">
|
||||
<span class="status-bar-item"><%= @status.right.post_count %></span>
|
||||
<span class="status-bar-item"><%= @status.right.media_count %></span>
|
||||
<span class="status-bar-item theme-badge"><%= @status.right.theme_badge %></span>
|
||||
<button class={["status-bar-item", "offline-badge", if(@status.right.offline_mode, do: "active")]} data-testid="status-offline-button" type="button" phx-click="toggle_offline_mode" title={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><%= dgettext("ui", "UI") %></span>
|
||||
<select class="status-bar-language-select" name="ui_language" data-testid="status-language-select">
|
||||
<%= for language <- @supported_ui_languages do %>
|
||||
|
||||
@@ -6,6 +6,7 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
|
||||
import Ecto.Query
|
||||
|
||||
alias BDS.Desktop.{FilePicker}
|
||||
alias BDS.Desktop.ShellLive.Notify
|
||||
alias BDS.{AI, I18n, Media}
|
||||
alias BDS.Media.Media, as: MediaRecord
|
||||
alias BDS.Media.Translation
|
||||
@@ -90,7 +91,7 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
|
||||
|> build_data()
|
||||
|
||||
if dirty? != was_dirty? do
|
||||
notify_parent({:media_editor_dirty, socket.assigns.media_id, dirty?})
|
||||
Notify.dirty(:media, socket.assigns.media_id, dirty?)
|
||||
end
|
||||
|
||||
{:noreply, socket}
|
||||
@@ -123,8 +124,11 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
|
||||
|> assign(:dirty?, false)
|
||||
|> build_data()
|
||||
|
||||
notify_parent({:media_editor_dirty, media.id, false})
|
||||
notify_parent({:media_editor_tab_meta, media.id, display_title(updated_media), updated_media.original_name || updated_media.mime_type || ""})
|
||||
Notify.dirty(:media, media.id, false)
|
||||
|
||||
Notify.tab_meta(:media, media.id, display_title(updated_media),
|
||||
updated_media.original_name || updated_media.mime_type || "")
|
||||
|
||||
{:noreply, socket}
|
||||
|
||||
{:ok, nil} ->
|
||||
@@ -218,7 +222,11 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("change_media_post_picker", %{"media_post_picker" => %{"query" => query}}, socket) do
|
||||
def handle_event(
|
||||
"change_media_post_picker",
|
||||
%{"media_post_picker" => %{"query" => query}},
|
||||
socket
|
||||
) do
|
||||
socket =
|
||||
socket
|
||||
|> assign(:post_picker_query, to_string(query || ""))
|
||||
@@ -351,7 +359,13 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
|
||||
{:noreply, build_data(socket)}
|
||||
|
||||
{:error, reason} ->
|
||||
notify_output(socket, dgettext("ui", "Refresh Translation"), inspect(reason), "error")
|
||||
notify_output(
|
||||
socket,
|
||||
dgettext("ui", "Refresh Translation"),
|
||||
inspect(reason),
|
||||
"error"
|
||||
)
|
||||
|
||||
{:noreply, build_data(socket)}
|
||||
end
|
||||
|
||||
@@ -468,8 +482,11 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
|
||||
|> assign(:save_state, :saved)
|
||||
|> build_data()
|
||||
|
||||
notify_parent({:media_editor_dirty, media.id, false})
|
||||
notify_parent({:media_editor_tab_meta, media.id, display_title(updated_media), updated_media.original_name || updated_media.mime_type || ""})
|
||||
Notify.dirty(:media, media.id, false)
|
||||
|
||||
Notify.tab_meta(:media, media.id, display_title(updated_media),
|
||||
updated_media.original_name || updated_media.mime_type || "")
|
||||
|
||||
notify_output(socket, dgettext("ui", "Media"), dgettext("ui", "Media saved"))
|
||||
socket
|
||||
|
||||
@@ -508,7 +525,7 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
|
||||
|> assign(:quick_actions_open?, false)
|
||||
|> build_data()
|
||||
|
||||
notify_parent({:media_editor_dirty, media.id, dirty?})
|
||||
Notify.dirty(:media, media.id, dirty?)
|
||||
socket
|
||||
end
|
||||
end
|
||||
@@ -549,12 +566,8 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
|
||||
end
|
||||
end
|
||||
|
||||
defp notify_parent(message) do
|
||||
send(self(), message)
|
||||
end
|
||||
|
||||
defp notify_output(socket, title, message, level \\ "info") do
|
||||
send(self(), {:media_editor_output, title, message, level})
|
||||
Notify.output(title, message, level)
|
||||
socket
|
||||
end
|
||||
|
||||
@@ -684,7 +697,6 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@spec media_editor_save_state_label(term()) :: term()
|
||||
def media_editor_save_state_label(:dirty), do: dgettext("ui", "Unsaved")
|
||||
def media_editor_save_state_label(:saved), do: dgettext("ui", "Saved")
|
||||
|
||||
@@ -1,26 +1,25 @@
|
||||
<div class="media-editor editor" data-testid="media-editor">
|
||||
<div class="editor-header">
|
||||
<div class="editor-tabs">
|
||||
<div class="media-editor ui-editor-shell flex h-full min-h-0 flex-col" data-testid="media-editor">
|
||||
<div class="editor-header ui-editor-header flex shrink-0 items-start justify-between gap-3">
|
||||
<div class="flex min-w-0 flex-1 overflow-hidden">
|
||||
<div class={[
|
||||
"editor-tab",
|
||||
"active",
|
||||
"ui-tab ui-tab-active ui-editor-tab-current inline-flex max-w-full items-center gap-2 overflow-hidden px-3 py-2",
|
||||
if(@media_editor.dirty?, do: "dirty")
|
||||
]}>
|
||||
<span class="editor-tab-title" data-testid="editor-title"><%= @media_editor.display_title %></span>
|
||||
<span class="truncate" data-testid="editor-title"><%= @media_editor.display_title %></span>
|
||||
<%= if @media_editor.dirty? do %>
|
||||
<span class="editor-tab-dirty" title={dgettext("ui", "Unsaved")}>●</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-actions">
|
||||
<div class="ui-editor-actions flex flex-wrap items-center justify-end gap-2">
|
||||
<%= if @media_editor.save_state in [:dirty, :saved] do %>
|
||||
<span class="auto-save-indicator"><%= media_editor_save_state_label(@media_editor.save_state) %></span>
|
||||
<% end %>
|
||||
|
||||
<div class="quick-actions-wrapper">
|
||||
<div class="quick-actions-wrapper relative">
|
||||
<button
|
||||
class="secondary quick-actions-btn"
|
||||
class="secondary quick-actions-btn ui-button ui-button-secondary inline-flex items-center gap-2"
|
||||
type="button"
|
||||
phx-click="toggle_media_editor_quick_actions"
|
||||
phx-target={@myself}
|
||||
@@ -30,16 +29,16 @@
|
||||
</button>
|
||||
|
||||
<%= if @media_editor.quick_actions_open? do %>
|
||||
<div class="quick-actions-menu">
|
||||
<div class="quick-actions-menu ui-dropdown-menu absolute right-0 top-full z-10 mt-2 flex min-w-72 flex-col">
|
||||
<%= if @media_editor.is_image do %>
|
||||
<button
|
||||
class="quick-action-item"
|
||||
class="quick-action-item ui-dropdown-item flex items-start gap-3 text-left"
|
||||
data-testid="editor-toolbar-overlay-button"
|
||||
type="button"
|
||||
phx-click="open_overlay"
|
||||
phx-value-kind="ai_suggestions"
|
||||
>
|
||||
<span class="quick-action-text">
|
||||
<span class="quick-action-text flex min-w-0 flex-1 flex-col">
|
||||
<strong><%= dgettext("ui", "AI Suggestions") %></strong>
|
||||
<small><%= dgettext("ui", "Review title, alt text, and caption suggestions") %></small>
|
||||
</span>
|
||||
@@ -50,13 +49,13 @@
|
||||
<% end %>
|
||||
|
||||
<button
|
||||
class="quick-action-item"
|
||||
class="quick-action-item ui-dropdown-item flex items-start gap-3 text-left"
|
||||
type="button"
|
||||
phx-click="detect_media_editor_language"
|
||||
phx-target={@myself}
|
||||
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><%= dgettext("ui", "Detect Language") %></strong>
|
||||
<small><%= dgettext("ui", "Persist the detected language for this media item") %></small>
|
||||
</span>
|
||||
@@ -66,14 +65,14 @@
|
||||
<div class="quick-actions-divider"></div>
|
||||
|
||||
<button
|
||||
class="quick-action-item"
|
||||
class="quick-action-item ui-dropdown-item flex items-start gap-3 text-left"
|
||||
data-testid="editor-toolbar-overlay-button"
|
||||
type="button"
|
||||
phx-click="open_overlay"
|
||||
phx-value-kind="language_picker"
|
||||
disabled={not @media_editor.can_translate?}
|
||||
>
|
||||
<span class="quick-action-text">
|
||||
<span class="quick-action-text flex min-w-0 flex-1 flex-col">
|
||||
<strong><%= dgettext("ui", "Translate") %></strong>
|
||||
<small><%= dgettext("ui", "Select a target language for this media item") %></small>
|
||||
</span>
|
||||
@@ -83,14 +82,14 @@
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<button class="secondary" type="button" phx-click="replace_media_editor_file" phx-target={@myself}>
|
||||
<button class="secondary ui-button ui-button-secondary" type="button" phx-click="replace_media_editor_file" phx-target={@myself}>
|
||||
<%= dgettext("ui", "Replace File") %>
|
||||
</button>
|
||||
<button data-testid="media-save-button" type="button" phx-click="save_media_editor" phx-target={@myself}>
|
||||
<button class="ui-button ui-button-primary" data-testid="media-save-button" type="button" phx-click="save_media_editor" phx-target={@myself}>
|
||||
<%= dgettext("ui", "Save") %>
|
||||
</button>
|
||||
<button
|
||||
class="secondary danger"
|
||||
class="secondary danger ui-button ui-button-secondary ui-button-danger"
|
||||
data-testid="media-delete-button"
|
||||
type="button"
|
||||
phx-click="open_overlay"
|
||||
@@ -101,14 +100,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-content media-editor">
|
||||
<div class="media-preview">
|
||||
<div class="editor-content media-editor grid min-h-0 flex-1 gap-4 overflow-auto p-4 xl:grid-cols-[minmax(320px,1fr)_minmax(0,1.2fr)]">
|
||||
<div class="media-preview flex min-h-[16rem] items-center justify-center">
|
||||
<%= if @media_editor.is_image and @media_editor.preview_url do %>
|
||||
<div class="media-preview-image">
|
||||
<img src={@media_editor.preview_url} alt={@media_editor.form["alt"] || @media_editor.original_name} />
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="media-preview-placeholder">
|
||||
<div class="media-preview-placeholder flex h-full w-full flex-col items-center justify-center gap-3">
|
||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="currentColor" opacity="0.3">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6z"></path>
|
||||
</svg>
|
||||
@@ -117,60 +116,60 @@
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="media-details">
|
||||
<form class="media-editor-details-form" data-testid="media-editor-form" phx-change="change_media_editor" phx-target={@myself}>
|
||||
<div class="editor-field">
|
||||
<div class="media-details min-w-0">
|
||||
<form class="media-editor-details-form flex flex-col gap-4" data-testid="media-editor-form" phx-change="change_media_editor" phx-target={@myself}>
|
||||
<div class="editor-field ui-field-stack flex flex-col gap-1.5">
|
||||
<label><%= dgettext("ui", "File Name") %></label>
|
||||
<input class="post-editor-input 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 class="editor-field">
|
||||
<div class="editor-field ui-field-stack flex flex-col gap-1.5">
|
||||
<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 class="editor-field-row">
|
||||
<div class="editor-field">
|
||||
<div class="editor-field-row ui-field-grid-2 grid gap-4 md:grid-cols-2">
|
||||
<div class="editor-field ui-field-stack flex flex-col gap-1.5">
|
||||
<label><%= dgettext("ui", "Size") %></label>
|
||||
<input class="post-editor-input 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>
|
||||
|
||||
<%= if @media_editor.dimensions do %>
|
||||
<div class="editor-field">
|
||||
<div class="editor-field ui-field-stack flex flex-col gap-1.5">
|
||||
<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>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="editor-field">
|
||||
<div class="editor-field ui-field-stack flex flex-col gap-1.5">
|
||||
<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 class="editor-field">
|
||||
<div class="editor-field ui-field-stack flex flex-col gap-1.5">
|
||||
<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 class="editor-field">
|
||||
<div class="editor-field ui-field-stack flex flex-col gap-1.5">
|
||||
<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 class="editor-field">
|
||||
<div class="editor-field ui-field-stack flex flex-col gap-1.5">
|
||||
<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 class="editor-field">
|
||||
<div class="editor-field ui-field-stack flex flex-col gap-1.5">
|
||||
<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 class="editor-field">
|
||||
<div class="editor-field ui-field-stack flex flex-col gap-1.5">
|
||||
<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=""><%= dgettext("ui", "None") %></option>
|
||||
<%= for language <- @media_editor.languages do %>
|
||||
<option value={language} selected={language == @media_editor.form["language"]}><%= language_label(language) %></option>
|
||||
@@ -180,15 +179,15 @@
|
||||
</form>
|
||||
|
||||
<%= 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><%= dgettext("ui", "Translations") %></label>
|
||||
|
||||
<%= if Enum.empty?(@media_editor.translations) do %>
|
||||
<div class="no-linked-posts"><%= dgettext("ui", "No translations") %></div>
|
||||
<% else %>
|
||||
<div class="linked-posts-list">
|
||||
<div class="linked-posts-list flex flex-col gap-2">
|
||||
<%= for translation <- @media_editor.translations do %>
|
||||
<div class="linked-post-item">
|
||||
<div class="linked-post-item flex items-center justify-between gap-2">
|
||||
<button
|
||||
class="linked-post-title linked-post-link"
|
||||
type="button"
|
||||
@@ -198,7 +197,7 @@
|
||||
>
|
||||
<%= translation.flag %> <%= language_label(translation.language) %><%= if translation.title, do: " — #{translation.title}" %>
|
||||
</button>
|
||||
<button class="secondary compact" type="button" phx-click="refresh_media_translation" phx-target={@myself} 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}>
|
||||
<%= dgettext("ui", "Refresh") %>
|
||||
</button>
|
||||
<button class="unlink-btn" type="button" phx-click="delete_media_translation" phx-target={@myself} phx-value-language={translation.language}>×</button>
|
||||
@@ -209,7 +208,7 @@
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="editor-field linked-posts-section">
|
||||
<div class="editor-field linked-posts-section flex flex-col gap-2">
|
||||
<label>
|
||||
<%= dgettext("ui", "Linked Posts") %>
|
||||
<button class="add-link-btn" type="button" phx-click="toggle_media_post_picker" phx-target={@myself}>
|
||||
@@ -218,7 +217,7 @@
|
||||
</label>
|
||||
|
||||
<%= if @media_editor.post_picker_open? do %>
|
||||
<div class="post-picker">
|
||||
<div class="post-picker flex flex-col gap-3">
|
||||
<div class="post-picker-search">
|
||||
<input
|
||||
type="text"
|
||||
@@ -233,7 +232,7 @@
|
||||
<%= if Enum.empty?(@media_editor.post_picker_results) do %>
|
||||
<div class="no-posts"><%= dgettext("ui", "No posts to link") %></div>
|
||||
<% else %>
|
||||
<div class="post-picker-list">
|
||||
<div class="post-picker-list flex flex-col gap-2">
|
||||
<%= for result <- @media_editor.post_picker_results do %>
|
||||
<button class="post-picker-item" type="button" phx-click="link_media_to_post" phx-target={@myself} phx-value-post-id={result.post_id}>
|
||||
<%= result.title %>
|
||||
@@ -250,9 +249,9 @@
|
||||
<%= if Enum.empty?(@media_editor.linked_posts) do %>
|
||||
<div class="no-linked-posts"><%= dgettext("ui", "Not linked to any posts") %></div>
|
||||
<% else %>
|
||||
<div class="linked-posts-list">
|
||||
<div class="linked-posts-list flex flex-col gap-2">
|
||||
<%= for linked_post <- @media_editor.linked_posts do %>
|
||||
<div class="linked-post-item">
|
||||
<div class="linked-post-item flex items-center justify-between gap-2">
|
||||
<button
|
||||
class="linked-post-title linked-post-link"
|
||||
type="button"
|
||||
@@ -275,29 +274,29 @@
|
||||
|
||||
<%= if @media_editor.editing_translation do %>
|
||||
<div class="translation-modal-backdrop">
|
||||
<div class="translation-modal">
|
||||
<div class="translation-modal-header">
|
||||
<div class="translation-modal flex max-h-[80vh] w-full max-w-2xl flex-col overflow-hidden">
|
||||
<div class="translation-modal-header flex items-center justify-between gap-3">
|
||||
<h2><%= dgettext("ui", "Edit Translation") %></h2>
|
||||
<button class="translation-modal-close" type="button" phx-click="close_media_translation_editor" phx-target={@myself}>×</button>
|
||||
</div>
|
||||
<form class="translation-modal-body" phx-change="change_media_translation" phx-target={@myself}>
|
||||
<form class="translation-modal-body flex flex-col gap-4 overflow-auto" phx-change="change_media_translation" phx-target={@myself}>
|
||||
<input type="hidden" name="media_translation[language]" value={@media_editor.editing_translation["language"]} />
|
||||
<div class="editor-field">
|
||||
<div class="editor-field ui-field-stack flex flex-col gap-1.5">
|
||||
<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 class="editor-field">
|
||||
<div class="editor-field ui-field-stack flex flex-col gap-1.5">
|
||||
<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 class="editor-field">
|
||||
<div class="editor-field ui-field-stack flex flex-col gap-1.5">
|
||||
<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>
|
||||
</form>
|
||||
<div class="translation-modal-footer">
|
||||
<button class="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-target={@myself}><%= dgettext("ui", "Save") %></button>
|
||||
<div class="translation-modal-footer flex items-center justify-end gap-2">
|
||||
<button class="secondary ui-button ui-button-secondary" type="button" phx-click="close_media_translation_editor" phx-target={@myself}><%= dgettext("ui", "Cancel") %></button>
|
||||
<button class="ui-button ui-button-primary" type="button" phx-click="save_media_translation" phx-target={@myself}><%= dgettext("ui", "Save") %></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,9 +3,10 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
|
||||
|
||||
use Phoenix.LiveComponent
|
||||
|
||||
|
||||
use Gettext, backend: BDS.Gettext
|
||||
|
||||
alias BDS.Desktop.ShellLive.Notify
|
||||
|
||||
alias BDS.Desktop.ShellLive.MenuEditor.{
|
||||
DraftManagement,
|
||||
PageCategory,
|
||||
@@ -238,7 +239,11 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
|
||||
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.")
|
||||
subtitle:
|
||||
dgettext(
|
||||
"ui",
|
||||
"Manage the central blog navigation outline and save it to meta/menu.opml."
|
||||
)
|
||||
})
|
||||
|
||||
socket
|
||||
@@ -248,7 +253,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
|
||||
end
|
||||
|
||||
defp notify_output(title, message, level) do
|
||||
send(self(), {:menu_editor_output, title, message, level})
|
||||
Notify.output(title, message, level)
|
||||
end
|
||||
|
||||
attr(:menu_editor, :map, required: true)
|
||||
@@ -407,7 +412,6 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
|
||||
"""
|
||||
end
|
||||
|
||||
|
||||
@spec row_label(term(), term()) :: term()
|
||||
def row_label(item, category_titles) do
|
||||
if item.kind == :category_archive do
|
||||
@@ -430,8 +434,11 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
|
||||
def editing_title(_menu_editor), do: dgettext("ui", "Select Page")
|
||||
|
||||
@spec editing_hint(term()) :: term()
|
||||
def editing_hint(%{draft: %{type: :category}}), do: dgettext("ui", "Select an existing category or press Enter to create a new archive entry")
|
||||
def editing_hint(_menu_editor), do: dgettext("ui", "Select a page below or press Enter to create a submenu")
|
||||
def editing_hint(%{draft: %{type: :category}}),
|
||||
do: dgettext("ui", "Select an existing category or press Enter to create a new archive entry")
|
||||
|
||||
def editing_hint(_menu_editor),
|
||||
do: dgettext("ui", "Select a page below or press Enter to create a submenu")
|
||||
|
||||
@spec editing_placeholder(term()) :: term()
|
||||
def editing_placeholder(%{draft: %{type: :category}}),
|
||||
|
||||
@@ -31,7 +31,11 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.State do
|
||||
|
||||
%{
|
||||
title: dgettext("ui", "Blog Menu Editor"),
|
||||
description: dgettext("ui", "Manage the central blog navigation outline and save it to meta/menu.opml."),
|
||||
description:
|
||||
dgettext(
|
||||
"ui",
|
||||
"Manage the central blog navigation outline and save it to meta/menu.opml."
|
||||
),
|
||||
items: state.items,
|
||||
selected_id: state.selected_id,
|
||||
draft: draft,
|
||||
|
||||
@@ -1,51 +1,51 @@
|
||||
<div class="menu-editor-view" data-testid="menu-editor" phx-window-keydown={if(@menu_editor.draft, do: "menu_editor_keydown")} phx-target={@myself}>
|
||||
<div class="menu-editor-header">
|
||||
<div>
|
||||
<div class="menu-editor-view ui-editor-shell flex h-full min-h-0 flex-col p-4" data-testid="menu-editor" phx-window-keydown={if(@menu_editor.draft, do: "menu_editor_keydown")} phx-target={@myself}>
|
||||
<div class="menu-editor-header flex shrink-0 items-start justify-between gap-3">
|
||||
<div class="ui-field-stack">
|
||||
<h2><%= @menu_editor.title %></h2>
|
||||
<p><%= @menu_editor.description %></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="menu-editor-main">
|
||||
<div class="menu-editor-tree-wrap">
|
||||
<div class="menu-editor-toolbar" data-testid="menu-editor-toolbar" role="toolbar" aria-label={@menu_editor.title}>
|
||||
<button class="menu-editor-tool" data-testid="menu-editor-toolbar-button" data-action="add-entry" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="add-entry" phx-target={@myself} title={dgettext("ui", "menuEditor.addEntry")}>
|
||||
<div class="menu-editor-main min-h-0 flex-1 overflow-hidden">
|
||||
<div class="menu-editor-tree-wrap ui-section-card flex h-full min-h-0 flex-col">
|
||||
<div class="menu-editor-toolbar ui-toolbar flex flex-wrap items-center gap-2" data-testid="menu-editor-toolbar" role="toolbar" aria-label={@menu_editor.title}>
|
||||
<button class="menu-editor-tool inline-flex h-9 min-w-9 items-center justify-center" data-testid="menu-editor-toolbar-button" data-action="add-entry" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="add-entry" phx-target={@myself} title={dgettext("ui", "menuEditor.addEntry")}>
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M7 2h2v5h5v2H9v5H7V9H2V7h5V2z" /></svg>
|
||||
</button>
|
||||
|
||||
<button class="menu-editor-tool" data-testid="menu-editor-toolbar-button" data-action="save" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="save" phx-target={@myself} title={dgettext("ui", "menuEditor.save")}>
|
||||
<button class="menu-editor-tool inline-flex h-9 min-w-9 items-center justify-center" data-testid="menu-editor-toolbar-button" data-action="save" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="save" phx-target={@myself} title={dgettext("ui", "menuEditor.save")}>
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M2 2h9l3 3v9H2V2zm2 1v3h6V3H4zm0 9h8V7H4v5z" /></svg>
|
||||
</button>
|
||||
|
||||
<button class="menu-editor-tool" data-testid="menu-editor-toolbar-button" data-action="add-category-archive" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="add-category-archive" phx-target={@myself} title={dgettext("ui", "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"><%= dgettext("ui", "menuEditor.addCategoryArchiveShort") %></span>
|
||||
</button>
|
||||
|
||||
<button class="menu-editor-tool" data-testid="menu-editor-toolbar-button" data-action="move-up" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="move-up" phx-target={@myself} title={dgettext("ui", "menuEditor.moveUp")} disabled={not @menu_editor.can_move_up?}>
|
||||
<button class="menu-editor-tool inline-flex h-9 min-w-9 items-center justify-center" data-testid="menu-editor-toolbar-button" data-action="move-up" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="move-up" phx-target={@myself} title={dgettext("ui", "menuEditor.moveUp")} disabled={not @menu_editor.can_move_up?}>
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M8 3l4 4H9v6H7V7H4l4-4z" /></svg>
|
||||
</button>
|
||||
|
||||
<button class="menu-editor-tool" data-testid="menu-editor-toolbar-button" data-action="move-down" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="move-down" phx-target={@myself} title={dgettext("ui", "menuEditor.moveDown")} disabled={not @menu_editor.can_move_down?}>
|
||||
<button class="menu-editor-tool inline-flex h-9 min-w-9 items-center justify-center" data-testid="menu-editor-toolbar-button" data-action="move-down" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="move-down" phx-target={@myself} title={dgettext("ui", "menuEditor.moveDown")} disabled={not @menu_editor.can_move_down?}>
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M7 3h2v6h3l-4 4-4-4h3V3z" /></svg>
|
||||
</button>
|
||||
|
||||
<button class="menu-editor-tool" data-testid="menu-editor-toolbar-button" data-action="indent" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="indent" phx-target={@myself} title={dgettext("ui", "menuEditor.indent")} disabled={not @menu_editor.can_indent?}>
|
||||
<button class="menu-editor-tool inline-flex h-9 min-w-9 items-center justify-center" data-testid="menu-editor-toolbar-button" data-action="indent" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="indent" phx-target={@myself} title={dgettext("ui", "menuEditor.indent")} disabled={not @menu_editor.can_indent?}>
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M2 4h8v2H2V4zm0 3h4v2H2V7zm0 3h8v2H2v-2zm6-1 3 2-3 2V9z" /></svg>
|
||||
</button>
|
||||
|
||||
<button class="menu-editor-tool" data-testid="menu-editor-toolbar-button" data-action="unindent" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="unindent" phx-target={@myself} title={dgettext("ui", "menuEditor.unindent")} disabled={not @menu_editor.can_unindent?}>
|
||||
<button class="menu-editor-tool inline-flex h-9 min-w-9 items-center justify-center" data-testid="menu-editor-toolbar-button" data-action="unindent" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="unindent" phx-target={@myself} title={dgettext("ui", "menuEditor.unindent")} disabled={not @menu_editor.can_unindent?}>
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M2 4h8v2H2V4zm0 3h4v2H2V7zm0 3h8v2H2v-2zm3-1-3 2 3 2V9z" /></svg>
|
||||
</button>
|
||||
|
||||
<button class="menu-editor-tool" data-testid="menu-editor-toolbar-button" data-action="delete" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="delete" phx-target={@myself} title={dgettext("ui", "menuEditor.delete")} disabled={not @menu_editor.can_delete?}>
|
||||
<button class="menu-editor-tool inline-flex h-9 min-w-9 items-center justify-center" data-testid="menu-editor-toolbar-button" data-action="delete" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="delete" phx-target={@myself} title={dgettext("ui", "menuEditor.delete")} disabled={not @menu_editor.can_delete?}>
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M6 2h4l1 1h3v2H2V3h3l1-1zm-1 4h2v6H5V6zm4 0h2v6H9V6z" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<%= if @menu_editor.items == [] do %>
|
||||
<div class="menu-editor-empty"><%= dgettext("ui", "menuEditor.empty") %></div>
|
||||
<div class="menu-editor-empty flex min-h-0 flex-1 items-center justify-center"><%= dgettext("ui", "menuEditor.empty") %></div>
|
||||
<% else %>
|
||||
<div id="menu-editor-tree-shell" class="menu-editor-tree-shell" phx-hook="MenuEditorTree">
|
||||
<div id="menu-editor-tree-shell" class="menu-editor-tree-shell min-h-0 flex-1 overflow-auto" phx-hook="MenuEditorTree">
|
||||
<ul class="menu-editor-tree-level">
|
||||
<.menu_tree_level items={@menu_editor.items} menu_editor={@menu_editor} depth={0} myself={@myself} />
|
||||
</ul>
|
||||
|
||||
@@ -3,9 +3,11 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
|
||||
|
||||
use Phoenix.LiveComponent
|
||||
|
||||
import Phoenix.HTML, only: [raw: 1]
|
||||
import Ecto.Query
|
||||
|
||||
alias BDS.{Embeddings, Generation, Git, Posts, Repo}
|
||||
alias BDS.{Embeddings, Generation, Git, HelpDocs, Posts, Repo}
|
||||
alias BDS.Desktop.ShellLive.Notify
|
||||
alias BDS.MapUtils
|
||||
alias BDS.Settings.Setting
|
||||
use Gettext, backend: BDS.Gettext
|
||||
@@ -13,6 +15,8 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
|
||||
embed_templates("misc_editor_html/*")
|
||||
|
||||
@misc_routes [
|
||||
:documentation,
|
||||
:api_documentation,
|
||||
:site_validation,
|
||||
:metadata_diff,
|
||||
:translation_validation,
|
||||
@@ -87,7 +91,12 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
|
||||
|
||||
case Generation.apply_validation(project_id, report) do
|
||||
{:ok, result} ->
|
||||
notify_output(dgettext("ui", "Site Validation"), dgettext("ui", "Validation changes applied"), inspect(result))
|
||||
notify_output(
|
||||
dgettext("ui", "Site Validation"),
|
||||
dgettext("ui", "Validation changes applied"),
|
||||
inspect(result)
|
||||
)
|
||||
|
||||
notify_command("validate_site")
|
||||
{:noreply, socket}
|
||||
end
|
||||
@@ -108,7 +117,9 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
|
||||
|
||||
notify_output(
|
||||
dgettext("ui", "Translation Validation"),
|
||||
dgettext("ui", "Deleted %{dbRows} DB rows and %{files} files, flushed %{flushed} translations to disk",
|
||||
dgettext(
|
||||
"ui",
|
||||
"Deleted %{dbRows} DB rows and %{files} files, flushed %{flushed} translations to disk",
|
||||
dbRows: result.deleted_database_rows,
|
||||
files: result.deleted_files,
|
||||
flushed: result.flushed_translations
|
||||
@@ -193,7 +204,11 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
|
||||
|
||||
next_payload = Map.put(payload, :pairs, next_pairs)
|
||||
notify_tab_meta(tab_type, tab_id, %{payload: next_payload})
|
||||
notify_output(dgettext("ui", "Find Duplicates"), dgettext("ui", "Selected pairs dismissed"))
|
||||
|
||||
notify_output(
|
||||
dgettext("ui", "Find Duplicates"),
|
||||
dgettext("ui", "Selected pairs dismissed")
|
||||
)
|
||||
|
||||
{:noreply, assign(socket, :selected_pairs, MapSet.new()) |> build_data()}
|
||||
|
||||
@@ -242,20 +257,39 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
|
||||
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)
|
||||
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: []
|
||||
@@ -325,19 +359,19 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
|
||||
# ── Private helpers ────────────────────────────────────────────────────────
|
||||
|
||||
defp notify_command(action, params \\ %{}) do
|
||||
send(self(), {:misc_editor_command, action, params})
|
||||
Notify.command(action, params)
|
||||
end
|
||||
|
||||
defp notify_output(title, message, detail \\ nil, level \\ "info") do
|
||||
send(self(), {:misc_editor_output, title, message, detail, level})
|
||||
Notify.output(title, message, detail, level)
|
||||
end
|
||||
|
||||
defp notify_tab_meta(tab_type, tab_id, updates) do
|
||||
send(self(), {:misc_editor_tab_meta, tab_type, tab_id, updates})
|
||||
Notify.tab_meta_merge(tab_type, tab_id, updates)
|
||||
end
|
||||
|
||||
defp notify_open_sidebar_item(params, intent) do
|
||||
send(self(), {:open_sidebar_item, params, intent})
|
||||
Notify.open_sidebar_item(params, intent)
|
||||
end
|
||||
|
||||
defp rerun_action(assigns) do
|
||||
@@ -355,6 +389,8 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
|
||||
payload = Map.get(meta, :payload, %{})
|
||||
|
||||
case type do
|
||||
:documentation -> build_help_doc(type, meta)
|
||||
:api_documentation -> build_help_doc(type, meta)
|
||||
:site_validation -> build_site_validation(meta, payload)
|
||||
:metadata_diff -> build_metadata_diff(assigns, meta, payload)
|
||||
:translation_validation -> build_translation_validation(meta, payload)
|
||||
@@ -365,6 +401,18 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
|
||||
|
||||
defp do_build(_assigns), do: nil
|
||||
|
||||
defp build_help_doc(type, meta) do
|
||||
help_doc = HelpDocs.fetch(type)
|
||||
|
||||
%{
|
||||
kind: type,
|
||||
title: Map.get(meta, :title, help_doc.title),
|
||||
subtitle: Map.get(meta, :subtitle, help_doc.subtitle),
|
||||
summary: %{},
|
||||
markdown: help_doc.markdown
|
||||
}
|
||||
end
|
||||
|
||||
defp build_site_validation(meta, payload) do
|
||||
summary = Map.get(payload, :summary, %{})
|
||||
|
||||
@@ -430,7 +478,9 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
|
||||
subtitle: Map.get(meta, :subtitle, ""),
|
||||
summary: %{},
|
||||
summary_text:
|
||||
dgettext("ui", "Checked DB rows: %{dbRows} · Checked files: %{files} · Invalid DB rows: %{invalidDb} · Invalid files: %{invalidFiles}",
|
||||
dgettext(
|
||||
"ui",
|
||||
"Checked DB rows: %{dbRows} · Checked files: %{files} · Invalid DB rows: %{invalidDb} · Invalid files: %{invalidFiles}",
|
||||
dbRows: report.checked_database_row_count,
|
||||
files: report.checked_filesystem_file_count,
|
||||
invalidDb: length(report.invalid_database_rows),
|
||||
|
||||
@@ -1,28 +1,52 @@
|
||||
<div class={["misc-editor-shell", misc_class(@misc_editor.kind)]} data-testid="misc-editor">
|
||||
<div class="misc-editor-header">
|
||||
<div class={["misc-editor-shell flex h-full min-h-0 flex-col overflow-hidden", misc_class(@misc_editor.kind)]} data-testid="misc-editor">
|
||||
<div class="misc-editor-header flex shrink-0 items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2><%= @misc_editor.title %></h2>
|
||||
<p><%= @misc_editor.subtitle %></p>
|
||||
</div>
|
||||
<div class="misc-editor-actions">
|
||||
<button class="secondary" type="button" phx-click="rerun_misc_editor" phx-target={@myself}><%= dgettext("ui", "Refresh") %></button>
|
||||
<div class="misc-editor-actions flex flex-wrap items-center justify-end gap-2">
|
||||
<%= if refreshable?(@misc_editor.kind) do %>
|
||||
<button class="secondary ui-button ui-button-secondary" type="button" phx-click="rerun_misc_editor" phx-target={@myself}><%= dgettext("ui", "Refresh") %></button>
|
||||
<% end %>
|
||||
<%= if @misc_editor.kind == :site_validation do %>
|
||||
<button class="primary" type="button" phx-click="apply_site_validation" 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>
|
||||
<button class="primary ui-button ui-button-primary" type="button" phx-click="apply_site_validation" phx-target={@myself} disabled={Enum.empty?(@misc_editor.missing_url_paths) and Enum.empty?(@misc_editor.extra_url_paths) and Enum.empty?(@misc_editor.updated_post_url_paths)}><%= dgettext("ui", "Apply") %></button>
|
||||
<% end %>
|
||||
<%= if @misc_editor.kind == :find_duplicates do %>
|
||||
<button class="secondary" type="button" phx-click="dismiss_selected_duplicates" phx-target={@myself} disabled={MapSet.size(@misc_editor.selected_pairs) == 0}><%= dgettext("ui", "Dismiss Checked") %></button>
|
||||
<button class="secondary ui-button ui-button-secondary" type="button" phx-click="dismiss_selected_duplicates" phx-target={@myself} disabled={MapSet.size(@misc_editor.selected_pairs) == 0}><%= dgettext("ui", "Dismiss Checked") %></button>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="misc-editor-summary">
|
||||
<div class="misc-editor-summary flex flex-wrap gap-2">
|
||||
<%= for {label, value} <- summary_items(@misc_editor) do %>
|
||||
<div class="misc-summary-pill"><span><%= label %></span><strong><%= value %></strong></div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="misc-editor-content">
|
||||
<div class="misc-editor-content min-h-0 flex-1 overflow-auto">
|
||||
<%= case @misc_editor.kind do %>
|
||||
<% :documentation -> %>
|
||||
<div class="documentation-view">
|
||||
<main class="documentation-scroll">
|
||||
<div class="documentation-content markdown-body">
|
||||
<article class="documentation-article help-doc-markdown" data-testid="help-documentation">
|
||||
<%= markdown_html(@misc_editor.markdown) %>
|
||||
</article>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<% :api_documentation -> %>
|
||||
<div class="documentation-view">
|
||||
<main class="documentation-scroll">
|
||||
<div class="documentation-content markdown-body">
|
||||
<article class="documentation-article help-doc-markdown" data-testid="help-api-documentation">
|
||||
<%= markdown_html(@misc_editor.markdown) %>
|
||||
</article>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<% :site_validation -> %>
|
||||
<div class="misc-columns">
|
||||
<section class="misc-card"><h3><%= 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>
|
||||
@@ -35,7 +59,7 @@
|
||||
<div class="metadata-diff-tabs" role="tablist">
|
||||
<%= for tab <- @misc_editor.tabs do %>
|
||||
<button
|
||||
class={["metadata-diff-tab", if(@misc_editor.active_tab == tab.id, do: "active")]}
|
||||
class={["metadata-diff-tab", "ui-tab", if(@misc_editor.active_tab == tab.id, do: "active ui-tab-active")]}
|
||||
data-testid="metadata-diff-tab"
|
||||
data-entity-tab={tab.id}
|
||||
type="button"
|
||||
@@ -45,7 +69,7 @@
|
||||
>
|
||||
<span><%= tab.label %></span>
|
||||
<%= if tab.badge_count > 0 do %>
|
||||
<span class="tab-badge"><%= tab.badge_count %></span>
|
||||
<span class="tab-badge ui-badge"><%= tab.badge_count %></span>
|
||||
<% end %>
|
||||
</button>
|
||||
<% end %>
|
||||
@@ -71,7 +95,7 @@
|
||||
<%= if @misc_editor.repair_enabled do %>
|
||||
<div class="metadata-diff-field-pill-actions">
|
||||
<button
|
||||
class="secondary metadata-diff-action-button"
|
||||
class="secondary metadata-diff-action-button ui-button ui-button-secondary"
|
||||
data-testid="metadata-diff-repair-button"
|
||||
data-direction="db_to_file"
|
||||
data-field={field.field_name}
|
||||
@@ -85,7 +109,7 @@
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="secondary metadata-diff-action-button"
|
||||
class="secondary metadata-diff-action-button ui-button ui-button-secondary"
|
||||
data-testid="metadata-diff-repair-button"
|
||||
data-direction="file_to_db"
|
||||
data-field={field.field_name}
|
||||
@@ -149,7 +173,7 @@
|
||||
<div class="orphan-files-actions">
|
||||
<span class="misc-summary-pill"><%= length(@misc_editor.orphan_files) %></span>
|
||||
<button
|
||||
class="secondary metadata-diff-action-button"
|
||||
class="secondary metadata-diff-action-button ui-button ui-button-secondary"
|
||||
data-testid="metadata-diff-import-button"
|
||||
type="button"
|
||||
phx-click="import_metadata_diff_orphans"
|
||||
@@ -256,8 +280,8 @@
|
||||
</section>
|
||||
|
||||
<div class="translation-validation-actions">
|
||||
<button class="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" phx-target={@myself} data-testid="translation-validation-fix" disabled={not @misc_editor.can_fix?}><%= dgettext("ui", "translationValidation.fix") %></button>
|
||||
<button class="secondary ui-button ui-button-secondary" type="button" phx-click="rerun_misc_editor" phx-target={@myself} data-testid="translation-validation-revalidate"><%= dgettext("ui", "translationValidation.revalidate") %></button>
|
||||
<button class="primary ui-button ui-button-primary" type="button" phx-click="fix_translation_validation" phx-target={@myself} data-testid="translation-validation-fix" disabled={not @misc_editor.can_fix?}><%= dgettext("ui", "translationValidation.fix") %></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -270,7 +294,7 @@
|
||||
<span>→</span>
|
||||
<button class="linkish" type="button" phx-click="open_duplicate_post" phx-target={@myself} phx-value-id={BDS.MapUtils.attr(pair, :post_id_b)} phx-value-title={BDS.MapUtils.attr(pair, :title_b)}><%= BDS.MapUtils.attr(pair, :title_b) %></button>
|
||||
<span class="misc-summary-pill"><%= if(BDS.MapUtils.attr(pair, :exact_match), do: dgettext("ui", "Exact Match"), else: "#{Float.round((BDS.MapUtils.attr(pair, :similarity) || 0.0) * 100, 1)}%") %></span>
|
||||
<button class="secondary" 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>
|
||||
<button class="secondary ui-button ui-button-secondary" type="button" phx-click="dismiss_duplicate_pair" phx-target={@myself} phx-value-post-id-a={BDS.MapUtils.attr(pair, :post_id_a)} phx-value-post-id-b={BDS.MapUtils.attr(pair, :post_id_b)}><%= dgettext("ui", "Dismiss") %></button>
|
||||
</article>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -282,7 +306,7 @@
|
||||
<% else %>
|
||||
<form class="git-diff-toolbar" phx-change="select_git_diff_file" phx-target={@myself}>
|
||||
<label for="git-diff-file-select"><%= dgettext("ui", "gitDiff.changedFiles") %></label>
|
||||
<select 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 %>
|
||||
<option value={file_path} selected={file_path == @misc_editor.selected_file_path}><%= file_path %></option>
|
||||
<% end %>
|
||||
|
||||
82
lib/bds/desktop/shell_live/notify.ex
Normal file
82
lib/bds/desktop/shell_live/notify.ex
Normal file
@@ -0,0 +1,82 @@
|
||||
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 schedule_auto_save(atom(), term()) :: :ok
|
||||
def schedule_auto_save(type, id) do
|
||||
send(self(), {:schedule_auto_save, type, id})
|
||||
:ok
|
||||
end
|
||||
|
||||
@spec cancel_auto_save(atom(), term()) :: :ok
|
||||
def cancel_auto_save(type, id) do
|
||||
send(self(), {:cancel_auto_save, type, id})
|
||||
:ok
|
||||
end
|
||||
|
||||
@spec parent(term()) :: :ok
|
||||
def parent(message) do
|
||||
send(self(), message)
|
||||
:ok
|
||||
end
|
||||
end
|
||||
@@ -64,8 +64,6 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
|
||||
|
||||
def markdown_link(text, url), do: "[#{text}](#{url})"
|
||||
|
||||
|
||||
|
||||
def project_metadata(nil), do: %{main_language: "en", blog_languages: []}
|
||||
|
||||
def project_metadata(project_id) do
|
||||
|
||||
@@ -11,6 +11,7 @@ defmodule BDS.Desktop.ShellLive.OverlayManager do
|
||||
|
||||
alias BDS.Desktop.ShellLive.{
|
||||
MediaEditor,
|
||||
Notify,
|
||||
PostEditor,
|
||||
TabHelpers
|
||||
}
|
||||
@@ -148,8 +149,12 @@ defmodule BDS.Desktop.ShellLive.OverlayManager do
|
||||
socket
|
||||
|
||||
result ->
|
||||
send(self(), {:post_editor_insert_content, post_id,
|
||||
ShellOverlayComponents.markdown_link(result.title, result.canonical_url)})
|
||||
send(
|
||||
self(),
|
||||
{:post_editor_insert_content, post_id,
|
||||
ShellOverlayComponents.markdown_link(result.title, result.canonical_url)}
|
||||
)
|
||||
|
||||
socket
|
||||
end
|
||||
|
||||
@@ -166,7 +171,7 @@ defmodule BDS.Desktop.ShellLive.OverlayManager do
|
||||
"[#{result.original_name}](bds-media://#{result.media_id})"
|
||||
end
|
||||
|
||||
send(self(), {:post_editor_insert_content, post_id, syntax})
|
||||
Notify.parent({:post_editor_insert_content, post_id, syntax})
|
||||
socket
|
||||
end
|
||||
|
||||
@@ -191,7 +196,7 @@ defmodule BDS.Desktop.ShellLive.OverlayManager do
|
||||
end
|
||||
|
||||
if details do
|
||||
send(self(), {:post_editor_insert_content, post_id, details})
|
||||
Notify.parent({:post_editor_insert_content, post_id, details})
|
||||
end
|
||||
|
||||
socket
|
||||
@@ -209,7 +214,7 @@ defmodule BDS.Desktop.ShellLive.OverlayManager do
|
||||
socket =
|
||||
case {socket.assigns[:shell_overlay], current_tab} do
|
||||
{%{kind: :language_picker}, %{type: :post, id: post_id}} ->
|
||||
send(self(), {:post_editor_translate, post_id, code})
|
||||
Notify.parent({:post_editor_translate, post_id, code})
|
||||
socket
|
||||
|
||||
{%{kind: :language_picker}, %{type: :media, id: media_id}} ->
|
||||
@@ -233,13 +238,15 @@ defmodule BDS.Desktop.ShellLive.OverlayManager do
|
||||
|
||||
socket =
|
||||
case {socket.assigns[:shell_overlay], current_tab} do
|
||||
{%{kind: :confirm_delete, delete_action: %{source: :sidebar, route: route, id: id}},
|
||||
_tab} ->
|
||||
{%{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)})
|
||||
send(
|
||||
self(),
|
||||
{:post_editor_apply_ai_suggestions, post_id, Overlay.selected_ai_fields(overlay)}
|
||||
)
|
||||
|
||||
socket
|
||||
|
||||
{%{kind: :ai_suggestions} = overlay, %{type: :media, id: media_id}} ->
|
||||
@@ -258,8 +265,10 @@ defmodule BDS.Desktop.ShellLive.OverlayManager do
|
||||
|
||||
socket
|
||||
|> 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})
|
||||
)
|
||||
|> callbacks.reload.(workbench)
|
||||
|
||||
{:error, reason} ->
|
||||
@@ -331,8 +340,7 @@ defmodule BDS.Desktop.ShellLive.OverlayManager do
|
||||
}
|
||||
end
|
||||
|
||||
assign(socket, :shell_overlay,
|
||||
Overlay.set_ai_suggestions(overlay, suggestions))
|
||||
assign(socket, :shell_overlay, Overlay.set_ai_suggestions(overlay, suggestions))
|
||||
else
|
||||
socket
|
||||
end
|
||||
@@ -355,13 +363,12 @@ defmodule BDS.Desktop.ShellLive.OverlayManager do
|
||||
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]))}"
|
||||
"#{reason.kind}: #{inspect(Map.drop(reason, [:kind]))}"
|
||||
else
|
||||
inspect(reason)
|
||||
end
|
||||
|
||||
assign(socket, :shell_overlay,
|
||||
Overlay.set_ai_suggestions_error(overlay, message))
|
||||
assign(socket, :shell_overlay, Overlay.set_ai_suggestions_error(overlay, message))
|
||||
else
|
||||
socket
|
||||
end
|
||||
@@ -444,5 +451,4 @@ defmodule BDS.Desktop.ShellLive.OverlayManager do
|
||||
rescue
|
||||
_error -> "en"
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
@@ -30,10 +30,10 @@ defmodule BDS.Desktop.ShellLive.PanelRenderer do
|
||||
|
||||
~H"""
|
||||
<%= if Enum.any?(@editor_toolbar_buttons) do %>
|
||||
<div class="editor-toolbar">
|
||||
<div class="editor-toolbar flex items-center gap-2">
|
||||
<%= for button <- @editor_toolbar_buttons do %>
|
||||
<button
|
||||
class={["editor-toolbar-button", if(button.destructive, do: "is-destructive")]}
|
||||
class={["editor-toolbar-button inline-flex items-center justify-center", if(button.destructive, do: "is-destructive")]}
|
||||
data-testid="editor-toolbar-overlay-button"
|
||||
type="button"
|
||||
phx-click="open_overlay"
|
||||
@@ -50,15 +50,15 @@ defmodule BDS.Desktop.ShellLive.PanelRenderer do
|
||||
defp render_task_entries(assigns) do
|
||||
~H"""
|
||||
<%= if Enum.empty?(Map.get(@task_status, :tasks, [])) do %>
|
||||
<div class="panel-entry panel-empty-state">
|
||||
<div class="panel-entry ui-panel-entry panel-empty-state ui-empty-state">
|
||||
<strong><%= dgettext("ui", "Tasks") %></strong>
|
||||
<span><%= dgettext("ui", "No background tasks running") %></span>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="task-list">
|
||||
<div class="task-list flex flex-col gap-2">
|
||||
<%= for task <- Map.get(@task_status, :tasks, []) do %>
|
||||
<div class="panel-entry task-entry">
|
||||
<div class="task-entry-header">
|
||||
<div class="panel-entry ui-panel-entry task-entry flex flex-col gap-2">
|
||||
<div class="task-entry-header flex items-center justify-between gap-2">
|
||||
<strong><%= task.name %></strong>
|
||||
<span class={"task-status task-status-#{task.status}"}><%= Map.get(task, :status_label, task.status |> to_string() |> String.capitalize()) %></span>
|
||||
</div>
|
||||
@@ -79,15 +79,16 @@ defmodule BDS.Desktop.ShellLive.PanelRenderer do
|
||||
defp render_output_entries(assigns) do
|
||||
~H"""
|
||||
<%= if Enum.empty?(@output_entries) do %>
|
||||
<div class="panel-entry panel-empty-state output-list">
|
||||
<div class="panel-entry ui-panel-entry panel-empty-state ui-empty-state output-list">
|
||||
<strong><%= dgettext("ui", "Output") %></strong>
|
||||
<span><%= dgettext("ui", "No shell output yet") %></span>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="output-list">
|
||||
<div class="output-list flex flex-col gap-2">
|
||||
<%= for entry <- @output_entries do %>
|
||||
<div class={[
|
||||
"panel-entry",
|
||||
"ui-panel-entry",
|
||||
"output-entry",
|
||||
if(Map.get(entry, :level) == "error", do: "output-entry-error")
|
||||
]}>
|
||||
@@ -104,26 +105,26 @@ defmodule BDS.Desktop.ShellLive.PanelRenderer do
|
||||
end
|
||||
|
||||
defp render_post_links(assigns) do
|
||||
links = post_link_entries(assigns)
|
||||
panel_links = assigns[:panel_post_links] || %{backlinks: [], outlinks: []}
|
||||
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:backlinks, Map.get(links, :backlinks, []))
|
||||
|> assign(:outlinks, Map.get(links, :outlinks, []))
|
||||
|> assign(:backlinks, Map.get(panel_links, :backlinks, []))
|
||||
|> assign(:outlinks, Map.get(panel_links, :outlinks, []))
|
||||
|
||||
~H"""
|
||||
<%= if Enum.empty?(@backlinks) and Enum.empty?(@outlinks) do %>
|
||||
<div class="panel-entry panel-empty-state">
|
||||
<div class="panel-entry ui-panel-entry panel-empty-state ui-empty-state">
|
||||
<strong><%= dgettext("ui", "Post Links") %></strong>
|
||||
<span><%= dgettext("ui", "No post links yet") %></span>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="git-log-list">
|
||||
<div class="git-log-list flex flex-col gap-2">
|
||||
<%= if Enum.any?(@backlinks) do %>
|
||||
<div class="panel-entry"><strong><%= dgettext("ui", "Backlinks") %></strong></div>
|
||||
<div class="panel-entry ui-panel-entry"><strong><%= dgettext("ui", "Backlinks") %></strong></div>
|
||||
<%= for entry <- @backlinks do %>
|
||||
<button
|
||||
class="panel-entry task-entry"
|
||||
class="panel-entry ui-panel-entry task-entry"
|
||||
type="button"
|
||||
phx-click="pin_sidebar_item"
|
||||
phx-value-route="post"
|
||||
@@ -138,10 +139,10 @@ defmodule BDS.Desktop.ShellLive.PanelRenderer do
|
||||
<% end %>
|
||||
|
||||
<%= if Enum.any?(@outlinks) do %>
|
||||
<div class="panel-entry"><strong><%= dgettext("ui", "Links To") %></strong></div>
|
||||
<div class="panel-entry ui-panel-entry"><strong><%= dgettext("ui", "Links To") %></strong></div>
|
||||
<%= for entry <- @outlinks do %>
|
||||
<button
|
||||
class="panel-entry task-entry"
|
||||
class="panel-entry ui-panel-entry task-entry"
|
||||
type="button"
|
||||
phx-click="pin_sidebar_item"
|
||||
phx-value-route="post"
|
||||
@@ -160,13 +161,13 @@ defmodule BDS.Desktop.ShellLive.PanelRenderer do
|
||||
end
|
||||
|
||||
defp render_git_log(assigns) do
|
||||
entries = git_log_entries(assigns)
|
||||
entries = assigns[:panel_git_entries] || []
|
||||
assigns = assign(assigns, :git_entries, entries)
|
||||
|
||||
~H"""
|
||||
<%= if Enum.empty?(@git_entries) do %>
|
||||
<div class="git-log-list">
|
||||
<div class="panel-entry panel-empty-state">
|
||||
<div class="git-log-list flex flex-col gap-2">
|
||||
<div class="panel-entry ui-panel-entry panel-empty-state ui-empty-state">
|
||||
<strong><%= dgettext("ui", "Git Log") %></strong>
|
||||
<span><%= dgettext("ui", "No git history yet") %></span>
|
||||
</div>
|
||||
@@ -174,7 +175,7 @@ defmodule BDS.Desktop.ShellLive.PanelRenderer do
|
||||
<% else %>
|
||||
<div class="git-log-list">
|
||||
<%= for entry <- @git_entries do %>
|
||||
<div class="panel-entry task-entry">
|
||||
<div class="panel-entry ui-panel-entry task-entry">
|
||||
<strong><%= short_commit_hash(entry.hash) %> <%= entry.subject || dgettext("ui", "No commit subject") %></strong>
|
||||
<span><%= entry.hash %></span>
|
||||
</div>
|
||||
@@ -188,14 +189,14 @@ defmodule BDS.Desktop.ShellLive.PanelRenderer do
|
||||
assigns = assign(assigns, :panel_label, ShellData.route_label(tab))
|
||||
|
||||
~H"""
|
||||
<div class="panel-entry">
|
||||
<div class="panel-entry ui-panel-entry">
|
||||
<strong><%= @panel_label %></strong>
|
||||
<span><%= dgettext("ui", "The shared lower panel is available for tasks, output, git details, and editor-specific diagnostics.") %></span>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp post_link_entries(assigns) do
|
||||
def fetch_post_link_entries(assigns) do
|
||||
case assigns.current_tab do
|
||||
%{type: :post, id: post_id} ->
|
||||
%{
|
||||
@@ -225,7 +226,7 @@ defmodule BDS.Desktop.ShellLive.PanelRenderer do
|
||||
|> Enum.reject(&is_nil/1)
|
||||
end
|
||||
|
||||
defp git_log_entries(assigns) do
|
||||
def fetch_git_log_entries(assigns) do
|
||||
case git_history_target(assigns.current_tab) do
|
||||
nil ->
|
||||
[]
|
||||
|
||||
@@ -5,6 +5,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
||||
|
||||
alias BDS.{AI, Posts, Preview}
|
||||
alias BDS.Desktop.ShellData
|
||||
alias BDS.Desktop.ShellLive.Notify
|
||||
alias BDS.Desktop.ShellLive.PostEditor.{DraftManagement, ListValues, Persistence, PostMetadata}
|
||||
alias BDS.Posts.Post
|
||||
alias BDS.Tags
|
||||
@@ -47,6 +48,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
||||
]
|
||||
|
||||
use Gettext, backend: BDS.Gettext
|
||||
|
||||
import PostMetadata,
|
||||
only: [
|
||||
blank?: 1,
|
||||
@@ -180,7 +182,11 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
||||
|> build_data()
|
||||
|
||||
if dirty? != was_dirty? do
|
||||
notify_parent({:post_editor_dirty, post_id, dirty?})
|
||||
Notify.dirty(:post, post_id, dirty?)
|
||||
end
|
||||
|
||||
if dirty? do
|
||||
Notify.schedule_auto_save(:post, post_id)
|
||||
end
|
||||
|
||||
{:noreply, socket}
|
||||
@@ -202,6 +208,14 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
||||
{:noreply, do_delete(socket)}
|
||||
end
|
||||
|
||||
def handle_event("archive_post_editor", _params, socket) do
|
||||
{:noreply, do_archive(socket)}
|
||||
end
|
||||
|
||||
def handle_event("unarchive_post_editor", _params, socket) do
|
||||
{:noreply, do_unarchive(socket)}
|
||||
end
|
||||
|
||||
def handle_event("set_post_editor_mode", %{"mode" => mode}, socket) do
|
||||
normalized_mode = normalize_mode(mode)
|
||||
|
||||
@@ -368,6 +382,8 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
||||
editing_canonical_language?(translations, active_language, canonical_language),
|
||||
can_publish?: post.status == :draft,
|
||||
can_delete?: post.status == :published,
|
||||
can_archive?: post.status in [:draft, :published],
|
||||
can_unarchive?: post.status == :archived,
|
||||
has_published_version?: has_published_version?(post),
|
||||
discard_label: discard_label(post),
|
||||
discard_title: discard_title(post),
|
||||
@@ -455,12 +471,11 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
||||
|> assign(:dirty?, false)
|
||||
|> build_data()
|
||||
|
||||
notify_parent(
|
||||
{:post_editor_tab_meta, post.id, record_title(record, refreshed_post),
|
||||
Atom.to_string(record_status(record))}
|
||||
)
|
||||
Notify.tab_meta(:post, post.id, record_title(record, refreshed_post),
|
||||
Atom.to_string(record_status(record)))
|
||||
|
||||
notify_parent({:post_editor_dirty, post.id, false})
|
||||
Notify.dirty(:post, post.id, false)
|
||||
Notify.cancel_auto_save(:post, post.id)
|
||||
notify_output(socket, dgettext("ui", "Post"), dgettext("ui", "Post saved"))
|
||||
socket
|
||||
|
||||
@@ -496,12 +511,10 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
||||
|> assign(:dirty?, false)
|
||||
|> build_data()
|
||||
|
||||
notify_parent(
|
||||
{:post_editor_tab_meta, post.id, record_title(record, refreshed_post),
|
||||
Atom.to_string(record_status(record))}
|
||||
)
|
||||
Notify.tab_meta(:post, post.id, record_title(record, refreshed_post),
|
||||
Atom.to_string(record_status(record)))
|
||||
|
||||
notify_parent({:post_editor_dirty, post.id, false})
|
||||
Notify.dirty(:post, post.id, false)
|
||||
notify_output(socket, dgettext("ui", "Post"), dgettext("ui", "Post published"))
|
||||
socket
|
||||
|
||||
@@ -533,13 +546,11 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
||||
|> assign(:dirty?, false)
|
||||
|> build_data()
|
||||
|
||||
notify_parent(
|
||||
{:post_editor_tab_meta, post.id,
|
||||
restored_post.title || restored_post.slug || restored_post.id,
|
||||
Atom.to_string(restored_post.status || :draft)}
|
||||
)
|
||||
Notify.tab_meta(:post, post.id,
|
||||
restored_post.title || restored_post.slug || restored_post.id,
|
||||
Atom.to_string(restored_post.status || :draft))
|
||||
|
||||
notify_parent({:post_editor_dirty, post.id, false})
|
||||
Notify.dirty(:post, post.id, false)
|
||||
socket
|
||||
|
||||
{:error, reason} ->
|
||||
@@ -554,7 +565,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
||||
|
||||
case Posts.delete_post(post_id) do
|
||||
{:ok, :deleted} ->
|
||||
notify_parent({:close_tab, :post, post_id})
|
||||
Notify.close_tab(:post, post_id)
|
||||
socket
|
||||
|
||||
{:error, reason} ->
|
||||
@@ -563,6 +574,72 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
||||
end
|
||||
end
|
||||
|
||||
defp do_archive(socket) do
|
||||
case socket.assigns.post do
|
||||
nil ->
|
||||
socket
|
||||
|
||||
%Post{} = post ->
|
||||
case Posts.archive_post(post.id) do
|
||||
{:ok, archived_post} ->
|
||||
socket =
|
||||
socket
|
||||
|> assign(:post, archived_post)
|
||||
|> assign(:drafts, %{})
|
||||
|> assign(:dirty?, false)
|
||||
|> assign(:quick_actions_open?, false)
|
||||
|> build_data()
|
||||
|
||||
Notify.tab_meta(
|
||||
:post,
|
||||
post.id,
|
||||
archived_post.title || archived_post.slug || archived_post.id,
|
||||
"archived"
|
||||
)
|
||||
|
||||
Notify.dirty(:post, post.id, false)
|
||||
notify_output(socket, dgettext("ui", "Post"), dgettext("ui", "Post archived"))
|
||||
|
||||
{:error, reason} ->
|
||||
notify_output(socket, dgettext("ui", "Post"), inspect(reason), "error")
|
||||
|> build_data()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp do_unarchive(socket) do
|
||||
case socket.assigns.post do
|
||||
nil ->
|
||||
socket
|
||||
|
||||
%Post{} = post ->
|
||||
case Posts.unarchive_post(post.id) do
|
||||
{:ok, unarchived_post} ->
|
||||
socket =
|
||||
socket
|
||||
|> assign(:post, unarchived_post)
|
||||
|> assign(:drafts, %{})
|
||||
|> assign(:dirty?, false)
|
||||
|> assign(:quick_actions_open?, false)
|
||||
|> build_data()
|
||||
|
||||
Notify.tab_meta(
|
||||
:post,
|
||||
post.id,
|
||||
unarchived_post.title || unarchived_post.slug || unarchived_post.id,
|
||||
"draft"
|
||||
)
|
||||
|
||||
Notify.dirty(:post, post.id, false)
|
||||
notify_output(socket, dgettext("ui", "Post"), dgettext("ui", "Post unarchived"))
|
||||
|
||||
{:error, reason} ->
|
||||
notify_output(socket, dgettext("ui", "Post"), inspect(reason), "error")
|
||||
|> build_data()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp do_detect_language(socket) do
|
||||
if Map.get(socket.assigns, :offline_mode, true) do
|
||||
notify_output(
|
||||
@@ -589,7 +666,10 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
||||
{:ok, %{language_code: language_code}}
|
||||
when is_binary(language_code) and language_code != "" ->
|
||||
socket
|
||||
|> put_component_draft_field("language", normalize_language(language_code, socket.assigns.canonical_language))
|
||||
|> put_component_draft_field(
|
||||
"language",
|
||||
normalize_language(language_code, socket.assigns.canonical_language)
|
||||
)
|
||||
|> build_data()
|
||||
|
||||
{:error, reason} ->
|
||||
@@ -638,7 +718,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
||||
|> assign(:quick_actions_open?, false)
|
||||
|> build_data()
|
||||
|
||||
notify_parent({:post_editor_dirty, post_id, false})
|
||||
Notify.dirty(:post, post_id, false)
|
||||
socket
|
||||
else
|
||||
{:error, reason} ->
|
||||
@@ -685,13 +765,16 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
||||
socket
|
||||
|> assign(:post, updated_post)
|
||||
|> assign(:project_metadata, metadata)
|
||||
|> assign(:drafts, Map.put(socket.assigns.drafts, active_language, refreshed_form))
|
||||
|> assign(
|
||||
:drafts,
|
||||
Map.put(socket.assigns.drafts, active_language, refreshed_form)
|
||||
)
|
||||
|> assign(:save_state, :dirty)
|
||||
|> assign(:dirty?, true)
|
||||
|> assign(:shell_overlay, nil)
|
||||
|> build_data()
|
||||
|
||||
notify_parent({:post_editor_dirty, post_id, true})
|
||||
Notify.dirty(:post, post_id, true)
|
||||
socket
|
||||
|
||||
{:error, reason} ->
|
||||
@@ -734,7 +817,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
||||
|> put_component_draft_field(field_key(kind), updated)
|
||||
|> build_data()
|
||||
|
||||
notify_parent({:post_editor_dirty, socket.assigns.post_id, true})
|
||||
Notify.dirty(:post, socket.assigns.post_id, true)
|
||||
assign(socket, :dirty?, true)
|
||||
end
|
||||
end
|
||||
@@ -764,7 +847,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
||||
|> put_component_draft_field(field_key(kind), updated)
|
||||
|> build_data()
|
||||
|
||||
notify_parent({:post_editor_dirty, socket.assigns.post_id, true})
|
||||
Notify.dirty(:post, socket.assigns.post_id, true)
|
||||
assign(socket, :dirty?, true)
|
||||
end
|
||||
end
|
||||
@@ -800,12 +883,8 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
||||
defp assign_query(socket, :tags, value), do: assign(socket, :tag_query, value)
|
||||
defp assign_query(socket, :categories, value), do: assign(socket, :category_query, value)
|
||||
|
||||
defp notify_parent(message) do
|
||||
send(self(), message)
|
||||
end
|
||||
|
||||
defp notify_output(socket, title, message, level \\ "info") do
|
||||
send(self(), {:post_editor_output, title, message, level})
|
||||
Notify.output(title, message, level)
|
||||
socket
|
||||
end
|
||||
|
||||
@@ -822,5 +901,4 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
||||
@spec post_editor_mode_label(term()) :: term()
|
||||
def post_editor_mode_label(:markdown), do: dgettext("ui", "Markdown")
|
||||
def post_editor_mode_label(:preview), do: dgettext("ui", "Preview")
|
||||
|
||||
end
|
||||
|
||||
@@ -168,11 +168,19 @@ defmodule BDS.Desktop.ShellLive.PostEditor.PostMetadata do
|
||||
|
||||
@spec gallery_count(term()) :: term()
|
||||
def gallery_count(form) do
|
||||
form
|
||||
|> Map.get("content", "")
|
||||
|> to_string()
|
||||
|> then(&Regex.scan(~r/!\[[^\]]*\]\([^\)]+\)/, &1))
|
||||
|> length()
|
||||
content = form |> Map.get("content", "") |> to_string()
|
||||
|
||||
image_count =
|
||||
content
|
||||
|> then(&Regex.scan(~r/!\[[^\]]*\]\([^\)]+\)/, &1))
|
||||
|> length()
|
||||
|
||||
gallery_macro_count =
|
||||
content
|
||||
|> then(&Regex.scan(~r/\[\[gallery\]\]/i, &1))
|
||||
|> length()
|
||||
|
||||
max(image_count, gallery_macro_count)
|
||||
end
|
||||
|
||||
@spec preview_url(term(), term(), term(), term()) :: term()
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
<div class="post-editor editor" data-testid="post-editor">
|
||||
<div class="editor-header">
|
||||
<div class="editor-tabs">
|
||||
<div class={["editor-tab", "active", if(@post_editor.dirty?, do: "dirty")]}>
|
||||
<span class="editor-tab-title" data-testid="editor-title"><%= @post_editor.display_title %></span>
|
||||
<div class="post-editor ui-editor-shell flex h-full min-h-0 flex-col" data-testid="post-editor">
|
||||
<div class="editor-header ui-editor-header flex shrink-0 items-start justify-between gap-3">
|
||||
<div class="flex min-w-0 flex-1 overflow-hidden">
|
||||
<div class={["ui-tab ui-tab-active ui-editor-tab-current inline-flex max-w-full items-center gap-2 overflow-hidden px-3 py-2", if(@post_editor.dirty?, do: "dirty")]}>
|
||||
<span class="truncate" data-testid="editor-title"><%= @post_editor.display_title %></span>
|
||||
<%= if @post_editor.dirty? do %>
|
||||
<span class="editor-tab-dirty" title={dgettext("ui", "Unsaved")}>●</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-actions">
|
||||
<span class={["status-badge", "status-#{@post_editor.status}"]} data-testid="post-status-badge">
|
||||
<div class="ui-editor-actions flex flex-wrap items-center justify-end gap-2">
|
||||
<span class={["status-badge", "ui-badge", "status-#{@post_editor.status}"]} data-testid="post-status-badge">
|
||||
<%= post_status_label(@post_editor.status) %>
|
||||
</span>
|
||||
<%= if @post_editor.save_state in [:saving] do %>
|
||||
<span class="auto-save-indicator"><%= post_editor_save_state_label(@post_editor.save_state) %></span>
|
||||
<% end %>
|
||||
|
||||
<div class="quick-actions-wrapper">
|
||||
<div class="quick-actions-wrapper relative">
|
||||
<button
|
||||
class="secondary quick-actions-btn"
|
||||
class="secondary quick-actions-btn ui-button ui-button-secondary inline-flex items-center gap-2"
|
||||
type="button"
|
||||
phx-click="toggle_post_editor_quick_actions"
|
||||
phx-target={@myself}
|
||||
@@ -29,9 +29,9 @@
|
||||
</button>
|
||||
|
||||
<%= if @post_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">
|
||||
<button
|
||||
class="quick-action-item"
|
||||
class="quick-action-item ui-dropdown-item flex items-start gap-3 text-left"
|
||||
data-testid="editor-toolbar-overlay-button"
|
||||
type="button"
|
||||
phx-click="open_overlay"
|
||||
@@ -39,7 +39,7 @@
|
||||
disabled={not @post_editor.detect_language_enabled?}
|
||||
>
|
||||
<span class="quick-action-icon">🤖</span>
|
||||
<span class="quick-action-text">
|
||||
<span class="quick-action-text flex min-w-0 flex-1 flex-col">
|
||||
<strong><%= dgettext("ui", "AI Suggestions") %></strong>
|
||||
<small><%= dgettext("ui", "Review title, excerpt, and content suggestions") %></small>
|
||||
</span>
|
||||
@@ -48,7 +48,7 @@
|
||||
<div class="quick-actions-divider"></div>
|
||||
|
||||
<button
|
||||
class="quick-action-item"
|
||||
class="quick-action-item ui-dropdown-item flex items-start gap-3 text-left"
|
||||
data-testid="editor-toolbar-overlay-button"
|
||||
type="button"
|
||||
phx-click="open_overlay"
|
||||
@@ -56,41 +56,77 @@
|
||||
disabled={not @post_editor.can_translate?}
|
||||
>
|
||||
<span class="quick-action-icon">🌍</span>
|
||||
<span class="quick-action-text">
|
||||
<span class="quick-action-text flex min-w-0 flex-1 flex-col">
|
||||
<strong><%= dgettext("ui", "Translate") %></strong>
|
||||
<small><%= dgettext("ui", "Select a target language for this post") %></small>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<%= if @post_editor.can_archive? or @post_editor.can_unarchive? do %>
|
||||
<div class="quick-actions-divider"></div>
|
||||
|
||||
<%= if @post_editor.can_archive? do %>
|
||||
<button
|
||||
class="quick-action-item ui-dropdown-item flex items-start gap-3 text-left"
|
||||
data-testid="post-archive-button"
|
||||
type="button"
|
||||
phx-click="archive_post_editor"
|
||||
phx-target={@myself}
|
||||
>
|
||||
<span class="quick-action-icon">📦</span>
|
||||
<span class="quick-action-text flex min-w-0 flex-1 flex-col">
|
||||
<strong><%= dgettext("ui", "Archive") %></strong>
|
||||
<small><%= dgettext("ui", "Move this post to the archive") %></small>
|
||||
</span>
|
||||
</button>
|
||||
<% end %>
|
||||
|
||||
<%= if @post_editor.can_unarchive? do %>
|
||||
<button
|
||||
class="quick-action-item ui-dropdown-item flex items-start gap-3 text-left"
|
||||
data-testid="post-unarchive-button"
|
||||
type="button"
|
||||
phx-click="unarchive_post_editor"
|
||||
phx-target={@myself}
|
||||
>
|
||||
<span class="quick-action-icon">📤</span>
|
||||
<span class="quick-action-text flex min-w-0 flex-1 flex-col">
|
||||
<strong><%= dgettext("ui", "Unarchive") %></strong>
|
||||
<small><%= dgettext("ui", "Restore this post to draft") %></small>
|
||||
</span>
|
||||
</button>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= if @post_editor.can_publish? do %>
|
||||
<button class="success" data-testid="post-publish-button" type="button" phx-click="publish_post_editor" phx-target={@myself}>
|
||||
<button class="success ui-button ui-button-primary" data-testid="post-publish-button" type="button" phx-click="publish_post_editor" phx-target={@myself}>
|
||||
<%= dgettext("ui", "Publish") %>
|
||||
</button>
|
||||
<% end %>
|
||||
<%= if @post_editor.can_publish? do %>
|
||||
<button class="secondary danger" data-testid="post-discard-button" type="button" phx-click="discard_post_editor" phx-target={@myself} title={@post_editor.discard_title}>
|
||||
<button class="secondary danger ui-button ui-button-secondary ui-button-danger" data-testid="post-discard-button" type="button" phx-click="discard_post_editor" phx-target={@myself} title={@post_editor.discard_title}>
|
||||
<%= @post_editor.discard_label %>
|
||||
</button>
|
||||
<% end %>
|
||||
<%= if @post_editor.can_delete? do %>
|
||||
<button class="secondary danger" data-testid="post-delete-button" type="button" phx-click="delete_post_editor" phx-target={@myself}>
|
||||
<button class="secondary danger ui-button ui-button-secondary ui-button-danger" data-testid="post-delete-button" type="button" phx-click="delete_post_editor" phx-target={@myself}>
|
||||
<%= dgettext("ui", "Delete") %>
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="post-editor-form editor-content" data-testid="post-editor-form" phx-change="change_post_editor" phx-target={@myself}>
|
||||
<div class="metadata-toggle-header">
|
||||
<form class="post-editor-form editor-content flex min-h-0 flex-1 flex-col gap-4 overflow-auto p-4" data-testid="post-editor-form" phx-change="change_post_editor" phx-target={@myself}>
|
||||
<div class="metadata-toggle-header flex items-center justify-between gap-3">
|
||||
<button class={["metadata-toggle", if(@post_editor.metadata_expanded, do: "expanded")]} type="button" phx-click="toggle_post_metadata" phx-target={@myself}>
|
||||
<span class="metadata-toggle-chevron"><%= if @post_editor.metadata_expanded, do: "▼", else: "▶" %></span>
|
||||
<span><%= dgettext("ui", "Metadata") %></span>
|
||||
</button>
|
||||
|
||||
<div class="editor-translations-flags" aria-label={dgettext("ui", "Translations")}>
|
||||
<div class="editor-translations-flags flex flex-wrap items-center gap-2" aria-label={dgettext("ui", "Translations")}>
|
||||
<%= for flag <- @post_editor.translation_flags do %>
|
||||
<button
|
||||
class={[
|
||||
@@ -111,18 +147,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class={["editor-header-row", if(not @post_editor.metadata_expanded, do: "is-collapsed")]}>
|
||||
<div class="editor-meta">
|
||||
<div class="editor-field">
|
||||
<div class={["editor-header-row grid gap-4 xl:grid-cols-[minmax(0,2fr)_minmax(280px,1fr)]", if(not @post_editor.metadata_expanded, do: "is-collapsed")]}>
|
||||
<div class="editor-meta flex min-w-0 flex-col gap-4">
|
||||
<div class="editor-field ui-field-stack flex flex-col gap-1.5">
|
||||
<label><%= dgettext("ui", "Title") %></label>
|
||||
<input class="post-editor-input" type="text" name="post_editor[title]" value={@post_editor.form["title"]} />
|
||||
<input class="post-editor-input ui-input" type="text" name="post_editor[title]" value={@post_editor.form["title"]} />
|
||||
</div>
|
||||
|
||||
<div class="editor-field">
|
||||
<div class="editor-field ui-field-stack flex flex-col gap-1.5">
|
||||
<label><%= dgettext("ui", "Tags") %></label>
|
||||
<div class="tag-input-container">
|
||||
<div class="tag-input-container relative">
|
||||
<input type="hidden" name="post_editor[tags]" value={@post_editor.form["tags"]} />
|
||||
<div class="tag-input-wrapper">
|
||||
<div class="tag-input-wrapper flex flex-wrap items-center gap-2">
|
||||
<%= for tag <- @post_editor.tag_chips do %>
|
||||
<span class={["tag-chip", if(tag.color, do: "has-color")]} style={tag_chip_style(tag.color)}>
|
||||
<span><%= tag.name %></span>
|
||||
@@ -141,7 +177,7 @@
|
||||
</div>
|
||||
|
||||
<%= if String.trim(@post_editor.tag_query || "") != "" and (Enum.any?(@post_editor.tag_suggestions) or @post_editor.tag_query_addable?) do %>
|
||||
<div class="tag-suggestions">
|
||||
<div class="tag-suggestions mt-2 flex flex-col">
|
||||
<%= for tag <- @post_editor.tag_suggestions do %>
|
||||
<button class="tag-suggestion" type="button" phx-click="add_post_editor_tag" phx-value-tag={tag.name} phx-target={@myself}>
|
||||
<%= if tag.color do %>
|
||||
@@ -162,22 +198,22 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-field">
|
||||
<div class="editor-field ui-field-stack flex flex-col gap-1.5">
|
||||
<label><%= dgettext("ui", "Author") %></label>
|
||||
<input class="post-editor-input" type="text" name="post_editor[author]" value={@post_editor.form["author"]} />
|
||||
<input class="post-editor-input ui-input" type="text" name="post_editor[author]" value={@post_editor.form["author"]} />
|
||||
</div>
|
||||
|
||||
<div class="editor-field">
|
||||
<div class="editor-field ui-field-stack flex flex-col gap-1.5">
|
||||
<label><%= dgettext("ui", "Language") %></label>
|
||||
<div class="editor-language-row">
|
||||
<select class="post-editor-input" name="post_editor[language]">
|
||||
<div class="editor-language-row flex items-center gap-2">
|
||||
<select class="post-editor-input ui-input" name="post_editor[language]">
|
||||
<%= for language <- @post_editor.languages do %>
|
||||
<option value={language} selected={language == @post_editor.form["language"]}><%= String.upcase(language) %></option>
|
||||
<% end %>
|
||||
</select>
|
||||
|
||||
<button
|
||||
class="secondary compact"
|
||||
class="secondary compact ui-button ui-button-secondary ui-button-compact"
|
||||
data-testid="post-detect-language-button"
|
||||
type="button"
|
||||
phx-click="detect_post_editor_language"
|
||||
@@ -189,7 +225,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-field">
|
||||
<div class="editor-field ui-field-stack flex flex-col gap-1.5">
|
||||
<label class="editor-checkbox-label">
|
||||
<input type="hidden" name="post_editor[do_not_translate]" value="false" />
|
||||
<input type="checkbox" name="post_editor[do_not_translate]" value="true" checked={@post_editor.form["do_not_translate"]} />
|
||||
@@ -197,17 +233,17 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="editor-field-row">
|
||||
<div class="editor-field">
|
||||
<div class="editor-field-row ui-field-grid-2 grid gap-4 md:grid-cols-2">
|
||||
<div class="editor-field ui-field-stack flex flex-col gap-1.5">
|
||||
<label><%= dgettext("ui", "Slug") %></label>
|
||||
<input class="post-editor-input is-readonly" type="text" readonly value={@post_editor.slug} />
|
||||
<input class="post-editor-input ui-input is-readonly ui-input-readonly" type="text" readonly value={@post_editor.slug} />
|
||||
</div>
|
||||
|
||||
<div class="editor-field">
|
||||
<div class="editor-field ui-field-stack flex flex-col gap-1.5">
|
||||
<label><%= dgettext("ui", "Categories") %></label>
|
||||
<div class="tag-input-container">
|
||||
<div class="tag-input-container relative">
|
||||
<input type="hidden" name="post_editor[categories]" value={@post_editor.form["categories"]} />
|
||||
<div class="tag-input-wrapper">
|
||||
<div class="tag-input-wrapper flex flex-wrap items-center gap-2">
|
||||
<%= for category <- @post_editor.category_values do %>
|
||||
<span class="tag-chip">
|
||||
<span><%= category %></span>
|
||||
@@ -226,7 +262,7 @@
|
||||
</div>
|
||||
|
||||
<%= if String.trim(@post_editor.category_query || "") != "" and (Enum.any?(@post_editor.category_suggestions) or @post_editor.category_query_addable?) do %>
|
||||
<div class="tag-suggestions">
|
||||
<div class="tag-suggestions mt-2 flex flex-col">
|
||||
<%= for category <- @post_editor.category_suggestions do %>
|
||||
<button class="tag-suggestion" type="button" phx-click="add_post_editor_category" phx-value-category={category} phx-target={@myself}>
|
||||
<span class="tag-suggestion-name"><%= category %></span>
|
||||
@@ -246,9 +282,9 @@
|
||||
</div>
|
||||
|
||||
<%= if @post_editor.show_template_selector? do %>
|
||||
<div class="editor-field">
|
||||
<div class="editor-field ui-field-stack flex flex-col gap-1.5">
|
||||
<label><%= dgettext("ui", "Template") %></label>
|
||||
<select class="post-editor-input" name="post_editor[template_slug]">
|
||||
<select class="post-editor-input ui-input" name="post_editor[template_slug]">
|
||||
<option value=""><%= dgettext("ui", "Default") %></option>
|
||||
<%= for template <- @post_editor.template_options do %>
|
||||
<option value={template.slug} selected={template.slug == @post_editor.form["template_slug"]}><%= template.title %></option>
|
||||
@@ -257,9 +293,9 @@
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="post-editor-links-panel">
|
||||
<div class="post-editor-links-panel flex flex-col gap-3">
|
||||
<strong><%= dgettext("ui", "Post Links") %></strong>
|
||||
<div class="post-editor-links-columns">
|
||||
<div class="post-editor-links-columns grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<span class="post-editor-links-label"><%= dgettext("ui", "Backlinks") %></span>
|
||||
<%= if Enum.any?(@post_editor.post_links.backlinks) do %>
|
||||
@@ -288,15 +324,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="editor-media-panel post-editor-side-panel">
|
||||
<aside class="editor-media-panel post-editor-side-panel flex flex-col gap-3">
|
||||
<div class="post-editor-side-panel-header">
|
||||
<strong><%= dgettext("ui", "Linked Media") %></strong>
|
||||
</div>
|
||||
|
||||
<%= if Enum.any?(@post_editor.linked_media) do %>
|
||||
<ul class="post-editor-media-list">
|
||||
<ul class="post-editor-media-list flex flex-col gap-2">
|
||||
<%= for item <- @post_editor.linked_media do %>
|
||||
<li class="post-editor-media-item">
|
||||
<li class="post-editor-media-item flex flex-col gap-1">
|
||||
<span class="post-editor-media-title"><%= item.name %></span>
|
||||
<span class="post-editor-media-meta"><%= dgettext("ui", "Order") %>: <%= item.sort_order %></span>
|
||||
</li>
|
||||
@@ -314,19 +350,19 @@
|
||||
</button>
|
||||
|
||||
<div class={["editor-excerpt-panel", if(not @post_editor.excerpt_expanded, do: "is-collapsed")]}>
|
||||
<div class="editor-field">
|
||||
<div class="editor-field flex flex-col gap-1.5">
|
||||
<label><%= dgettext("ui", "Excerpt") %></label>
|
||||
<textarea class="post-editor-textarea post-editor-excerpt" name="post_editor[excerpt]" rows="4"><%= @post_editor.form["excerpt"] %></textarea>
|
||||
<textarea class="post-editor-textarea post-editor-excerpt ui-textarea" name="post_editor[excerpt]" rows="4"><%= @post_editor.form["excerpt"] %></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-body">
|
||||
<div class="editor-toolbar">
|
||||
<div class="editor-toolbar-left">
|
||||
<div class="editor-body flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<div class="editor-toolbar ui-toolbar flex items-center gap-3">
|
||||
<div class="editor-toolbar-left ui-toolbar-group flex items-center gap-2">
|
||||
<label><%= dgettext("ui", "Content") %></label>
|
||||
</div>
|
||||
|
||||
<div class="editor-toolbar-center">
|
||||
<div class="editor-toolbar-center ui-toolbar-group flex flex-1 justify-center">
|
||||
<div class="editor-mode-toggle">
|
||||
<%= for mode <- [:markdown, :preview] do %>
|
||||
<button
|
||||
@@ -342,7 +378,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-toolbar-right">
|
||||
<div class="editor-toolbar-right ui-toolbar-group flex items-center gap-2">
|
||||
<%= if @post_editor.mode == :markdown do %>
|
||||
<button
|
||||
class="insert-post-link-button"
|
||||
@@ -362,6 +398,14 @@
|
||||
>
|
||||
<%= dgettext("ui", "Insert Media") %>
|
||||
</button>
|
||||
<button
|
||||
class="add-gallery-images-button"
|
||||
type="button"
|
||||
phx-click="add_gallery_images"
|
||||
phx-value-post-id={@post_editor.id}
|
||||
>
|
||||
<%= dgettext("ui", "Add Gallery Images") %>
|
||||
</button>
|
||||
<% end %>
|
||||
|
||||
<%= if @post_editor.gallery_count > 0 do %>
|
||||
@@ -379,7 +423,7 @@
|
||||
</div>
|
||||
|
||||
<%= if @post_editor.mode == :preview do %>
|
||||
<div class="editor-preview post-editor-preview" data-testid="post-editor-preview">
|
||||
<div class="editor-preview post-editor-preview flex min-h-0 flex-1" data-testid="post-editor-preview">
|
||||
<%= if @post_editor.preview_url do %>
|
||||
<iframe class="editor-preview-frame" src={@post_editor.preview_url}></iframe>
|
||||
<% else %>
|
||||
@@ -398,14 +442,14 @@
|
||||
data-monaco-word-wrap="on"
|
||||
data-monaco-insert-event="post-editor-insert-content"
|
||||
>
|
||||
<div id={"post-editor-monaco-#{@post_editor.id}"} class="monaco-editor-instance" phx-update="ignore"></div>
|
||||
<div id={"post-editor-monaco-#{@post_editor.id}"} class="monaco-editor-instance min-h-0 flex-1" phx-update="ignore"></div>
|
||||
<textarea id={"post-editor-content-#{@post_editor.id}"} class="monaco-editor-input post-editor-content" data-testid="post-editor-content" data-post-editor-id={@post_editor.id} name="post_editor[content]" rows="18" spellcheck="false"><%= @post_editor.form["content"] %></textarea>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="editor-footer">
|
||||
<div class="editor-footer flex shrink-0 flex-wrap gap-4">
|
||||
<span><strong><%= dgettext("ui", "Created") %>:</strong> <%= @post_editor.footer.created_at %></span>
|
||||
<span><strong><%= dgettext("ui", "Updated") %>:</strong> <%= @post_editor.footer.updated_at %></span>
|
||||
<%= if @post_editor.footer.published_at do %>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user