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())
|
|> 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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 [];
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user