feat: first take on sidebars

This commit is contained in:
2026-04-25 20:26:55 +02:00
parent 7ebea742a5
commit 55b3071696
11 changed files with 951 additions and 168 deletions

View File

@@ -6,6 +6,7 @@ defmodule BDS.UI.ShellPage do
alias BDS.UI.Dashboard
alias BDS.UI.MenuBar
alias BDS.UI.Registry
alias BDS.UI.Sidebar
alias BDS.UI.Session
alias BDS.UI.Workbench
@@ -84,7 +85,7 @@ defmodule BDS.UI.ShellPage do
session: Session.serialize(workbench),
task_status: task_status,
content: %{
sidebar: sidebar_content(),
sidebar: sidebar_content(projects.active_project_id),
dashboard: dashboard,
assistant_cards: assistant_cards(),
editor_meta: editor_meta(task_status)
@@ -162,72 +163,15 @@ defmodule BDS.UI.ShellPage do
}
end
defp sidebar_content do
%{
"posts" => %{
title: "Posts",
subtitle: "Drafts, published entries, and archive history",
sections: [
%{
title: "Drafts",
items: [
%{id: "post-welcome", title: "Welcome to bDS2", meta: "Updated today", badge: "draft", route: "post"},
%{id: "post-launch-plan", title: "Launch plan", meta: "Updated yesterday", badge: "draft", route: "post"}
]
},
%{
title: "Published",
items: [
%{id: "post-roadmap", title: "Roadmap", meta: "Published Feb 10, 2026", badge: "2 langs", route: "post"}
]
},
%{
title: "Archived",
items: [
%{id: "post-retrospective", title: "Retrospective", meta: "Archived Jan 12, 2026", badge: "archive", route: "post"}
]
}
]
},
"pages" => simple_list_view("Pages", "Standalone pages", [
%{id: "page-about", title: "About", meta: "Static page", route: "post"},
%{id: "page-contact", title: "Contact", meta: "Static page", route: "post"}
]),
"media" => simple_list_view("Media", "Images and files", [
%{id: "media-hero", title: "hero-shot.jpg", meta: "Image asset", route: "media"},
%{id: "media-banner", title: "launch-banner.png", meta: "Image asset", route: "media"}
]),
"scripts" => simple_list_view("Scripts", "Automation helpers", [
%{id: "script-import", title: "Import posts", meta: "Lua utility", route: "scripts"},
%{id: "script-sync", title: "Sync tags", meta: "Lua utility", route: "scripts"}
]),
"templates" => simple_list_view("Templates", "Site rendering", [
%{id: "template-post", title: "post.liquid", meta: "Post template", route: "templates"},
%{id: "template-list", title: "list.liquid", meta: "List template", route: "templates"}
]),
"tags" => simple_list_view("Tags", "Tag management", [
%{id: "tag-launch", title: "launch", meta: "12 posts", route: "tags"},
%{id: "tag-writing", title: "writing", meta: "7 posts", route: "tags"}
]),
"chat" => simple_list_view("Chat", "AI conversations", [
%{id: "chat-planning", title: "Planning session", meta: "Offline gated", route: "chat"},
%{id: "chat-translation", title: "Translation QA", meta: "Offline gated", route: "chat"}
]),
"import" => simple_list_view("Import", "Import definitions", [
%{id: "import-wordpress", title: "WordPress import", meta: "Ready", route: "import"}
]),
"git" => simple_list_view("Git", "Working tree and history", [
%{id: "git-working-tree", title: "Working tree", meta: "3 changed files", route: "git_diff"}
]),
"settings" => simple_list_view("Settings", "Project and publishing", [
%{id: "settings-project", title: "Project", meta: "Paths and defaults", route: "settings"},
%{id: "settings-ai", title: "AI", meta: "Offline controls", route: "settings"}
])
}
end
defp sidebar_content(project_id) do
Sidebar.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
defp simple_list_view(title, subtitle, items) do
%{title: title, subtitle: subtitle, sections: [%{title: title, items: items}]}
Sidebar.empty_snapshot()
end
defp dashboard_content(project_id) do

295
lib/bds/ui/sidebar.ex Normal file
View File

