diff --git a/lib/bds/desktop/router.ex b/lib/bds/desktop/router.ex index 6c177d6..7a49184 100644 --- a/lib/bds/desktop/router.ex +++ b/lib/bds/desktop/router.ex @@ -42,6 +42,15 @@ defmodule BDS.Desktop.Router do |> Plug.Conn.send_resp(200, BDS.Desktop.ShellController.projects_json()) 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) diff --git a/lib/bds/desktop/shell_controller.ex b/lib/bds/desktop/shell_controller.ex index 98d8f1d..11b4192 100644 --- a/lib/bds/desktop/shell_controller.ex +++ b/lib/bds/desktop/shell_controller.ex @@ -1,6 +1,8 @@ defmodule BDS.Desktop.ShellController do @moduledoc false + alias BDS.UI.Sidebar + def index_html do BDS.UI.ShellPage.render() end @@ -48,6 +50,28 @@ defmodule BDS.Desktop.ShellController do 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)} @@ -149,6 +173,17 @@ defmodule BDS.Desktop.ShellController do } 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 diff --git a/lib/bds/ui/sidebar.ex b/lib/bds/ui/sidebar.ex index 448032a..233d633 100644 --- a/lib/bds/ui/sidebar.ex +++ b/lib/bds/ui/sidebar.ex @@ -13,38 +13,52 @@ defmodule BDS.UI.Sidebar do 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 = 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), + "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" => posts_view([], %{}, false), - "pages" => posts_view([], %{}, true), - "media" => media_view([]), + "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([]), @@ -55,15 +69,67 @@ defmodule BDS.UI.Sidebar do } end - defp posts_view(posts, translation_counts, pages?) do - filtered_posts = Enum.filter(posts, &(page_post?(&1) == pages?)) - grouped_posts = group_posts(filtered_posts) + 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) + 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) + end + + defp posts_view_data(base_posts, filtered_posts, translation_counts, pages?, filters) do + limited_posts = Enum.take(filtered_posts, filters.display_limit) + grouped_posts = group_posts(limited_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"), + 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(base_posts, & &1.tags), + available_categories: available_categories(base_posts, pages?), + 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), @@ -72,20 +138,61 @@ defmodule BDS.UI.Sidebar do } end - defp media_view(media_items) do + defp media_view(project_id, params) do + media_items = list_media(project_id) + filters = normalize_filter_params(params) + filtered_media = apply_media_filters(media_items, filters) + + media_view_data(media_items, filtered_media, filters) + end + + defp media_view_data(base_media, filtered_media, filters) do + limited_media = Enum.take(filtered_media, filters.display_limit) + %{ title: "Media", subtitle: "Images and files", layout: "media_grid", - empty_message: "No items", + 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(base_media, & &1.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(media_items, fn media -> + 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" + route: "media", + updated_at: media.updated_at, + tags: media.tags || [], + search_blob: media_search_blob(media) } end) } @@ -167,9 +274,12 @@ defmodule BDS.UI.Sidebar do 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" + route: "post", + search_blob: post_search_blob(post) } end) } @@ -184,10 +294,13 @@ defmodule BDS.UI.Sidebar do 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 + published_at: post.published_at, + language: post.language } ) end @@ -212,7 +325,11 @@ defmodule BDS.UI.Sidebar do title: media.title, original_name: media.original_name, mime_type: media.mime_type, - size: media.size + size: media.size, + tags: media.tags, + alt: media.alt, + caption: media.caption, + updated_at: media.updated_at } ) end @@ -267,6 +384,162 @@ defmodule BDS.UI.Sidebar 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 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 diff --git a/priv/i18n/locales/de.json b/priv/i18n/locales/de.json index 62c6de7..6010149 100644 --- a/priv/i18n/locales/de.json +++ b/priv/i18n/locales/de.json @@ -45,6 +45,25 @@ "render.video.vimeoTitle": "Vimeo-Video", "render.video.youtubeTitle": "YouTube-Video", "sidebar.chat.yesterday": "Gestern", + "sidebar.tags": "Schlagwörter", + "sidebar.categories": "Kategorien", + "sidebar.clearTags": "Tags löschen", + "sidebar.clearCategories": "Kategorien löschen", + "sidebar.noPostsYet": "Noch keine Beiträge", + "sidebar.noPagesYet": "Noch keine Seiten", + "sidebar.noMediaYet": "Noch keine Medien", + "sidebar.search": "Suchen", + "sidebar.searchPostsPlaceholder": "Beiträge durchsuchen...", + "sidebar.searchPagesPlaceholder": "Seiten durchsuchen...", + "sidebar.searchMediaPlaceholder": "Medien durchsuchen...", + "sidebar.toggleFilters": "Filter umschalten", + "sidebar.results": "%{count} Ergebnisse", + "sidebar.resultsFor": "%{count} Ergebnisse für \"%{query}\"", + "sidebar.clearFilters": "Filter löschen", + "sidebar.noMatchingPosts": "Keine passenden Beiträge", + "sidebar.loadMore": "Mehr laden (%{loaded} von %{total})", + "sidebar.loading": "Lädt...", + "sidebar.noMediaFiles": "Keine Mediendateien", "%{count} media": "%{count} Medien", "%{count} posts": "%{count} Beiträge", "2 langs": "2 Sprachen", diff --git a/priv/i18n/locales/en.json b/priv/i18n/locales/en.json index 32ef9d0..4705955 100644 --- a/priv/i18n/locales/en.json +++ b/priv/i18n/locales/en.json @@ -45,6 +45,25 @@ "render.video.vimeoTitle": "Vimeo video", "render.video.youtubeTitle": "YouTube video", "sidebar.chat.yesterday": "Yesterday", + "sidebar.tags": "Tags", + "sidebar.categories": "Categories", + "sidebar.clearTags": "Clear tags", + "sidebar.clearCategories": "Clear categories", + "sidebar.noPostsYet": "No posts yet", + "sidebar.noPagesYet": "No pages yet", + "sidebar.noMediaYet": "No media yet", + "sidebar.search": "Search", + "sidebar.searchPostsPlaceholder": "Search posts...", + "sidebar.searchPagesPlaceholder": "Search pages...", + "sidebar.searchMediaPlaceholder": "Search media...", + "sidebar.toggleFilters": "Toggle Filters", + "sidebar.results": "%{count} results", + "sidebar.resultsFor": "%{count} results for \"%{query}\"", + "sidebar.clearFilters": "Clear filters", + "sidebar.noMatchingPosts": "No matching posts", + "sidebar.loadMore": "Load more (%{loaded} of %{total})", + "sidebar.loading": "Loading...", + "sidebar.noMediaFiles": "No media files", "%{count} media": "%{count} media", "%{count} posts": "%{count} posts", "2 langs": "2 langs", diff --git a/priv/i18n/locales/es.json b/priv/i18n/locales/es.json index 401d396..e9519b0 100644 --- a/priv/i18n/locales/es.json +++ b/priv/i18n/locales/es.json @@ -45,6 +45,25 @@ "render.video.vimeoTitle": "Vídeo de Vimeo", "render.video.youtubeTitle": "Vídeo de YouTube", "sidebar.chat.yesterday": "Ayer", + "sidebar.tags": "Etiquetas", + "sidebar.categories": "Categorías", + "sidebar.clearTags": "Limpiar etiquetas", + "sidebar.clearCategories": "Limpiar categorías", + "sidebar.noPostsYet": "Aún no hay entradas", + "sidebar.noPagesYet": "Aún no hay páginas", + "sidebar.noMediaYet": "Aún no hay medios", + "sidebar.search": "Buscar", + "sidebar.searchPostsPlaceholder": "Buscar entradas...", + "sidebar.searchPagesPlaceholder": "Buscar páginas...", + "sidebar.searchMediaPlaceholder": "Buscar medios...", + "sidebar.toggleFilters": "Alternar filtros", + "sidebar.results": "%{count} resultados", + "sidebar.resultsFor": "%{count} resultados para \"%{query}\"", + "sidebar.clearFilters": "Limpiar filtros", + "sidebar.noMatchingPosts": "No hay entradas coincidentes", + "sidebar.loadMore": "Cargar más (%{loaded} de %{total})", + "sidebar.loading": "Cargando...", + "sidebar.noMediaFiles": "No hay archivos multimedia", "%{count} media": "%{count} medios", "%{count} posts": "%{count} publicaciones", "2 langs": "2 idiomas", diff --git a/priv/i18n/locales/fr.json b/priv/i18n/locales/fr.json index 698934a..5ca50c8 100644 --- a/priv/i18n/locales/fr.json +++ b/priv/i18n/locales/fr.json @@ -45,6 +45,25 @@ "render.video.vimeoTitle": "Vidéo Vimeo", "render.video.youtubeTitle": "Vidéo YouTube", "sidebar.chat.yesterday": "Hier", + "sidebar.tags": "Étiquettes", + "sidebar.categories": "Catégories", + "sidebar.clearTags": "Effacer les étiquettes", + "sidebar.clearCategories": "Effacer les catégories", + "sidebar.noPostsYet": "Aucun article pour le moment", + "sidebar.noPagesYet": "Aucune page pour le moment", + "sidebar.noMediaYet": "Aucun média pour le moment", + "sidebar.search": "Rechercher", + "sidebar.searchPostsPlaceholder": "Rechercher des articles...", + "sidebar.searchPagesPlaceholder": "Rechercher des pages...", + "sidebar.searchMediaPlaceholder": "Rechercher des médias...", + "sidebar.toggleFilters": "Afficher/masquer les filtres", + "sidebar.results": "%{count} résultats", + "sidebar.resultsFor": "%{count} résultats pour \"%{query}\"", + "sidebar.clearFilters": "Effacer les filtres", + "sidebar.noMatchingPosts": "Aucun article correspondant", + "sidebar.loadMore": "Charger plus (%{loaded} sur %{total})", + "sidebar.loading": "Chargement...", + "sidebar.noMediaFiles": "Aucun fichier média", "%{count} media": "%{count} médias", "%{count} posts": "%{count} articles", "2 langs": "2 langues", diff --git a/priv/i18n/locales/it.json b/priv/i18n/locales/it.json index 0fa6f49..99e9131 100644 --- a/priv/i18n/locales/it.json +++ b/priv/i18n/locales/it.json @@ -45,6 +45,25 @@ "render.video.vimeoTitle": "Video Vimeo", "render.video.youtubeTitle": "Video YouTube", "sidebar.chat.yesterday": "Ieri", + "sidebar.tags": "Tag", + "sidebar.categories": "Categorie", + "sidebar.clearTags": "Cancella tag", + "sidebar.clearCategories": "Cancella categorie", + "sidebar.noPostsYet": "Nessun post", + "sidebar.noPagesYet": "Nessuna pagina", + "sidebar.noMediaYet": "Nessun media", + "sidebar.search": "Cerca", + "sidebar.searchPostsPlaceholder": "Cerca post...", + "sidebar.searchPagesPlaceholder": "Cerca pagine...", + "sidebar.searchMediaPlaceholder": "Cerca media...", + "sidebar.toggleFilters": "Mostra/nascondi filtri", + "sidebar.results": "%{count} risultati", + "sidebar.resultsFor": "%{count} risultati per \"%{query}\"", + "sidebar.clearFilters": "Cancella filtri", + "sidebar.noMatchingPosts": "Nessun post corrispondente", + "sidebar.loadMore": "Carica altro (%{loaded} di %{total})", + "sidebar.loading": "Caricamento...", + "sidebar.noMediaFiles": "Nessun file multimediale", "%{count} media": "%{count} media", "%{count} posts": "%{count} post", "2 langs": "2 lingue", diff --git a/priv/ui/app.css b/priv/ui/app.css index fba482a..c02bf4a 100644 --- a/priv/ui/app.css +++ b/priv/ui/app.css @@ -1563,6 +1563,201 @@ button { font-size: 28px; } +.sidebar-actions { + display: flex; + align-items: center; + gap: 6px; +} + +.sidebar-action { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--vscode-descriptionForeground); + cursor: pointer; +} + +.sidebar-action:hover, +.sidebar-action.active { + background: var(--vscode-toolbar-hoverBackground); + color: var(--vscode-foreground); +} + +.search-box { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + gap: 6px; + padding: 10px 12px 0; +} + +.search-box input { + min-width: 0; + border: 1px solid var(--vscode-input-border); + border-radius: 6px; + background: var(--vscode-input-background); + color: var(--vscode-foreground); + padding: 7px 10px; +} + +.search-box button, +.clear-filter, +.filter-status button, +.load-more-button, +.calendar-year-header, +.calendar-month, +.filter-header, +.filter-chip { + border: none; + cursor: pointer; +} + +.search-box button, +.clear-search { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 30px; + height: 30px; + padding: 0 8px; + border-radius: 6px; + background: var(--vscode-input-background); + color: var(--vscode-foreground); +} + +.search-box button:hover, +.clear-search:hover, +.clear-filter:hover, +.load-more-button:hover, +.calendar-year-header:hover, +.calendar-month:hover, +.filter-header:hover, +.filter-chip:hover { + background: var(--vscode-list-hoverBackground); +} + +.calendar-view, +.filter-panel, +.filter-status, +.sidebar-load-more { + padding: 10px 12px 0; +} + +.collapsible-header { + width: 100%; + display: flex; + align-items: center; + gap: 8px; + padding: 6px 0; + background: transparent; + color: var(--vscode-foreground); + text-align: left; +} + +.collapse-icon { + width: 12px; + color: var(--vscode-descriptionForeground); +} + +.calendar-years, +.calendar-months, +.filter-chips { + display: flex; + flex-direction: column; + gap: 6px; + padding-top: 6px; +} + +.calendar-year-header, +.calendar-month { + width: 100%; + display: flex; + align-items: center; + gap: 8px; + padding: 6px 8px; + border-radius: 6px; + background: transparent; + color: var(--vscode-foreground); + text-align: left; +} + +.calendar-year-header.selected, +.calendar-month.selected, +.filter-chip.active { + background: var(--vscode-list-activeSelectionBackground); + color: var(--vscode-list-activeSelectionForeground); +} + +.year-count, +.month-count, +.sidebar-section-count { + margin-left: auto; + color: var(--vscode-descriptionForeground); + font-size: 11px; +} + +.month-label, +.year-label { + flex: 1; +} + +.calendar-months { + padding-left: 18px; +} + +.filter-section { + padding-top: 4px; +} + +.filter-header { + width: 100%; + padding: 6px 0; + background: transparent; + color: var(--vscode-foreground); + text-align: left; +} + +.filter-chips { + flex-direction: row; + flex-wrap: wrap; +} + +.filter-chip { + padding: 5px 10px; + border-radius: 999px; + background: var(--vscode-input-background); + color: var(--vscode-foreground); +} + +.filter-status { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + color: var(--vscode-descriptionForeground); + font-size: 12px; +} + +.filter-status button, +.load-more-button { + padding: 6px 10px; + border-radius: 6px; + background: var(--vscode-input-background); + color: var(--vscode-foreground); +} + +.sidebar-load-more { + padding-bottom: 12px; +} + +.load-more-button { + width: 100%; +} + .media-item-info { display: flex; flex-direction: column; diff --git a/priv/ui/app.js b/priv/ui/app.js index 9d14810..64319f3 100644 --- a/priv/ui/app.js +++ b/priv/ui/app.js @@ -14,6 +14,8 @@ const state = { session: hydrateSession(clone(bootstrap.session)), status: clone(bootstrap.status), projects: normalizeProjects(bootstrap.projects), + sidebarContent: clone(bootstrap.content.sidebar), + sidebarFilters: hydrateSidebarFilters(bootstrap.content.sidebar), projectMenuOpen: false, taskStatus: normalizeTaskStatus(bootstrap.task_status), handledTaskResults: {}, @@ -135,6 +137,7 @@ function renderActivityButton(view) { function renderSidebar() { const view = currentSidebarView(); const data = currentSidebarData(); + const filterState = currentSidebarFilterState(view.id); root.querySelector(".sidebar").innerHTML = ` + ${data.filters?.enabled ? ` + + ` : ""} -