|
1 | 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ |
2 | 2 | import React, { useEffect, useState, useMemo, useCallback } from 'react'; |
3 | | -import { ArrowLeft, ChevronDown } from 'lucide-react'; |
| 3 | +import { ArrowLeft, ChevronDown, Plus } from 'lucide-react'; |
4 | 4 | import { useParams, useNavigate } from 'react-router'; |
5 | 5 | import axios from 'axios'; |
6 | 6 | import { useTranslation } from 'react-i18next'; |
@@ -116,6 +116,27 @@ const MasterPage: React.FC = () => { |
116 | 116 | return filteredVersions.slice(0, visibleCount); |
117 | 117 | }, [filteredVersions, visibleCount]); |
118 | 118 |
|
| 119 | + // Group visible versions by attributes for rendering |
| 120 | + const groupedVisibleVersions = useMemo(() => { |
| 121 | + const groups: { [key: string]: { header: { released: string, majorFormat: string, label: string, country: string }, versions: MasterVersion[] } } = {}; |
| 122 | + visibleVersions.forEach(version => { |
| 123 | + const key = `${version.released || 'N/A'}|${version.majorFormat}|${version.label}|${version.country || ''}`; |
| 124 | + if (!groups[key]) { |
| 125 | + groups[key] = { |
| 126 | + header: { |
| 127 | + released: version.released, |
| 128 | + majorFormat: version.majorFormat, |
| 129 | + label: version.label, |
| 130 | + country: version.country |
| 131 | + }, |
| 132 | + versions: [] |
| 133 | + }; |
| 134 | + } |
| 135 | + groups[key].versions.push(version); |
| 136 | + }); |
| 137 | + return Object.values(groups); |
| 138 | + }, [visibleVersions]); |
| 139 | + |
119 | 140 | // Auto-fetch release details for visible versions |
120 | 141 | const fetchReleaseDetails = useCallback(async (releaseId: number) => { |
121 | 142 | if (releaseDetailsCache.has(releaseId) || loadingReleaseIds.has(releaseId)) return; |
@@ -323,84 +344,104 @@ const MasterPage: React.FC = () => { |
323 | 344 | </div> |
324 | 345 | ) |
325 | 346 | ) : ( |
326 | | - <> |
327 | | - <div className="space-y-4"> |
328 | | - {visibleVersions.map((version) => { |
329 | | - const details = releaseDetailsCache.get(version.id); |
330 | | - const isLoadingDetails = loadingReleaseIds.has(version.id); |
331 | | - const formats = details?.availableFormats || []; |
332 | | - |
333 | | - return ( |
334 | | - <div key={version.id} className="card bg-base-200 shadow-sm hover:shadow-md transition-shadow"> |
335 | | - <div className="card-body p-4"> |
336 | | - {/* Version header row */} |
337 | | - <div className="flex flex-col sm:flex-row sm:items-center gap-3"> |
338 | | - <div className="flex items-center gap-3 flex-1 min-w-0"> |
339 | | - <span className="font-bold text-lg flex-shrink-0">{version.released || 'N/A'}</span> |
340 | | - <span className="font-medium text-sm truncate" title={version.majorFormat}>{version.majorFormat}</span> |
341 | | - <span className="text-xs text-gray-500 truncate" title={version.label}> |
342 | | - {version.label} |
343 | | - </span> |
| 347 | + <div className="space-y-4"> |
| 348 | + {groupedVisibleVersions.map((group, groupIdx) => ( |
| 349 | + <div key={groupIdx} className="bg-base-200/30 rounded-lg p-4 border border-base-200 hover:border-base-300 transition-colors"> |
| 350 | + {/* Group Header Info */} |
| 351 | + <div className="flex flex-wrap items-center gap-x-2 gap-y-1 mb-3"> |
| 352 | + <span className="font-bold text-lg whitespace-nowrap">{group.header.released || 'N/A'}</span> |
| 353 | + <span className="text-base-content/40 hidden sm:inline">•</span> |
| 354 | + <span className="font-medium text-base-content/90 truncate max-w-[200px] sm:max-w-[250px]" title={group.header.majorFormat}>{group.header.majorFormat}</span> |
| 355 | + <span className="text-base-content/40 hidden sm:inline">•</span> |
| 356 | + <span className="text-base-content/70 truncate max-w-[200px] sm:max-w-[250px]" title={group.header.label}>{group.header.label}</span> |
| 357 | + <span className="text-base-content/40 hidden sm:inline">•</span> |
| 358 | + <span className="text-sm bg-base-200 px-2 py-0.5 rounded font-medium whitespace-nowrap">{group.header.country || t('versions.unknown')}</span> |
| 359 | + </div> |
| 360 | + |
| 361 | + {/* Formats list for this group */} |
| 362 | + <div className="flex flex-col gap-2 pl-2 border-l-2 border-base-300/50"> |
| 363 | + {(() => { |
| 364 | + let isGroupLoading = false; |
| 365 | + const uniqueFormatsMap = new Map(); |
| 366 | + |
| 367 | + group.versions.forEach(version => { |
| 368 | + if (loadingReleaseIds.has(version.id)) { |
| 369 | + isGroupLoading = true; |
| 370 | + } |
| 371 | + const details = releaseDetailsCache.get(version.id); |
| 372 | + if (details && details.availableFormats) { |
| 373 | + details.availableFormats.forEach(format => { |
| 374 | + const key = `${format.text || ''}|${(format.descriptions || []).join(',')}`; |
| 375 | + if (!uniqueFormatsMap.has(key)) { |
| 376 | + uniqueFormatsMap.set(key, { details, format, versionId: version.id }); |
| 377 | + } |
| 378 | + }); |
| 379 | + } |
| 380 | + }); |
| 381 | + |
| 382 | + if (isGroupLoading) { |
| 383 | + return ( |
| 384 | + <div className="text-sm text-base-content/40 flex items-center py-1"> |
| 385 | + <span className="loading loading-spinner loading-xs mr-2"></span> |
| 386 | + {t('versions.loadingFormats')} |
344 | 387 | </div> |
345 | | - <div className="text-xs bg-base-100 rounded px-2 py-1.5 flex items-center gap-1.5 flex-shrink-0" title={version.country}> |
346 | | - <span className="opacity-70">🌍</span> |
347 | | - <span className="font-medium">{version.country || t('versions.unknown')}</span> |
348 | | - </div> |
349 | | - </div> |
350 | | - |
351 | | - {/* Format variants section */} |
352 | | - <div className="mt-3"> |
353 | | - {isLoadingDetails ? ( |
354 | | - <div className="flex items-center gap-2 text-sm text-gray-400"> |
355 | | - <span className="loading loading-spinner loading-xs"></span> |
356 | | - {t('versions.loadingFormats')} |
357 | | - </div> |
358 | | - ) : formats.length > 0 ? ( |
359 | | - <div className="flex flex-wrap gap-2"> |
360 | | - {formats.map((format, index) => ( |
361 | | - <button |
362 | | - key={index} |
363 | | - className="btn btn-sm btn-outline h-auto py-1.5 normal-case max-w-full" |
364 | | - onClick={() => details && handleFormatClick(details, format)} |
365 | | - disabled={isSubmitting} |
366 | | - title={t('versions.clickToAdd')} |
367 | | - style={getFormatButtonStyle(format.text, format.descriptions)} |
368 | | - > |
369 | | - <div className="text-left w-full break-words whitespace-normal overflow-hidden"> |
370 | | - <span className="font-semibold">{format.name}</span> |
371 | | - {format.text && <span className="ml-1 break-words">{format.text}</span>} |
372 | | - {format.descriptions?.length > 0 && ( |
373 | | - <span className="block text-xs opacity-70 break-words"> |
374 | | - {format.descriptions.join(', ')} |
375 | | - </span> |
376 | | - )} |
377 | | - </div> |
378 | | - </button> |
379 | | - ))} |
| 388 | + ); |
| 389 | + } |
| 390 | + |
| 391 | + const uniqueFormats = Array.from(uniqueFormatsMap.values()); |
| 392 | + |
| 393 | + if (uniqueFormats.length === 0) { |
| 394 | + return null; |
| 395 | + } |
| 396 | + |
| 397 | + return uniqueFormats.map((item, index) => { |
| 398 | + const { details, format, versionId } = item; |
| 399 | + |
| 400 | + const descStr = format.descriptions?.join(', ') || ''; |
| 401 | + const displayTitle = format.text || descStr || format.name; |
| 402 | + const displaySubtitle = format.text ? descStr : ''; |
| 403 | + |
| 404 | + return ( |
| 405 | + <button |
| 406 | + key={`${versionId}-fmt-${index}`} |
| 407 | + className="btn btn-sm btn-outline border-base-300 hover:border-primary/50 normal-case justify-start text-left group/btn transition-all w-full h-auto py-2 px-3" |
| 408 | + onClick={() => details && handleFormatClick(details, format)} |
| 409 | + disabled={isSubmitting} |
| 410 | + title={t('versions.clickToAdd')} |
| 411 | + style={getFormatButtonStyle(format.text, format.descriptions)} |
| 412 | + > |
| 413 | + <div className="flex flex-col items-start min-w-0 flex-1 w-full gap-0.5"> |
| 414 | + <div className="flex items-center w-full"> |
| 415 | + <span className="font-bold whitespace-normal break-words overflow-hidden text-sm mr-1.5">{displayTitle}</span> |
| 416 | + <Plus size={16} className="opacity-0 group-hover/btn:opacity-100 transition-opacity ml-auto flex-shrink-0" /> |
| 417 | + </div> |
| 418 | + {displaySubtitle && ( |
| 419 | + <span className="text-xs opacity-80 break-words whitespace-normal leading-tight mt-0.5 text-base-content/80 font-medium"> |
| 420 | + {displaySubtitle} |
| 421 | + </span> |
| 422 | + )} |
380 | 423 | </div> |
381 | | - ) : !isLoadingDetails && ( |
382 | | - <p className="text-xs text-gray-500">{t('versions.noFormats')}</p> |
383 | | - )} |
384 | | - </div> |
385 | | - </div> |
386 | | - </div> |
387 | | - ); |
388 | | - })} |
389 | | - </div> |
390 | | - |
391 | | - {/* See more button */} |
392 | | - {hasMore && ( |
393 | | - <div className="flex justify-center mt-6"> |
394 | | - <button |
395 | | - className="btn btn-ghost gap-2" |
396 | | - onClick={handleShowMore} |
397 | | - > |
398 | | - <ChevronDown size={18} /> |
399 | | - {t('versions.seeMore', { remaining: remaining > VERSIONS_PER_PAGE ? VERSIONS_PER_PAGE : remaining, total: filteredVersions.length })} |
400 | | - </button> |
| 424 | + </button> |
| 425 | + ); |
| 426 | + }); |
| 427 | + })()} |
| 428 | + </div> |
401 | 429 | </div> |
402 | | - )} |
403 | | - </> |
| 430 | + ))} |
| 431 | + </div> |
| 432 | + )} |
| 433 | + |
| 434 | + {/* See more button */} |
| 435 | + {hasMore && ( |
| 436 | + <div className="flex justify-center mt-6"> |
| 437 | + <button |
| 438 | + className="btn btn-ghost gap-2" |
| 439 | + onClick={handleShowMore} |
| 440 | + > |
| 441 | + <ChevronDown size={18} /> |
| 442 | + {t('versions.seeMore', { remaining: remaining > VERSIONS_PER_PAGE ? VERSIONS_PER_PAGE : remaining, total: filteredVersions.length })} |
| 443 | + </button> |
| 444 | + </div> |
404 | 445 | )} |
405 | 446 | </div> |
406 | 447 | </div> |
|
0 commit comments