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())
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)

View File

@@ -1,6 +1,10 @@
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
@@ -50,6 +54,18 @@ defmodule BDS.Desktop.ShellController do
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) || %{}
@@ -187,6 +203,43 @@ defmodule BDS.Desktop.ShellController do
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

View File

@@ -1636,6 +1636,36 @@ button {
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 {
flex: 1;
min-width: 0;

View File

@@ -439,6 +439,7 @@ function renderSidebarMediaItem(item, view) {
const itemRoute = item.route || view.editor_route;
const tabId = tabIdForItem(item, itemRoute);
const active = tabRef && tabRef.type === itemRoute && tabRef.id === tabId;
const thumbnail = renderMediaThumbnail(item);
return `
<button
@@ -449,7 +450,7 @@ function renderSidebarMediaItem(item, view) {
type="button"
title="${escapeHtmlAttribute(item.title || "") }"
>
<span class="media-thumbnail">${escapeHtml(mediaThumbnailGlyph(item.mime_type))}</span>
${thumbnail}
<span class="media-item-info">
<span class="media-item-name">${escapeHtml(item.title || "")}</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) {
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) => {
button.onclick = () => {
openTab(button.dataset.openRoute, button.dataset.openTab, button.dataset.openTitle, true);
@@ -2420,6 +2462,10 @@ function mediaThumbnailGlyph(mimeType) {
return "📄";
}
function mediaThumbnailUrl(mediaId) {
return `/api/media-thumbnail/${encodeURIComponent(mediaId)}`;
}
function buildDashboardTagCloudItems(items) {
if (!Array.isArray(items) || !items.length) {
return [];

View File

@@ -312,12 +312,44 @@ defmodule BDS.DesktopTest do
assert payload["result"]["url"] == "http://127.0.0.1:4123/"
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
groups
|> Enum.flat_map(& &1.items)
|> Enum.find(&Map.get(&1, :id) == id)
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) when timeout <= 0 do

View File

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