feat: switch to phoenix liveview

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-04-25 23:04:16 +02:00
parent fac55bfb3b
commit 2f34040aed
23 changed files with 1835 additions and 3785 deletions

View File

@@ -0,0 +1,42 @@
defmodule BDS.Desktop.Endpoint do
@moduledoc false
use Phoenix.Endpoint, otp_app: :bds
@session_options [
store: :cookie,
key: "_bds_desktop_key",
signing_salt: "desktop-shell"
]
socket "/live", Phoenix.LiveView.Socket,
websocket: [connect_info: [session: @session_options]]
plug Plug.Session, @session_options
plug :maybe_require_desktop_auth
plug Plug.Static,
at: "/assets",
from: {:bds, "priv/ui"},
only: ["app.css", "live.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"]
plug BDS.Desktop.Router
defp maybe_require_desktop_auth(conn, _opts) do
if System.get_env("BDS_DESKTOP_AUTOMATION") in ["1", "true", "TRUE"] do
conn
else
Desktop.Auth.call(conn, [])
end
end
end

View File

@@ -0,0 +1,9 @@
defmodule BDS.Desktop.ErrorHTML do
@moduledoc false
use Phoenix.Component
def render(_template, _assigns) do
"not found"
end
end

View File

@@ -0,0 +1,9 @@
defmodule BDS.Desktop.HealthController do
@moduledoc false
use Phoenix.Controller, formats: [:html]
def show(conn, _params) do
text(conn, "ok")
end
end

View File

@@ -0,0 +1,26 @@
defmodule BDS.Desktop.Layouts do
@moduledoc false
use Phoenix.Component
def root(assigns) do
~H"""
<!DOCTYPE html>
<html lang={@page_language || "en"}>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title><%= @page_title || "Blogging Desktop Server" %></title>
<meta name="csrf-token" content={Phoenix.Controller.get_csrf_token()} />
<link phx-track-static rel="stylesheet" href="/assets/app.css" />
</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>
</body>
</html>
"""
end
end

View File

@@ -0,0 +1,59 @@
defmodule BDS.Desktop.MediaController do
@moduledoc false
use Phoenix.Controller, formats: [:html]
alias BDS.Media
alias BDS.Media.Media, as: MediaRecord
alias BDS.Projects
alias BDS.Repo
def thumbnail(conn, %{"media_id" => media_id} = params) do
case active_media_thumbnail(media_id, Map.get(params, "size")) do
{:ok, content_type, path} ->
conn
|> Plug.Conn.put_resp_content_type(content_type)
|> Plug.Conn.send_file(200, path)
:error ->
send_resp(conn, 404, "not found")
end
end
defp active_media_thumbnail(media_id, size) do
with %{} = project <- Projects.get_active_project(),
%MediaRecord{} = media <- Repo.get(MediaRecord, media_id),
true <- media.project_id == project.id,
relative_path when is_binary(relative_path) <- Media.thumbnail_paths(media)[thumbnail_size(size)],
absolute_path = Path.join(Projects.project_data_dir(project), relative_path),
true <- File.exists?(absolute_path) do
{:ok, thumbnail_content_type(relative_path), absolute_path}
else
_other -> :error
end
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
:error
end
defp thumbnail_size(size) do
case to_string(size || "small") do
"medium" -> :medium
"large" -> :large
"ai" -> :ai
_other -> :small
end
end
defp thumbnail_content_type(path) do
case Path.extname(path) do
".jpg" -> "image/jpeg"
".jpeg" -> "image/jpeg"
_other -> "image/webp"
end
end
end

View File

