Skip to content

Commit 3d432cb

Browse files
committed
Add custom-size QR code download on ad detail page (v2.3.135)
Admins can now enter a width in cm, see the computed pixel size at 300 DPI, and download a crisp upscaled QR code PNG for print materials. Uses ImageMagick nearest-neighbor interpolation to preserve sharp QR edges. Also fixes pre-existing credo nesting warning in maybe_regenerate_qr_code.
1 parent 5cb2213 commit 3d432cb

16 files changed

Lines changed: 725 additions & 412 deletions

File tree

lib/animina/ads/qr_code.ex

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,28 @@ defmodule Animina.Ads.QrCode do
4343
end
4444
end
4545

46+
@doc """
47+
Resizes a QR code PNG to the given pixel size using nearest-neighbor
48+
interpolation (crisp edges). Returns `{:ok, tmp_path}` or `{:error, reason}`.
49+
"""
50+
def resize(source_path, pixel_size) when is_integer(pixel_size) and pixel_size > 0 do
51+
with :ok <- check_dependencies() do
52+
tmp_path =
53+
Path.join(
54+
System.tmp_dir!(),
55+
"qr_resized_#{pixel_size}_#{System.unique_integer([:positive])}.png"
56+
)
57+
58+
size_str = "#{pixel_size}x#{pixel_size}"
59+
{cmd, args} = magick_cmd([source_path, "-filter", "point", "-resize", size_str, tmp_path])
60+
61+
case System.cmd(cmd, args, stderr_to_stdout: true) do
62+
{_, 0} -> {:ok, tmp_path}
63+
{err, _} -> {:error, "ImageMagick resize failed: #{err}"}
64+
end
65+
end
66+
end
67+
4668
@doc """
4769
Generates a QR code PNG for the given ad.
4870

lib/animina_web/controllers/ad_qr_controller.ex

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ defmodule AniminaWeb.AdQrController do
66
use AniminaWeb, :controller
77

88
alias Animina.Ads
9+
alias Animina.Ads.QrCode
910

1011
def download(conn, %{"id" => id}) do
1112
case Ads.get_ad(id) do
@@ -27,6 +28,41 @@ defmodule AniminaWeb.AdQrController do
2728
end
2829
end
2930

31+
def download_sized(conn, %{"id" => id, "size" => size_str}) do
32+
with {size, ""} <- Integer.parse(size_str),
33+
true <- size >= 100 and size <= 10_000,
34+
%{qr_code_path: path} when is_binary(path) <- Ads.get_ad(id),
35+
true <- File.exists?(path),
36+
{:ok, tmp_path} <- QrCode.resize(path, size) do
37+
conn =
38+
conn
39+
|> put_resp_header("content-type", "image/png")
40+
|> put_resp_header(
41+
"content-disposition",
42+
"attachment; filename=\"qr_#{size}x#{size}.png\""
43+
)
44+
|> send_file(200, tmp_path)
45+
46+
File.rm(tmp_path)
47+
conn
48+
else
49+
false ->
50+
conn |> put_status(:not_found) |> text("QR code file not found")
51+
52+
nil ->
53+
conn |> put_status(:not_found) |> text("Not found")
54+
55+
%{qr_code_path: _} ->
56+
conn |> put_status(:not_found) |> text("Not found")
57+
58+
{:error, reason} ->
59+
conn |> put_status(:unprocessable_entity) |> text("Resize failed: #{reason}")
60+
61+
_ ->
62+
conn |> put_status(:bad_request) |> text("Invalid size (must be 100–10000)")
63+
end
64+
end
65+
3066
def show(conn, %{"id" => id}) do
3167
case Ads.get_ad(id) do
3268
%{qr_code_path: path} when is_binary(path) ->

lib/animina_web/live/admin/ad_detail_live.ex

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ defmodule AniminaWeb.Admin.AdDetailLive do
66
alias Animina.Ads.QrCode
77
alias AniminaWeb.Layouts
88

9+
require Logger
10+
911
import AniminaWeb.Helpers.AdminHelpers, only: [parse_int: 2, format_datetime: 1]
1012

1113
use AniminaWeb.Helpers.PaginationHelpers, filter_events: []
@@ -22,6 +24,11 @@ defmodule AniminaWeb.Admin.AdDetailLive do
2224
|> push_navigate(to: ~p"/admin/ads")}
2325

2426
ad ->
27+
ad = maybe_regenerate_qr_code(ad)
28+
29+
default_cm = 5.0
30+
default_pixels = cm_to_pixels(default_cm)
31+
2532
{:ok,
2633
socket
2734
|> assign(
@@ -34,7 +41,9 @@ defmodule AniminaWeb.Admin.AdDetailLive do
3441
daily_counts: Ads.daily_visit_counts(ad.id),
3542
os_breakdown: Ads.visit_breakdown(ad.id, :os),
3643
browser_breakdown: Ads.visit_breakdown(ad.id, :browser),
37-
device_breakdown: Ads.visit_breakdown(ad.id, :device_type)
44+
device_breakdown: Ads.visit_breakdown(ad.id, :device_type),
45+
custom_cm: default_cm,
46+
custom_pixels: default_pixels
3847
)
3948
|> stream(:visits, [])}
4049
end
@@ -81,6 +90,46 @@ defmodule AniminaWeb.Admin.AdDetailLive do
8190
end
8291
end
8392

93+
@impl true
94+
def handle_event("update-qr-size", %{"cm" => cm_str}, socket) do
95+
case Float.parse(cm_str) do
96+
{cm, _} when cm >= 1.0 and cm <= 80.0 ->
97+
{:noreply, assign(socket, custom_cm: cm, custom_pixels: cm_to_pixels(cm))}
98+
99+
_ ->
100+
{:noreply, socket}
101+
end
102+
end
103+
104+
defp cm_to_pixels(cm), do: round(cm * 300 / 2.54)
105+
106+
defp maybe_regenerate_qr_code(%{qr_code_path: path} = ad)
107+
when is_binary(path) and path != "" do
108+
if File.exists?(path), do: ad, else: regenerate_qr_code(ad, path)
109+
end
110+
111+
defp maybe_regenerate_qr_code(ad), do: ad
112+
113+
defp regenerate_qr_code(ad, path) do
114+
Logger.warning("QR code file missing for ad ##{ad.number}, regenerating: #{path}")
115+
116+
with {:ok, new_path} <- QrCode.generate(ad),
117+
{:ok, updated_ad} <- Ads.update_qr_code_path(ad, new_path) do
118+
updated_ad
119+
else
120+
{:error, reason} ->
121+
Logger.error("Failed to regenerate QR code for ad ##{ad.number}: #{inspect(reason)}")
122+
clear_stale_qr_path(ad)
123+
end
124+
end
125+
126+
defp clear_stale_qr_path(ad) do
127+
case Ads.update_qr_code_path(ad, nil) do
128+
{:ok, cleared_ad} -> cleared_ad
129+
_ -> ad
130+
end
131+
end
132+
84133
defp build_path(socket, overrides) do
85134
ad = socket.assigns.ad
86135

@@ -155,6 +204,29 @@ defmodule AniminaWeb.Admin.AdDetailLive do
155204
<.icon name="hero-arrow-down-tray-mini" class="w-3 h-3" />
156205
{gettext("Download QR")}
157206
</a>
207+
<form phx-change="update-qr-size" class="mt-2 flex flex-col items-center gap-1">
208+
<label class="text-xs text-base-content/50">{gettext("Width (cm)")}</label>
209+
<input
210+
type="number"
211+
name="cm"
212+
value={@custom_cm}
213+
step="0.1"
214+
min="1"
215+
max="80"
216+
class="input input-xs input-bordered w-20 text-center"
217+
/>
218+
<span class="text-xs text-base-content/50">
219+
{@custom_pixels} × {@custom_pixels} px
220+
</span>
221+
<a
222+
href={~p"/admin/ads/#{@ad.id}/qr-code/sized?size=#{@custom_pixels}"}
223+
class="btn btn-outline btn-xs"
224+
download
225+
>
226+
<.icon name="hero-arrow-down-tray-mini" class="w-3 h-3" />
227+
{gettext("Download custom size")}
228+
</a>
229+
</form>
158230
</div>
159231
<div :if={!@ad.qr_code_path} class="flex flex-col items-center gap-2">
160232
<div class="w-32 h-32 bg-base-300 rounded flex items-center justify-center">

lib/animina_web/router.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,7 @@ defmodule AniminaWeb.Router do
190190
# Ad QR code serving (admin-only)
191191
get "/admin/ads/:id/qr-code", AdQrController, :download
192192
get "/admin/ads/:id/qr-code/show", AdQrController, :show
193+
get "/admin/ads/:id/qr-code/sized", AdQrController, :download_sized
193194

194195
post "/role/switch", RoleController, :switch
195196
post "/my/settings/update-password", UserSessionController, :update_password

mix.exs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ defmodule Animina.MixProject do
44
def project do
55
[
66
app: :animina,
7-
version: "2.3.134",
7+
version: "2.3.135",
88
elixir: "~> 1.19",
99
elixirc_paths: elixirc_paths(Mix.env()),
1010
start_permanent: Mix.env() == :prod,

0 commit comments

Comments
 (0)