Task #517: App image upload, external links, built-in route lock
Admin Add/Edit App now supports: - Custom image upload (or fall back to Lucide icon) via the existing ServiceImageUploader; rendered on the home launcher when set. - Open mode picker: internal (default), external_tab (window.open), external_iframe (renders inside /embedded/:id). External URL input shown conditionally and required by the form when an external mode is chosen. - Route field is locked (readOnly + lock hint) when editing a built-in app, since those slugs are hardcoded in the SPA router. Backend: - apps schema gains image_url, external_url, open_mode (default 'internal'); drizzle-kit push applied. - New lib/db/src/built-in-apps.ts exports BUILTIN_APP_SLUGS + isBuiltinAppSlug, re-exported from lib/db. - PATCH /apps/:id rejects route changes whose previous slug is built-in with 400 + code='builtin_route_locked'. Same-route no-op is allowed; non-route updates on built-ins still work. Other: - New SPA route /embedded/:id and embedded-app page (iframe host with back + open-in-new-tab + error/not-embeddable states). - OpenAPI App / CreateAppBody / UpdateAppBody extended; codegen ran. - en/ar locales: admin.appImage, appExternalUrl, appOpenMode.*, builtinPathLocked, embeddedFrame.*. - New tests in apps-builtin-route-lock.test.mjs (4/4 pass) covering reject built-in route change, allow non-route built-in updates, allow non-builtin route changes, allow built-in same-route no-op. Notes / drift: - BUILTIN_APP_SLUGS is duplicated inline in admin.tsx (BUILTIN_APP_SLUGS_FE) because the browser bundle cannot import @workspace/db (pulls pg). Comment points at the canonical source; drift risk filed as a follow-up. - Pre-existing failures unrelated to this task: 3 tests in executive-meetings-* and tsc errors in api-server/src/routes/executive-meetings.ts. Out of scope.
This commit is contained in:
@@ -55,6 +55,7 @@ import {
|
||||
formatTime,
|
||||
formatNumber,
|
||||
} from "@/lib/i18n-format";
|
||||
import { resolveServiceImageUrl } from "@/lib/image-url";
|
||||
import {
|
||||
Clock,
|
||||
useNow,
|
||||
@@ -100,7 +101,7 @@ function AppIconContent({
|
||||
badgeCount = 0,
|
||||
pulse = false,
|
||||
}: {
|
||||
app: { nameAr: string; nameEn: string; iconName: string; color: string };
|
||||
app: { nameAr: string; nameEn: string; iconName: string; color: string; imageUrl?: string | null };
|
||||
badgeCount?: number;
|
||||
pulse?: boolean;
|
||||
}) {
|
||||
@@ -109,6 +110,10 @@ function AppIconContent({
|
||||
const name = lang === "ar" ? app.nameAr : app.nameEn;
|
||||
|
||||
const Icon = resolveIcon(app.iconName);
|
||||
// Task #517: when an admin uploads a custom image, render it in place
|
||||
// of the Lucide icon. The image fills the tile (object-cover) so the
|
||||
// gradient background still shows through transparent PNGs.
|
||||
const resolvedImage = resolveServiceImageUrl(app.imageUrl ?? null);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -120,7 +125,16 @@ function AppIconContent({
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<Icon size={30} color="#FFFFFF" />
|
||||
{resolvedImage ? (
|
||||
<img
|
||||
src={resolvedImage}
|
||||
alt=""
|
||||
className="absolute inset-0 w-full h-full object-cover rounded-[inherit]"
|
||||
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }}
|
||||
/>
|
||||
) : (
|
||||
<Icon size={30} color="#FFFFFF" />
|
||||
)}
|
||||
{badgeCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 bg-destructive text-destructive-foreground text-[10px] rounded-full min-w-[18px] h-[18px] px-1 flex items-center justify-center font-bold tabular-nums shadow">
|
||||
{badgeCount > 9 ? "9+" : badgeCount}
|
||||
@@ -473,6 +487,20 @@ export default function HomePage() {
|
||||
logAppOpen(app.id, { keepalive: true }).catch(() => {
|
||||
// Best-effort tracking; ignore failures so navigation isn't blocked.
|
||||
});
|
||||
// Task #517: branch on the admin-configured open mode.
|
||||
// external_tab — open the externalUrl in a new tab
|
||||
// external_iframe — navigate to /embedded/:id which renders externalUrl
|
||||
// inside an iframe shell with a back button
|
||||
// internal (default) — navigate to the SPA route
|
||||
const mode = app.openMode ?? "internal";
|
||||
if (mode === "external_tab" && app.externalUrl) {
|
||||
window.open(app.externalUrl, "_blank", "noopener,noreferrer");
|
||||
return;
|
||||
}
|
||||
if (mode === "external_iframe" && app.externalUrl) {
|
||||
setLocation(`/embedded/${app.id}`);
|
||||
return;
|
||||
}
|
||||
setLocation(app.route);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user