@@ -1,109 +1,28 @@
defmodule BDS.Desktop.Router do
@moduledoc false
use Plug.Router
use Phoenix.Router
plug :put_secret_key_base
import Phoenix.LiveView.Router
plug Plug.Session,
store: :cookie,
key: "_bds_desktop_key",
signing_salt: "desktop-shell"
plug :match
plug :maybe_require_desktop_auth
plug Plug.Static,
at: "/assets",
from: {:bds, "priv/ui"},
only: ["app.css", "app.js"]
plug :dispatch
get "/" do
conn
|> Plug.Conn.put_resp_content_type("text/html")
|> Plug.Conn.send_resp(200, BDS.Desktop.ShellController.index_html())
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, html: {BDS.Desktop.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
end
get "/health" do
Plug.Conn.send_resp(conn, 200, "ok")
end
scope "/", BDS.Desktop do
pipe_through :browser
get "/api/tasks" do
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(200, BDS.Desktop.ShellController.task_status_json())
end
get "/health", HealthController, :show
get "/media-thumbnail/:media_id", MediaController, :thumbnail
get "/api/projects" do
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(200, BDS.Desktop.ShellController.projects_json())
end
get "/api/media-thumbnail/:media_id" do
BDS.Desktop.ShellController.media_thumbnail(conn, media_id, conn.params)
end
post "/api/sidebar" do
{:ok, body, conn} = Plug.Conn.read_body(conn)
payload = if body == "", do: %{}, else: Jason.decode!(body)
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(200, BDS.Desktop.ShellController.sidebar_json(payload))
end
post "/api/projects" do
{:ok, body, conn} = Plug.Conn.read_body(conn)
payload = if body == "", do: %{}, else: Jason.decode!(body)
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(200, BDS.Desktop.ShellController.upsert_project_json(payload))
end
post "/api/project-folder" do
{:ok, body, conn} = Plug.Conn.read_body(conn)
payload = if body == "", do: %{}, else: Jason.decode!(body)
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(200, BDS.Desktop.ShellController.choose_project_folder_json(payload))
end
post "/api/commands" do
{:ok, body, conn} = Plug.Conn.read_body(conn)
payload = if body == "", do: %{}, else: Jason.decode!(body)
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(200, BDS.Desktop.ShellController.command_json(payload))
end
match _ do
Plug.Conn.send_resp(conn, 404, "not found")
end
defp put_secret_key_base(conn, _opts) do
if conn.secret_key_base do
conn
else
%{conn | secret_key_base: desktop_secret_key_base()}
end
end
defp desktop_secret_key_base do
Application.get_env(:bds, :desktop)[:secret_key_base] ||
raise "missing :desktop secret_key_base configuration"
end
defp maybe_require_desktop_auth(conn, _opts) do
if System.get_env("BDS_DESKTOP_AUTOMATION") in ["1", "true", "TRUE"] do
conn
else
Desktop.Auth.call(conn, [])
live_session :desktop_shell,
root_layout: {BDS.Desktop.Layouts, :root} do
live "/", ShellLive, :index
end
end
end

View File

@@ -29,7 +29,7 @@ defmodule BDS.Desktop.Server do
def init(_opts) do
{:ok, bandit_pid} =
Bandit.start_link(
plug: BDS.Desktop.Router,
plug: BDS.Desktop.Endpoint,
scheme: :http,
ip: {127, 0, 0, 1},
port: port(),

View File

@@ -1,249 +0,0 @@
defmodule BDS.Desktop.ShellController do
@moduledoc false
alias BDS.Media
alias BDS.Media.Media, as: MediaRecord
alias BDS.Projects
alias BDS.Repo
alias BDS.UI.Sidebar
def index_html do
BDS.UI.ShellPage.render()
end
def task_status_json do
Jason.encode!(BDS.Tasks.status_snapshot())
end
def projects_json do
Jason.encode!(BDS.Projects.shell_snapshot())
rescue
error in [Exqlite.Error] ->
if String.contains?(Exception.message(error), "no such table: projects") do
Jason.encode!(default_project_snapshot())
else
reraise error, __STACKTRACE__
end
end
def upsert_project_json(payload) when is_map(payload) do
case normalize_project_request(payload) do
{:create, attrs} -> create_project_json(attrs)
{:select, project_id} -> select_project_json(project_id)
:error -> Jason.encode!(%{status: "error", error: %{message: "Missing project name or project_id"}})
end
end
def choose_project_folder_json(payload \\ %{}) when is_map(payload) do
prompt = Map.get(payload, "prompt") || Map.get(payload, :prompt) || "Select existing blog folder"
case folder_picker().choose_directory(prompt) do
{:ok, path} -> Jason.encode!(project_folder_payload(path))
:cancel -> Jason.encode!(%{status: "cancel"})
{:error, error} -> Jason.encode!(%{status: "error", error: normalize_error(error)})
end
end
def command_json(payload) when is_map(payload) do
action = Map.get(payload, "action") || Map.get(payload, :action)
params = Map.get(payload, "params") || Map.get(payload, :params) || %{}
case BDS.Desktop.ShellCommands.execute(action, params) do
{:ok, result} -> Jason.encode!(%{status: "ok", result: result})
{:error, error} -> Jason.encode!(%{status: "error", error: normalize_error(error)})
end
end
def media_thumbnail(conn, media_id, params \\ %{}) when is_binary(media_id) do
case active_media_thumbnail(media_id, Map.get(params, "size") || Map.get(params, :size)) do
{:ok, content_type, path} ->
conn
|> Plug.Conn.put_resp_content_type(content_type)
|> Plug.Conn.send_file(200, path)
:error ->
Plug.Conn.send_resp(conn, 404, "not found")
end
end
def sidebar_json(payload) when is_map(payload) do
view = Map.get(payload, "view") || Map.get(payload, :view)
filters = Map.get(payload, "filters") || Map.get(payload, :filters) || %{}
data =
try do
case active_project_id() do
nil -> Sidebar.view(nil, view, filters)
project_id -> Sidebar.view(project_id, view, filters)
end
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, filters)
end
Jason.encode!(%{status: "ok", view: view, data: data})
end
defp normalize_error(error) when is_map(error), do: error
defp normalize_error(error), do: %{message: inspect(error)}
defp normalize_project_request(payload) do
cond do
present?(Map.get(payload, "name") || Map.get(payload, :name)) ->
{:create,
%{
name: String.trim(Map.get(payload, "name") || Map.get(payload, :name)),
description: blank_to_nil(Map.get(payload, "description") || Map.get(payload, :description)),
data_path: blank_to_nil(Map.get(payload, "data_path") || Map.get(payload, :data_path))
}}
present?(Map.get(payload, "project_id") || Map.get(payload, :project_id)) ->
{:select, Map.get(payload, "project_id") || Map.get(payload, :project_id)}
true ->
:error
end
end
defp create_project_json(attrs) do
with {:ok, project} <- BDS.Projects.create_project(attrs),
{:ok, active_project} <- BDS.Projects.set_active_project(project.id) do
Jason.encode!(%{status: "ok", project: project_response(active_project), active_project_id: active_project.id})
else
{:error, error} -> Jason.encode!(%{status: "error", error: normalize_error(error)})
end
end
defp select_project_json(project_id) do
case BDS.Projects.set_active_project(project_id) do
{:ok, project} ->
Jason.encode!(%{status: "ok", project: project_response(project), active_project_id: project.id})
{:error, :not_found} ->
Jason.encode!(%{status: "error", error: %{message: "Project not found"}})
{:error, error} ->
Jason.encode!(%{status: "error", error: normalize_error(error)})
end
end
defp project_response(project) do
%{id: project.id, name: project.name, slug: project.slug, data_path: project.data_path, is_active: project.is_active}
end
defp project_folder_payload(path) do
normalized_path = Path.expand(path)
project_metadata = read_project_metadata(normalized_path)
existing_project = find_project_by_data_path(normalized_path)
%{
status: "ok",
path: normalized_path,
name: Map.get(project_metadata, "name") || Path.basename(normalized_path),
description: Map.get(project_metadata, "description"),
existing_project_id: existing_project && existing_project.id
}
end
defp read_project_metadata(path) do
project_json_path = Path.join([path, "meta", "project.json"])
case File.read(project_json_path) do
{:ok, contents} -> Jason.decode!(contents)
{:error, :enoent} -> %{}
end
end
defp find_project_by_data_path(path) do
normalized_path = Path.expand(path)
BDS.Projects.list_projects()
|> Enum.find(fn project ->
case project.data_path do
value when is_binary(value) -> Path.expand(value) == normalized_path
_other -> false
end
end)
end
defp folder_picker do
Application.get_env(:bds, :desktop, [])[:folder_picker] || BDS.Desktop.FolderPicker
end
defp default_project_snapshot do
%{
active_project_id: "default",
projects: [
%{
id: "default",
name: "My Blog",
slug: "my-blog",
data_path: nil,
is_active: true
}
]
}
end
defp active_project_id do
BDS.Projects.shell_snapshot().active_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
nil
end
defp present?(value) when is_binary(value), do: String.trim(value) != ""
defp present?(_value), do: false
defp active_media_thumbnail(media_id, size) do
with %{} = project <- Projects.get_active_project(),
%MediaRecord{} = media <- Repo.get(MediaRecord, media_id),
true <- media.project_id == project.id,
relative_path when is_binary(relative_path) <- Media.thumbnail_paths(media)[thumbnail_size(size)],
absolute_path = Path.join(Projects.project_data_dir(project), relative_path),
true <- File.exists?(absolute_path) do
{:ok, thumbnail_content_type(relative_path), absolute_path}
else
_other -> :error
end
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
:error
end
defp thumbnail_size(size) do
case to_string(size || "small") do
"medium" -> :medium
"large" -> :large
"ai" -> :ai
_other -> :small
end
end
defp thumbnail_content_type(path) do
case Path.extname(path) do
".jpg" -> "image/jpeg"
".jpeg" -> "image/jpeg"
_other -> "image/webp"
end
end
defp blank_to_nil(value) when is_binary(value) do
trimmed = String.trim(value)
if trimmed == "", do: nil, else: trimmed
end
defp blank_to_nil(_value), do: nil
end

View File

@@ -0,0 +1,246 @@
defmodule BDS.Desktop.ShellData do
@moduledoc false
alias BDS.I18n
alias BDS.Projects
alias BDS.UI.Dashboard
alias BDS.UI.Sidebar
alias BDS.UI.Workbench
def title do
Application.get_env(:bds, :desktop)[:title] || "Blogging Desktop Server"
end
def ui_language do
I18n.current_ui_locale()
end
def translations do
I18n.get_ui_translations(ui_language())
end
def supported_ui_languages do
Enum.map(I18n.supported_languages(), fn language ->
%{code: language.code, flag: I18n.flag(language.code)}
end)
end
def translate(key, bindings \\ %{}) do
text = Map.get(translations(), to_string(key), to_string(key))
Enum.reduce(bindings, text, fn {binding, value}, acc ->
String.replace(acc, "%{#{binding}}", to_string(value))
end)
end
def project_snapshot do
Projects.shell_snapshot()
rescue
error in [Exqlite.Error, DBConnection.OwnershipError] ->
if match?(%Exqlite.Error{}, error) and not String.contains?(Exception.message(error), "no such table: projects") do
reraise error, __STACKTRACE__
end
default_project_snapshot()
end
def current_project(projects_snapshot) do
Enum.find(projects_snapshot.projects, &(&1.id == projects_snapshot.active_project_id)) ||
List.first(projects_snapshot.projects)
end
def dashboard(project_id) do
Dashboard.snapshot(project_id)
rescue
error in [Exqlite.Error, DBConnection.OwnershipError] ->
if match?(%Exqlite.Error{}, error) and not String.contains?(Exception.message(error), "no such table") do
reraise error, __STACKTRACE__
end
Dashboard.empty_snapshot()
end
def sidebar_view(project_id, view_id) do
Sidebar.view(project_id, view_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
Sidebar.view(nil, view_id, %{})
end
def assistant_cards do
[
%{label: "Offline Gate", text: "Automatic AI actions stay gated by airplane mode."},
%{label: "Filesystem Sync", text: "Metadata flush, diffing, and rebuild hooks still need editor wiring."},
%{label: "Desktop Runtime", text: "The app window is now served from LiveView state."}
]
end
def editor_meta(task_status) do
[
%{label: "Status", value: task_status.running_task_message || "Idle"},
%{label: "Mode", value: "Offline"},
%{label: "Main Language", value: ui_language()}
]
end
def status_bar(workbench, task_status, dashboard) do
Workbench.status_bar(workbench,
post_count: dashboard.post_stats.total_posts,
media_count: dashboard.media_stats.media_count,
theme_badge: "desktop-shell",
ui_language: ui_language(),
offline_mode: true,
running_task_message: task_status.running_task_message,
running_task_overflow: task_status.running_task_overflow,
active_post_status: nil
)
end
def panel_tabs(workbench) do
[:tasks, :output, :git_log, workbench.panel.active_tab]
|> Enum.uniq()
end
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")
end
end
def dashboard_status_label(status) do
case to_string(status) do
"draft" -> translate("dashboard.status.draft")
"published" -> translate("dashboard.status.published")
"archived" -> translate("dashboard.status.archived")
other -> other |> String.replace("_", " ") |> String.capitalize()
end
end
def dashboard_post_count_label(count) do
normalized_count = count || 0
key = if normalized_count == 1, do: "dashboard.postCount.one", else: "dashboard.postCount.other"
translate(key, %{count: normalized_count})
end
def dashboard_tag_cloud_items(items) when is_list(items) do
top_items =
items
|> Enum.sort_by(fn item -> -(item.count || 0) end)
|> Enum.take(40)
counts = Enum.map(top_items, &(&1.count || 0))
max_count = Enum.max([1 | counts])
min_count = Enum.min([max_count | counts])
range = max(max_count - min_count, 1)
top_items
|> Enum.map(fn item ->
font_size = 11 + (((item.count || 0) - min_count) / range) * 11
Map.merge(item, %{font_size: font_size, color: normalize_dashboard_tag_color(item.color)})
end)
|> Enum.sort_by(&String.downcase(to_string(&1.tag || "")))
end
def render_dashboard_tag_style(item) do
declarations = ["font-size: #{Float.round(item.font_size || 11, 1)}px;"]
declarations =
if item.color do
declarations ++ [
"background-color: #{item.color};",
"color: #{dashboard_contrast_color(item.color)};"
]
else
declarations
end
Enum.join(declarations, " ")
end
def format_dashboard_month(year, month) do
{:ok, date} = Date.new(year, month, 1)
Calendar.strftime(date, "%b")
end
def format_dashboard_date(nil), do: ""
def format_dashboard_date(timestamp) do
timestamp
|> DateTime.from_unix!(:millisecond)
|> Calendar.strftime("%x")
end
def route_label(route) do
case to_string(route) do
"git_log" -> "Git Log"
"post_links" -> "Post Links"
other -> other |> String.replace("_", " ") |> String.split() |> Enum.map_join(" ", &String.capitalize/1)
end
end
def format_bytes(bytes) do
normalized_bytes = max(bytes || 0, 0)
cond do
normalized_bytes == 0 ->
"0 B"
true ->
units = ["B", "KB", "MB", "GB"]
unit_index = min(trunc(:math.log(normalized_bytes) / :math.log(1024)), length(units) - 1)
value = normalized_bytes / :math.pow(1024, unit_index)
decimals = if value >= 10 or unit_index == 0, do: 0, else: 1
unit = Enum.at(units, unit_index)
:erlang.float_to_binary(value, decimals: decimals) <> " " <> unit
end
end
defp default_project_snapshot do
%{
active_project_id: "default",
projects: [
%{
id: "default",
name: "My Blog",
slug: "my-blog",
data_path: nil,
is_active: true
}
]
}
end
defp normalize_dashboard_tag_color(nil), do: nil
defp normalize_dashboard_tag_color(""), do: nil
defp normalize_dashboard_tag_color("#" <> rest = color) when byte_size(rest) == 6 do
if String.match?(rest, ~r/\A[0-9a-fA-F]{6}\z/), do: color, else: nil
end
defp normalize_dashboard_tag_color(_color), do: nil
defp dashboard_contrast_color("#" <> rgb) do
<<r::binary-size(2), g::binary-size(2), b::binary-size(2)>> = rgb
{red, _} = Integer.parse(r, 16)
{green, _} = Integer.parse(g, 16)
{blue, _} = Integer.parse(b, 16)
luminance = (red * 299 + green * 587 + blue * 114) / 1000
if luminance > 150, do: "#1e1e1e", else: "#ffffff"
end
defp dashboard_contrast_color(_color), do: "#ffffff"
end

View File

@@ -0,0 +1,630 @@
defmodule BDS.Desktop.ShellLive do
@moduledoc false
use Phoenix.LiveView
import Phoenix.HTML
alias BDS.Desktop.ShellData
alias BDS.UI.Workbench
@refresh_interval 1_500
@impl true
def mount(_params, _session, socket) do
if connected?(socket) do
:timer.send_interval(@refresh_interval, :refresh_task_status)
end
workbench = Workbench.new()
{:ok,
socket
|> assign(:page_title, ShellData.title())
|> assign(:page_language, ShellData.ui_language())
|> reload_shell(workbench)}
end
@impl true
def handle_event("toggle_sidebar", _params, socket) do
{:noreply, reload_shell(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))}
end
def handle_event("toggle_assistant_sidebar", _params, socket) do
{:noreply, reload_shell(socket, Workbench.toggle_assistant_sidebar(socket.assigns.workbench))}
end
def handle_event("select_view", %{"view" => view_id}, socket) do
workbench = Workbench.click_activity(socket.assigns.workbench, String.to_existing_atom(view_id))
{:noreply, reload_shell(socket, workbench)}
end
def handle_event("select_panel_tab", %{"tab" => tab}, socket) do
workbench =
socket.assigns.workbench
|> Workbench.set_panel_visible(true)
|> Workbench.set_panel_tab(String.to_existing_atom(tab))
{:noreply, reload_shell(socket, workbench)}
end
@impl true
def handle_info(:refresh_task_status, socket) do
task_status = BDS.Tasks.status_snapshot()
{: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))}
end
@impl true
def render(assigns) do
~H"""
<div class="app" id="bds-shell-app">
<div class="window-titlebar" data-region="title-bar">
<div class="window-titlebar-menu-bar is-hidden">
<button class="window-titlebar-menu-button" type="button">File</button>
<button class="window-titlebar-menu-button" type="button">Edit</button>
<button class="window-titlebar-menu-button" type="button">View</button>
<button class="window-titlebar-menu-button" type="button">Blog</button>
<button class="window-titlebar-menu-button" type="button">Help</button>
</div>
<div class="window-titlebar-drag-region"></div>
<div class="window-titlebar-title" data-testid="window-title"><%= @page_title %></div>
<div class="window-titlebar-actions">
<button
class="window-titlebar-action-button"
data-testid="toggle-sidebar"
type="button"
phx-click="toggle_sidebar"
aria-label="Toggle sidebar"
title="Toggle sidebar"
>
<span class={["window-titlebar-sidebar-icon", if(@workbench.sidebar_visible, do: "is-active", else: "is-inactive")]}>
<span class="window-titlebar-sidebar-pane"></span>
</span>
</button>
<button
class="window-titlebar-action-button"
data-testid="toggle-panel"
type="button"
phx-click="toggle_panel"
aria-label="Toggle panel"
title="Toggle panel"
>
<span class={["window-titlebar-panel-icon", if(@workbench.panel.visible, do: "is-active", else: "is-inactive")]}>
<span class="window-titlebar-panel-pane"></span>
</span>
</button>
<button
class="window-titlebar-action-button"
data-testid="toggle-assistant"
type="button"
phx-click="toggle_assistant_sidebar"
aria-label="Toggle assistant"
title="Toggle assistant"
>
<span class={["window-titlebar-assistant-icon", if(@workbench.assistant_sidebar_visible, do: "is-active", else: "is-inactive")]}>
<span class="window-titlebar-assistant-pane"></span>
</span>
</button>
</div>
</div>
<div class="app-main">
<aside class="activity-bar" data-region="activity-bar">
<div class="activity-bar-top">
<%= for button <- Enum.filter(@activity_buttons, &(&1.activity_group == :top)) do %>
<button
class={["activity-bar-item", if(button.active, do: "active")]}
data-testid="activity-button"
data-view={button.id}
data-active={to_string(button.active)}
type="button"
phx-click="select_view"
phx-value-view={button.id}
title={activity_label(button.label)}
aria-label={activity_label(button.label)}
>
<%= raw(ShellData.activity_icon(button.id)) %>
</button>
<% end %>
</div>
<div class="activity-bar-bottom">
<%= for button <- Enum.filter(@activity_buttons, &(&1.activity_group == :bottom)) do %>
<button
class={["activity-bar-item", if(button.active, do: "active")]}
data-testid="activity-button"
data-view={button.id}
data-active={to_string(button.active)}
type="button"
phx-click="select_view"
phx-value-view={button.id}
title={activity_label(button.label)}
aria-label={activity_label(button.label)}
>
<%= raw(ShellData.activity_icon(button.id)) %>
</button>
<% end %>
</div>
</aside>
<section
class={["sidebar-shell", if(not @workbench.sidebar_visible, do: "is-hidden")]}
data-testid="sidebar-shell"
style={"width: #{@workbench.sidebar_width}px;"}
>
<div class="sidebar" data-region="sidebar">
<div class="sidebar-content sidebar-body">
<div class="sidebar-section">
<div class="sidebar-section-header">
<span><%= String.upcase(sidebar_header_label(@sidebar_header)) %></span>
</div>
</div>
<%= render_sidebar_body(assigns) %>
</div>
</div>
<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"></div>
<section class="editor-shell" data-region="editor">
<div class="editor-empty">
<div class="dashboard-content">
<h1 data-testid="editor-title"><%= translated("dashboard.title") %></h1>
<p class="text-muted"><%= translated("dashboard.subtitle") %></p>
<div class="dashboard-stats">
<div class="stat-card">
<div class="stat-number"><%= @dashboard.post_stats.total_posts || 0 %></div>
<div class="stat-label"><%= translated("dashboard.stats.totalPosts") %></div>
<div class="stat-breakdown">
<span class="stat-tag stat-published"><%= translated("dashboard.stats.published", %{count: @dashboard.post_stats.published_count || 0}) %></span>
<span class="stat-tag stat-draft"><%= translated("dashboard.stats.drafts", %{count: @dashboard.post_stats.draft_count || 0}) %></span>
<%= if (@dashboard.post_stats.archived_count || 0) > 0 do %>
<span class="stat-tag stat-archived"><%= translated("dashboard.stats.archived", %{count: @dashboard.post_stats.archived_count || 0}) %></span>
<% end %>
</div>
</div>
<div class="stat-card">
<div class="stat-number"><%= @dashboard.media_stats.media_count || 0 %></div>
<div class="stat-label"><%= translated("dashboard.stats.mediaFiles") %></div>
<div class="stat-breakdown">
<span class="stat-tag"><%= translated("dashboard.stats.images", %{count: @dashboard.media_stats.image_count || 0}) %></span>
<span class="stat-tag"><%= ShellData.format_bytes(@dashboard.media_stats.total_bytes || 0) %></span>
</div>
</div>
<div class="stat-card">
<div class="stat-number"><%= length(@dashboard.tag_cloud_items || []) %></div>
<div class="stat-label"><%= translated("dashboard.stats.tags") %></div>
<div class="stat-breakdown">
<span class="stat-tag"><%= translated("dashboard.stats.categories", %{count: length(@dashboard.category_counts || [])}) %></span>
</div>
</div>
</div>
<%= if Enum.any?(@dashboard.timeline_entries || []) do %>
<div class="dashboard-section">
<h4><%= translated("dashboard.section.postsOverTime") %></h4>
<div class="timeline-chart">
<%= for entry <- @dashboard.timeline_entries || [] do %>
<div class="timeline-bar-container">
<div class="timeline-bar" style={"height: #{timeline_height(entry, @dashboard.timeline_entries || [])}%"}>
<span class="timeline-bar-count"><%= entry.count || 0 %></span>
</div>
<div class="timeline-bar-label">
<span class="timeline-bar-label-month"><%= ShellData.format_dashboard_month(entry.year, entry.month) %></span>
<span class="timeline-bar-label-year"><%= entry.year %></span>
</div>
</div>
<% end %>
</div>
</div>
<% end %>
<%= if Enum.any?(@dashboard_tag_cloud_items) do %>
<div class="dashboard-section">
<h4><%= translated("dashboard.section.tags") %></h4>
<div class="tag-cloud">
<%= for item <- @dashboard_tag_cloud_items do %>
<span class={["dashboard-tag", if(item.color, do: "has-color")]} style={ShellData.render_dashboard_tag_style(item)} title={ShellData.dashboard_post_count_label(item.count)}><%= item.tag %></span>
<% end %>
<%= if length(@dashboard.tag_cloud_items || []) > 40 do %>
<span class="text-muted tag-cloud-more"><%= translated("dashboard.tagCloud.more", %{count: length(@dashboard.tag_cloud_items) - 40}) %></span>
<% end %>
</div>
</div>
<% end %>
<%= if Enum.any?(@dashboard.category_counts || []) do %>
<div class="dashboard-section">
<h4><%= translated("dashboard.section.categories") %></h4>
<div class="tag-cloud">
<%= for category <- @dashboard.category_counts || [] do %>
<span class="dashboard-tag dashboard-category" title={ShellData.dashboard_post_count_label(category.count || 0)}>
<%= category.category || "" %>
<span class="tag-count"><%= category.count || 0 %></span>
</span>
<% end %>
</div>
</div>
<% end %>
<%= if Enum.any?(@dashboard.recent_posts || []) do %>
<div class="dashboard-section">
<h4><%= translated("dashboard.section.recentlyUpdated") %></h4>
<div class="recent-posts-list">
<%= for post <- @dashboard.recent_posts || [] do %>
<button class="recent-post-item" type="button">
<span class="recent-post-title"><%= post.title || "" %></span>
<span class={"recent-post-status status-#{post.status || "draft"}"}><%= ShellData.dashboard_status_label(post.status || "draft") %></span>
<span class="recent-post-date"><%= ShellData.format_dashboard_date(post.updated_at) %></span>
</button>
<% end %>
</div>
</div>
<% end %>
<div class="dashboard-inspector-meta" hidden>
<%= for item <- @editor_meta do %>
<section class="editor-meta-row">
<strong data-testid="editor-meta-label"><%= translated(item.label) %></strong>
<span><%= translated(item.value) %></span>
</section>
<% end %>
</div>
</div>
</div>
</section>
<section class={["panel-shell", if(not @workbench.panel.visible, do: "is-hidden")]} data-region="panel">
<div class="panel-header">
<div class="panel-tabs">
<%= for tab <- @panel_tabs do %>
<button
class={["panel-tab", if(@workbench.panel.active_tab == tab, do: "active")]}
type="button"
phx-click="select_panel_tab"
phx-value-tab={tab}
>
<%= panel_tab_label(tab) %>
</button>
<% end %>
</div>
</div>
<div class="panel-content">
<%= render_panel_body(assigns) %>
</div>
</section>
</main>
<section
class={["assistant-sidebar-shell", if(not @workbench.assistant_sidebar_visible, do: "is-hidden")]}
data-testid="assistant-shell"
style={"width: #{@workbench.assistant_sidebar_width}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-header">
<strong><%= translated("Assistant") %></strong>
</div>
<div class="assistant-content">
<%= for card <- @assistant_cards do %>
<section class="assistant-card">
<strong><%= translated(card.label) %></strong>
<span><%= translated(card.text) %></span>
</section>
<% end %>
</div>
</aside>
</section>
</div>
<footer class="status-bar" data-region="status-bar">
<div class="status-bar-left">
<div class="project-selector">
<button class="project-selector-trigger" type="button" title={translated("Switch project")}>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" class="project-icon">
<path d="M14.5 3H7.71l-.85-.85A.5.5 0 0 0 6.5 2h-5a.5.5 0 0 0-.5.5v11a.5.5 0 0 0 .5.5h13a.5.5 0 0 0 .5-.5v-10a.5.5 0 0 0-.5-.5zm-13 1h5.29l.85.85c.1.1.23.15.36.15h6.5v9h-13V4z"></path>
</svg>
<span class="project-name"><%= @current_project && @current_project.name || "My Blog" %></span>
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" class="dropdown-arrow">
<path d="M4.5 5.5L8 9l3.5-3.5h-7z"></path>
</svg>
</button>
</div>
<button class="status-bar-item status-bar-task-button" type="button" phx-click="toggle_panel">
<span><%= @status.left.running_task_message || translated("Idle") %></span>
<%= if (@status.left.running_task_overflow || 0) > 0 do %>
<span class="status-bar-count">+<%= @status.left.running_task_overflow %></span>
<% end %>
</button>
</div>
<div class="status-bar-right">
<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")]} type="button" title={translated("Toggle offline mode")}>✈</button>
<label class="status-bar-item language-badge">
<span><%= translated("UI") %></span>
<select class="status-bar-language-select">
<%= for language <- @supported_ui_languages do %>
<option selected={language.code == @page_language} value={language.code}><%= language.flag %></option>
<% end %>
</select>
</label>
<span class="status-bar-item brand"><%= @status.right.brand %></span>
</div>
</footer>
</div>
"""
end
defp reload_shell(socket, workbench) do
projects = ShellData.project_snapshot()
dashboard = ShellData.dashboard(projects.active_project_id)
sidebar_data = ShellData.sidebar_view(projects.active_project_id, Atom.to_string(workbench.active_view))
task_status = BDS.Tasks.status_snapshot()
activity_buttons = Workbench.activity_buttons(workbench, 0)
socket
|> assign(:workbench, workbench)
|> assign(:projects, projects)
|> assign(:current_project, ShellData.current_project(projects))
|> assign(:dashboard, dashboard)
|> assign(:dashboard_tag_cloud_items, ShellData.dashboard_tag_cloud_items(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(:task_status, task_status)
|> assign(:status, ShellData.status_bar(workbench, task_status, dashboard))
|> assign(:activity_buttons, activity_buttons)
|> assign(:panel_tabs, ShellData.panel_tabs(workbench))
|> assign(:supported_ui_languages, ShellData.supported_ui_languages())
end
defp render_sidebar_body(assigns) do
case assigns.sidebar_data.layout do
"post_list" -> render_post_sidebar(assigns)
"media_grid" -> render_media_sidebar(assigns)
"entity_list" -> render_entity_sidebar(assigns)
"nav_list" -> render_nav_sidebar(assigns)
_other -> render_default_sidebar(assigns)
end
end
defp render_post_sidebar(assigns) do
~H"""
<%= for section <- @sidebar_data.sections || [] do %>
<section class="sidebar-section">
<div class="sidebar-section-title">
<span class={"section-icon status-#{section.status || "draft"}"}>●</span>
<span data-testid="sidebar-section-title"><%= translated(section.title) %></span>
<span class="sidebar-section-count"><%= section.count || length(section.items || []) %></span>
</div>
<div class="sidebar-list">
<%= for item <- section.items || [] do %>
<button class="sidebar-item sidebar-post-item post-type-post" type="button">
<span class="post-type-icon" title="post">●</span>
<span class="sidebar-item-content">
<span class="sidebar-item-title-row">
<span class="sidebar-item-title"><%= item.title || "" %></span>
</span>
<span class="sidebar-item-meta"><%= format_sidebar_timestamp(item.meta_timestamp) %></span>
</span>
</button>
<% end %>
</div>
</section>
<% end %>
<%= if Enum.empty?(@sidebar_data.sections || []) do %>
<div class="sidebar-empty">
<p><%= translated(@sidebar_data.empty_message || "No items") %></p>
</div>
<% end %>
"""
end
defp render_media_sidebar(assigns) do
~H"""
<%= if Enum.any?(@sidebar_data.items || []) do %>
<div class="sidebar-list media-grid">
<%= for item <- @sidebar_data.items || [] do %>
<button class="media-item" type="button" title={item.title || ""}>
<span class={media_thumbnail_class(item)}>
<%= if image_media?(item) do %>
<span class="media-thumbnail-fallback"><%= media_thumbnail_glyph(item.mime_type) %></span>
<img class="media-thumbnail-image" src={"/media-thumbnail/#{item.id}"} alt="" loading="lazy" decoding="async" />
<% else %>
<span class="media-thumbnail-fallback"><%= media_thumbnail_glyph(item.mime_type) %></span>
<% end %>
</span>
<span class="media-item-info">
<span class="media-item-name"><%= item.title || "" %></span>
<span class="media-item-size"><%= item.meta || "" %></span>
</span>
</button>
<% end %>
</div>
<% else %>
<div class="sidebar-empty">
<p><%= translated(@sidebar_data.empty_message || "No items") %></p>
</div>
<% end %>
"""
end
defp render_entity_sidebar(assigns) do
~H"""
<%= if Enum.any?(@sidebar_data.items || []) do %>
<div class="settings-nav-list">
<%= for item <- @sidebar_data.items || [] do %>
<button class="chat-list-item" type="button">
<span class="chat-item-content">
<span class="chat-item-title"><%= item.title || "" %></span>
<span class="chat-item-date"><%= translated(item.meta || "") %></span>
</span>
</button>
<% end %>
</div>
<% else %>
<div class="sidebar-empty">
<p><%= translated(@sidebar_data.empty_message || "No items") %></p>
</div>
<% end %>
"""
end
defp render_nav_sidebar(assigns) do
~H"""
<div class="settings-nav-list">
<%= for item <- @sidebar_data.items || [] do %>
<button class="settings-nav-entry" type="button">
<span class="settings-nav-entry-icon"><%= item.icon || "" %></span>
<span><%= translated(item.title || "") %></span>
</button>
<% end %>
</div>
"""
end
defp render_default_sidebar(assigns) do
~H"""
<%= for section <- @sidebar_data.sections || [] do %>
<section class="sidebar-section">
<div class="sidebar-section-header">
<span data-testid="sidebar-section-title"><%= translated(section.title) %></span>
</div>
<div class="sidebar-section-items">
<%= for item <- section.items || [] do %>
<div class="sidebar-list-item"><%= item.title || "" %></div>
<% end %>
</div>
</section>
<% end %>
"""
end
defp render_panel_body(assigns) do
case assigns.workbench.panel.active_tab do
:tasks -> render_task_entries(assigns)
:output -> render_output_entries(assigns)
:git_log -> render_git_log(assigns)
other -> render_generic_panel(assigns, other)
end
end
defp render_task_entries(assigns) do
~H"""
<%= if Enum.empty?(@task_status.tasks || []) do %>
<div class="panel-entry panel-empty-state">
<strong><%= translated("Tasks") %></strong>
<span><%= translated("No background tasks running") %></span>
</div>
<% else %>
<div class="task-list">
<%= for task <- @task_status.tasks || [] do %>
<div class="panel-entry task-entry">
<div class="task-entry-header">
<strong><%= task.name %></strong>
<span class={"task-status task-status-#{task.status}"}><%= task.status |> to_string() |> String.capitalize() %></span>
</div>
<span><%= task.message || task.group_name || "" %></span>
</div>
<% end %>
</div>
<% end %>
"""
end
defp render_output_entries(assigns) do
~H"""
<div class="panel-entry panel-empty-state output-list">
<strong><%= translated("Output") %></strong>
<span><%= translated("No shell output yet") %></span>
</div>
"""
end
defp render_git_log(assigns) do
~H"""
<div class="git-log-list">
<div class="panel-entry">
<strong><%= translated("Git Log") %></strong>
<span><%= translated("Working tree integration is not wired yet in the shell, but the tab is selectable and ready for command output.") %></span>
</div>
</div>
"""
end
defp render_generic_panel(assigns, tab) do
assigns = assign(assigns, :panel_label, ShellData.route_label(tab))
~H"""
<div class="panel-entry">
<strong><%= @panel_label %></strong>
<span><%= translated("The shared lower panel is available for tasks, output, git details, and editor-specific diagnostics.") %></span>
</div>
"""
end
defp translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings)
defp panel_tab_label(:tasks), do: translated("Tasks")
defp panel_tab_label(:output), do: translated("Output")
defp panel_tab_label(:git_log), do: translated("Git Log")
defp panel_tab_label(tab), do: ShellData.route_label(tab)
defp activity_label("AI Assistant"), do: "Chat"
defp activity_label("Source Control"), do: "Git"
defp activity_label(label), do: translated(label)
defp active_sidebar_label(activity_buttons, active_view, sidebar_data) do
Enum.find_value(activity_buttons, translated(sidebar_data.title || ""), fn button ->
if button.id == active_view, do: activity_label(button.label), else: nil
end)
end
defp sidebar_header_label(label), do: translated(label)
defp timeline_height(entry, entries) do
max_count =
entries
|> Enum.map(&(&1.count || 0))
|> Enum.max(fn -> 1 end)
max(4, ((entry.count || 0) / max_count) * 100)
end
defp format_sidebar_timestamp(nil), do: ""
defp format_sidebar_timestamp(timestamp) do
timestamp
|> DateTime.from_unix!(:millisecond)
|> Calendar.strftime("%x")
end
defp image_media?(item), do: String.starts_with?(to_string(item.mime_type || ""), "image/")
defp media_thumbnail_class(item) do
if image_media?(item), do: "media-thumbnail has-image", else: "media-thumbnail"
end
defp media_thumbnail_glyph(mime_type) do
case String.split(to_string(mime_type || ""), "/", parts: 2) do
["image", _rest] -> "IMG"
["video", _rest] -> "VID"
["audio", _rest] -> "AUD"
["application", _rest] -> "DOC"
_other -> "FILE"
end
end
end