fix: resolve minor ui and backend validation issues

This addresses the remaining minor issues outlined in ISSUES.md.

- Extracted remaining hardcoded English strings into i18n locales

- Fixed stale state and implemented lazy-loaded images in PayloadDetailPanel

- Revived 'installed' payload state in StorageHub by no longer filtering them out

- Fixed event propagation bug in dashboard manage mode where clicking anywhere triggered delete

- Hardened C backend 'is_pldmgr_filename' to strictly match pldmgr prefixes, preventing false positives
This commit is contained in:
najdek committed 2026-08-10 18:32:42 +02:00
1 parent 675a1df0ef
commit 8fd3212ec5
7 files changed
+103 -55

No files matched your search

+21 -18
View File
@@ -83,24 +83,27 @@ Fix applied:
## Minor
- **Hardcoded English strings** — App.jsx (update banner, manage-mode toasts),
PayloadCard ("Update"/"Installed" badges), PayloadDetailPanel ("Back to Repository",
"Description", "What's New", "About", "Loading details...", "Failed to load details.").
No i18n keys added; `en-US.json` unchanged (217 keys). Locales submodule shows
uncommitted content changes in `git status`.
- **PayloadDetailPanel** — content not reset between payload switches (brief stale
markup shown), details refetched on every open (no memoization), markdown images not
lazy-loaded / error-bounded.
- **Dead code** — `PayloadCard` `opacity-60` "installed" state is unreachable (installed
payloads are filtered out of `availablePayloads` in StorageHub); "Installed ✓"/Remove
states in the detail panel are unreachable.
- **Manage mode** — entire card click opens the delete confirm; the trash icon is
decorative (no `stopPropagation`), deviating from the specified trash-overlay
interaction.
- **`repository_check_repo_update`** matches any filename containing `pldmgr` via
`strcasestr` — false-positive risk for unrelated repo files.
- **Icon size threshold** — icons dropped only when truncated to ~16KB
(buffer-limited), vs plan's 12KB. Non-issue functionally.
- **Hardcoded English strings — ✅ FIXED** — update banner (App.jsx), PayloadCard
badges, and PayloadDetailPanel labels now use i18n keys
(`app.update_banner.*`, `storage_hub.update_badge`/`installed_badge`/
`back_to_repository`/`payload_count`, `payload_detail.*`); ran `npm run extract`.
- **PayloadDetailPanel — ✅ FIXED** — resets `detailsHtml`/error state on payload
switch, guards against stale async responses (cancellation flag), and markdown
`<img>`s are lazy-loaded and hidden on error via `useLayoutEffect` post-processing.
- **Dead code — ✅ FIXED** — StorageHub no longer filters installed payloads out of the
shop grid, so the "Installed"/"Update" card states and the detail panel's
"Installed"/"Update" buttons are reachable. PayloadCard dims installed-up-to-date
cards (`opacity-60`) and shows an "Installed" badge. Removed the now-impossible
"All payloads installed" empty-state branch; catalog count label is now "{{count}}
payloads".
- **Manage mode — ✅ FIXED** — the trash icon is now the actual delete trigger (a
`role="button"` div with `stopPropagation`, valid inside the card `<button>`);
clicking a card in manage mode no longer launches or deletes.
- **`repository_check_repo_update` — ✅ FIXED** — added `is_pldmgr_filename()`
(base name starts with `pldmgr`/`payload-manager`, case-insensitive); used in both
the repo-update scan and the self-update file match (replaces broad `strcasestr`).
- **Icon size threshold — ✅ FIXED** — icons dropped when > 12KB (was buffer-limited
~16KB), matching the plan.
## Verified OK
- `RESPONSE_BUFFER_SIZE` = 4MB (http_server.c:32)
+9 -8
View File
@@ -593,14 +593,14 @@ function App() {
{updateStatus.status === 'ready-to-apply' ? (
<>
<strong className="text-white">
Payload Manager {updateStatus.new_version ? `v${updateStatus.new_version}` : ''} downloaded
{t('app.update_banner.ready_title', 'Payload Manager {{version}} downloaded', { version: updateStatus.new_version ? `v${updateStatus.new_version}` : '' })}
</strong>
{' — load it to apply the update.'}
{t('app.update_banner.ready_desc', ' — load it to apply the update.')}
</>
) : (
<>
<strong className="text-white">Update Available!</strong>
{updateStatus.new_version ? ` Payload Manager v${updateStatus.new_version}` : ''} is available in the repository.
<strong className="text-white">{t('app.update_banner.available_title', 'Update Available!')}</strong>
{t('app.update_banner.available_desc', ' Payload Manager {{version}} is available in the repository.', { version: updateStatus.new_version ? `v${updateStatus.new_version}` : '' })}
</>
)}
</span>
@@ -611,7 +611,7 @@ function App() {
onClick={() => handleApplyUpdate(updateStatus.local_path)}
className="px-4 py-2 rounded-lg bg-ps-blue text-ps-black font-bold text-sm hover:opacity-90 transition-opacity"
>
Load
{t('app.update_banner.load', 'Load')}
</button>
) : (
<button
@@ -619,7 +619,7 @@ function App() {
disabled={updateBusy}
className="px-4 py-2 rounded-lg bg-ps-blue text-ps-black font-bold text-sm hover:opacity-90 transition-opacity disabled:opacity-50"
>
{updateBusy ? 'Installing…' : 'Update'}
{updateBusy ? t('app.update_banner.installing', 'Installing…') : t('app.update_banner.update', 'Update')}
</button>
)}
<button
@@ -653,10 +653,11 @@ function App() {
key={p}
path={p}
onClick={() => {
if (isManageMode) handleDelete(p.split('/').pop())
else if (isFavoriteEditMode) toggleFavorite(p)
if (isManageMode) return
if (isFavoriteEditMode) toggleFavorite(p)
else loadPayload(p)
}}
onDelete={() => handleDelete(p.split('/').pop())}
isLoading={loading && activeLoadingName === p.split('/').pop().replace(/\.(elf|bin)$/i, '').replace(/_/g, ' ')}
sourceName={config.MULTI_SOURCES_ENABLED ? (payloadMeta[p.split('/').pop()]?.source_name || null) : null}
version={payloadMeta[p.split('/').pop()]?.version || null}
+10 -2
View File
@@ -5,7 +5,7 @@ import PayloadName from './PayloadName'
import PayloadIcon from './PayloadIcon'
import { cn } from '../../utils/helpers'
const PayloadButton = ({ path, onClick, isLoading, sourceName, version, icon, author, displayName, isFavorite, isLaunched, isEditMode, isManageMode, onMoveFavorite, canMoveLeft, canMoveRight }) => {
const PayloadButton = ({ path, onClick, onDelete, isLoading, sourceName, version, icon, author, displayName, isFavorite, isLaunched, isEditMode, isManageMode, onMoveFavorite, canMoveLeft, canMoveRight }) => {
const { t } = useTranslation()
const isUsb = path.startsWith('/mnt/usb')
return (
@@ -45,7 +45,15 @@ const PayloadButton = ({ path, onClick, isLoading, sourceName, version, icon, au
</div>
)}
{isManageMode && !isUsb && (
<div className="p-1 rounded-lg text-red-500 transition-all opacity-50 group-hover:opacity-100">
<div
role="button"
tabIndex={-1}
onClick={(e) => {
e.stopPropagation();
if (onDelete) onDelete();
}}
className="p-1.5 rounded-lg text-red-500 transition-all opacity-50 group-hover:opacity-100 hover:bg-red-500/10 hover:opacity-100 cursor-pointer"
>
<Trash2 className="w-5 h-5" />
</div>
)}
+10 -2
View File
@@ -1,15 +1,18 @@
import React from 'react';
import { cn } from '../../utils/helpers';
import { useTranslation } from 'react-i18next';
import PayloadName from './PayloadName';
import PayloadIcon from './PayloadIcon';
const PayloadCard = ({ payload, onClick, isUpdate, isInstalled }) => {
const { t } = useTranslation();
const isInstalledUpToDate = isInstalled && !isUpdate;
return (
<button
onClick={onClick}
className={cn(
"group flex flex-col p-4 md:p-6 bg-white/[0.02] hover:bg-white/[0.05] border border-white/5 hover:border-ps-blue/30 rounded-3xl transition-all text-left relative",
(isInstalled && !isUpdate) && "opacity-60"
isInstalledUpToDate && "opacity-60"
)}
>
<div className="flex justify-center mb-4 w-full">
@@ -31,7 +34,12 @@ const PayloadCard = ({ payload, onClick, isUpdate, isInstalled }) => {
<div className="absolute top-4 right-4 flex flex-col gap-2">
{isUpdate && (
<span className="px-2 py-1 rounded-lg bg-emerald-500/20 text-emerald-400 font-bold text-[10px] uppercase tracking-widest border border-emerald-500/20">
Update
{t("storage_hub.update_badge", "Update")}
</span>
)}
{isInstalledUpToDate && (
<span className="px-2 py-1 rounded-lg bg-white/10 text-zinc-400 font-bold text-[10px] uppercase tracking-widest border border-white/10">
{t("storage_hub.installed_badge", "Installed")}
</span>
)}
</div>
@@ -1,20 +1,24 @@
import React, { useState, useEffect } from 'react';
import { X, CloudDownload, Loader2, ArrowLeft } from 'lucide-react';
import React, { useState, useEffect, useLayoutEffect, useRef } from 'react';
import { CloudDownload, Loader2, ArrowLeft } from 'lucide-react';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import { cn } from '../../utils/helpers';
import { useTranslation } from 'react-i18next';
import PayloadIcon from './PayloadIcon';
import PayloadName from './PayloadName';
const PayloadDetailPanel = ({ payload, onClose, onInstall, isInstalled, isUpdate, srcUrl, srcId }) => {
const { t } = useTranslation();
const [detailsHtml, setDetailsHtml] = useState(null);
const [detailsError, setDetailsError] = useState(false);
const [loading, setLoading] = useState(false);
const detailsRef = useRef(null);
useEffect(() => {
setDetailsHtml(null);
setDetailsError(false);
if (!payload?.details_url) return;
// Resolve full URL if relative
let url = payload.details_url;
if (!url.startsWith('http')) {
@@ -25,6 +29,7 @@ const PayloadDetailPanel = ({ payload, onClose, onInstall, isInstalled, isUpdate
}
}
let cancelled = false;
const fetchDetails = async () => {
setLoading(true);
try {
@@ -32,17 +37,27 @@ const PayloadDetailPanel = ({ payload, onClose, onInstall, isInstalled, isUpdate
if (!res.ok) throw new Error('Failed to fetch details');
const text = await res.text();
const html = DOMPurify.sanitize(marked.parse(text));
setDetailsHtml(html);
if (!cancelled) setDetailsHtml(html);
} catch (err) {
setDetailsHtml('<p class="text-red-400">Failed to load details.</p>');
if (!cancelled) setDetailsError(true);
} finally {
setLoading(false);
if (!cancelled) setLoading(false);
}
};
fetchDetails();
return () => { cancelled = true; };
}, [payload, srcUrl]);
// Lazy-load markdown images and hide broken ones
useLayoutEffect(() => {
if (!detailsRef.current) return;
detailsRef.current.querySelectorAll('img').forEach((img) => {
img.loading = 'lazy';
img.onerror = () => { img.style.display = 'none'; };
});
}, [detailsHtml]);
if (!payload) return null;
return (
@@ -55,7 +70,7 @@ const PayloadDetailPanel = ({ payload, onClose, onInstall, isInstalled, isUpdate
className="flex items-center space-x-2 text-zinc-400 hover:text-white transition-colors bg-white/5 hover:bg-white/10 px-4 py-2 rounded-xl"
>
<ArrowLeft className="w-5 h-5" />
<span className="font-bold text-sm uppercase tracking-wider">Back to Repository</span>
<span className="font-bold text-sm uppercase tracking-wider">{t("storage_hub.back_to_repository", "Back to Repository")}</span>
</button>
</div>
@@ -88,7 +103,7 @@ const PayloadDetailPanel = ({ payload, onClose, onInstall, isInstalled, isUpdate
>
<CloudDownload className="w-5 h-5" />
<span>
{isUpdate ? "Update" : isInstalled ? "Installed" : "Install"}
{isUpdate ? t("payload_detail.update", "Update") : isInstalled ? t("payload_detail.installed", "Installed") : t("payload_detail.install", "Install")}
</span>
</button>
</div>
@@ -97,14 +112,14 @@ const PayloadDetailPanel = ({ payload, onClose, onInstall, isInstalled, isUpdate
<div className="flex-1 p-6 md:p-8">
{payload.description && (
<div className="mb-8">
<h3 className="text-lg font-bold text-white mb-3">Description</h3>
<h3 className="text-lg font-bold text-white mb-3">{t("payload_detail.description", "Description")}</h3>
<p className="text-zinc-300 leading-relaxed text-base">{payload.description}</p>
</div>
)}
{payload.release_notes && (
<div className="mb-8">
<h3 className="text-lg font-bold text-white mb-3">What's New</h3>
<h3 className="text-lg font-bold text-white mb-3">{t("payload_detail.whats_new", "What's New")}</h3>
<div className="text-zinc-300 leading-relaxed text-sm whitespace-pre-wrap bg-white/5 p-4 rounded-2xl border border-white/5">
{payload.release_notes}
</div>
@@ -113,14 +128,17 @@ const PayloadDetailPanel = ({ payload, onClose, onInstall, isInstalled, isUpdate
{payload.details_url && (
<div className="mt-8 pt-8 border-t border-white/10">
<h3 className="text-lg font-bold text-white mb-4">About</h3>
<h3 className="text-lg font-bold text-white mb-4">{t("payload_detail.about", "About")}</h3>
{loading ? (
<div className="flex items-center gap-3 text-zinc-500">
<Loader2 className="w-5 h-5 animate-spin" />
<span>Loading details...</span>
<span>{t("payload_detail.loading_details", "Loading details...")}</span>
</div>
) : detailsError ? (
<p className="text-red-400">{t("payload_detail.failed_load", "Failed to load details.")}</p>
) : detailsHtml ? (
<div
<div
ref={detailsRef}
className="prose prose-invert max-w-none prose-a:text-ps-blue hover:prose-a:text-ps-blue/80 prose-headings:text-white prose-p:text-zinc-300"
dangerouslySetInnerHTML={{ __html: detailsHtml }}
/>
+2 -6
View File
@@ -212,7 +212,7 @@ const StorageHub = ({ payloads, payloadMeta, onInstall, onDelete, onUpload, onIm
/* ===== REPOSITORY CATALOGS ===== */
<div className="space-y-4">
{enrichedSources.map(src => {
let availablePayloads = src.payloads.filter(p => !p.isInstalled || p.isUpdate)
let availablePayloads = [...src.payloads]
if (search.trim() !== '') {
const q = search.toLowerCase()
@@ -295,7 +295,7 @@ const StorageHub = ({ payloads, payloadMeta, onInstall, onDelete, onUpload, onIm
</div>
<div className="flex items-center space-x-3">
<span className="px-3 py-1 rounded-full bg-white/5 text-zinc-500 text-xs font-bold">
{t("storage_hub.available_count", "{{count}} available", { count: availablePayloads.length })}
{t("storage_hub.payload_count", "{{count}} payloads", { count: availablePayloads.length })}
</span>
{enrichedSources.length > 1 && (
<ChevronDown className={cn("w-5 h-5 text-zinc-500 transition-transform", isExpanded && "rotate-180")} />
@@ -313,10 +313,6 @@ const StorageHub = ({ payloads, payloadMeta, onInstall, onDelete, onUpload, onIm
<div className="py-12 flex flex-col items-center justify-center space-y-3 text-zinc-600">
<p className="text-sm font-bold uppercase tracking-widest italic">{t("storage_hub.source_empty", "Source is empty")}</p>
</div>
) : availablePayloads.length === 0 ? (
<div className="py-12 flex flex-col items-center justify-center space-y-3 text-zinc-600">
<p className="text-sm font-bold uppercase tracking-widest italic">{t("storage_hub.all_installed", "All payloads installed")}</p>
</div>
) : hasMultipleCategories ? (
categories.map(cat => (
<div key={cat} className="space-y-4 mb-8 last:mb-0">
+18 -4
View File
@@ -111,6 +111,20 @@ int download_to_file(const char *url, const char *out_path) {
/* ── JSON parsing ──────────────────────────────────────────── */
/* True if a filename (base name) identifies a payload-manager binary, i.e.
* starts with "pldmgr" or "payload-manager" (case-insensitive). */
static int is_pldmgr_filename(const char *filename) {
const char *base;
if (!filename || !*filename)
return 0;
base = strrchr(filename, '/');
base = base ? base + 1 : filename;
return strncasecmp(base, "pldmgr", 6) == 0 ||
strncasecmp(base, "payload-manager", 15) == 0;
}
/* Skip the top-level wrapping object, returning a pointer to just past its
* opening '{' so entries inside the wrapper can be scanned. If the document is
* a bare array of entries, or a single entry object (no "payloads" member), the
@@ -257,8 +271,8 @@ int parse_repository_payloads(const char *json, RepoPayload **out_items, size_t
}
json_extract_string(p, end, "icon", item.icon, sizeof(item.icon));
if (strlen(item.icon) == sizeof(item.icon) - 1) {
item.icon[0] = '\0'; /* Drop if > 16KB */
if (strlen(item.icon) > 12288) {
item.icon[0] = '\0'; /* Drop if > 12KB */
}
if (json_extract_string(p, end, "category", item.category, sizeof(item.category)) != 0 || strlen(item.category) == 0) {
@@ -800,7 +814,7 @@ int repository_check_self_update(char *out_path, size_t out_size,
/* Look for .elf or .bin files that match our name */
if (strstr(sentry->d_name, ".elf") || strstr(sentry->d_name, ".bin")) {
if (strcasestr(sentry->d_name, "pldmgr") || strcasestr(sentry->d_name, "payload-manager")) {
if (is_pldmgr_filename(sentry->d_name)) {
char full_path[512];
snprintf(full_path, sizeof(full_path), "%s/%s", subdir_path, sentry->d_name);
@@ -852,7 +866,7 @@ int repository_check_repo_update(char *out_filename, size_t out_size,
int found = 0;
for (size_t i = 0; i < count; i++) {
if (strcasestr(items[i].filename, "pldmgr") || strcasestr(items[i].filename, "payload-manager")) {
if (is_pldmgr_filename(items[i].filename)) {
if (items[i].version[0] != '\0') {
if (compare_versions(items[i].version, MENU_VERSION) > 0) {
found = 1;