feat: sidebar for media now shows thumbnails
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user