feat: terminal UI over the shared workbench core (issue #26, phase 4)
This commit is contained in:
591
lib/bds/tui.ex
591
lib/bds/tui.ex
@@ -1,38 +1,593 @@
|
||||
defmodule BDS.TUI do
|
||||
@moduledoc """
|
||||
Terminal UI entry point (issue #26), served per SSH connection by
|
||||
`ExRatatui.SSH.Daemon` in server mode and runnable locally.
|
||||
Terminal UI shell (issue #26, phase 4).
|
||||
|
||||
Placeholder shell for now: renders the app banner while the workbench
|
||||
renderer lands in a later phase. The UI locale is the server-side UI
|
||||
language setting, same as the GUI.
|
||||
An `ExRatatui.App` over the shared UI core: `BDS.UI.Sidebar` for view
|
||||
data, `BDS.UI.PostEditor.*` for the draft/save/publish workflow, and
|
||||
`BDS.Events` for multi-client synchronization. Served per SSH connection
|
||||
by `ExRatatui.SSH.Daemon` in server mode and runnable locally.
|
||||
|
||||
## Keys
|
||||
|
||||
Sidebar focus: `↑/↓`/`j/k` navigate, `enter` open, `n` new post,
|
||||
`1..5` switch view (posts/media/templates/scripts/tags), `r` refresh.
|
||||
Editor focus: text keys edit, `ctrl+s` save, `ctrl+p` publish,
|
||||
`ctrl+t` edit title, `ctrl+l` cycle language, `ctrl+g` AI suggestions,
|
||||
`esc` back to sidebar. `ctrl+q` quits, `esc` also closes a media
|
||||
preview. The UI locale is the server-side UI language setting.
|
||||
"""
|
||||
|
||||
use ExRatatui.App
|
||||
use Gettext, backend: BDS.Gettext
|
||||
|
||||
alias BDS.AI
|
||||
alias BDS.Desktop.UILocale
|
||||
alias BDS.Events
|
||||
alias BDS.Posts
|
||||
alias BDS.Projects
|
||||
alias BDS.UI.PostEditor.{Draft, Metadata, Persistence}
|
||||
alias BDS.UI.Sidebar
|
||||
alias ExRatatui.Layout
|
||||
alias ExRatatui.Layout.Rect
|
||||
alias ExRatatui.Widgets.Paragraph
|
||||
alias ExRatatui.Style
|
||||
alias ExRatatui.Widgets.{Block, List, Paragraph, Tabs, Textarea}
|
||||
|
||||
@views ~w(posts media templates scripts tags)
|
||||
|
||||
# ── Mount ────────────────────────────────────────────────────────────────
|
||||
|
||||
@impl true
|
||||
def mount(_opts) do
|
||||
UILocale.put(BDS.Desktop.ShellData.ui_language())
|
||||
{:ok, %{}}
|
||||
:ok = Events.subscribe()
|
||||
|
||||
project_id = Projects.shell_snapshot().active_project_id
|
||||
|
||||
state = %{
|
||||
project_id: project_id,
|
||||
view: "posts",
|
||||
sidebar: nil,
|
||||
items: [],
|
||||
selected: 0,
|
||||
focus: :sidebar,
|
||||
editor: nil,
|
||||
preview: nil,
|
||||
image: nil,
|
||||
busy: false,
|
||||
status: nil
|
||||
}
|
||||
|
||||
{:ok, load_sidebar(state)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(_state, frame) do
|
||||
text =
|
||||
"bDS2 — " <>
|
||||
dgettext("ui", "Blogging Desktop Server") <>
|
||||
"\n\n" <>
|
||||
dgettext("ui", "Press q to quit.")
|
||||
|
||||
[{%Paragraph{text: text}, %Rect{x: 0, y: 0, width: frame.width, height: frame.height}}]
|
||||
end
|
||||
# ── Events: global keys ──────────────────────────────────────────────────
|
||||
|
||||
@impl true
|
||||
def handle_event(%ExRatatui.Event.Key{code: "q"}, state), do: {:stop, state}
|
||||
def handle_event(%ExRatatui.Event.Key{code: "q", modifiers: ["ctrl"]}, state),
|
||||
do: {:stop, state}
|
||||
|
||||
def handle_event(%ExRatatui.Event.Key{code: "esc"}, %{image: image} = state)
|
||||
when image != nil,
|
||||
do: {:noreply, %{state | image: nil}}
|
||||
|
||||
def handle_event(%ExRatatui.Event.Key{kind: "press"} = key, %{focus: :sidebar} = state),
|
||||
do: sidebar_key(key, state)
|
||||
|
||||
def handle_event(%ExRatatui.Event.Key{kind: "press"} = key, %{focus: :editor} = state),
|
||||
do: editor_key(key, state)
|
||||
|
||||
def handle_event(_event, state), do: {:noreply, state}
|
||||
|
||||
# ── Events: sidebar focus ────────────────────────────────────────────────
|
||||
|
||||
defp sidebar_key(%{code: code}, state) when code in ["down", "j"],
|
||||
do: {:noreply, move_selection(state, 1)}
|
||||
|
||||
defp sidebar_key(%{code: code}, state) when code in ["up", "k"],
|
||||
do: {:noreply, move_selection(state, -1)}
|
||||
|
||||
defp sidebar_key(%{code: code}, state) when code in ~w(1 2 3 4 5) do
|
||||
view = Enum.at(@views, String.to_integer(code) - 1)
|
||||
{:noreply, load_sidebar(%{state | view: view, selected: 0, image: nil})}
|
||||
end
|
||||
|
||||
defp sidebar_key(%{code: "r"}, state), do: {:noreply, load_sidebar(state)}
|
||||
|
||||
defp sidebar_key(%{code: "n"}, %{project_id: project_id} = state)
|
||||
when is_binary(project_id) do
|
||||
case Posts.create_post(%{project_id: project_id, title: "", content: ""}) do
|
||||
{:ok, post} ->
|
||||
{:noreply, state |> load_sidebar() |> open_post(post.id)}
|
||||
|
||||
{:error, reason} ->
|
||||
{:noreply, toast(state, dgettext("ui", "New Post"), inspect(reason))}
|
||||
end
|
||||
end
|
||||
|
||||
defp sidebar_key(%{code: "enter"}, state) do
|
||||
case selected_item(state) do
|
||||
%{route: "post", id: post_id} -> {:noreply, open_post(state, post_id)}
|
||||
%{route: "media", id: media_id} -> {:noreply, open_media(state, media_id)}
|
||||
%{id: _id} -> {:noreply, toast(state, view_label(state.view), dgettext("ui", "Editing this item is not available in the terminal UI yet."))}
|
||||
_other -> {:noreply, state}
|
||||
end
|
||||
end
|
||||
|
||||
defp sidebar_key(%{code: "tab"}, %{editor: editor} = state) when editor != nil,
|
||||
do: {:noreply, %{state | focus: :editor}}
|
||||
|
||||
defp sidebar_key(_key, state), do: {:noreply, state}
|
||||
|
||||
# ── Events: editor focus ─────────────────────────────────────────────────
|
||||
|
||||
defp editor_key(%{code: "esc"}, %{editor: %{editing_title?: true}} = state),
|
||||
do: {:noreply, put_in(state.editor.editing_title?, false)}
|
||||
|
||||
defp editor_key(%{code: "esc"}, state), do: {:noreply, %{state | focus: :sidebar}}
|
||||
|
||||
defp editor_key(%{code: "s", modifiers: ["ctrl"]}, state), do: {:noreply, persist(state, :save)}
|
||||
|
||||
defp editor_key(%{code: "p", modifiers: ["ctrl"]}, state),
|
||||
do: {:noreply, persist(state, :publish)}
|
||||
|
||||
defp editor_key(%{code: "t", modifiers: ["ctrl"]}, state),
|
||||
do: {:noreply, update_in(state.editor.editing_title?, &(not &1))}
|
||||
|
||||
defp editor_key(%{code: "l", modifiers: ["ctrl"]}, state), do: {:noreply, cycle_language(state)}
|
||||
|
||||
defp editor_key(%{code: "g", modifiers: ["ctrl"]}, state),
|
||||
do: {:noreply, request_ai_suggestions(state)}
|
||||
|
||||
defp editor_key(key, %{editor: %{editing_title?: true}} = state),
|
||||
do: {:noreply, title_key(key, state)}
|
||||
|
||||
defp editor_key(%{code: code, modifiers: modifiers}, %{editor: editor} = state) do
|
||||
:ok = ExRatatui.textarea_handle_key(editor.textarea, code, modifiers)
|
||||
dirty = editor.dirty or code not in ~w(up down left right home end pageup pagedown)
|
||||
{:noreply, put_in(state.editor.dirty, dirty)}
|
||||
end
|
||||
|
||||
defp title_key(%{code: "enter"}, state), do: put_in(state.editor.editing_title?, false)
|
||||
|
||||
defp title_key(%{code: "backspace"}, state),
|
||||
do: state |> update_in([:editor, :title], &String.slice(&1, 0..-2//1)) |> mark_dirty()
|
||||
|
||||
defp title_key(%{code: code, modifiers: modifiers}, state)
|
||||
when byte_size(code) >= 1 and modifiers == [] do
|
||||
if String.length(code) == 1 do
|
||||
state |> update_in([:editor, :title], &(&1 <> code)) |> mark_dirty()
|
||||
else
|
||||
state
|
||||
end
|
||||
end
|
||||
|
||||
defp title_key(_key, state), do: state
|
||||
|
||||
defp mark_dirty(state), do: put_in(state.editor.dirty, true)
|
||||
|
||||
# ── Info: event bus + AI results ─────────────────────────────────────────
|
||||
|
||||
@impl true
|
||||
def handle_info({:entity_changed, %{entity: entity, entity_id: id, action: action}}, state) do
|
||||
state = load_sidebar(state)
|
||||
|
||||
state =
|
||||
if state.editor && state.editor.post.id == id && entity == "post" do
|
||||
if action == :deleted, do: %{state | editor: nil, focus: :sidebar}, else: state
|
||||
else
|
||||
state
|
||||
end
|
||||
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
def handle_info({:settings_changed, "ui.language"}, state) do
|
||||
UILocale.put(BDS.Desktop.ShellData.ui_language())
|
||||
{:noreply, load_sidebar(state)}
|
||||
end
|
||||
|
||||
def handle_info({:ai_suggestions_result, :post, post_id, result}, state) do
|
||||
state = %{state | busy: false}
|
||||
|
||||
if state.editor && state.editor.post.id == post_id do
|
||||
attrs =
|
||||
%{}
|
||||
|> maybe_put_suggestion(:title, Map.get(result, :title))
|
||||
|> maybe_put_suggestion(:excerpt, Map.get(result, :excerpt))
|
||||
|
||||
case attrs == %{} || Posts.update_post(post_id, attrs) do
|
||||
{:ok, _post} ->
|
||||
{:noreply,
|
||||
state
|
||||
|> reload_editor()
|
||||
|> toast(dgettext("ui", "AI Suggestions"), dgettext("ui", "Suggestions applied."))}
|
||||
|
||||
true ->
|
||||
{:noreply, toast(state, dgettext("ui", "AI Suggestions"), dgettext("ui", "No suggestions returned."))}
|
||||
|
||||
{:error, reason} ->
|
||||
{:noreply, toast(state, dgettext("ui", "AI Suggestions"), inspect(reason))}
|
||||
end
|
||||
else
|
||||
{:noreply, state}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_info({:ai_suggestions_error, :post, _post_id, reason}, state) do
|
||||
{:noreply, toast(%{state | busy: false}, dgettext("ui", "AI Suggestions"), inspect(reason))}
|
||||
end
|
||||
|
||||
def handle_info(_message, state), do: {:noreply, state}
|
||||
|
||||
# ── Render ───────────────────────────────────────────────────────────────
|
||||
|
||||
@impl true
|
||||
def render(state, frame) do
|
||||
area = %Rect{x: 0, y: 0, width: frame.width, height: frame.height}
|
||||
[tabs_rect, body_rect, status_rect] = Layout.split(area, :vertical, [
|
||||
{:length, 1},
|
||||
{:min, 3},
|
||||
{:length, 1}
|
||||
])
|
||||
|
||||
[sidebar_rect, main_rect] = Layout.split(body_rect, :horizontal, [
|
||||
{:length, min(40, div(frame.width, 3))},
|
||||
{:min, 20}
|
||||
])
|
||||
|
||||
view_tabs(state, tabs_rect) ++
|
||||
sidebar_widgets(state, sidebar_rect) ++
|
||||
main_widgets(state, main_rect) ++
|
||||
status_widgets(state, status_rect) ++
|
||||
image_widgets(state, body_rect)
|
||||
end
|
||||
|
||||
defp view_tabs(state, rect) do
|
||||
[
|
||||
{%Tabs{
|
||||
titles: Enum.map(@views, &view_label/1),
|
||||
selected: Enum.find_index(@views, &(&1 == state.view)),
|
||||
highlight_style: %Style{fg: :cyan, modifiers: [:bold]}
|
||||
}, rect}
|
||||
]
|
||||
end
|
||||
|
||||
defp sidebar_widgets(state, rect) do
|
||||
items =
|
||||
Enum.map(state.items, fn
|
||||
{:header, title} -> "── #{title}"
|
||||
{:item, item} -> " #{item_label(item)}"
|
||||
end)
|
||||
|
||||
focused? = state.focus == :sidebar
|
||||
|
||||
[
|
||||
{%List{
|
||||
items: items,
|
||||
selected: state.selected,
|
||||
highlight_style:
|
||||
if(focused?,
|
||||
do: %Style{fg: :black, bg: :cyan},
|
||||
else: %Style{modifiers: [:bold]}
|
||||
),
|
||||
block: %Block{title: view_label(state.view), borders: [:all]}
|
||||
}, rect}
|
||||
]
|
||||
end
|
||||
|
||||
defp main_widgets(%{editor: nil} = state, rect) do
|
||||
text =
|
||||
case selected_item(state) do
|
||||
%{route: "media"} = item ->
|
||||
dgettext("ui", "Press enter to preview %{name}.", name: item_label(item))
|
||||
|
||||
_other ->
|
||||
dgettext("ui", "Select an entry and press enter to open it.")
|
||||
end
|
||||
|
||||
[{%Paragraph{text: text, block: %Block{title: "bDS2", borders: [:all]}}, rect}]
|
||||
end
|
||||
|
||||
defp main_widgets(%{editor: editor} = state, rect) do
|
||||
[title_rect, body_rect] = Layout.split(rect, :vertical, [{:length, 3}, {:min, 3}])
|
||||
|
||||
title_block_label =
|
||||
if editor.editing_title?,
|
||||
do: dgettext("ui", "Title (editing — enter to confirm)"),
|
||||
else: dgettext("ui", "Title (ctrl+t to edit)")
|
||||
|
||||
title_widget =
|
||||
{%Paragraph{
|
||||
text: editor.title,
|
||||
block: %Block{title: title_block_label, borders: [:all]},
|
||||
style:
|
||||
if(editor.editing_title?, do: %Style{fg: :yellow}, else: %Style{})
|
||||
}, title_rect}
|
||||
|
||||
body_title =
|
||||
"#{dgettext("ui", "Content")} [#{editor.language}]" <>
|
||||
if(editor.dirty, do: " •", else: "")
|
||||
|
||||
body_widget =
|
||||
{%Textarea{
|
||||
state: editor.textarea,
|
||||
block: %Block{title: body_title, borders: [:all]},
|
||||
cursor_style:
|
||||
if(state.focus == :editor and not editor.editing_title?,
|
||||
do: %Style{bg: :cyan},
|
||||
else: %Style{}
|
||||
)
|
||||
}, body_rect}
|
||||
|
||||
[title_widget, body_widget]
|
||||
end
|
||||
|
||||
defp status_widgets(state, rect) do
|
||||
text =
|
||||
cond do
|
||||
state.busy -> dgettext("ui", "Working…")
|
||||
state.status -> state.status
|
||||
true -> default_status(state)
|
||||
end
|
||||
|
||||
[{%Paragraph{text: text, style: %Style{fg: :dark_gray}}, rect}]
|
||||
end
|
||||
|
||||
defp image_widgets(%{image: nil}, _rect), do: []
|
||||
|
||||
defp image_widgets(%{image: %{widget: widget, title: title}}, rect) do
|
||||
overlay = %Rect{
|
||||
x: rect.x + 2,
|
||||
y: rect.y + 1,
|
||||
width: max(rect.width - 4, 10),
|
||||
height: max(rect.height - 2, 5)
|
||||
}
|
||||
|
||||
[inner] = Layout.split(overlay, :vertical, [{:min, 3}])
|
||||
|
||||
[
|
||||
{%Block{title: title <> " — " <> dgettext("ui", "esc to close"), borders: [:all]}, overlay},
|
||||
{widget,
|
||||
%Rect{x: inner.x + 1, y: inner.y + 1, width: inner.width - 2, height: inner.height - 2}}
|
||||
]
|
||||
end
|
||||
|
||||
defp default_status(%{focus: :sidebar}),
|
||||
do:
|
||||
dgettext(
|
||||
"ui",
|
||||
"enter open · n new post · 1-5 views · r refresh · ctrl+q quit"
|
||||
)
|
||||
|
||||
defp default_status(%{focus: :editor}),
|
||||
do:
|
||||
dgettext(
|
||||
"ui",
|
||||
"ctrl+s save · ctrl+p publish · ctrl+t title · ctrl+l language · ctrl+g AI · esc back"
|
||||
)
|
||||
|
||||
# ── Sidebar data ─────────────────────────────────────────────────────────
|
||||
|
||||
defp load_sidebar(state) do
|
||||
view = Sidebar.view(state.project_id, state.view)
|
||||
items = flatten_items(view, state.view)
|
||||
|
||||
selected =
|
||||
case items do
|
||||
[] -> 0
|
||||
_some -> seek_item(items, clamp(state.selected, length(items)), 1)
|
||||
end
|
||||
|
||||
%{state | sidebar: view, items: items, selected: selected}
|
||||
end
|
||||
|
||||
defp flatten_items(view, route) do
|
||||
sections =
|
||||
cond do
|
||||
is_list(view[:sections]) -> view[:sections]
|
||||
is_list(view[:items]) -> [%{title: nil, items: view[:items]}]
|
||||
true -> []
|
||||
end
|
||||
|
||||
Enum.flat_map(sections, fn section ->
|
||||
headers = if section[:title], do: [{:header, section.title}], else: []
|
||||
|
||||
headers ++
|
||||
Enum.map(section[:items] || [], fn item ->
|
||||
{:item, Map.put_new(item, :route, route_for(item, route))}
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
defp route_for(item, route), do: item[:route] || String.trim_trailing(route, "s")
|
||||
|
||||
defp move_selection(state, delta) do
|
||||
count = length(state.items)
|
||||
|
||||
if count == 0 do
|
||||
state
|
||||
else
|
||||
next = seek_item(state.items, clamp(state.selected + delta, count), delta)
|
||||
%{state | selected: next}
|
||||
end
|
||||
end
|
||||
|
||||
# Selection skips section headers in the given direction; parks on the
|
||||
# boundary when only headers remain that way.
|
||||
defp seek_item(items, index, delta) do
|
||||
case Enum.at(items, index) do
|
||||
{:item, _} ->
|
||||
index
|
||||
|
||||
{:header, _} ->
|
||||
next = clamp(index + sign(delta), length(items))
|
||||
if next == index, do: index, else: seek_item(items, next, delta)
|
||||
|
||||
nil ->
|
||||
index
|
||||
end
|
||||
end
|
||||
|
||||
defp sign(delta) when delta < 0, do: -1
|
||||
defp sign(_delta), do: 1
|
||||
|
||||
defp clamp(value, count), do: value |> max(0) |> min(count - 1)
|
||||
|
||||
defp selected_item(state) do
|
||||
case Enum.at(state.items, state.selected) do
|
||||
{:item, item} -> item
|
||||
_other -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp item_label(item) do
|
||||
title =
|
||||
item[:title] || item[:name] || item[:original_name] || item[:label] || item[:id] || "?"
|
||||
|
||||
status = item[:status]
|
||||
if status, do: "#{title} (#{status})", else: to_string(title)
|
||||
end
|
||||
|
||||
defp view_label("posts"), do: dgettext("ui", "Posts")
|
||||
defp view_label("media"), do: dgettext("ui", "Media")
|
||||
defp view_label("templates"), do: dgettext("ui", "Templates")
|
||||
defp view_label("scripts"), do: dgettext("ui", "Scripts")
|
||||
defp view_label("tags"), do: dgettext("ui", "Tags")
|
||||
|
||||
# ── Post editor ──────────────────────────────────────────────────────────
|
||||
|
||||
defp open_post(state, post_id) do
|
||||
case Posts.get_post(post_id) do
|
||||
nil ->
|
||||
toast(state, dgettext("ui", "Posts"), dgettext("ui", "Post not found."))
|
||||
|
||||
post ->
|
||||
metadata = Metadata.project_metadata(post.project_id)
|
||||
language = Metadata.canonical_language(post, metadata)
|
||||
%{state | editor: build_editor(post, metadata, language), focus: :editor, image: nil}
|
||||
end
|
||||
end
|
||||
|
||||
defp build_editor(post, metadata, language) do
|
||||
form = Draft.persisted_form(post, metadata, language)
|
||||
textarea = ExRatatui.textarea_new()
|
||||
:ok = ExRatatui.textarea_insert_str(textarea, form["content"] || "")
|
||||
|
||||
%{
|
||||
post: post,
|
||||
metadata: metadata,
|
||||
language: language,
|
||||
form: form,
|
||||
title: form["title"] || "",
|
||||
textarea: textarea,
|
||||
editing_title?: false,
|
||||
dirty: false
|
||||
}
|
||||
end
|
||||
|
||||
defp reload_editor(%{editor: nil} = state), do: state
|
||||
|
||||
defp reload_editor(%{editor: editor} = state) do
|
||||
case Posts.get_post(editor.post.id) do
|
||||
nil -> %{state | editor: nil, focus: :sidebar}
|
||||
post -> %{state | editor: build_editor(post, editor.metadata, editor.language)}
|
||||
end
|
||||
end
|
||||
|
||||
defp persist(%{editor: editor} = state, action) do
|
||||
draft = %{
|
||||
editor.form
|
||||
| "title" => editor.title,
|
||||
"content" => ExRatatui.textarea_get_value(editor.textarea)
|
||||
}
|
||||
|
||||
case Persistence.persist(editor.post, draft, editor.language, editor.metadata, action) do
|
||||
{:ok, _result} ->
|
||||
message =
|
||||
case action do
|
||||
:publish -> dgettext("ui", "Published.")
|
||||
_save -> dgettext("ui", "Saved.")
|
||||
end
|
||||
|
||||
state
|
||||
|> reload_editor()
|
||||
|> load_sidebar()
|
||||
|> toast(editor_title(editor), message)
|
||||
|
||||
{:error, reason} ->
|
||||
toast(state, editor_title(editor), inspect(reason))
|
||||
end
|
||||
end
|
||||
|
||||
defp cycle_language(%{editor: editor} = state) do
|
||||
if editor.dirty do
|
||||
toast(state, editor_title(editor), dgettext("ui", "Save before switching languages."))
|
||||
else
|
||||
languages = Metadata.languages(editor.metadata)
|
||||
index = Enum.find_index(languages, &(&1 == editor.language)) || 0
|
||||
next = Enum.at(languages, rem(index + 1, length(languages)))
|
||||
%{state | editor: build_editor(editor.post, editor.metadata, next)}
|
||||
end
|
||||
end
|
||||
|
||||
defp request_ai_suggestions(%{editor: editor} = state) do
|
||||
if AI.airplane_mode?() and not AI.airplane_endpoint_configured?() do
|
||||
toast(
|
||||
state,
|
||||
dgettext("ui", "AI Suggestions"),
|
||||
dgettext("ui", "AI is unavailable in airplane mode without a local endpoint.")
|
||||
)
|
||||
else
|
||||
parent = self()
|
||||
post_id = editor.post.id
|
||||
language = editor.metadata.main_language || "en"
|
||||
|
||||
{:ok, _pid} =
|
||||
Task.Supervisor.start_child(BDS.TCP.TaskSupervisor, fn ->
|
||||
case AI.analyze_post(post_id, language: language) do
|
||||
{:ok, result} -> send(parent, {:ai_suggestions_result, :post, post_id, result})
|
||||
{:error, reason} -> send(parent, {:ai_suggestions_error, :post, post_id, reason})
|
||||
end
|
||||
end)
|
||||
|
||||
%{state | busy: true}
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_put_suggestion(attrs, _key, nil), do: attrs
|
||||
|
||||
defp maybe_put_suggestion(attrs, key, value) do
|
||||
case String.trim(to_string(value)) do
|
||||
"" -> attrs
|
||||
trimmed -> Map.put(attrs, key, trimmed)
|
||||
end
|
||||
end
|
||||
|
||||
defp editor_title(editor),
|
||||
do: Metadata.display_title(editor.title, editor.post.slug, editor.post.id)
|
||||
|
||||
# ── Media preview ────────────────────────────────────────────────────────
|
||||
|
||||
defp open_media(state, media_id) do
|
||||
with %{} = media <- BDS.Media.get_media(media_id),
|
||||
true <- String.starts_with?(to_string(media.mime_type || ""), "image/"),
|
||||
project when project != nil <- Projects.get_project(media.project_id),
|
||||
path = Path.join(Projects.project_data_dir(project), media.file_path),
|
||||
{:ok, bytes} <- File.read(path),
|
||||
{:ok, widget} <- ExRatatui.Image.new(bytes) do
|
||||
%{state | image: %{title: item_media_title(media), widget: widget}}
|
||||
else
|
||||
false ->
|
||||
toast(state, dgettext("ui", "Media"), dgettext("ui", "Only images can be previewed."))
|
||||
|
||||
_other ->
|
||||
toast(state, dgettext("ui", "Media"), dgettext("ui", "Could not load this file."))
|
||||
end
|
||||
end
|
||||
|
||||
defp item_media_title(media), do: media.title || media.original_name || media.id
|
||||
|
||||
# ── Status line ──────────────────────────────────────────────────────────
|
||||
|
||||
defp toast(state, title, message), do: %{state | status: "#{title}: #{message}"}
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user