feat: support versioned plugin details embeds

This commit is contained in:
thgO.O 2026-05-21 23:02:50 -03:00
parent 026a1a0402
commit 42fd774fda
No known key found for this signature in database
GPG Key ID: EDC540B3D14756CB
3 changed files with 170 additions and 108 deletions

View File

@ -322,7 +322,12 @@ public class HomeController(
public async Task<IActionResult> GetPluginDetails(
[ModelBinder(typeof(PluginSlugModelBinder))]
PluginSlug pluginSlug,
[FromQuery] PluginDetailsViewModel? model)
[ModelBinder(typeof(PluginVersionModelBinder))]
PluginVersion? version = null,
[ModelBinder(typeof(BtcPayHostVersionModelBinder))]
PluginVersion? btcpayVersion = null,
bool includePreRelease = false,
[FromQuery] PluginDetailsViewModel? model = null)
{
if (pluginSlug is null)
return NotFound();
@ -341,9 +346,27 @@ public class HomeController(
? " (hv.up_count - hv.down_count) DESC, r.created_at DESC "
: " r.created_at DESC ";
var versionFilter = version is null ? string.Empty : "AND v.ver = @version";
var versionSource = btcpayVersion is null
? "versions v"
: "get_all_versions(@btcpayVersion, @includePreRelease) gv JOIN versions v ON v.plugin_slug = gv.plugin_slug AND v.ver = gv.ver";
var versionsQuery = btcpayVersion is null
? """
SELECT array_agg(array_to_string(ver, '.') ORDER BY ver DESC)
FROM versions
WHERE plugin_slug = v.plugin_slug
"""
: """
SELECT array_agg(array_to_string(gv.ver, '.') ORDER BY gv.ver DESC)
FROM get_all_versions(@btcpayVersion, @includePreRelease) gv
WHERE gv.plugin_slug = v.plugin_slug
""";
var prms = new
{
pluginSlug = pluginSlug.ToString(),
version = version?.VersionParts,
btcpayVersion = btcpayVersion?.VersionParts,
includePreRelease,
currentUserId = userId,
isAdmin,
skip = model.Skip,
@ -369,15 +392,12 @@ public class HomeController(
WHERE b2.plugin_slug = v.plugin_slug
ORDER BY b2.id ASC
LIMIT 1) AS created_at,
(
SELECT array_agg(array_to_string(ver, '.') ORDER BY ver DESC)
FROM versions
WHERE plugin_slug = v.plugin_slug
) AS versions
FROM versions v
(" + versionsQuery + @") AS versions
FROM " + versionSource + @"
JOIN builds b ON b.plugin_slug = v.plugin_slug AND b.id = v.build_id
JOIN plugins p ON b.plugin_slug = p.slug
WHERE v.plugin_slug = @pluginSlug
" + versionFilter + @"
AND b.manifest_info IS NOT NULL
AND b.build_info IS NOT NULL
AND (

View File

@ -16,6 +16,8 @@
var writeReviewUrl = Model.EmbedMode ? $"{pluginUrl}#write-review" : "#write-review";
var writeReviewTarget = Model.EmbedMode ? "_blank" : null;
var writeReviewRel = Model.EmbedMode ? "noopener noreferrer" : null;
var btcpayVersionRoute = Context.Request.Query["btcpayVersion"].ToString();
var includePreReleaseRoute = Context.Request.Query["includePreRelease"].ToString();
var currentRating = Model.RatingFilter;
var containerClass = Model.EmbedMode ? "container-fluid px-0" : "container";
DateTimeOffset.TryParse(Model.Plugin.BuildInfo?["buildDate"]?.ToString(), out var buildDate);
@ -106,7 +108,112 @@
<p>@Model.Plugin.Description</p>
</div>
</div>
</div>
<div class="col-12 col-lg-4 mt-4 mt-lg-0">
<div class="card">
<div class="card-body">
<div class="row mb-3">
<div class="col-6 d-flex align-items-center"><strong>Version</strong></div>
<div class="col-6 text-end">
<div class="dropdown d-inline-block">
<button id="version-dropdown-btn" class="btn btn-secondary btn-sm dropdown-toggle" type="button"
data-bs-toggle="dropdown" aria-expanded="false">
@string.Join(".", Model.Plugin.Version.Split('.').Take(3))
</button>
<ul class="dropdown-menu dropdown-menu-end" style="max-height: 140px; overflow-y: auto;">
@foreach (var v in Model.PluginVersions)
{
var shortV = string.Join(".", v.Split('.').Take(3));
<li>
<a class="dropdown-item @(v == Model.Plugin.Version ? "active" : "")"
href="#" data-version="@v" data-display="@shortV">@shortV</a>
</li>
}
</ul>
</div>
<input type="hidden" id="version-selector" name="Plugin.Version" value="@Model.Plugin.Version" />
</div>
</div>
<div class="row mb-3">
<div class="col-6"><strong>Published</strong></div>
<div class="col-6 text-end">
<span id="plugin-published-date">@(buildDate != default ? buildDate.ToString("MMM dd, yyyy") : "")</span>
</div>
</div>
<div id="plugin-repository-row" class="row mb-3 @(string.IsNullOrEmpty(sourceUrl) ? "d-none" : "")">
<div class="col-6"><strong>Repository</strong></div>
<div class="col-6 text-end">
<a id="plugin-source-url" href="@sourceUrl" target="_blank" rel="noopener noreferrer">View Source</a>
</div>
</div>
@if (!string.IsNullOrEmpty(Model.Plugin.Documentation))
{
<div class="row mb-3">
<div class="col-6"><strong>Documentation</strong></div>
<div class="col-6 text-end">
<a href="@Model.Plugin.Documentation" target="_blank" rel="noopener noreferrer">View Documentation</a>
</div>
</div>
}
<div id="plugin-min-btcpay-row" class="row mb-3 @(string.IsNullOrEmpty(Model.Plugin.BTCPayMinVersion) ? "d-none" : "")">
<div class="col-6"><strong>Min. BTCPay Version</strong></div>
<div class="col-6 text-end">
<span id="plugin-min-btcpay-version">@Model.Plugin.BTCPayMinVersion</span>
</div>
</div>
<div id="plugin-max-btcpay-row" class="row mb-3 @(string.IsNullOrEmpty(Model.Plugin.BTCPayMaxVersion) ? "d-none" : "")">
<div class="col-6"><strong>Max. BTCPay Version</strong></div>
<div class="col-6 text-end">
<span id="plugin-max-btcpay-version">@Model.Plugin.BTCPayMaxVersion</span>
</div>
</div>
@if (dependencies != null)
{
foreach (var dep in dependencies)
{
if (string.Equals(dep["Identifier"]?.ToString(), "BTCPayServer", StringComparison.OrdinalIgnoreCase)) continue;
<div class="row mb-3">
<div class="col-6"><strong>@dep["Identifier"]</strong></div>
<div class="col-6 text-end">@dep["Condition"]</div>
</div>
}
}
<div class="row mb-3">
<div class="col-6"><strong>Created</strong></div>
<div class="col-6 text-end">@Model.Plugin.CreatedDate.ToString("MMM dd, yyyy")</div>
</div>
<div class="row mb-3">
<div class="col-6"><strong>Rating</strong></div>
<div class="col-6 text-end">
<span class="d-inline-flex align-items-center gap-1 lh-1">
<span class="fw-semibold">@((Model.Plugin.RatingSummary?.Average ?? 0m).ToString("0.0"))</span>
<span class="text-warning"><vc:icon symbol="star-fill" /></span>
<span class="text-muted small">(@(Model.Plugin.RatingSummary?.TotalReviews ?? 0))</span>
</span>
</div>
</div>
@if (!Model.EmbedMode)
{
<a id="download-btn"
asp-action="Download" asp-controller="Api"
asp-route-pluginSlug="@Model.Plugin.ProjectSlug"
asp-route-version="@Model.Plugin.Version"
class="btn btn-primary w-100" target="_blank" rel="noopener noreferrer">Download</a>
}
else
{
<button type="button"
id="btcpay-install-plugin-btn"
class="btn btn-primary w-100"
data-plugin-install
disabled="@(string.IsNullOrWhiteSpace(pluginIdentifier) ? "disabled" : null)">Install in BTCPay Server</button>
}
</div>
</div>
</div>
<div class="col-12 col-lg-8">
@if (mediaItems.Any())
{
<div class="card mt-4">
@ -189,6 +296,9 @@
<a class="text-decoration-none text-reset d-block"
asp-action="GetPluginDetails" asp-controller="Home"
asp-route-pluginSlug="@Model.Plugin.ProjectSlug"
asp-route-version="@Model.Plugin.Version"
asp-route-btcpayVersion="@btcpayVersionRoute"
asp-route-includePreRelease="@includePreReleaseRoute"
asp-route-embed="@embedRoute"
asp-route-sort="@Model.Sort" asp-route-skip="0" asp-route-count="@Model.Count"
asp-route-RatingFilter="@(isActive ? null : star)" asp-fragment="reviews">
@ -219,12 +329,18 @@
<a class="dropdown-item @(Model.Sort == "newest" ? "active" : null)"
asp-action="GetPluginDetails" asp-controller="Home"
asp-route-pluginSlug="@Model.Plugin.ProjectSlug" asp-route-sort="newest"
asp-route-version="@Model.Plugin.Version"
asp-route-btcpayVersion="@btcpayVersionRoute"
asp-route-includePreRelease="@includePreReleaseRoute"
asp-route-embed="@embedRoute"
asp-route-skip="0" asp-route-count="@Model.Count"
asp-route-RatingFilter="@Model.RatingFilter" asp-fragment="reviews">Newest</a>
<a class="dropdown-item @(Model.Sort == "helpful" ? "active" : null)"
asp-action="GetPluginDetails" asp-controller="Home"
asp-route-pluginSlug="@Model.Plugin.ProjectSlug" asp-route-sort="helpful"
asp-route-version="@Model.Plugin.Version"
asp-route-btcpayVersion="@btcpayVersionRoute"
asp-route-includePreRelease="@includePreReleaseRoute"
asp-route-embed="@embedRoute"
asp-route-skip="0" asp-route-count="@Model.Count"
asp-route-RatingFilter="@Model.RatingFilter" asp-fragment="reviews">Most helpful</a>
@ -372,107 +488,7 @@
</div>
</div>
<div class="col-12 col-lg-4 mt-4 mt-lg-0">
<div class="card plugin-details-metadata">
<div class="card-body">
<div class="row mb-3">
<div class="col-6 d-flex align-items-center"><strong>Version</strong></div>
<div class="col-6 text-end">
@if (Model.EmbedMode)
{
<span>@string.Join(".", Model.Plugin.Version.Split('.').Take(3))</span>
}
else
{
<div class="dropdown d-inline-block">
<button id="version-dropdown-btn" class="btn btn-secondary btn-sm dropdown-toggle" type="button"
data-bs-toggle="dropdown" aria-expanded="false">
@string.Join(".", Model.Plugin.Version.Split('.').Take(3))
</button>
<ul class="dropdown-menu dropdown-menu-end" style="max-height: 140px; overflow-y: auto;">
@foreach (var v in Model.PluginVersions)
{
var shortV = string.Join(".", v.Split('.').Take(3));
<li>
<a class="dropdown-item @(v == Model.Plugin.Version ? "active" : "")"
href="#" data-version="@v" data-display="@shortV">@shortV</a>
</li>
}
</ul>
</div>
<input type="hidden" id="version-selector" name="Plugin.Version" value="@Model.Plugin.Version" />
}
</div>
</div>
<div class="row mb-3">
<div class="col-6"><strong>Published</strong></div>
<div class="col-6 text-end">
<span id="plugin-published-date">@(buildDate != default ? buildDate.ToString("MMM dd, yyyy") : "")</span>
</div>
</div>
<div id="plugin-repository-row" class="row mb-3 @(string.IsNullOrEmpty(sourceUrl) ? "d-none" : "")">
<div class="col-6"><strong>Repository</strong></div>
<div class="col-6 text-end">
<a id="plugin-source-url" href="@sourceUrl" target="_blank" rel="noopener noreferrer">View Source</a>
</div>
</div>
@if (!string.IsNullOrEmpty(Model.Plugin.Documentation))
{
<div class="row mb-3">
<div class="col-6"><strong>Documentation</strong></div>
<div class="col-6 text-end">
<a href="@Model.Plugin.Documentation" target="_blank" rel="noopener noreferrer">View Documentation</a>
</div>
</div>
}
<div id="plugin-min-btcpay-row" class="row mb-3 @(string.IsNullOrEmpty(Model.Plugin.BTCPayMinVersion) ? "d-none" : "")">
<div class="col-6"><strong>Min. BTCPay Version</strong></div>
<div class="col-6 text-end">
<span id="plugin-min-btcpay-version">@Model.Plugin.BTCPayMinVersion</span>
</div>
</div>
<div id="plugin-max-btcpay-row" class="row mb-3 @(string.IsNullOrEmpty(Model.Plugin.BTCPayMaxVersion) ? "d-none" : "")">
<div class="col-6"><strong>Max. BTCPay Version</strong></div>
<div class="col-6 text-end">
<span id="plugin-max-btcpay-version">@Model.Plugin.BTCPayMaxVersion</span>
</div>
</div>
@if (dependencies != null)
{
foreach (var dep in dependencies)
{
if (string.Equals(dep["Identifier"]?.ToString(), "BTCPayServer", StringComparison.OrdinalIgnoreCase)) continue;
<div class="row mb-3">
<div class="col-6"><strong>@dep["Identifier"]</strong></div>
<div class="col-6 text-end">@dep["Condition"]</div>
</div>
}
}
<div class="row mb-3">
<div class="col-6"><strong>Created</strong></div>
<div class="col-6 text-end">@Model.Plugin.CreatedDate.ToString("MMM dd, yyyy")</div>
</div>
<div class="row mb-3">
<div class="col-6"><strong>Rating</strong></div>
<div class="col-6 text-end">
<span class="d-inline-flex align-items-center gap-1 lh-1">
<span class="fw-semibold">@((Model.Plugin.RatingSummary?.Average ?? 0m).ToString("0.0"))</span>
<span class="text-warning"><vc:icon symbol="star-fill" /></span>
<span class="text-muted small">(@(Model.Plugin.RatingSummary?.TotalReviews ?? 0))</span>
</span>
</div>
</div>
@if (!Model.EmbedMode)
{
<a id="download-btn"
asp-action="Download" asp-controller="Api"
asp-route-pluginSlug="@Model.Plugin.ProjectSlug"
asp-route-version="@Model.Plugin.Version"
class="btn btn-primary w-100" target="_blank" rel="noopener noreferrer">Download</a>
}
</div>
</div>
<div class="col-12 col-lg-4">
@if (Model.Contributors?.Any() == true)
{
<div class="card mt-4">

View File

@ -7,6 +7,7 @@
let lastHeight = 0;
let heightPostQueued = false;
let hiddenPluginIdentifiers = new Set();
let hostOrigin = null;
function applyHostColorMode(colorMode) {
if (colorMode !== "light" && colorMode !== "dark") {
@ -114,6 +115,23 @@
}, "*");
}
function postInstallRequest() {
const identifier = embedPage.dataset.pluginIdentifier || "";
const slug = embedPage.dataset.pluginSlug || "";
const versionInput = document.getElementById("version-selector");
const version = versionInput ? versionInput.value : "";
if (!hostOrigin || !identifier || !version) {
return;
}
window.parent.postMessage({
type: "pb:install-requested",
identifier: identifier,
slug: slug || null,
version: version
}, hostOrigin);
}
function buildDetailsUrl(slug) {
const url = new URL("/public/plugins/" + encodeURIComponent(slug), window.location.origin);
url.searchParams.set("embed", "1");
@ -130,6 +148,7 @@
return;
}
hostOrigin = event.origin;
hiddenPluginIdentifiers = normalizeHiddenPluginIdentifiers(data.hiddenPluginIdentifiers);
applyHostColorMode(data.colorMode);
applyHiddenPluginFilter();
@ -171,6 +190,13 @@
});
});
document.querySelectorAll("[data-plugin-install]").forEach(function (button) {
button.addEventListener("click", function (event) {
event.preventDefault();
postInstallRequest();
});
});
window.addEventListener("message", handleHostContext);
window.addEventListener("resize", scheduleHeightPost);