Ctrl K
ComunidadGuías…

dashboard/src/components/Dashboard.tsx

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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<HTMLDivElement>(null);
  const contentRef = React.useRef<HTMLDivElement>(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 (
    <div ref={boxRef} className="absolute inset-0 overflow-hidden">
      <div
        ref={contentRef}
        className={className}
        style={{ display: '-webkit-box', WebkitBoxOrient: 'vertical', WebkitLineClamp: lines, overflow: 'hidden' }}
      >
        {children}
      </div>
    </div>
  );
};

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<DashboardProps> = ({
  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<VersionFilters>({
    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<AppVersion | null>(null);
  const [showEditModal, setShowEditModal] = React.useState(false);
  const [showDeleteModal, setShowDeleteModal] = React.useState(false);
  const [showDownloadModal, setShowDownloadModal] = React.useState(false);
  const [expandedApps, setExpandedApps] = React.useState<Record<string, boolean>>({});
  const [showEditAppModal, setShowEditAppModal] = React.useState(false);
  const [showDeleteAppModal, setShowDeleteAppModal] = React.useState(false);
  const [selectedAppData, setSelectedAppData] = React.useState<AppListItem | null>(null);
  const [publishingTuf, setPublishingTuf] = React.useState<Record<string, boolean>>({});
  const [openArtifactsPopoverId, setOpenArtifactsPopoverId] = React.useState<string | null>(null);
  const [hoveredArtifactsPopoverId, setHoveredArtifactsPopoverId] = React.useState<string | null>(null);
  const [suppressHoverArtifactsPopoverId, setSuppressHoverArtifactsPopoverId] = React.useState<string | null>(null);
  const [isRegeneratingReportKey, setIsRegeneratingReportKey] = React.useState(false);
  const [selectionMode, setSelectionMode] = React.useState(false);
  const [selection, setSelection] = React.useState<Map<string, SelectedVersion>>(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<AppVersion>).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<AppVersion>;
    }
    return { items: [], total: 0, page: 1, limit: 9 };
  }, [apps]);

  const appVersions = paginatedVersions.items || [];

  const searchedAppList = useSearch<AppListItem | AppVersion>(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<BulkDeleteOutcome> => {
    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<Record<string, { count: number; label: string; unsigned: number }>>((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 (
      <div className="mt-8">
        <div className="flex flex-col lg:flex-row lg:items-center gap-4 mb-6">
        <button
          onClick={onBackClick}
          className="self-start px-4 py-2 bg-theme-card text-theme-primary rounded-lg hover:bg-theme-card-hover transition-colors flex items-center gap-2"
        >
          <svg 
            xmlns="http://www.w3.org/2000/svg" 
            width="16" 
            height="16" 
            viewBox="0 0 24 24" 
            fill="none" 
            stroke="currentColor" 
            strokeWidth="2" 
            strokeLinecap="round" 
            strokeLinejoin="round"
          >
            <path d="M19 12H5M12 19l-7-7 7-7"/>
          </svg>
          Back
        </button>

        <button
          onClick={() => setShowCiCommandModal(true)}
          className="self-start px-4 py-2 bg-theme-card text-theme-primary rounded-lg hover:bg-theme-card-hover transition-colors flex items-center gap-2"
        >
          <i className="fas fa-terminal"></i>
          CI command
        </button>

          {appData?.Reports && (
            <div
              className="relative group w-full lg:w-auto lg:ml-auto lg:min-w-[420px] h-12 rounded-lg border border-blue-400/30 bg-blue-500/10 px-3 flex items-center gap-2"
            >
              <span className="inline-flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-blue-200 flex-shrink-0">
                <svg
                  className="w-3.5 h-3.5"
                  fill="none"
                  stroke="currentColor"
                  viewBox="0 0 24 24"
                >
                  <path
                    strokeLinecap="round"
                    strokeLinejoin="round"
                    strokeWidth="2"
                    d="M7 8h10M7 12h6m-6 4h10M5 21h14a2 2 0 002-2V7l-5-5H5a2 2 0 00-2 2v15a2 2 0 002 2z"
                  />
                </svg>
                Report key
              </span>

              {isReportKeysLoading ? (
                <p className="text-xs text-white/70 truncate">Loading...</p>
              ) : reportKeyForApp?.key_value ? (
                <>
                  <p
                    className="font-mono text-xs text-white/95 overflow-x-auto whitespace-nowrap flex-1 min-w-0"
                  >
                    {reportKeyForApp.key_value}
                  </p>
                  <div className="flex items-center gap-1.5 flex-shrink-0">
                    <button
                      type="button"
                      onClick={handleCopyReportKey}
                      className="p-1.5 rounded-md bg-theme-card text-theme-primary hover:bg-theme-card-hover transition-colors"
                      aria-label="Copy report key"
                      title="Copy report key"
                    >
                      <svg
                        className="w-3.5 h-3.5"
                        fill="none"
                        stroke="currentColor"
                        viewBox="0 0 24 24"
                      >
                        <path
                          strokeLinecap="round"
                          strokeLinejoin="round"
                          strokeWidth="2"
                          d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
                        />
                      </svg>
                    </button>
                    <button
                      type="button"
                      onClick={handleRegenerateReportKey}
                      disabled={isRegeneratingReportKey}
                      className="p-1.5 rounded-md bg-red-500/20 text-red-300 border border-red-400/30 hover:bg-red-500/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
                      aria-label="Regenerate report key"
                      title={isRegeneratingReportKey ? 'Regenerating...' : 'Regenerate report key'}
                    >
                      <svg
                        className={`w-3.5 h-3.5 ${isRegeneratingReportKey ? 'animate-spin' : ''}`}
                        fill="none"
                        stroke="currentColor"
                        viewBox="0 0 24 24"
                      >
                        <path
                          strokeLinecap="round"
                          strokeLinejoin="round"
                          strokeWidth="2"
                          d="M21 12a9 9 0 10-2.64 6.36M21 3v6h-6"
                        />
                      </svg>
                    </button>
                  </div>
                </>
              ) : (
                <p className="text-xs text-white/70 truncate">Not available yet</p>
              )}

              {reportKeyForApp?.key_value && (
                <div className="pointer-events-none absolute left-1/2 top-full z-20 mt-2 -translate-x-1/2 whitespace-nowrap rounded-md border border-theme-card-hover bg-gray-900 px-2 py-1 text-[11px] text-theme-primary opacity-0 shadow-lg transition-opacity duration-150 group-hover:opacity-100">
                  Updated: {formatDate(reportKeyForApp.updated_at)}
                </div>
              )}
            </div>
          )}
        </div>

        {appData?.Private && appData.ID && (
          <DownloadTokensSection appId={appData.ID} downloadMode={appData.DownloadMode} formatDate={formatDate} />
        )}

        {/* Filters Section */}
        <div className="mb-6">
          <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4 mb-4">
            <Dropdown
              ariaLabel="Channel"
              placeholder="All channels"
              value={filters.channel}
              onChange={(channel) => setFilters(prev => ({ ...prev, channel }))}
              options={[
                { value: '', label: 'All channels' },
                ...channels.map(channel => ({ value: channel.ChannelName, label: channel.ChannelName })),
              ]}
            />

            <Dropdown
              ariaLabel="Platform"
              placeholder="All platforms"
              value={filters.platform}
              onChange={(platform) => setFilters(prev => ({ ...prev, platform }))}
              options={[
                { value: '', label: 'All platforms' },
                ...platforms.map(platform => ({
                  value: platform.PlatformName,
                  label: platform.PlatformName,
                  icon: getPlatformIcon(platform.PlatformName),
                })),
              ]}
            />

            <Dropdown
              ariaLabel="Architecture"
              placeholder="All architectures"
              value={filters.arch}
              onChange={(arch) => setFilters(prev => ({ ...prev, arch }))}
              options={[
                { value: '', label: 'All architectures' },
                ...architectures.map(arch => ({ value: arch.ArchID, label: arch.ArchID })),
              ]}
            />

            <Dropdown<boolean | null>
              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' },
              ]}
            />

            <Dropdown<boolean | null>
              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' },
              ]}
            />
          </div>

          <div className="flex flex-wrap items-center gap-3">
          {appVersions.length > 0 && (
            <button
              onClick={() => {
                setSelectionMode(prev => !prev);
                setSelection(new Map());
              }}
              className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-colors text-theme-primary ${
                selectionMode ? 'bg-theme-card-hover' : 'bg-theme-card hover:bg-theme-card-hover'
              }`}
            >
              <i className={`fas ${selectionMode ? 'fa-times' : 'fa-check-square'}`}></i>
              {selectionMode ? 'Cancel selection' : 'Select'}
            </button>
          )}

          {/* Reset Filters Button */}
          {(filters.channel || filters.platform || filters.arch || filters.published !== null || filters.critical !== null) && (
            <button
              onClick={() => setFilters({
                channel: '',
                published: null,
                critical: null,
                platform: '',
                arch: ''
              })}
              className="flex items-center gap-2 px-4 py-2 bg-theme-card hover:bg-theme-card-hover text-theme-primary rounded-lg transition-colors"
            >
              <svg 
                xmlns="http://www.w3.org/2000/svg" 
                width="16" 
                height="16" 
                viewBox="0 0 24 24" 
                fill="none" 
                stroke="currentColor" 
                strokeWidth="2" 
                strokeLinecap="round" 
                strokeLinejoin="round"
              >
                <line x1="18" y1="6" x2="6" y2="18"></line>
                <line x1="6" y1="6" x2="18" y2="18"></line>
              </svg>
              Reset Filters
            </button>
          )}
          </div>
        </div>

        <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
          {isLoading ? (
            <div className="col-span-full flex justify-center items-center h-64">
              <div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-theme-primary"></div>
            </div>
          ) : appVersions.length === 0 ? (
            <div className="col-span-full text-center text-theme-primary text-xl">
              No versions have been uploaded yet.
            </div>
          ) : (
            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 (
              <div
                key={app.ID}
                onClick={selectionMode ? () => 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)';
                  }
                }}
              >
                <div className={`flex items-center mb-4 min-w-0 w-full ${selectionMode ? 'justify-start' : 'justify-end'}`}>
                  <div className="flex gap-2 flex-shrink-0 items-center">
                    {selectionMode ? (
                      <label
                        className="flex items-center gap-2 cursor-pointer text-sm font-semibold text-theme-primary"
                        onClick={(e) => e.stopPropagation()}
                      >
                        <input
                          type="checkbox"
                          checked={isSelected}
                          onChange={() => toggleSelection(app)}
                          className="h-4 w-4 cursor-pointer accent-purple-500"
                        />
                        {isSelected ? 'Selected' : 'Select'}
                      </label>
                    ) : (
                      <ActionIcons
                        onDownload={() => 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}
                      />
                    )}
                  </div>
                </div>
                <div className="sharedCardContent relative flex w-full min-w-0 flex-col">
                  <h3
                    className="text-2xl font-extrabold tracking-tight text-theme-primary"
                    title={`Version ${app.Version}`}
                  >
                    {app.Version}
                  </h3>
                  <p className="mt-1 mb-6 text-sm text-white/70">
                    <span className="mr-2 inline-flex items-center rounded-full border border-purple-300/45 bg-purple-500/30 px-2 py-0.5 text-xs font-semibold text-purple-100">
                      {app.Channel}
                    </span>
                    {formatDate(app.Updated_at)}
                  </p>
                  <div className="flex flex-wrap gap-2">
                    <span className={`${STATUS_BADGE} ${
                      app.Published
                        ? 'text-green-300 border-green-500/40'
                        : 'text-slate-200 border-slate-400/40'
                    }`}>
                      <span className={`${STATUS_DOT} ${app.Published ? 'bg-green-500' : 'bg-slate-400'}`}></span>
                      {app.Published ? 'Published' : 'Not published'}
                    </span>
                    {app.Critical && (
                      <span className={`${STATUS_BADGE} text-red-300 border-red-500/45`}>
                        <span className={`${STATUS_DOT} bg-red-500`}></span>
                        Critical
                      </span>
                    )}
                    {app.Intermediate && (
                      <span className={`${STATUS_BADGE} text-amber-300 border-amber-500/45`}>
                        <span className={`${STATUS_DOT} bg-amber-500`}></span>
                        Intermediate
                      </span>
                    )}
                    {tufStatus && (
                      <span
                        className={`${STATUS_BADGE} ${TUF_BADGE_STYLE[tufStatus].badge}`}
                        title={TUF_BADGE_STYLE[tufStatus].hint}
                      >
                        <span className={`${STATUS_DOT} ${TUF_BADGE_STYLE[tufStatus].dot}`}></span>
                        {TUF_BADGE_STYLE[tufStatus].label}
                      </span>
                    )}
                    {isIncompleteRollout && (
                      <span
                        className={`${STATUS_BADGE} text-blue-300 border-blue-500/45`}
                        title="Staged rollout is not fully deployed"
                      >
                        <span className={`${STATUS_DOT} bg-blue-500`}></span>
                        Rollout {app.RolloutPercent}%
                      </span>
                    )}
                  </div>
                  {app.Artifacts.length > 0 && (
                    <>
                    <p className={SECTION}>Artifacts</p>
                    <div
                      className="relative group w-full min-w-0"
                      onMouseEnter={() => {
                        setHoveredArtifactsPopoverId(app.ID);
                      }}
                      onMouseLeave={() => {
                        setHoveredArtifactsPopoverId(prev => (prev === app.ID ? null : prev));
                        setSuppressHoverArtifactsPopoverId(prev => (prev === app.ID ? null : prev));
                      }}
                    >
                      <button
                        type="button"
                        className="flex w-full min-w-0 max-w-full flex-wrap gap-2 text-left"
                        title={`Artifacts: ${artifactSummary.visibleSummary}${artifactSummary.hiddenGroupsCount > 0 ? ` +${artifactSummary.hiddenGroupsCount}` : ''}`}
                        onClick={(e) => {
                          e.stopPropagation();
                          setOpenArtifactsPopoverId(prev => {
                            const isClosing = prev === app.ID;
                            if (isClosing) {
                              setSuppressHoverArtifactsPopoverId(app.ID);
                              return null;
                            }
                            setSuppressHoverArtifactsPopoverId(null);
                            return app.ID;
                          });
                        }}
                      >
                        {artifactSummary.visibleGroups.map(group => (
                          <span key={group.label} className={PLATFORM_CHIP}>
                            <i className={`${getPlatformIcon(group.label)} opacity-90`}></i>
                            {group.label}
                            <b className="font-bold tabular-nums">{group.count}</b>
                            {tufStatus && group.unsigned > 0 && (
                              <i
                                className="fas fa-shield-alt text-[11px] text-amber-300"
                                title={`${group.unsigned} not signed`}
                              ></i>
                            )}
                          </span>
                        ))}
                        {artifactSummary.hiddenGroupsCount > 0 && (
                          <span className={PLATFORM_CHIP}>+{artifactSummary.hiddenGroupsCount}</span>
                        )}
                      </button>
                      <div
                        className={`absolute left-0 right-0 bottom-full z-20 mb-2 rounded-lg border border-theme-card-hover bg-gray-900 p-3 shadow-xl transition-opacity duration-150 max-h-44 overflow-hidden flex flex-col ${
                          openArtifactsPopoverId === app.ID ||
                          (hoveredArtifactsPopoverId === app.ID && suppressHoverArtifactsPopoverId !== app.ID)
                            ? 'opacity-100 pointer-events-auto'
                            : 'opacity-0 pointer-events-none'
                        }`}
                        onClick={(e) => {
                          e.stopPropagation();
                          setOpenArtifactsPopoverId(null);
                          setSuppressHoverArtifactsPopoverId(app.ID);
                        }}
                      >
                        <p className="mb-2 text-xs font-semibold text-theme-primary">
                          Artifact details
                        </p>
                        <div className="overflow-y-auto pr-1 flex-1 min-h-0">
                          {artifactSummary.details.map((detail, index) => (
                            <p key={`${app.ID}-artifact-${index}`} className="text-xs text-white/80 break-all mb-1 last:mb-0">
                              {detail}
                            </p>
                          ))}
                        </div>
                      </div>
                    </div>
                    </>
                  )}
                  {/* The grid stretches every card to the tallest in its row; the changelog
                      absorbs that extra height so the actions stay on one baseline. */}
                  <div className="flex flex-1 flex-col pt-4">
                  {tufStatus && tufStatus !== 'all-signed' && (
                  <div className="mb-3">
                      <button
                        onClick={(e) => handleTufPublish(e, app)}
                        disabled={publishingTuf[app.ID]}
                        className="flex w-full items-center justify-center gap-2 rounded-lg border border-amber-500/55 bg-violet-950/50 px-3 py-2 text-[13px] font-bold text-amber-300 transition-colors hover:bg-amber-500/20 disabled:cursor-not-allowed disabled:opacity-50"
                        title="Publish TUF artifacts"
                      >
                        <i className={`fas ${publishingTuf[app.ID] ? 'fa-spinner fa-spin' : 'fa-shield-alt'}`}></i>
                        {publishingTuf[app.ID] ? 'Signing…' : 'Sign remaining artifacts with TUF'}
                      </button>
                  </div>
                  )}
                  <div className={`${CHANGELOG_SLOT} border-l-2 border-white/20`}>
                    {app.Changelog?.[0]?.Changes?.trim() ? (
                      <FillClamp className="pl-3 text-sm text-white/80 prose prose-sm prose-invert max-w-none">
                        <ReactMarkdown
                          components={{
                            p: ({ children }) => <p className="m-0">{children}</p>,
                            ul: ({ children }) => <ul className="m-0 pl-4">{children}</ul>,
                            ol: ({ children }) => <ol className="m-0 pl-4">{children}</ol>,
                            li: ({ children }) => <li className="m-0">{children}</li>,
                            strong: ({ children }) => <strong className="font-semibold">{children}</strong>,
                            em: ({ children }) => <em className="italic">{children}</em>,
                            code: ({ children }) => <code className="bg-theme-input px-1 rounded text-xs">{children}</code>,
                            h1: ({ children }) => <h1 className="text-base font-bold m-0">{children}</h1>,
                            h2: ({ children }) => <h2 className="text-sm font-bold m-0">{children}</h2>,
                            h3: ({ children }) => <h3 className="text-sm font-semibold m-0">{children}</h3>,
                          }}
                        >
                          {app.Changelog[0].Changes}
                        </ReactMarkdown>
                      </FillClamp>
                    ) : (
                      <p className="pl-3 text-sm text-white/60 italic">
                        Changelog not provided
                      </p>
                    )}
                  </div>
                  <div className={FOOTER_SLOT}>
                    {app.Changelog?.[0]?.Changes?.trim() && (
                      <button
                        onClick={(e) => {
                          e.stopPropagation();
                          onChangelogClick(app.Version, app.Changelog);
                        }}
                        className="px-4 py-2 bg-theme-card text-theme-primary rounded-lg hover:bg-theme-card-hover transition-colors flex items-center gap-2"
                      >
                        View full changelog
                      </button>
                    )}
                  </div>
                  </div>
                </div>
              </div>
              );
            })
          )}
        </div>

        {selectionMode && (
          <div className="sticky bottom-4 z-30 mt-6 flex flex-wrap items-center gap-3 rounded-lg border border-white/20 bg-violet-950/80 px-4 py-3 backdrop-blur-lg">
            <span className="font-semibold text-theme-primary">
              {selection.size} selected
            </span>

            <button
              onClick={handleSelectPage}
              className="rounded-lg border border-white/25 px-3 py-1.5 text-sm font-semibold text-theme-primary transition-colors hover:bg-white/10"
            >
              Select all on page
            </button>

            {paginatedVersions.total > appVersions.length && (
              <button
                onClick={handleSelectAllMatching}
                disabled={isSelectingAll}
                className="rounded-lg border border-white/25 px-3 py-1.5 text-sm font-semibold text-theme-primary transition-colors hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-50"
              >
                {isSelectingAll ? 'Loading...' : `Select all ${paginatedVersions.total} matching filters`}
              </button>
            )}

            <button
              onClick={() => setSelection(new Map())}
              disabled={selection.size === 0}
              className="rounded-lg border border-white/25 px-3 py-1.5 text-sm font-semibold text-theme-primary transition-colors hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-50"
            >
              Clear
            </button>

            <button
              onClick={() => setShowBulkDeleteModal(true)}
              disabled={selection.size === 0}
              className={`ml-auto ${BTN_DANGER}`}
            >
              Delete {selection.size} version{selection.size === 1 ? '' : 's'}
            </button>
          </div>
        )}

        {totalPages > 1 && (
          <div className="flex justify-center gap-2 mt-8">
            <button
              onClick={() => handlePageChange(1)}
              disabled={currentPage === 1}
              className="px-4 py-2 rounded-lg bg-theme-card text-theme-primary hover:bg-theme-card-hover disabled:opacity-50 disabled:cursor-not-allowed"
              title="First page"
            >
              <i className="fas fa-angle-double-left"></i>
            </button>
            <button
              onClick={() => handlePageChange(Math.max(1, currentPage - 1))}
              disabled={currentPage === 1}
              className="px-4 py-2 rounded-lg bg-theme-card text-theme-primary hover:bg-theme-card-hover disabled:opacity-50 disabled:cursor-not-allowed"
              title="Previous page"
            >
              <i className="fas fa-angle-left"></i>
            </button>
            <span className="px-4 py-2 text-theme-primary">
              Page {currentPage} of {totalPages}
            </span>
            <button
              onClick={() => handlePageChange(Math.min(totalPages, currentPage + 1))}
              disabled={currentPage === totalPages}
              className="px-4 py-2 rounded-lg bg-theme-card text-theme-primary hover:bg-theme-card-hover disabled:opacity-50 disabled:cursor-not-allowed"
              title="Next page"
            >
              <i className="fas fa-angle-right"></i>
            </button>
            <button
              onClick={() => handlePageChange(totalPages)}
              disabled={currentPage === totalPages}
              className="px-4 py-2 rounded-lg bg-theme-card text-theme-primary hover:bg-theme-card-hover disabled:opacity-50 disabled:cursor-not-allowed"
              title="Last page"
            >
              <i className="fas fa-angle-double-right"></i>
            </button>
          </div>
        )}

        {showEditModal && selectedVersion && (
          <EditVersionModal
            appName={selectedApp}
            version={selectedVersion.Version}
            channel={selectedVersion.Channel}
            currentData={{
              ID: selectedVersion.ID,
              Published: selectedVersion.Published,
              Critical: selectedVersion.Critical,
              Intermediate: selectedVersion.Intermediate,
              RolloutPercent: selectedVersion.RolloutPercent,
              Changelog: selectedVersion.Changelog[0]?.Changes || '',
              Artifacts: selectedVersion.Artifacts
            }}
            onClose={() => {
              setShowEditModal(false);
              setSelectedVersion(null);
            }}
            onSave={handleEditSave}
          />
        )}

        {showDeleteModal && selectedVersion && (
          <DeleteConfirmationModal
            version={`${selectedVersion.Version}`}
            onClose={() => {
              setShowDeleteModal(false);
              setSelectedVersion(null);
            }}
            onConfirm={handleDeleteConfirm}
          />
        )}

        {showBulkDeleteModal && selectedApp && (
          <DeleteVersionsConfirmationModal
            appName={selectedApp}
            versions={[...selection.values()]}
            onClose={() => setShowBulkDeleteModal(false)}
            onConfirm={handleBulkDeleteConfirm}
          />
        )}

        {showDownloadModal && selectedVersion && (
          <DownloadArtifactsModal
            artifacts={selectedVersion.Artifacts}
            onClose={() => {
              setShowDownloadModal(false);
              setSelectedVersion(null);
            }}
          />
        )}

        {showCiCommandModal && (
          <CiUploadCommandModal
            appName={selectedApp}
            isTuf={Boolean(appData?.Tuf)}
            isPrivate={Boolean(appData?.Private)}
            onClose={() => setShowCiCommandModal(false)}
          />
        )}
      </div>
    );
  }

  return (
    <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mt-8">
      {isLoading ? (
        <div className="col-span-full flex justify-center items-center h-64">
          <div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-theme-primary"></div>
        </div>
      ) : !filteredAppList || filteredAppList.length === 0 ? (
        <div className="col-span-full text-center text-theme-primary text-xl">
          {searchTerm ? 'No applications found matching your search.' : 'No applications have been created yet.'}
        </div>
      ) : (
        filteredAppList.map((app) => (
          <div
            key={app.ID}
            onClick={() => 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' }}
          >
            <div className="flex items-start mb-4 min-w-0 w-full">
              <div className="relative w-12 h-12 flex-shrink-0">
                <div className="sharedCardIcon w-12 h-12">
                  <AppLogo name={app.AppName} logo={app.Logo} />
                </div>
                {app.Private && (
                  <div className="absolute -bottom-1 -right-1 bg-red-500 rounded-full p-1 z-10">
                    <svg 
                      className="w-3 h-3 text-theme-primary" 
                      fill="none" 
                      stroke="currentColor" 
                      viewBox="0 0 24 24"
                    >
                      <path 
                        strokeLinecap="round" 
                        strokeLinejoin="round" 
                        strokeWidth="2" 
                        d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
                      />
                    </svg>
                  </div>
                )}
              </div>
              <div className="ml-4 flex-1 min-w-0 min-h-12 flex flex-col justify-center">
                <h3 
                  className="text-xl font-semibold truncate max-w-[200px] overflow-hidden sharedCardTitle" 
                  title={app.AppName}
                >
                  {app.AppName}
                </h3>
                {(app.Tuf || app.Reports || app.CdnEdge) && (
                  <div className="mt-1 flex items-center gap-1 flex-wrap">
                    {app.Tuf && (
                      <span className="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium bg-blue-500/20 text-blue-300 border border-blue-400/30">
                        <svg 
                          className="w-3 h-3" 
                          fill="none" 
                          stroke="currentColor" 
                          viewBox="0 0 24 24"
                        >
                          <path 
                            strokeLinecap="round" 
                            strokeLinejoin="round" 
                            strokeWidth="2" 
                            d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
                          />
                        </svg>
                        TUF
                      </span>
                    )}
                    {app.Reports && (
                      <span className="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium bg-blue-500/20 text-blue-300 border border-blue-400/30">
                        <svg
                          className="w-3 h-3"
                          fill="none"
                          stroke="currentColor"
                          viewBox="0 0 24 24"
                        >
                          <path
                            strokeLinecap="round"
                            strokeLinejoin="round"
                            strokeWidth="2"
                            d="M9 17v-6m3 6V7m3 10v-4m3 8H6a2 2 0 01-2-2V5a2 2 0 012-2h8l6 6v10a2 2 0 01-2 2z"
                          />
                        </svg>
                        Reports
                      </span>
                    )}
                    {app.CdnEdge && (
                      <span className="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium bg-blue-500/20 text-blue-300 border border-blue-400/30">
                        <svg
                          className="w-3 h-3"
                          fill="none"
                          stroke="currentColor"
                          viewBox="0 0 24 24"
                        >
                          <path
                            strokeLinecap="round"
                            strokeLinejoin="round"
                            strokeWidth="2"
                            d="M3 15a4 4 0 014-4 5 5 0 019.9-1A3.5 3.5 0 1120 16H7a4 4 0 01-4-1z"
                          />
                        </svg>
                        CDN
                      </span>
                    )}
                  </div>
                )}
              </div>
              <div className="flex items-center h-12 gap-2 flex-shrink-0 ml-auto">
                <button
                  onClick={(e) => {
                    e.stopPropagation();
                    toggleFavorite(app.ID);
                  }}
                  className={`p-2 transition-colors duration-200 ${favorites.has(app.ID) ? 'text-yellow-400 hover:text-yellow-300' : 'text-theme-primary opacity-40 hover:opacity-100'}`}
                  title={favorites.has(app.ID) ? 'Remove from favorites' : 'Add to favorites'}
                  aria-pressed={favorites.has(app.ID)}
                >
                  <i className="fas fa-star"></i>
                </button>
                <button
                  onClick={(e) => handleEditApp(e, app)}
                  className="p-2 text-theme-primary hover:text-theme-primary-hover transition-colors duration-200"
                  title="Edit app"
                >
                  <i className="fas fa-edit"></i>
                </button>
                <button
                  onClick={(e) => handleDeleteApp(e, app)}
                  className="p-2 text-theme-danger hover:text-theme-primary-hover transition-colors duration-200"
                  title="Delete app"
                >
                  <i className="fas fa-trash"></i>
                </button>
              </div>
            </div>
            <div className="relative sharedCardContent">
              <div className="flex items-center gap-2">
                <p className={`text-sm text-white/70 flex-1 ${!expandedApps[app.ID] && 'line-clamp-1'} sharedCardDescription`}>
                  {app.Description || 'No description available'}
                </p>
                {app.Description && app.Description.length > 50 && (
                  <button
                    onClick={(e) => {
                      e.stopPropagation();
                      setExpandedApps(prev => ({
                        ...prev,
                        [app.ID]: !prev[app.ID]
                      }));
                    }}
                    className="w-6 h-6 flex-shrink-0 flex items-center justify-center rounded-full bg-theme-card hover:bg-theme-card-hover transition-colors"
                    title={expandedApps[app.ID] ? 'Collapse' : 'Expand'}
                  >
                    <i className={`fas ${expandedApps[app.ID] ? 'fa-chevron-up' : 'fa-chevron-down'} text-theme-primary text-xs`}></i>
                  </button>
                )}
              </div>
            </div>
          </div>
        ))
      )}

      {showEditAppModal && selectedAppData && (
        <EditAppModal
          onClose={() => {
            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 && (
        <DeleteAppConfirmationModal
          appName={selectedAppData.AppName}
          onClose={() => {
            setShowDeleteAppModal(false);
            setSelectedAppData(null);
          }}
          onConfirm={handleDeleteAppConfirm}
        />
      )}
    </div>
  );
};