import React from 'react'; import { useAppsQuery, AppVersion, AppListItem, ChangelogEntry, PaginatedResponse, BulkDeleteOutcome } from '@/hooks/use-query/useAppsQuery'; import { ActionIcons } from '@/components/ActionIcons'; import { EditVersionModal } from '@/components/modals/EditVersionModal'; import { DeleteConfirmationModal } from '@/components/modals/DeleteConfirmationModal'; import { DeleteVersionsConfirmationModal, SelectedVersion } from '@/components/modals/DeleteVersionsConfirmationModal'; import { DownloadArtifactsModal } from '@/components/modals/DownloadArtifactsModal'; import { EditAppModal } from '@/components/modals/EditAppModal'; import { DeleteAppConfirmationModal } from '@/components/modals/DeleteAppConfirmationModal'; import { CiUploadCommandModal } from '@/components/modals/CiUploadCommandModal'; import { useSearchParams } from 'react-router-dom'; import axiosInstance, { type ApiError } from '@/config/axios'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useSearch } from '@/hooks/useSearch.ts'; import { useFavoriteApps } from '@/hooks/useFavoriteApps'; import { usePlatformQuery } from '@/hooks/use-query/usePlatformQuery'; import { useArchitectureQuery } from '@/hooks/use-query/useArchitectureQuery'; import { useChannelQuery } from '@/hooks/use-query/useChannelQuery'; import { useToast } from '@/hooks/useToast'; import ReactMarkdown from 'react-markdown'; import { getPlatformIcon } from '@/utils/platformIcon'; import { useAppDataQuery } from '@/hooks/use-query/useAppDataQuery'; import { AppLogo } from '@/components/common/AppLogo'; import { Dropdown } from '@/components/common/Dropdown'; import { DownloadTokensSection } from '@/components/DownloadTokensSection'; import '@/styles/cards.css'; import { BTN_DANGER, PLATFORM_CHIP, SECTION_LABEL, STATUS_BADGE, STATUS_DOT, TUF_BADGE_STYLE, } from '@/components/common/ui'; const CHANGELOG_SLOT = 'relative flex-1 min-h-[42px]'; const FOOTER_SLOT = 'mt-4 min-h-[40px]'; // Clamps to as many whole lines as fit the parent's height. Content is absolutely // positioned so it never feeds back into the card height (which would stop it shrinking). const FillClamp: React.FC<{ className: string; children: React.ReactNode }> = ({ className, children }) => { const boxRef = React.useRef(null); const contentRef = React.useRef(null); const [lines, setLines] = React.useState(2); React.useLayoutEffect(() => { const box = boxRef.current; const content = contentRef.current; if (!box || !content) return; const slot = box.parentElement; const update = () => { const lineHeight = parseFloat(getComputedStyle(content).lineHeight) || 20; if (slot) slot.style.minHeight = `${Math.ceil(lineHeight * 2)}px`; setLines(Math.max(2, Math.floor(box.clientHeight / lineHeight))); }; update(); const observer = new ResizeObserver(update); observer.observe(box); return () => { observer.disconnect(); if (slot) slot.style.minHeight = ''; }; }, []); return (
{children}
); }; const SECTION = `${SECTION_LABEL} mt-5 mb-2`; interface DashboardProps { selectedApp: string | null; onAppClick: (appName: string) => void; onChangelogClick: (version: string, changelog: ChangelogEntry[]) => void; onBackClick: () => void; refreshKey?: number; searchTerm: string; } interface VersionFilters { channel: string; published: boolean | null; critical: boolean | null; platform: string; arch: string; } interface ReportKeyItem { id: string; app_id: string; app_name: string; key_value: string; updated_at: string; } interface ReportKeysResponse { report_keys: ReportKeyItem[]; } export const Dashboard: React.FC = ({ selectedApp, onAppClick, onChangelogClick, onBackClick, refreshKey = 0, searchTerm }) => { const [searchParams, setSearchParams] = useSearchParams(); const currentPage = parseInt(searchParams.get('page') || '1', 10); const queryClient = useQueryClient(); const [filters, setFilters] = React.useState({ channel: '', published: null, critical: null, platform: '', arch: '' }); React.useEffect(() => { if (!selectedApp || typeof window === 'undefined') { return; } window.scrollTo({ top: 0, behavior: 'auto' }); }, [selectedApp]); const { platforms } = usePlatformQuery(); const { architectures } = useArchitectureQuery(); const { channels } = useChannelQuery(); const { apps, updateApp, deleteApp, deleteVersions, fetchAllMatchingVersions, isLoading } = useAppsQuery( selectedApp || undefined, currentPage, refreshKey, filters ); const [selectedVersion, setSelectedVersion] = React.useState(null); const [showEditModal, setShowEditModal] = React.useState(false); const [showDeleteModal, setShowDeleteModal] = React.useState(false); const [showDownloadModal, setShowDownloadModal] = React.useState(false); const [expandedApps, setExpandedApps] = React.useState>({}); const [showEditAppModal, setShowEditAppModal] = React.useState(false); const [showDeleteAppModal, setShowDeleteAppModal] = React.useState(false); const [selectedAppData, setSelectedAppData] = React.useState(null); const [publishingTuf, setPublishingTuf] = React.useState>({}); const [openArtifactsPopoverId, setOpenArtifactsPopoverId] = React.useState(null); const [hoveredArtifactsPopoverId, setHoveredArtifactsPopoverId] = React.useState(null); const [suppressHoverArtifactsPopoverId, setSuppressHoverArtifactsPopoverId] = React.useState(null); const [isRegeneratingReportKey, setIsRegeneratingReportKey] = React.useState(false); const [selectionMode, setSelectionMode] = React.useState(false); const [selection, setSelection] = React.useState>(new Map()); const [isSelectingAll, setIsSelectingAll] = React.useState(false); const [showBulkDeleteModal, setShowBulkDeleteModal] = React.useState(false); const [showCiCommandModal, setShowCiCommandModal] = React.useState(false); const { toastSuccess, toastError } = useToast(); const appList = React.useMemo(() => { if (!apps) return []; if (Array.isArray(apps)) { return apps as AppListItem[]; } if ('items' in apps) { return (apps as PaginatedResponse).items; } return []; }, [apps]); const paginatedVersions = React.useMemo(() => { if (!apps) return { items: [], total: 0, page: 1, limit: 9 }; if ('items' in apps) { return apps as PaginatedResponse; } return { items: [], total: 0, page: 1, limit: 9 }; }, [apps]); const appVersions = paginatedVersions.items || []; const searchedAppList = useSearch(appList, searchTerm) as AppListItem[]; const { favorites, toggleFavorite } = useFavoriteApps(); const filteredAppList = React.useMemo(() => { if (!searchedAppList) return searchedAppList; return [ ...searchedAppList.filter(app => favorites.has(app.ID)), ...searchedAppList.filter(app => !favorites.has(app.ID)), ]; }, [searchedAppList, favorites]); const { data: appData } = useAppDataQuery(selectedApp); const { data: reportKeysData, isLoading: isReportKeysLoading } = useQuery({ queryKey: ['reportKeys', selectedApp], queryFn: async () => { const response = await axiosInstance.get('/report-keys/list'); return response.data as ReportKeysResponse; }, enabled: !!selectedApp, }); const reportKeyForApp = React.useMemo(() => { const keys = reportKeysData?.report_keys; if (!keys || keys.length === 0 || !selectedApp) { return null; } const matchedKeys = keys .filter((key) => key.app_id === appData?.ID || key.app_name === selectedApp) .sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()); return matchedKeys[0] || null; }, [reportKeysData, appData?.ID, selectedApp]); const handleCopyReportKey = async () => { if (!reportKeyForApp?.key_value) { return; } try { await navigator.clipboard.writeText(reportKeyForApp.key_value); toastSuccess('Report key copied'); } catch (error) { toastError('Failed to copy report key'); } }; const handleRegenerateReportKey = async () => { if (!appData?.ID || isRegeneratingReportKey) { return; } const confirmed = window.confirm( 'Are you sure you want to regenerate the report key? This can affect clients and prevent them from sending reports.' ); if (!confirmed) { return; } setIsRegeneratingReportKey(true); try { await axiosInstance.post('/report-keys/regenerate', { app_id: appData.ID, }); await queryClient.invalidateQueries({ queryKey: ['reportKeys', selectedApp] }); await queryClient.refetchQueries({ queryKey: ['reportKeys', selectedApp] }); toastSuccess('Report key regenerated'); } catch (e) { const error = e as ApiError; const errorMessage = error.response?.data?.message || error.message || 'Failed to regenerate report key'; toastError(errorMessage); } finally { setIsRegeneratingReportKey(false); } }; const handlePageChange = (page: number) => { setSearchParams({ page: page.toString() }); }; const handleDownload = (app: AppVersion) => { if (app.Artifacts.length === 1) { // First try to fetch the link with authentication axiosInstance.get(app.Artifacts[0].link) .then(response => { // Check if the response is JSON with a download_url if (response.data && typeof response.data === 'object' && 'download_url' in response.data) { // If it's a JSON with download_url, use that URL window.open(response.data.download_url, '_blank', 'noopener,noreferrer'); } else { // Otherwise, it's a direct link to a file, use it directly window.open(app.Artifacts[0].link, '_blank', 'noopener,noreferrer'); } }) .catch((error) => { if (error?.response?.status === 404) { toastError('Download is not available'); return; } // If there's an error (like 401), it might be a direct link to a public file // In that case, just open the link directly window.open(app.Artifacts[0].link, '_blank', 'noopener,noreferrer'); }); } else { setSelectedVersion(app); setShowDownloadModal(true); } }; const handleEdit = (app: AppVersion) => { setSelectedVersion(app); setShowEditModal(true); }; const handleDelete = (app: AppVersion) => { setSelectedVersion(app); setShowDeleteModal(true); }; const handleEditApp = (e: React.MouseEvent, app: AppListItem) => { e.stopPropagation(); setSelectedAppData(app); setShowEditAppModal(true); }; const handleDeleteApp = (e: React.MouseEvent, app: AppListItem) => { e.stopPropagation(); setSelectedAppData(app); setShowDeleteAppModal(true); }; const handleEditSave = async (data: { Published: boolean; Critical: boolean; Intermediate: boolean; Changelog: string; Platform?: string; Arch?: string; Files?: File[]; app_name: string; version: string; channel: string; rollout?: number; }) => { if (selectedVersion) { await updateApp(selectedVersion.ID, data); setShowEditModal(false); setSelectedVersion(null); queryClient.invalidateQueries({ queryKey: ['apps'] }); // Force a refetch to ensure we have the latest data await queryClient.refetchQueries({ queryKey: ['apps'] }); } }; const handleDeleteConfirm = async () => { if (selectedVersion) { await deleteApp(selectedVersion.ID); setShowDeleteModal(false); setSelectedVersion(null); queryClient.invalidateQueries({ queryKey: ['apps'] }); } }; const handleEditAppSave = async () => { setShowEditAppModal(false); setSelectedAppData(null); queryClient.invalidateQueries({ queryKey: ['apps'] }); queryClient.invalidateQueries({ queryKey: ['appData'] }); }; // Selection is page-local on purpose: what is selected must stay on screen, so // anything that changes the visible set drops it. "Select all matching" is the // one deliberate exception and it announces its own count. React.useEffect(() => { setSelectionMode(false); setSelection(new Map()); }, [selectedApp, currentPage, filters]); const toggleSelection = (app: AppVersion) => { setSelection(prev => { const next = new Map(prev); if (next.has(app.ID)) { next.delete(app.ID); } else { next.set(app.ID, { id: app.ID, version: app.Version, channel: app.Channel }); } return next; }); }; const handleSelectPage = () => { setSelection(new Map( appVersions.map(app => [app.ID, { id: app.ID, version: app.Version, channel: app.Channel }]) )); }; const handleSelectAllMatching = async () => { setIsSelectingAll(true); try { const all = await fetchAllMatchingVersions(paginatedVersions.total); setSelection(new Map( all.map(app => [app.ID, { id: app.ID, version: app.Version, channel: app.Channel }]) )); } catch { toastError('Failed to load all matching versions'); } finally { setIsSelectingAll(false); } }; const handleBulkDeleteConfirm = async ( ids: string[], onProgress: (done: number, total: number) => void, ): Promise => { const outcome = await deleteVersions(ids, onProgress); setSelection(prev => { const next = new Map(prev); outcome.deletedIds.forEach(id => next.delete(id)); return next; }); await queryClient.invalidateQueries({ queryKey: ['apps'] }); await queryClient.refetchQueries({ queryKey: ['apps'] }); if (outcome.deletedIds.length > 0) { toastSuccess(`Deleted ${outcome.deletedIds.length} version${outcome.deletedIds.length === 1 ? '' : 's'}`); } if (outcome.error) { toastError(outcome.error); } else { setSelectionMode(false); } return outcome; }; const handleDeleteAppConfirm = async () => { if (selectedAppData) { try { await axiosInstance.delete(`/app/delete?id=${selectedAppData.ID}`); setShowDeleteAppModal(false); setSelectedAppData(null); queryClient.invalidateQueries({ queryKey: ['apps'] }); } catch (error) { console.error('Error deleting app:', error); throw error; } } }; const totalPages = Math.ceil((paginatedVersions?.total || 0) / 9); const formatDate = (dateString: string) => { const date = new Date(dateString); const day = date.getDate(); const month = date.toLocaleString('en-US', { month: 'long' }); const year = date.getFullYear(); const hours = date.getHours().toString().padStart(2, '0'); const minutes = date.getMinutes().toString().padStart(2, '0'); return `${day} ${month} ${year}, ${hours}:${minutes}`; }; const handleTufPublish = async (e: React.MouseEvent, version: AppVersion) => { e.stopPropagation(); if (!appData?.ID) { toastError('App ID not found'); return; } const versionId = version.ID; setPublishingTuf(prev => ({ ...prev, [versionId]: true })); try { const response = await axiosInstance.post('/tuf/v1/artifacts/publish', { app_id: appData.ID, version: version.Version }); // Extract task_id from response const responseData = response.data?.data; const taskId = responseData?.task_id; if (taskId) { // Save to localStorage history (similar to bootstrap) const savedHistory = localStorage.getItem('tuf-history'); let history: Array<{ id: string; timestamp: string; appName: string; operation: 'generate' | 'bootstrap' | 'publish' | 'unsign'; status: 'success' | 'failed' | 'pending'; taskId?: string; version?: string; }> = []; if (savedHistory) { try { history = JSON.parse(savedHistory); } catch (e) { console.error('Failed to load TUF history:', e); } } const newEntry = { id: Date.now().toString() + Math.random().toString(36).substr(2, 9), timestamp: responseData.last_update || new Date().toISOString(), appName: selectedApp || appData.AppName, operation: 'publish' as const, status: 'pending' as const, taskId: taskId, }; const updatedHistory = [newEntry, ...history].slice(0, 20); // Keep last 20 entries localStorage.setItem('tuf-history', JSON.stringify(updatedHistory)); } toastSuccess(`TUF artifacts publishing successfully started for version ${version.Version}`); // Invalidate and refetch queries with correct parameters // Add a delay to allow server to process the request and update artifacts setTimeout(async () => { // Invalidate all apps queries queryClient.invalidateQueries({ queryKey: ['apps'] }); queryClient.invalidateQueries({ queryKey: ['appData', selectedApp] }); // Refetch with exact query key to ensure we get updated data await queryClient.refetchQueries({ queryKey: ['apps', selectedApp, currentPage, refreshKey, filters] }); await queryClient.refetchQueries({ queryKey: ['appData', selectedApp] }); }, 2000); // Also refetch after a longer delay to catch status updates (when signing completes) setTimeout(async () => { await queryClient.refetchQueries({ queryKey: ['apps', selectedApp, currentPage, refreshKey, filters] }); }, 5000); } catch (e) { const error = e as ApiError; const errorMessage = error.response?.data?.message || error.message || 'Failed to publish TUF artifacts'; toastError(errorMessage); } finally { setPublishingTuf(prev => ({ ...prev, [versionId]: false })); } }; const getTufSignStatus = (version: AppVersion): 'all-signed' | 'partial' | 'none' | null => { if (!appData?.Tuf) { return null; } // Consider artifacts that have TufTaskID (not null/undefined) OR have TufTaskID === null and TufSigned === false const tufArtifacts = version.Artifacts.filter(artifact => { // Feeds are the updater's index, not a TUF target: the server never signs them, // so counting them would leave every version stuck on 'partial'. if (artifact.IsFeed) { return false; } // Include artifacts with TufTaskID (not null/undefined) if (artifact.TufTaskID) { return true; } // Also include artifacts with TufTaskID === null and TufSigned === false (not signed) if (artifact.TufTaskID === null && artifact.TufSigned === false) { return true; } return false; }); // If TUF is enabled but no artifacts match the criteria, check if there are any artifacts at all if (tufArtifacts.length === 0) { if (version.Artifacts.some(artifact => !artifact.IsFeed)) { return 'none'; } else { return null; } } // Count signed artifacts (TufSigned === true) const signedCount = tufArtifacts.filter(artifact => artifact.TufSigned === true).length; if (signedCount === tufArtifacts.length) { return 'all-signed'; } if (signedCount > 0) { return 'partial'; } return 'none'; }; const getArtifactSummary = (artifacts: AppVersion['Artifacts']) => { const groupedByPlatform = artifacts.reduce>((acc, artifact) => { const rawPlatform = artifact.platform?.trim() || 'N/A'; const key = rawPlatform.toLowerCase(); if (!acc[key]) { acc[key] = { count: 0, label: rawPlatform, unsigned: 0 }; } acc[key].count += 1; if (artifact.TufSigned === false && !artifact.IsFeed) { acc[key].unsigned += 1; } return acc; }, {}); const sortedGroups = Object.entries(groupedByPlatform) .sort((a, b) => b[1].count - a[1].count || a[1].label.localeCompare(b[1].label)) .map(([, value]) => ({ count: value.count, label: value.label, unsigned: value.unsigned, })); const visibleGroups = sortedGroups.slice(0, 6); const hiddenGroupsCount = Math.max(sortedGroups.length - visibleGroups.length, 0); return { visibleSummary: visibleGroups.map(item => `${item.label}(${item.count})`).join(' '), visibleGroups, hiddenGroupsCount, details: artifacts.map(artifact => { const platform = artifact.platform?.trim() || 'N/A'; const arch = artifact.arch?.trim() || 'N/A'; const pkg = artifact.package?.trim() || 'N/A'; return `${platform}/${arch} - ${pkg}`; }), }; }; if (selectedApp) { return (
{appData?.Reports && (
Report key {isReportKeysLoading ? (

Loading...

) : reportKeyForApp?.key_value ? ( <>

{reportKeyForApp.key_value}

) : (

Not available yet

)} {reportKeyForApp?.key_value && (
Updated: {formatDate(reportKeyForApp.updated_at)}
)}
)}
{appData?.Private && appData.ID && ( )} {/* Filters Section */}
setFilters(prev => ({ ...prev, channel }))} options={[ { value: '', label: 'All channels' }, ...channels.map(channel => ({ value: channel.ChannelName, label: channel.ChannelName })), ]} /> setFilters(prev => ({ ...prev, platform }))} options={[ { value: '', label: 'All platforms' }, ...platforms.map(platform => ({ value: platform.PlatformName, label: platform.PlatformName, icon: getPlatformIcon(platform.PlatformName), })), ]} /> setFilters(prev => ({ ...prev, arch }))} options={[ { value: '', label: 'All architectures' }, ...architectures.map(arch => ({ value: arch.ArchID, label: arch.ArchID })), ]} /> ariaLabel="Publication status" placeholder="Publication status" value={filters.published} onChange={(published) => setFilters(prev => ({ ...prev, published }))} options={[ { value: null, label: 'Any publication status' }, { value: true, label: 'Published' }, { value: false, label: 'Not published' }, ]} /> ariaLabel="Critical status" placeholder="Critical status" value={filters.critical} onChange={(critical) => setFilters(prev => ({ ...prev, critical }))} options={[ { value: null, label: 'Any critical status' }, { value: true, label: 'Critical' }, { value: false, label: 'Not critical' }, ]} />
{appVersions.length > 0 && ( )} {/* Reset Filters Button */} {(filters.channel || filters.platform || filters.arch || filters.published !== null || filters.critical !== null) && ( )}
{isLoading ? (
) : appVersions.length === 0 ? (
No versions have been uploaded yet.
) : ( appVersions.map((app) => { const tufStatus = getTufSignStatus(app); const isDangerZone = app.Published && tufStatus !== null && tufStatus !== 'all-signed'; const isIncompleteRollout = app.Published && app.RolloutPercent != null && app.RolloutPercent < 100; const artifactSummary = getArtifactSummary(app.Artifacts); const isSelected = selection.has(app.ID); return (
toggleSelection(app) : undefined} className={`sharedCard backdrop-blur-lg rounded-lg p-6 text-theme-primary transition-all relative ${ isDangerZone ? 'border-2 border-red-500' : isIncompleteRollout ? 'border-2 border-amber-500 bg-theme-card hover:bg-theme-card-hover' : 'bg-theme-card hover:bg-theme-card-hover' } ${selectionMode ? 'cursor-pointer' : ''} ${ isSelected ? 'ring-2 ring-purple-400' : '' }`} style={{ '--card-color': isDangerZone ? '#EF4444' : isIncompleteRollout ? '#F59E0B' : '#8B5CF6', ...(isDangerZone ? { backgroundColor: 'rgba(239, 68, 68, 0.25)', boxShadow: '0 10px 25px -5px rgba(239, 68, 68, 0.5), 0 0 0 1px rgba(239, 68, 68, 0.3)', animation: 'pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite' } : isIncompleteRollout ? { animation: 'glowAmber 2.4s ease-in-out infinite' } : {}) }} onMouseEnter={(e) => { if (isDangerZone) { (e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(239, 68, 68, 0.35)'; } }} onMouseLeave={(e) => { if (isDangerZone) { (e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(239, 68, 68, 0.25)'; } }} >
{selectionMode ? ( ) : ( handleDownload(app)} onEdit={() => handleEdit(app)} onDelete={() => handleDelete(app)} showDownload={app.Artifacts.length === 1 ? !!app.Artifacts[0].link : true} artifactLink={app.Artifacts.length === 1 ? app.Artifacts[0].link : undefined} /> )}

{app.Version}

{app.Channel} {formatDate(app.Updated_at)}

{app.Published ? 'Published' : 'Not published'} {app.Critical && ( Critical )} {app.Intermediate && ( Intermediate )} {tufStatus && ( {TUF_BADGE_STYLE[tufStatus].label} )} {isIncompleteRollout && ( Rollout {app.RolloutPercent}% )}
{app.Artifacts.length > 0 && ( <>

Artifacts

{ setHoveredArtifactsPopoverId(app.ID); }} onMouseLeave={() => { setHoveredArtifactsPopoverId(prev => (prev === app.ID ? null : prev)); setSuppressHoverArtifactsPopoverId(prev => (prev === app.ID ? null : prev)); }} >
{ e.stopPropagation(); setOpenArtifactsPopoverId(null); setSuppressHoverArtifactsPopoverId(app.ID); }} >

Artifact details

{artifactSummary.details.map((detail, index) => (

{detail}

))}
)} {/* The grid stretches every card to the tallest in its row; the changelog absorbs that extra height so the actions stay on one baseline. */}
{tufStatus && tufStatus !== 'all-signed' && (
)}
{app.Changelog?.[0]?.Changes?.trim() ? (

{children}

, ul: ({ children }) =>
    {children}
, ol: ({ children }) =>
    {children}
, li: ({ children }) =>
  • {children}
  • , strong: ({ children }) => {children}, em: ({ children }) => {children}, code: ({ children }) => {children}, h1: ({ children }) =>

    {children}

    , h2: ({ children }) =>

    {children}

    , h3: ({ children }) =>

    {children}

    , }} > {app.Changelog[0].Changes}
    ) : (

    Changelog not provided

    )}
    {app.Changelog?.[0]?.Changes?.trim() && ( )}
    ); }) )}
    {selectionMode && (
    {selection.size} selected {paginatedVersions.total > appVersions.length && ( )}
    )} {totalPages > 1 && (
    Page {currentPage} of {totalPages}
    )} {showEditModal && selectedVersion && ( { setShowEditModal(false); setSelectedVersion(null); }} onSave={handleEditSave} /> )} {showDeleteModal && selectedVersion && ( { setShowDeleteModal(false); setSelectedVersion(null); }} onConfirm={handleDeleteConfirm} /> )} {showBulkDeleteModal && selectedApp && ( setShowBulkDeleteModal(false)} onConfirm={handleBulkDeleteConfirm} /> )} {showDownloadModal && selectedVersion && ( { setShowDownloadModal(false); setSelectedVersion(null); }} /> )} {showCiCommandModal && ( setShowCiCommandModal(false)} /> )}
    ); } return (
    {isLoading ? (
    ) : !filteredAppList || filteredAppList.length === 0 ? (
    {searchTerm ? 'No applications found matching your search.' : 'No applications have been created yet.'}
    ) : ( filteredAppList.map((app) => (
    onAppClick(app.AppName)} className={"bg-theme-card backdrop-blur-lg rounded-lg p-6 text-theme-primary hover:bg-theme-card-hover transition-colors cursor-pointer sharedCard"} style={{ '--card-color': '#8B5CF6' }} >
    {app.Private && (
    )}

    {app.AppName}

    {(app.Tuf || app.Reports || app.CdnEdge) && (
    {app.Tuf && ( TUF )} {app.Reports && ( Reports )} {app.CdnEdge && ( CDN )}
    )}

    {app.Description || 'No description available'}

    {app.Description && app.Description.length > 50 && ( )}
    )) )} {showEditAppModal && selectedAppData && ( { setShowEditAppModal(false); setSelectedAppData(null); }} onSuccess={handleEditAppSave} appData={{ id: selectedAppData.ID, app: selectedAppData.AppName, description: selectedAppData.Description, logo: selectedAppData.Logo, tuf: selectedAppData.Tuf, reports: selectedAppData.Reports, cdn: selectedAppData.CdnEdge, private: selectedAppData.Private, downloadMode: selectedAppData.DownloadMode }} /> )} {showDeleteAppModal && selectedAppData && ( { setShowDeleteAppModal(false); setSelectedAppData(null); }} onConfirm={handleDeleteAppConfirm} /> )}
    ); };