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" @default_page_size 500 def snapshot(nil), do: empty_snapshot() def snapshot(project_id) when is_binary(project_id) do %{ "posts" => view(project_id, "posts"), "pages" => view(project_id, "pages"), "media" => view(project_id, "media"), "scripts" => view(project_id, "scripts"), "templates" => view(project_id, "templates"), "tags" => view(project_id, "tags"), "chat" => view(project_id, "chat"), "import" => entity_list_view("Import", "Import definitions", "import", []), "git" => git_view(), "settings" => settings_nav_view() } end def view(project_id, view_id, params \\ %{}) def view(nil, view_id, _params), do: empty_view(view_id) def view(project_id, view_id, params) when is_binary(project_id) do normalized_view = normalize_view_id(view_id) case normalized_view do "posts" -> posts_view(project_id, params, false) "pages" -> posts_view(project_id, params, true) "media" -> media_view(project_id, params) "scripts" -> entity_list_view("Scripts", "Automation helpers", "scripts", list_scripts(project_id)) "templates" -> entity_list_view("Templates", "Site rendering", "templates", list_templates(project_id)) "tags" -> tags_nav_view(list_tags(project_id)) "chat" -> entity_list_view("Chat", "AI conversations", "chat", list_conversations()) "import" -> entity_list_view("Import", "Import definitions", "import", []) "git" -> git_view() "settings" -> settings_nav_view() _other -> empty_view(normalized_view) end end def empty_snapshot do %{ "posts" => empty_view("posts"), "pages" => empty_view("pages"), "media" => empty_view("media"), "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 empty_view("posts"), do: posts_view_data([], [], %{}, false, empty_filter_params(), %{}) defp empty_view("pages"), do: posts_view_data([], [], %{}, true, empty_filter_params(), %{}) defp empty_view("media"), do: media_view_data([], [], empty_filter_params(), %{}) defp empty_view("scripts"), do: entity_list_view("Scripts", "Automation helpers", "scripts", []) defp empty_view("templates"), do: entity_list_view("Templates", "Site rendering", "templates", []) defp empty_view("tags"), do: tags_nav_view([]) defp empty_view("chat"), do: entity_list_view("Chat", "AI conversations", "chat", []) defp empty_view("import"), do: entity_list_view("Import", "Import definitions", "import", []) defp empty_view("git"), do: git_view() defp empty_view("settings"), do: settings_nav_view() defp empty_view(_other), do: %{title: "", subtitle: "", layout: "entity_list", items: [], empty_message: "No items"} defp posts_view(project_id, params, pages?) do posts = list_posts(project_id) translation_counts = translation_counts(project_id) tag_colors = tag_color_map(project_id) filters = normalize_filter_params(params) base_posts = Enum.filter(posts, &(page_post?(&1) == pages?)) filtered_posts = apply_post_filters(base_posts, filters) posts_view_data(base_posts, filtered_posts, translation_counts, pages?, filters, tag_colors) end defp posts_view_data(base_posts, filtered_posts, translation_counts, pages?, filters, tag_colors) do limited_posts = Enum.take(filtered_posts, filters.display_limit) grouped_posts = group_posts(limited_posts) available_tags = available_tags(base_posts, & &1.tags) available_categories = available_categories(base_posts, pages?) %{ 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: "sidebar.noPagesYet", else: "sidebar.noPostsYet"), filters: %{ enabled: true, search_placeholder: if(pages?, do: "sidebar.searchPagesPlaceholder", else: "sidebar.searchPostsPlaceholder"), toggle_filters_label: "sidebar.toggleFilters", archive_label: "render.archive", tags_label: "sidebar.tags", categories_label: "sidebar.categories", clear_tags_label: "sidebar.clearTags", clear_categories_label: "sidebar.clearCategories", clear_filters_label: "sidebar.clearFilters", results_label: "sidebar.results", results_for_label: "sidebar.resultsFor", no_results_label: "sidebar.noMatchingPosts", year_month_counts: year_month_counts(base_posts, &post_filter_timestamp/1), available_tags: available_tags, available_tag_colors: Map.take(tag_colors, available_tags), available_categories: available_categories, max_items: @default_page_size, display_limit: filters.display_limit, loaded_count: length(limited_posts), total_count: length(filtered_posts), has_more: length(filtered_posts) > filters.display_limit, has_active_filters: filter_active?(filters), selected: %{ search: filters.search, year: filters.year, month: filters.month, tags: filters.tags, categories: filters.categories } }, 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(project_id, params) do media_items = list_media(project_id) tag_colors = tag_color_map(project_id) filters = normalize_filter_params(params) filtered_media = apply_media_filters(media_items, filters) media_view_data(media_items, filtered_media, filters, tag_colors) end defp media_view_data(base_media, filtered_media, filters, tag_colors) do limited_media = Enum.take(filtered_media, filters.display_limit) available_tags = available_tags(base_media, & &1.tags) %{ title: "Media", subtitle: "Images and files", layout: "media_grid", empty_message: "sidebar.noMediaFiles", filters: %{ enabled: true, search_placeholder: "sidebar.searchMediaPlaceholder", toggle_filters_label: "sidebar.toggleFilters", archive_label: "render.archive", tags_label: "sidebar.tags", clear_tags_label: "sidebar.clearTags", clear_filters_label: "sidebar.clearFilters", results_label: "sidebar.results", results_for_label: "sidebar.resultsFor", no_results_label: "sidebar.noMediaFiles", year_month_counts: year_month_counts(base_media, &Map.get(&1, :updated_at)), available_tags: available_tags, available_tag_colors: Map.take(tag_colors, available_tags), available_categories: [], max_items: @default_page_size, display_limit: filters.display_limit, loaded_count: length(limited_media), total_count: length(filtered_media), has_more: length(filtered_media) > filters.display_limit, has_active_filters: filter_active?(filters), selected: %{ search: filters.search, year: filters.year, month: filters.month, tags: filters.tags, categories: [] } }, items: Enum.map(limited_media, fn media -> %{ id: media.id, title: display_media_title(media), meta: media_size_label(media.size), mime_type: media.mime_type, route: "media", updated_at: media.updated_at, tags: media.tags || [], search_blob: media_search_blob(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 || [], tags: post.tags || [], status: Atom.to_string(post.status), 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", search_blob: post_search_blob(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, excerpt: post.excerpt, status: post.status, tags: post.tags, categories: post.categories, updated_at: post.updated_at, published_at: post.published_at, language: post.language } ) 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, tags: media.tags, alt: media.alt, caption: media.caption, updated_at: media.updated_at } ) 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 normalize_view_id(view_id) when is_atom(view_id), do: Atom.to_string(view_id) defp normalize_view_id(view_id) when is_binary(view_id), do: view_id defp normalize_view_id(_other), do: "" defp normalize_filter_params(params) when is_map(params) do %{ search: normalize_string(Map.get(params, "search") || Map.get(params, :search)), year: normalize_integer(Map.get(params, "year") || Map.get(params, :year)), month: normalize_integer(Map.get(params, "month") || Map.get(params, :month)), tags: normalize_string_list(Map.get(params, "tags") || Map.get(params, :tags)), categories: normalize_string_list(Map.get(params, "categories") || Map.get(params, :categories)), display_limit: max( @default_page_size, normalize_integer(Map.get(params, "display_limit") || Map.get(params, :display_limit)) || @default_page_size ) } end defp normalize_filter_params(_params), do: empty_filter_params() defp empty_filter_params do %{search: nil, year: nil, month: nil, tags: [], categories: [], display_limit: @default_page_size} end defp filter_active?(filters) do present?(filters.search) or not is_nil(filters.year) or filters.tags != [] or filters.categories != [] end defp apply_post_filters(posts, filters) do Enum.filter(posts, fn post -> matches_search?(post_search_blob(post), filters.search) and matches_year_month?(post_filter_timestamp(post), filters.year, filters.month) and matches_overlap?(post.tags, filters.tags) and matches_overlap?(filtered_categories(post.categories), filters.categories) end) end defp apply_media_filters(media_items, filters) do Enum.filter(media_items, fn media -> matches_search?(media_search_blob(media), filters.search) and matches_year_month?(media.updated_at, filters.year, filters.month) and matches_overlap?(media.tags, filters.tags) end) end defp matches_search?(_text, nil), do: true defp matches_search?(text, search) do String.contains?(String.downcase(text), String.downcase(search)) end defp matches_year_month?(_timestamp, nil, _month), do: true defp matches_year_month?(nil, _year, _month), do: false defp matches_year_month?(timestamp, year, month) do datetime = DateTime.from_unix!(timestamp, :millisecond) datetime.year == year and (is_nil(month) or datetime.month == month) end defp matches_overlap?(_values, []), do: true defp matches_overlap?(values, filters) do normalized_values = MapSet.new(Enum.map(values || [], &normalize_term/1)) Enum.all?(filters, fn filter -> MapSet.member?(normalized_values, normalize_term(filter)) end) end defp year_month_counts(items, timestamp_fun) do items |> Enum.reduce(%{}, fn item, acc -> case timestamp_fun.(item) do timestamp when is_integer(timestamp) -> datetime = DateTime.from_unix!(timestamp, :millisecond) Map.update(acc, {datetime.year, datetime.month}, 1, &(&1 + 1)) _other -> acc end end) |> Enum.map(fn {{year, month}, count} -> %{year: year, month: month, count: count} end) |> Enum.sort_by(fn entry -> {-entry.year, -entry.month} end) end defp available_tags(items, getter) do items |> Enum.flat_map(fn item -> getter.(item) || [] end) |> Enum.map(&to_string/1) |> Enum.reject(&(&1 == "")) |> Enum.uniq_by(&String.downcase/1) |> Enum.sort_by(&String.downcase/1) end defp available_categories(posts, pages?) do posts |> Enum.flat_map(&filtered_categories(&1.categories || [])) |> then(fn categories -> if pages?, do: Enum.reject(categories, &(normalize_term(&1) == @page_category)), else: categories end) |> Enum.map(&to_string/1) |> Enum.reject(&(&1 == "")) |> Enum.uniq_by(&String.downcase/1) |> Enum.sort_by(&String.downcase/1) end defp tag_color_map(project_id) do Repo.all(from tag in Tag, where: tag.project_id == ^project_id, select: {tag.name, tag.color}) |> Enum.reduce(%{}, fn {name, color}, acc -> case String.trim(to_string(color || "")) do "" -> acc trimmed -> Map.put(acc, to_string(name), trimmed) end end) end defp filtered_categories(categories) do Enum.reject(categories || [], &(normalize_term(&1) == @page_category)) end defp post_filter_timestamp(post), do: post.published_at || post.updated_at defp post_search_blob(post) do [post.title, post.slug, post.excerpt, Enum.join(post.tags || [], " "), Enum.join(post.categories || [], " ")] |> Enum.reject(&is_nil/1) |> Enum.join(" ") end defp media_search_blob(media) do [media.title, media.original_name, media.alt, media.caption, Enum.join(media.tags || [], " ")] |> Enum.reject(&is_nil/1) |> Enum.join(" ") end defp normalize_integer(nil), do: nil defp normalize_integer(value) when is_integer(value), do: value defp normalize_integer(value) when is_binary(value) do case Integer.parse(value) do {integer, ""} -> integer _other -> nil end end defp normalize_integer(_value), do: nil defp normalize_string(value) when is_binary(value) do trimmed = String.trim(value) if trimmed == "", do: nil, else: trimmed end defp normalize_string(_value), do: nil defp normalize_string_list(values) when is_list(values) do values |> Enum.map(&normalize_string/1) |> Enum.reject(&is_nil/1) end defp normalize_string_list(_values), do: [] defp normalize_term(value), do: value |> to_string() |> String.downcase() 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