feat: sidebar for media now shows thumbnails

This commit is contained in:
2026-04-25 21:08:39 +02:00
parent e02e5eb6f6
commit 8838b10403
6 changed files with 170 additions and 1 deletions

View File

@@ -42,6 +42,10 @@ defmodule BDS.Desktop.Router do
|> Plug.Conn.send_resp(200, BDS.Desktop.ShellController.projects_json()) |> Plug.Conn.send_resp(200, BDS.Desktop.ShellController.projects_json())
end end
get "/api/media-thumbnail/:media_id" do
BDS.Desktop.ShellController.media_thumbnail(conn, media_id, conn.params)
end
post "/api/sidebar" do post "/api/sidebar" do
{:ok, body, conn} = Plug.Conn.read_body(conn) {:ok, body, conn} = Plug.Conn.read_body(conn)
payload = if body == "", do: %{}, else: Jason.decode!(body) payload = if body == "", do: %{}, else: Jason.decode!(body)

View File

@@ -1,6 +1,10 @@
defmodule BDS.Desktop.ShellController do defmodule BDS.Desktop.ShellController do
@moduledoc false @moduledoc false
alias BDS.Media
alias BDS.Media.Media, as: MediaRecord
alias BDS.Projects
alias BDS.Repo
alias BDS.UI.Sidebar alias BDS.UI.Sidebar
def index_html do def index_html do
@@ -50,6 +54,18 @@ defmodule BDS.Desktop.ShellController do
end end
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 def sidebar_json(payload) when is_map(payload) do
view = Map.get(payload, "view") || Map.get(payload, :view) view = Map.get(payload, "view") || Map.get(payload, :view)
filters = Map.get(payload, "filters") || Map.get(payload, :filters) || %{} filters = Map.get(payload, "filters") || Map.get(payload, :filters) || %{}
@@ -187,6 +203,43 @@ defmodule BDS.Desktop.ShellController do
defp present?(value) when is_binary(value), do: String.trim(value) != "" defp present?(value) when is_binary(value), do: String.trim(value) != ""
defp present?(_value), do: false 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 defp blank_to_nil(value) when is_binary(value) do
trimmed = String.trim(value) trimmed = String.trim(value)
if trimmed == "", do: nil, else: trimmed if trimmed == "", do: nil, else: trimmed

View File

