Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions plugins/CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ the packaged plugin (`pkg_build.sh` only ships `source/community.applications/`)

## Unreleased

- Changed: Home sections now keep app cards at a fixed width, fill every available card slot, and use a narrow trailing Show More card
- Changed: App status badges now distinguish pending updates, Unraid-official apps, and upstream-official Docker images without relying on color
- Fixed: Dark app status badges now use readable high-contrast text, and stopped containers use a distinct warning state
- Changed: The monthly spotlight banner now ships with the plugin instead of being downloaded, so it appears instantly with no network request
- Added: New "Use Cloudflare CDN" setting to load the application feed from an alternative source; changing it refreshes the feed automatically
- Removed: Automatic fallback to the GitHub-hosted backup application feed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1791,7 +1791,7 @@ function getContent(init,category,description,newApp,startupDisplay) {
sticky docker when the user navigates the menu. */
caLeaveDockerMode();
var initialize;
var maxHomeApps = Math.max(1, caCardsPerRow()); // Row capacity; sections with a Show More tile render one fewer app to make room for it (server side), minimum 1
var maxHomeApps = Math.max(1, caCardsPerRow()); // Full, fixed-width app cards that fit; Show More is a separate partial card.
data.maxPerPage = getMaxPerPage();

enableSearch();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,11 +144,10 @@ public static function handleHomeStartupDisplay(array &$file, $maxHomeApps) {
continue;

$hasMore = (bool)($type['cat'] ?? false);
/* Cat sections overlay SHOW MORE on their last card. Render at least
two there (one real card plus the overlaid one) so a genuine app
always shows even when only a single card fits the row - the Show
More then lands on the 2nd card. Other sections just fill the row. */
$appsToShow = $hasMore ? max(2, $maxHomeApps) : $maxHomeApps;
/* Every slot that fits the row belongs to an application. Sections
with a full-category destination append a separate, narrow Show More
affordance after those cards instead of sacrificing the last app. */
$appsToShow = $maxHomeApps;

for ($i=0;$i<$GLOBALS['caSettings']['maxPerPage'];$i++) {
if ( ! isset($appsOfDay[$i])) continue;
Expand All @@ -161,27 +160,20 @@ public static function handleHomeStartupDisplay(array &$file, $maxHomeApps) {
if ( $homeCount >= $appsToShow ) break;
}
if ( $displayApplications['community'] ) {
/* Sections that link to a full category turn their last visible
card into the Show More affordance: the card still renders (just
dimmed) with a SHOW MORE label overlaid on top. The flag rides on
the template through to displayCard, which draws the overlay. Only
do this when another real card sits beside it, so a section never
shows nothing but a Show More. */
if ( $hasMore && count($display) >= 2 ) {
$lastIdx = count($display) - 1;
$display[$lastIdx]['homeShowMore'] = [
'cat' => $type['cat'],
'sortby' => $type['sortby'],
'sortdir' => $type['sortdir'],
'des' => $type['text1'],
];
}

$o['display'] .= "<div class='ca_homeTemplatesHeader'>{$type['text1']}</div>";
$o['display'] .= "<div class='ca_homeTemplatesLine2'>{$type['text2']}</div>";
$homeClass = "caHomeSpotlight";
$showMore = "";
if ($hasMore) {
$des = htmlspecialchars((string)$type['text1'], ENT_QUOTES, "UTF-8");
$cat = htmlspecialchars((string)$type['cat'], ENT_QUOTES, "UTF-8");
$sby = htmlspecialchars((string)$type['sortby'], ENT_QUOTES, "UTF-8");
$sdir = htmlspecialchars((string)$type['sortdir'], ENT_QUOTES, "UTF-8");
$label = htmlspecialchars(sprintf(tr("Show more %s"), (string)$type['text1']), ENT_QUOTES, "UTF-8");
$showMore = "<button type='button' class='ca_homeMoreCard homeMore' data-des='{$des}' data-category='{$cat}' data-sortby='{$sby}' data-sortdir='{$sdir}' aria-label='{$label}'><span class='ca_homeMoreArrow' aria-hidden='true'></span><span class='ca_homeMoreTitle'><span>".tr("Show")."</span><span>".tr("More")."</span></span></button>";
}

$o['display'] .= "<div class='ca_homeTemplates home{$type['type']} $homeClass'>".my_display_apps($display,"1",false,false,false,false)."</div>";
$o['display'] .= "<div class='ca_homeTemplates home{$type['type']} $homeClass'>".my_display_apps($display,"1",false,false,false,false).$showMore."</div>";
$o['script'] = "$('#templateSortButtons,#sortButtons').hide();";

} else {
Expand Down Expand Up @@ -519,4 +511,4 @@ public static function cacheDisplayApplications($categoryRegex, $filter, $displa
writeJsonFile(CA_PATHS['community-templates-catSearchResults'],$displayApplications);
}
}
?>
?>
Original file line number Diff line number Diff line change
Expand Up @@ -730,11 +730,8 @@ function caInitializeClickHandlers() {
scrollToTop();
post({ action: "changeSortOrder", sortOrder: sortOrder }, function() { getContent(false, category, description, false); });
}
/* "Show more" overlay on a Home section card. */
/* Partial "Show more" card at the end of a Home section. */
$(".mainArea").on("click", ".homeMore", function(e) {
/* The Show More overlay sits inside its (clickable) app card, so stop the
click here before it bubbles to the .ca_holder handler and also opens
that card's sidebar. mainArea is below body, so this fires first. */
e.stopPropagation();
caOpenHomeSection($(this).data("des"), $(this).data("category"), $(this).data("sortby"), $(this).data("sortdir"));
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1722,27 +1722,12 @@ function displayCard($template) {

$cardEnd = "</div>";

/* Home page Show More: this card is the last visible one in a section that
links to a full category. Lay a dimming scrim plus a SHOW MORE label over
the card itself (.ca_holder is position:relative, so the overlay's inset:0
covers it). The homeMore class drives the existing click handler; the
scrim is a sibling of the label so dimming the card never dims the text. */
$homeMore = "";
if ( $template['homeShowMore'] ?? false ) {
$sm = $template['homeShowMore'];
$des = htmlspecialchars((string)($sm['des'] ?? ""), ENT_QUOTES);
$cat = htmlspecialchars((string)($sm['cat'] ?? ""), ENT_QUOTES);
$sby = htmlspecialchars((string)($sm['sortby'] ?? ""), ENT_QUOTES);
$sdir = htmlspecialchars((string)($sm['sortdir'] ?? ""), ENT_QUOTES);
$homeMore = "<div class='ca_homeMoreOverlay homeMore' data-des='{$des}' data-category='{$cat}' data-sortby='{$sby}' data-sortdir='{$sdir}'><div class='ca_homeMoreScrim'></div><div class='ca_homeMoreText'><div class='ca_homeMoreTitle'>".tr("Show More")."</div><div class='ca_homeMoreSection'>{$des}</div></div></div>";
}

/* The corner-badge stack is rendered inside .ca_holder so it can position
absolutely against the card itself rather than relying on negative-margin
sibling tricks against an outer wrapper. Stack renders all applicable
badges, not just the highest-priority one — see caBuildCardFlag for the
priority ordering and wrap behavior. */
$cardFinish = "{$cardStart}{$cardFlag}{$card}{$homeMore}{$cardEnd}";
$cardFinish = "{$cardStart}{$cardFlag}{$card}{$cardEnd}";

return str_replace(["\t", "\n"], "", $cardFinish);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2068,7 +2068,7 @@ function caCollectBadges(array $template): array {
$badges = [];

if (!empty($template['UpdateAvailable'])) {
$badges[] = "<div class='betaCardBackground'><div class='installedCardText ca_center'>".tr("UPDATED")."</div></div>";
$badges[] = "<div class='betaCardBackground'><div class='installedCardText ca_center' title='".tr("An update is available for this application")."'>".tr("UPDATE AVAILABLE")."</div></div>";
}

if ((!empty($template['Installed']) || !empty($template['Uninstall'])) && empty($template['actionCentre'])) {
Expand All @@ -2079,7 +2079,7 @@ function caCollectBadges(array $template): array {
extra badge; languages add nothing. $template['Running'] is set in the
card + sidebar render paths (false = installed but stopped). */
if (empty($template['Plugin']) && empty($template['Language']) && isset($template['Running']) && !$template['Running']) {
$badges[] = "<div class='installedCardBackground'><div class='installedCardText ca_center'>".tr("Stopped")."</div></div>";
$badges[] = "<div class='stoppedCardBackground'><div class='installedCardText ca_center' title='".tr("This installed container is stopped")."'>".tr("STOPPED")."</div></div>";
}
}

Expand Down Expand Up @@ -2116,18 +2116,18 @@ function caCollectBadges(array $template): array {
$badges[] = "<div class='warningCardBackground'><div class='installedCardText ca_center' title='{$verMsg}'>".tr("Incompatible")."</div></div>";
}

/* LTOfficial supersedes Official the LT (Lime Technology) variant
carries the brand gradient and is a stronger claim than the plain
"Official" purple chip. Showing both would just be redundant
"OFFICIAL OFFICIAL" with no useful distinction. */
/* LTOfficial supersedes Official. Keep the two trust claims explicit in
visible text: the old shared "OFFICIAL" label forced users to infer from
colour whether Lime Technology or the upstream Docker publisher was the
authority. */
if (!empty($template['LTOfficial'])) {
$badges[] = "<div class='LTOfficialCardBackground'><div class='installedCardText ca_center' title='".tr("This is an official plugin")."'>".tr("OFFICIAL")."</div></div>";
$badges[] = "<div class='LTOfficialCardBackground'><div class='installedCardText ca_center' title='".tr("This application is officially maintained by Lime Technology")."'>".tr("UNRAID OFFICIAL")."</div></div>";
} elseif (!empty($template['Official'])) {
$badges[] = "<div class='officialCardBackground'><div class='installedCardText ca_center' title='".tr("This is an official container")."'>".tr("OFFICIAL")."</div></div>";
$badges[] = "<div class='officialCardBackground'><div class='installedCardText ca_center' title='".tr("This is an official image from the upstream Docker publisher")."'>".tr("DOCKER OFFICIAL")."</div></div>";
}

if (!empty($template['Beta'])) {
$badges[] = "<div class='betaCardBackground'><div class='installedCardText ca_center'>".tr("BETA")."</div></div>";
$badges[] = "<div class='betaCardBackground'><div class='installedCardText ca_center' title='".tr("This application is a beta release")."'>".tr("BETA")."</div></div>";
}

if (!empty($template['Trusted'])) {
Expand Down Expand Up @@ -2204,4 +2204,4 @@ function caBuildSidebarFlag(array $template): string {
if (empty($badges)) return "";

return "<div class='sidebarFlagStack'>".implode("", $badges)."</div>";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -432,10 +432,18 @@ body,
/* 2.2) Home Templates Section */
.ca_homeTemplates {
display: flex;
flex-wrap: nowrap; /* Prevent wrapping to enable horizontal scroll */
flex-wrap: nowrap;
height: 30.5rem;
overflow-x: auto; /* Enable horizontal scrolling */
overflow-y: hidden; /* Hide vertical scrollbar */
overflow-x: auto;
overflow-y: hidden;
}
/* Home rows pack in as many normal, fixed-width cards as the viewport can
hold. Never stretch them to consume leftover space: the narrow trailing
Show More card uses that remainder and remains horizontally scrollable. */
.ca_homeTemplates > .ca_holder {
flex: 0 0 36rem;
min-width: 36rem;
max-width: 36rem;
}
.ca_homeTemplatesHeader {
font-size: 2rem;
Expand All @@ -446,46 +454,48 @@ body,
margin-bottom: 1rem;
}

/* Home page SHOW MORE: the last visible card in a section is left in place but
dimmed, with a SHOW MORE label overlaid on it (it opens the full category).
The overlay fills the card (.ca_holder is position:relative). The scrim is a
separate layer so its opacity dims only the card, not the label on top. */
.ca_homeMoreOverlay {
position: absolute;
inset: 0;
/* Home page SHOW MORE is deliberately a partial card. It follows every full
app that fits the row instead of replacing or obscuring the final app. */
.ca_homeMoreCard {
flex: 0 0 8rem;
width: 8rem;
min-width: 8rem;
height: 26.5rem;
margin: 0.5rem 2rem 2rem 0.5rem;
padding: 1rem;
border: 1px solid var(--border-color);
border-radius: 1rem;
background: var(--mild-background-color);
color: var(--text-color);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
cursor: pointer;
z-index: 2;
}
.ca_homeMoreScrim {
position: absolute;
inset: 0;
background-color: var(--mild-background-color);
/* Dark mode (--theme-dark-mode 1) -> 0.8; light mode (0) -> 0.9.
calc drops 0.1 off the 0.9 base only when the dark flag is set. */
opacity: calc(0.9 - var(--theme-dark-mode, 0) * 0.1);
}
/* On hover the card beneath gets --template-hover-background, but the scrim
covers it. Tint the scrim to the same hover colour so the underlying card
visibly changes colour like a normal card hover (the orange glow already
bleeds past the scrim from .ca_holder:hover). */
.ca_holder:hover .ca_homeMoreScrim {
.ca_homeMoreCard:hover,
.ca_homeMoreCard:focus-visible {
background-color: var(--template-hover-background);
border-color: var(--brand-orange);
box-shadow: 0 0 12px 2px var(--ca-shadow-color);
outline: none;
}
.ca_homeMoreOverlay .ca_homeMoreText {
position: relative;
text-align: center;
color: var(--text-color);
.ca_homeMoreArrow::before {
content: "\f054";
font-family: fontAwesome;
font-size: 2.2rem;
color: var(--brand-orange);
}
.ca_homeMoreTitle {
font-size: 2rem;
font-weight: bold;
}
.ca_homeMoreSection {
font-size: 1.5rem;
margin-top: 0.25rem;
display: flex;
flex-direction: column;
font-size: 1.25rem;
font-weight: 600;
line-height: 1.2;
text-align: center;
text-transform: uppercase;
white-space: normal;
}

#caAlphaBar {
Expand Down Expand Up @@ -2721,6 +2731,7 @@ input[type="button"] {
.warningCardBackground,
.betaCardBackground,
.greenCardBackground,
.stoppedCardBackground,
.installedCardBackground {
padding: 0.35rem 0.75rem;
border-radius: 0.4rem;
Expand All @@ -2739,21 +2750,29 @@ input[type="button"] {
color: var(--ca-installed-badge-text-color);
}
.LTOfficialCardBackground {
/* Unraid brand gradient: Red #e22828 → Orange #f15a2c → Gold #ff8c2f */
background: linear-gradient(to right, #e22828 0%, #f15a2c 50%, #ff8c2f 100%);
/* Match the Community Apps Worker: official is a solid success badge. */
background-color: var(--ca-dark-green-color);
color: var(--ca-white-color);
}
.officialCardBackground,
.spotlightCardBackground {
background-color: var(--ca-dark-purple-color);
color: var(--ca-white-color);
}
.warningCardBackground {
background-color: var(--ca-dark-red-color);
color: var(--ca-white-color);
}
.betaCardBackground {
background-color: var(--brand-orange);
}
.greenCardBackground {
background-color: var(--ca-dark-green-color);
color: var(--ca-white-color);
}
.stoppedCardBackground {
background-color: var(--brand-orange);
color: var(--black);
}

.installedCardText {
Expand Down
41 changes: 41 additions & 0 deletions tests/test_card_badges.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php
/* Focused regression coverage for the status labels shared by cards and the
application sidebar. skin_helpers.php only defines functions at load time,
so a minimal translation stub is sufficient for these pure badge tests. */

function tr($text) {
return $text;
}

require_once dirname(__DIR__) . "/source/community.applications/usr/local/emhttp/plugins/community.applications/skins/Narrow/skin_helpers.php";

function badgeAssertContains(string $needle, array $badges, string $message): void {
if (strpos(implode("", $badges), $needle) === false) {
fwrite(STDERR, "FAIL: {$message}\n");
exit(1);
}
}

function badgeAssertNotContains(string $needle, array $badges, string $message): void {
if (strpos(implode("", $badges), $needle) !== false) {
fwrite(STDERR, "FAIL: {$message}\n");
exit(1);
}
}

$update = caCollectBadges(['UpdateAvailable' => true]);
badgeAssertContains('UPDATE AVAILABLE', $update, 'pending updates use an action-oriented label');
badgeAssertNotContains('>UPDATED<', $update, 'pending updates are not described as already updated');

$unraidOfficial = caCollectBadges(['LTOfficial' => true, 'Official' => true]);
badgeAssertContains('UNRAID OFFICIAL', $unraidOfficial, 'Unraid-maintained apps identify the authority');
badgeAssertNotContains('DOCKER OFFICIAL', $unraidOfficial, 'Unraid official supersedes upstream official');

$dockerOfficial = caCollectBadges(['Official' => true]);
badgeAssertContains('DOCKER OFFICIAL', $dockerOfficial, 'upstream Docker images identify the authority');

$stopped = caCollectBadges(['Installed' => true, 'Running' => false]);
badgeAssertContains('stoppedCardBackground', $stopped, 'stopped containers have a distinct visual state');
badgeAssertContains('STOPPED', $stopped, 'stopped containers expose their runtime state');

echo "card badge assertions passed\n";