@@ -0,0 +1,295 @@
defmodule BDS.UI.Sidebar do
@moduledoc false
import Ecto.Query
alias BDS.AI.ChatConversation
alias BDS.Media.Media
alias BDS.Posts.Post
alias BDS.Posts.Translation
alias BDS.Repo
alias BDS.Scripts.Script
alias BDS.Tags.Tag
alias BDS.Templates.Template
@page_category "page"
def snapshot(nil), do: empty_snapshot()
def snapshot(project_id) when is_binary(project_id) do
posts = list_posts(project_id)
translation_counts = translation_counts(project_id)
media_items = list_media(project_id)
scripts = list_scripts(project_id)
templates = list_templates(project_id)
tags = list_tags(project_id)
conversations = list_conversations()
%{
"posts" => posts_view(posts, translation_counts, false),
"pages" => posts_view(posts, translation_counts, true),
"media" => media_view(media_items),
"scripts" => entity_list_view("Scripts", "Automation helpers", "scripts", scripts),
"templates" =>
entity_list_view("Templates", "Site rendering", "templates", templates),
"tags" => tags_nav_view(tags),
"chat" => entity_list_view("Chat", "AI conversations", "chat", conversations),
"import" => entity_list_view("Import", "Import definitions", "import", []),
"git" => git_view(),
"settings" => settings_nav_view()
}
end
def empty_snapshot do
%{
"posts" => posts_view([], %{}, false),
"pages" => posts_view([], %{}, true),
"media" => media_view([]),
"scripts" => entity_list_view("Scripts", "Automation helpers", "scripts", []),
"templates" => entity_list_view("Templates", "Site rendering", "templates", []),
"tags" => tags_nav_view([]),
"chat" => entity_list_view("Chat", "AI conversations", "chat", []),
"import" => entity_list_view("Import", "Import definitions", "import", []),
"git" => git_view(),
"settings" => settings_nav_view()
}
end
defp posts_view(posts, translation_counts, pages?) do
filtered_posts = Enum.filter(posts, &(page_post?(&1) == pages?))
grouped_posts = group_posts(filtered_posts)
%{
title: if(pages?, do: "Pages", else: "Posts"),
subtitle: if(pages?, do: "Standalone pages", else: "Drafts, published entries, and archive history"),
layout: "post_list",
empty_message: if(pages?, do: "No items", else: "No items"),
sections: [
build_post_section("Drafts", :draft, grouped_posts.draft, translation_counts, false),
build_post_section("Published", :published, grouped_posts.published, translation_counts, true),
build_post_section("Archived", :archived, grouped_posts.archived, translation_counts, false)
]
}
end
defp media_view(media_items) do
%{
title: "Media",
subtitle: "Images and files",
layout: "media_grid",
empty_message: "No items",
items:
Enum.map(media_items, fn media ->
%{
id: media.id,
title: display_media_title(media),
meta: media_size_label(media.size),
mime_type: media.mime_type,
route: "media"
}
end)
}
end
defp tags_nav_view(tags) do
%{
title: "Tags",
subtitle: "Tag management",
layout: "nav_list",
items: [
%{id: "tags-cloud", title: "Tag Cloud", icon: "☁️", route: "tags"},
%{id: "tags-manage", title: "Create / Edit", icon: "✏️", route: "tags"},
%{id: "tags-merge", title: "Merge Tags", icon: "🔀", route: "tags"}
],
summary_badge: length(tags)
}
end
defp settings_nav_view do
%{
title: "Settings",
subtitle: "Project and publishing",
layout: "nav_list",
items: [
%{id: "settings-project", title: "Project", icon: "📁", route: "settings"},
%{id: "settings-editor", title: "Editor", icon: "📝", route: "settings"},
%{id: "settings-content", title: "Content", icon: "📋", route: "settings"},
%{id: "settings-ai", title: "AI", icon: "🤖", route: "settings"},
%{id: "settings-technology", title: "Technology", icon: "⚙️", route: "settings"},
%{id: "settings-publishing", title: "Publishing", icon: "🚀", route: "settings"},
%{id: "settings-data", title: "Data", icon: "🗄️", route: "settings"},
%{id: "settings-mcp", title: "MCP", icon: "🔌", route: "settings"},
%{id: "settings-style", title: "Style", icon: "🎨", route: "style"}
]
}
end
defp git_view do
%{
title: "Git",
subtitle: "Working tree and history",
layout: "entity_list",
empty_message: "No items",
items: [
%{id: "git-working-tree", title: "Working tree", meta: "Working tree and history", route: "git_diff", updated_at: nil}
]
}
end
defp entity_list_view(title, subtitle, route, items) do
%{
title: title,
subtitle: subtitle,
layout: "entity_list",
empty_message: "No items",
items:
Enum.map(items, fn item ->
%{
id: item.id,
title: item.title,
meta: Map.get(item, :meta),
updated_at: Map.get(item, :updated_at),
route: route
}
end)
}
end
defp build_post_section(title, status, posts, translation_counts, published_meta?) do
%{
id: Atom.to_string(status),
title: title,
status: Atom.to_string(status),
count: length(posts),
items:
Enum.map(posts, fn post ->
%{
id: post.id,
title: display_post_title(post),
categories: post.categories || [],
language_count: 1 + Map.get(translation_counts, post.id, 0),
meta_timestamp: if(published_meta?, do: post.published_at || post.updated_at, else: post.updated_at),
route: "post"
}
end)
}
end
defp list_posts(project_id) do
Repo.all(
from post in Post,
where: post.project_id == ^project_id,
order_by: [desc: post.updated_at, desc: post.created_at],
select: %{
id: post.id,
title: post.title,
slug: post.slug,
status: post.status,
categories: post.categories,
updated_at: post.updated_at,
published_at: post.published_at
}
)
end
defp translation_counts(project_id) do
Repo.all(
from translation in Translation,
where: translation.project_id == ^project_id,
group_by: translation.translation_for,
select: {translation.translation_for, count(translation.id)}
)
|> Map.new()
end
defp list_media(project_id) do
Repo.all(
from media in Media,
where: media.project_id == ^project_id,
order_by: [desc: media.updated_at, desc: media.created_at],
select: %{
id: media.id,
title: media.title,
original_name: media.original_name,
mime_type: media.mime_type,
size: media.size
}
)
end
defp list_scripts(project_id) do
Repo.all(
from script in Script,
where: script.project_id == ^project_id,
order_by: [desc: script.updated_at, desc: script.created_at],
select: %{id: script.id, title: script.title, updated_at: script.updated_at}
)
end
defp list_templates(project_id) do
Repo.all(
from template in Template,
where: template.project_id == ^project_id,
order_by: [desc: template.updated_at, desc: template.created_at],
select: %{id: template.id, title: template.title, updated_at: template.updated_at}
)
end
defp list_tags(project_id) do
Repo.all(
from tag in Tag,
where: tag.project_id == ^project_id,
order_by: [asc: tag.name],
select: %{id: tag.id, title: tag.name, updated_at: tag.updated_at}
)
end
defp list_conversations do
Repo.all(
from conversation in ChatConversation,
order_by: [desc: conversation.updated_at, desc: conversation.created_at],
select: %{id: conversation.id, title: conversation.title, updated_at: conversation.updated_at}
)
end
defp group_posts(posts) do
Enum.reduce(posts, %{draft: [], published: [], archived: []}, fn post, acc ->
case post.status do
:draft -> %{acc | draft: acc.draft ++ [post]}
:published -> %{acc | published: acc.published ++ [post]}
:archived -> %{acc | archived: acc.archived ++ [post]}
_other -> acc
end
end)
end
defp page_post?(post) do
Enum.any?(post.categories || [], &(String.downcase(to_string(&1)) == @page_category))
end
defp display_post_title(post) do
cond do
present?(post.title) -> post.title
present?(post.slug) -> post.slug
true -> "Untitled"
end
end
defp display_media_title(media) do
if present?(media.title), do: media.title, else: media.original_name || ""
end
defp media_size_label(size) when is_integer(size) and size < 1024, do: "#{size} B"
defp media_size_label(size) when is_integer(size) and size < 1024 * 1024 do
:erlang.float_to_binary(size / 1024, decimals: 1) <> " KB"
end
defp media_size_label(size) when is_integer(size) do
:erlang.float_to_binary(size / (1024 * 1024), decimals: 1) <> " MB"
end
defp media_size_label(_size), do: "0 B"
defp present?(value), do: value not in [nil, ""]
end