@@ -1636,6 +1636,36 @@ button {
font-size: 20px; font-size: 20px;
} }
.media-thumbnail.has-image {
position: relative;
}
.media-thumbnail-fallback {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.media-thumbnail-image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0;
transition: opacity 0.15s ease;
}
.media-thumbnail.is-loaded .media-thumbnail-image {
opacity: 1;
}
.media-thumbnail.is-loaded .media-thumbnail-fallback {
opacity: 0;
}
.media-item-info { .media-item-info {
flex: 1; flex: 1;
min-width: 0; min-width: 0;

View File

@@ -439,6 +439,7 @@ function renderSidebarMediaItem(item, view) {
const itemRoute = item.route || view.editor_route; const itemRoute = item.route || view.editor_route;
const tabId = tabIdForItem(item, itemRoute); const tabId = tabIdForItem(item, itemRoute);
const active = tabRef && tabRef.type === itemRoute && tabRef.id === tabId; const active = tabRef && tabRef.type === itemRoute && tabRef.id === tabId;
const thumbnail = renderMediaThumbnail(item);
return ` return `
<button <button
@@ -449,7 +450,7 @@ function renderSidebarMediaItem(item, view) {
type="button" type="button"
title="${escapeHtmlAttribute(item.title || "") }" title="${escapeHtmlAttribute(item.title || "") }"
> >
<span class="media-thumbnail">${escapeHtml(mediaThumbnailGlyph(item.mime_type))}</span> ${thumbnail}
<span class="media-item-info"> <span class="media-item-info">
<span class="media-item-name">${escapeHtml(item.title || "")}</span> <span class="media-item-name">${escapeHtml(item.title || "")}</span>
<span class="media-item-size">${escapeHtml(item.meta || "")}</span> <span class="media-item-size">${escapeHtml(item.meta || "")}</span>
@@ -458,6 +459,28 @@ function renderSidebarMediaItem(item, view) {
`; `;
} }
function renderMediaThumbnail(item) {
const fallback = escapeHtml(mediaThumbnailGlyph(item.mime_type));
if (!String(item.mime_type || "").startsWith("image/")) {
return `<span class="media-thumbnail"><span class="media-thumbnail-fallback">${fallback}</span></span>`;
}
return `
<span class="media-thumbnail has-image" data-media-thumbnail-state="pending">
<span class="media-thumbnail-fallback">${fallback}</span>
<img
class="media-thumbnail-image"
data-media-thumbnail-image
src="${escapeHtmlAttribute(mediaThumbnailUrl(item.id))}"
alt=""
loading="lazy"
decoding="async"
>
</span>
`;
}
function renderSidebarEntityList(data, view) { function renderSidebarEntityList(data, view) {
const items = Array.isArray(data.items) ? data.items : []; const items = Array.isArray(data.items) ? data.items : [];
@@ -1258,6 +1281,25 @@ function bindEvents() {
}; };
}); });
root.querySelectorAll("[data-media-thumbnail-image]").forEach((image) => {
const container = image.closest(".media-thumbnail");
image.onload = () => {
container?.classList.add("is-loaded");
container?.classList.remove("is-error");
};
image.onerror = () => {
container?.classList.add("is-error");
container?.classList.remove("is-loaded");
};
if (image.complete && image.naturalWidth > 0) {
container?.classList.add("is-loaded");
container?.classList.remove("is-error");
}
});
root.querySelectorAll("[data-open-tab]").forEach((button) => { root.querySelectorAll("[data-open-tab]").forEach((button) => {
button.onclick = () => { button.onclick = () => {
openTab(button.dataset.openRoute, button.dataset.openTab, button.dataset.openTitle, true); openTab(button.dataset.openRoute, button.dataset.openTab, button.dataset.openTitle, true);
@@ -2420,6 +2462,10 @@ function mediaThumbnailGlyph(mimeType) {
return "📄"; return "📄";
} }
function mediaThumbnailUrl(mediaId) {
return `/api/media-thumbnail/${encodeURIComponent(mediaId)}`;
}
function buildDashboardTagCloudItems(items) { function buildDashboardTagCloudItems(items) {
if (!Array.isArray(items) || !items.length) { if (!Array.isArray(items) || !items.length) {
return []; return [];

View File

@@ -312,12 +312,44 @@ defmodule BDS.DesktopTest do
assert payload["result"]["url"] == "http://127.0.0.1:4123/" assert payload["result"]["url"] == "http://127.0.0.1:4123/"
end end
test "desktop router serves active-project media thumbnails for the sidebar" do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
temp_dir = Path.join(System.tmp_dir!(), "bds-desktop-thumbnail-#{System.unique_integer([:positive])}")
File.mkdir_p!(temp_dir)
on_exit(fn ->
File.rm_rf(temp_dir)
end)
{:ok, project} = BDS.Projects.create_project(%{name: "Desktop Thumbnails", data_path: temp_dir})
{:ok, _active} = BDS.Projects.set_active_project(project.id)
source_path = Path.join(temp_dir, "sample.jpg")
File.write!(source_path, tiny_jpeg_binary())
assert {:ok, media} = BDS.Media.import_media(%{project_id: project.id, source_path: source_path})
conn = conn(:get, "/api/media-thumbnail/#{media.id}?k=#{Desktop.Auth.login_key()}")
conn = BDS.Desktop.Router.call(conn, BDS.Desktop.Router.init([]))
assert conn.status == 200
assert [content_type] = Plug.Conn.get_resp_header(conn, "content-type")
assert String.starts_with?(content_type, "image/webp")
assert byte_size(conn.resp_body) > 0
end
defp menu_item(groups, id) do defp menu_item(groups, id) do
groups groups
|> Enum.flat_map(& &1.items) |> Enum.flat_map(& &1.items)
|> Enum.find(&Map.get(&1, :id) == id) |> Enum.find(&Map.get(&1, :id) == id)
end end
defp tiny_jpeg_binary do
Image.new!(3, 2, color: [255, 0, 0])
|> Image.write!(:memory, suffix: ".jpg", quality: 85)
end
defp wait_for_task(task_id, matcher, timeout \\ 2_000) defp wait_for_task(task_id, matcher, timeout \\ 2_000)
defp wait_for_task(task_id, _matcher, timeout) when timeout <= 0 do defp wait_for_task(task_id, _matcher, timeout) when timeout <= 0 do

View File

@@ -199,12 +199,16 @@ defmodule BDS.UI.ShellTest do
assert js =~ "renderSidebarFilterStatus" assert js =~ "renderSidebarFilterStatus"
assert js =~ "applySidebarPostFilters" assert js =~ "applySidebarPostFilters"
assert js =~ "applySidebarMediaFilters" assert js =~ "applySidebarMediaFilters"
assert js =~ "media-thumbnail-image"
assert js =~ "/api/media-thumbnail/"
assert js =~ "loading=\"lazy\""
assert css =~ ".search-box" assert css =~ ".search-box"
assert css =~ ".filter-panel" assert css =~ ".filter-panel"
assert css =~ ".calendar-view" assert css =~ ".calendar-view"
assert css =~ ".filter-chip" assert css =~ ".filter-chip"
assert css =~ ".filter-status" assert css =~ ".filter-status"
assert css =~ ".media-thumbnail-image"
end end
test "clearing sidebar filters resets from the baseline seed instead of the filtered payload" do test "clearing sidebar filters resets from the baseline seed instead of the filtered payload" do