Merge branch 'main' into feat/cli-telemetry
This commit is contained in:
commit
9e836503c0
3 changed files with 257 additions and 103 deletions
118
backend/main.go
118
backend/main.go
|
|
@ -119,18 +119,29 @@ var (
|
|||
linkCacheMu sync.RWMutex
|
||||
)
|
||||
|
||||
// --- Negative cache (60s — prevents thundering herd during rate-limit events) ---
|
||||
// truncateBody caps a response body string for log output.
|
||||
func truncateBody(s string) string {
|
||||
const max = 120
|
||||
if len(s) > max {
|
||||
return s[:max] + "..."
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// --- Negative cache (60s normal / 90min Sentinel lockdown) ---
|
||||
|
||||
type negCacheEntry struct {
|
||||
Message string
|
||||
HTTPCode int
|
||||
ExpiresAt time.Time
|
||||
Message string
|
||||
HTTPCode int
|
||||
ExpiresAt time.Time
|
||||
IsSentinel bool // Sentinel WAF block: use lockdownTTL and suppress background refresh
|
||||
}
|
||||
|
||||
var (
|
||||
negCache = make(map[string]negCacheEntry)
|
||||
negCacheMu sync.RWMutex
|
||||
negCacheTTL = 60 * time.Second
|
||||
lockdownTTL = 90 * time.Minute
|
||||
)
|
||||
|
||||
// --- Singleflight (one in-flight Microsoft fetch per unique key) ---
|
||||
|
|
@ -833,7 +844,7 @@ func fetchSkuInfoFromMS(productID string) ([]byte, int, error) {
|
|||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
if attempt == 2 {
|
||||
return nil, http.StatusBadGateway, fmt.Errorf("Microsoft API returned HTTP %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
return nil, http.StatusBadGateway, fmt.Errorf("Microsoft API returned HTTP %d: %s", resp.StatusCode, truncateBody(string(bodyBytes)))
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
|
@ -892,9 +903,9 @@ func fetchDownloadLinksFromMS(productID, skuID string) ([]byte, error) {
|
|||
if exists && time.Since(cached.CreatedAt) < SESSION_TTL {
|
||||
sessionID = cached.SessionID
|
||||
jar = cached.Jar
|
||||
log.Printf("MS fetch /proxy: reusing session %s for product_id=%s\n", sessionID[:8], productID)
|
||||
log.Printf("MS fetch /proxy: reusing session %s for product_id=%s sku_id=%s\n", sessionID[:8], productID, skuID)
|
||||
} else {
|
||||
log.Printf("MS fetch /proxy: new session for product_id=%s\n", productID)
|
||||
log.Printf("MS fetch /proxy: new session for product_id=%s sku_id=%s\n", productID, skuID)
|
||||
sessionID, jar, _ = setupSession()
|
||||
|
||||
// Warm the session with a SKU info call so cookies from setup carry through
|
||||
|
|
@ -964,7 +975,7 @@ func fetchDownloadLinksFromMS(productID, skuID string) ([]byte, error) {
|
|||
log.Printf("MS fetch /proxy: Sentinel block for product_id=%s — session evicted\n", productID)
|
||||
return nil, &rateLimitError{"Sentinel marked this request as rejected."}
|
||||
}
|
||||
return nil, fmt.Errorf("Microsoft API returned HTTP %d: %s", resp.StatusCode, bodyStr)
|
||||
return nil, fmt.Errorf("Microsoft API returned HTTP %d: %s", resp.StatusCode, truncateBody(bodyStr))
|
||||
}
|
||||
|
||||
if len(bodyBytes) > 0 && bodyBytes[0] == '"' {
|
||||
|
|
@ -986,6 +997,13 @@ func fetchDownloadLinksFromMS(productID, skuID string) ([]byte, error) {
|
|||
return nil, &rateLimitError{msg}
|
||||
}
|
||||
if val, exists := errMap["Value"].(string); exists {
|
||||
if strings.Contains(val, "Sentinel") {
|
||||
cacheMutex.Lock()
|
||||
delete(sessionCache, productID)
|
||||
cacheMutex.Unlock()
|
||||
log.Printf("MS fetch /proxy: Sentinel block for product_id=%s — session evicted\n", productID)
|
||||
return nil, &rateLimitError{"Sentinel marked this request as rejected."}
|
||||
}
|
||||
return nil, fmt.Errorf("%s", val)
|
||||
}
|
||||
}
|
||||
|
|
@ -1042,7 +1060,7 @@ func enableCORS(next http.Handler) http.Handler {
|
|||
}
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
w.Header().Set("Access-Control-Expose-Headers", "X-MSDL-Link-Status, X-MSDL-Link-Expires")
|
||||
w.Header().Set("Access-Control-Expose-Headers", "X-MSDL-Link-Status, X-MSDL-Link-Expires, X-MSDL-Lockdown")
|
||||
if r.Method == "OPTIONS" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
|
|
@ -1167,6 +1185,38 @@ func handleProxy(w http.ResponseWriter, r *http.Request) {
|
|||
negKey := "link:" + cacheKey
|
||||
forceRefresh := r.URL.Query().Get("force") == "true"
|
||||
|
||||
// 0. Sentinel lockdown gate — checked before everything, including force refresh.
|
||||
// When a Sentinel block is active, zero Microsoft API calls are made regardless
|
||||
// of force=true, to avoid extending the block timer.
|
||||
negCacheMu.RLock()
|
||||
lockdownEntry, hasLockdown := negCache[negKey]
|
||||
negCacheMu.RUnlock()
|
||||
if hasLockdown && time.Now().Before(lockdownEntry.ExpiresAt) && lockdownEntry.IsSentinel {
|
||||
atomic.AddInt64(&mLinkNegHits, 1)
|
||||
w.Header().Set("X-MSDL-Lockdown", "1")
|
||||
linkCacheMu.RLock()
|
||||
staleEntry, hasStale := linkCache[cacheKey]
|
||||
linkCacheMu.RUnlock()
|
||||
if hasStale {
|
||||
atomic.AddInt64(&mLinkStale, 1)
|
||||
log.Printf("/proxy: product_id=%s sku_id=%s -> lockdown active, serving stale (expires %s)\n",
|
||||
productID, skuID, lockdownEntry.ExpiresAt.Format(time.RFC3339))
|
||||
w.Header().Set("X-MSDL-Link-Status", "stale")
|
||||
if exp := extractRawExpiry(staleEntry.RawJSON); exp != "" {
|
||||
w.Header().Set("X-MSDL-Link-Expires", exp)
|
||||
}
|
||||
w.Write(staleEntry.RawJSON)
|
||||
return
|
||||
}
|
||||
log.Printf("/proxy: product_id=%s sku_id=%s -> lockdown active, no stale available\n", productID, skuID)
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"error": lockdownEntry.Message,
|
||||
"lockdown": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 1. Check link cache (dynamic TTL, stale-on-failure) — skipped when force=true
|
||||
linkCacheMu.RLock()
|
||||
cached, hasCached := linkCache[cacheKey]
|
||||
|
|
@ -1229,10 +1279,33 @@ func handleProxy(w http.ResponseWriter, r *http.Request) {
|
|||
})
|
||||
|
||||
if err != nil {
|
||||
// 6. Stale-on-failure: serve expired cache entry rather than erroring out
|
||||
// Also fire a background refresh so the next request gets fresh data.
|
||||
isSentinel := strings.Contains(err.Error(), "Sentinel")
|
||||
|
||||
// Stale-on-failure: serve expired cache entry rather than erroring out.
|
||||
if hasCached {
|
||||
atomic.AddInt64(&mLinkStale, 1)
|
||||
|
||||
if isSentinel {
|
||||
// Sentinel lockdown: set 90-min neg cache, add lockdown header, skip
|
||||
// background refresh — retrying extends the Sentinel block timer.
|
||||
negCacheMu.Lock()
|
||||
negCache[negKey] = negCacheEntry{
|
||||
Message: err.Error(),
|
||||
HTTPCode: http.StatusTooManyRequests,
|
||||
ExpiresAt: time.Now().Add(lockdownTTL),
|
||||
IsSentinel: true,
|
||||
}
|
||||
negCacheMu.Unlock()
|
||||
log.Printf("/proxy: product_id=%s sku_id=%s -> Sentinel lockdown (90min), serving stale\n", productID, skuID)
|
||||
w.Header().Set("X-MSDL-Lockdown", "1")
|
||||
w.Header().Set("X-MSDL-Link-Status", "stale")
|
||||
if exp := extractRawExpiry(cached.RawJSON); exp != "" {
|
||||
w.Header().Set("X-MSDL-Link-Expires", exp)
|
||||
}
|
||||
w.Write(cached.RawJSON)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("/proxy: product_id=%s sku_id=%s -> fetch failed (%v), serving stale\n", productID, skuID, err)
|
||||
go func() {
|
||||
// Use singleflight so concurrent stale serves don't all hit Microsoft
|
||||
|
|
@ -1260,17 +1333,32 @@ func handleProxy(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
// No stale data available.
|
||||
code := http.StatusBadGateway
|
||||
// 4. Negative cache for rate-limit errors (only when no stale available)
|
||||
if _, isRL := err.(*rateLimitError); isRL {
|
||||
code = http.StatusTooManyRequests
|
||||
ttl := negCacheTTL
|
||||
if isSentinel {
|
||||
ttl = lockdownTTL
|
||||
}
|
||||
negCacheMu.Lock()
|
||||
negCache[negKey] = negCacheEntry{
|
||||
Message: err.Error(),
|
||||
HTTPCode: code,
|
||||
ExpiresAt: time.Now().Add(negCacheTTL),
|
||||
Message: err.Error(),
|
||||
HTTPCode: code,
|
||||
ExpiresAt: time.Now().Add(ttl),
|
||||
IsSentinel: isSentinel,
|
||||
}
|
||||
negCacheMu.Unlock()
|
||||
if isSentinel {
|
||||
log.Printf("/proxy: product_id=%s sku_id=%s -> Sentinel lockdown (90min), no stale\n", productID, skuID)
|
||||
w.Header().Set("X-MSDL-Lockdown", "1")
|
||||
w.WriteHeader(code)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
"lockdown": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
respondJSONError(w, code, err.Error())
|
||||
return
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ interface Props {
|
|||
highlight?: boolean
|
||||
}
|
||||
|
||||
const RELEASES_URL = 'https://github.com/starkSV/windows-iso-downloader/releases/latest'
|
||||
const RELEASES_URL = 'https://minxl.ink/msdl-github-release'
|
||||
|
||||
function InstallSteps() {
|
||||
return (
|
||||
|
|
@ -72,8 +72,8 @@ export default function CliHandoff({ productId, langName, langDisplay, defaultOp
|
|||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const command = langName
|
||||
? `msdl --id ${productId} --lang "${langName}"`
|
||||
: `msdl --id ${productId}`
|
||||
? `msdl.exe --id ${productId} --lang "${langName}"`
|
||||
: `msdl.exe --id ${productId}`
|
||||
|
||||
function handleCopy() {
|
||||
navigator.clipboard.writeText(command)
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ export default function ProductDetailPage() {
|
|||
const [expiryRemaining, setExpiryRemaining] = useState<number | null>(null)
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
const [linkStatus, setLinkStatus] = useState<'fresh' | 'cached' | 'stale' | null>(null)
|
||||
const [isLockdown, setIsLockdown] = useState(false)
|
||||
const expiryTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
const [meta, setMeta] = useState<{ badge: string; archs: string[]; active: boolean }>({ badge: '', archs: [], active: false })
|
||||
|
|
@ -199,17 +200,23 @@ export default function ProductDetailPage() {
|
|||
setLinkExpiry(null)
|
||||
setExpiryRemaining(null)
|
||||
setLinkStatus(null)
|
||||
setIsLockdown(false)
|
||||
if (expiryTimerRef.current) clearInterval(expiryTimerRef.current)
|
||||
|
||||
try {
|
||||
const r = await fetch(`${API_BASE}/proxy?product_id=${productId}&sku_id=${selectedSku.Id}`)
|
||||
const status = r.headers.get('X-MSDL-Link-Status') as 'fresh' | 'cached' | 'stale' | null
|
||||
const expiresHeader = r.headers.get('X-MSDL-Link-Expires')
|
||||
const lockdownHeader = r.headers.get('X-MSDL-Lockdown')
|
||||
const data = await r.json()
|
||||
if (data.error) throw new Error(data.error)
|
||||
if (data.error) {
|
||||
if (data.lockdown) setIsLockdown(true)
|
||||
throw new Error(data.error)
|
||||
}
|
||||
if (!data.ProductDownloadOptions?.length) throw new Error('No download links returned.')
|
||||
setDownloadLinks(data.ProductDownloadOptions)
|
||||
setLinkStatus(status)
|
||||
if (lockdownHeader === '1') setIsLockdown(true)
|
||||
let expiry: Date | null = null
|
||||
if (expiresHeader) {
|
||||
const d = new Date(expiresHeader)
|
||||
|
|
@ -271,11 +278,17 @@ export default function ProductDetailPage() {
|
|||
const r = await fetch(`${API_BASE}/proxy?product_id=${productId}&sku_id=${selectedSku.Id}&force=true`)
|
||||
const status = r.headers.get('X-MSDL-Link-Status') as 'fresh' | 'cached' | 'stale' | null
|
||||
const expiresHeader = r.headers.get('X-MSDL-Link-Expires')
|
||||
const lockdownHeader = r.headers.get('X-MSDL-Lockdown')
|
||||
const data = await r.json()
|
||||
if (data.error) throw new Error(data.error)
|
||||
if (data.error) {
|
||||
if (data.lockdown) setIsLockdown(true)
|
||||
throw new Error(data.error)
|
||||
}
|
||||
if (!data.ProductDownloadOptions?.length) throw new Error('No download links returned.')
|
||||
setDownloadLinks(data.ProductDownloadOptions)
|
||||
setLinkStatus(status)
|
||||
if (lockdownHeader === '1') setIsLockdown(true)
|
||||
else setIsLockdown(false)
|
||||
let expiry: Date | null = null
|
||||
if (expiresHeader) {
|
||||
const d = new Date(expiresHeader)
|
||||
|
|
@ -531,7 +544,10 @@ export default function ProductDetailPage() {
|
|||
>
|
||||
<div className="flex items-start gap-3 p-4 rounded-xl bg-red-500/8 border border-red-500/15 text-red-400 text-sm">
|
||||
<AlertTriangle size={15} className="flex-shrink-0 mt-0.5" />
|
||||
<span>{error}</span>
|
||||
<span>{isLockdown
|
||||
? 'Our server has been temporarily blocked by Microsoft and is in a 90-minute cooldown. Use the CLI to get your link instantly — it bypasses our server entirely.'
|
||||
: error
|
||||
}</span>
|
||||
</div>
|
||||
{selectedSku && (
|
||||
<CliHandoff
|
||||
|
|
@ -556,91 +572,141 @@ export default function ProductDetailPage() {
|
|||
transition={{ type: 'spring', stiffness: 300, damping: 28 }}
|
||||
>
|
||||
<div className="pt-4 border-t border-white/6 space-y-3">
|
||||
{/* Stale warning */}
|
||||
{linkStatus === 'stale' && (
|
||||
<div className="flex items-start gap-3 p-3.5 rounded-xl bg-amber-500/8 border border-amber-500/20 text-amber-400">
|
||||
<AlertTriangle size={14} className="flex-shrink-0 mt-0.5" />
|
||||
<p className="text-[12px] leading-relaxed">
|
||||
This link is cached and may have expired — Microsoft is currently limiting automated requests. Use the options below for a guaranteed fresh download.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expiry row */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className={`flex items-center gap-2 p-3 rounded-lg border text-[11px] flex-1 ${
|
||||
showRefresh
|
||||
? 'bg-red-500/6 border-red-500/15 text-red-400/80'
|
||||
: 'bg-amber-500/6 border-amber-500/12 text-amber-400/80'
|
||||
}`}>
|
||||
<AlertTriangle size={12} className="flex-shrink-0" />
|
||||
{expiryRemaining !== null
|
||||
? expiryRemaining <= 0
|
||||
? 'Links expired — refresh to get new ones'
|
||||
: `Expires in ${formatRemaining(expiryRemaining)} · Direct from Microsoft CDN`
|
||||
: 'Links expire in 24 hours · Direct from Microsoft CDN'
|
||||
}
|
||||
</div>
|
||||
{showRefresh && (
|
||||
<button
|
||||
onClick={handleRefreshLinks}
|
||||
disabled={isRefreshing}
|
||||
className="flex items-center gap-1.5 px-3 py-2.5 rounded-lg bg-blue-500/12 border border-blue-500/20 text-blue-400 text-[11px] font-medium hover:bg-blue-500/20 disabled:opacity-40 transition-all flex-shrink-0"
|
||||
>
|
||||
<motion.span
|
||||
animate={{ rotate: isRefreshing ? 360 : 0 }}
|
||||
transition={isRefreshing ? { duration: 1, repeat: Infinity, ease: 'linear' } : {}}
|
||||
className="inline-block"
|
||||
>
|
||||
<RefreshCw size={11} />
|
||||
</motion.span>
|
||||
Refresh
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Links */}
|
||||
{downloadLinks.map(link => {
|
||||
const filename = link.Uri.split('/').pop()?.split('?')[0] ?? 'download.iso'
|
||||
const arch = link.Architecture || archFromUri(link.Uri)
|
||||
const isCopied = copiedUri === link.Uri
|
||||
return (
|
||||
<div
|
||||
key={link.Uri}
|
||||
className="flex items-center justify-between gap-3 p-3.5 rounded-xl bg-white/3 border border-white/5 hover:border-white/9 transition-colors"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[12px] font-mono text-white truncate">{filename}</p>
|
||||
<p className="text-[11px] text-zinc-600 mt-0.5">{arch}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{linkStatus === 'stale' ? (
|
||||
/* Stale state: hide links, show banner + CLI recommendation */
|
||||
<>
|
||||
{isLockdown ? (
|
||||
/* Sentinel lockdown — no Refresh, no links, CLI only */
|
||||
<>
|
||||
<div className="flex items-start gap-3 p-3.5 rounded-xl bg-red-500/8 border border-red-500/20 text-red-400">
|
||||
<AlertTriangle size={14} className="flex-shrink-0 mt-0.5" />
|
||||
<p className="text-[12px] leading-relaxed">
|
||||
Our server has been temporarily blocked by Microsoft and is in a 90-minute cooldown. Use the CLI to get your link instantly — it bypasses our server entirely.
|
||||
</p>
|
||||
</div>
|
||||
{selectedSku && (
|
||||
<CliHandoff
|
||||
productId={productId!}
|
||||
langName={selectedSku.Language}
|
||||
langDisplay={selectedSku.LocalizedLanguage}
|
||||
highlight
|
||||
defaultOpen
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
/* Normal stale — show warning, CLI, and Refresh */
|
||||
<>
|
||||
<div className="flex items-start gap-3 p-3.5 rounded-xl bg-amber-500/8 border border-amber-500/20 text-amber-400">
|
||||
<AlertTriangle size={14} className="flex-shrink-0 mt-0.5" />
|
||||
<p className="text-[12px] leading-relaxed">
|
||||
These links are cached and have expired — Microsoft is currently limiting automated requests.
|
||||
</p>
|
||||
</div>
|
||||
{selectedSku && (
|
||||
<CliHandoff
|
||||
productId={productId!}
|
||||
langName={selectedSku.Language}
|
||||
langDisplay={selectedSku.LocalizedLanguage}
|
||||
highlight
|
||||
defaultOpen
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleCopy(link.Uri)}
|
||||
className="p-2 rounded-lg text-zinc-600 hover:text-white hover:bg-white/6 transition-all"
|
||||
title="Copy link"
|
||||
onClick={handleRefreshLinks}
|
||||
disabled={isRefreshing}
|
||||
className="w-full flex items-center justify-center gap-1.5 px-3 py-2.5 rounded-xl bg-white/4 border border-white/8 text-zinc-400 text-[12px] font-medium hover:bg-white/7 disabled:opacity-40 transition-all"
|
||||
>
|
||||
{isCopied
|
||||
? <Check size={13} className="text-green-400" />
|
||||
: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8} className="w-3.5 h-3.5" aria-hidden="true">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||
</svg>
|
||||
}
|
||||
<motion.span
|
||||
animate={{ rotate: isRefreshing ? 360 : 0 }}
|
||||
transition={isRefreshing ? { duration: 1, repeat: Infinity, ease: 'linear' } : {}}
|
||||
className="inline-block"
|
||||
>
|
||||
<RefreshCw size={12} />
|
||||
</motion.span>
|
||||
Try refreshing links
|
||||
</button>
|
||||
<a
|
||||
href={link.Uri}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-blue-500/12 border border-blue-500/20 text-blue-400 text-[11px] font-medium hover:bg-blue-500/20 transition-all"
|
||||
>
|
||||
<Download size={11} />
|
||||
{arch}
|
||||
<ExternalLink size={10} className="opacity-50" />
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
/* Fresh / cached state: show expiry row + links */
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className={`flex items-center gap-2 p-3 rounded-lg border text-[11px] flex-1 ${
|
||||
showRefresh
|
||||
? 'bg-red-500/6 border-red-500/15 text-red-400/80'
|
||||
: 'bg-amber-500/6 border-amber-500/12 text-amber-400/80'
|
||||
}`}>
|
||||
<AlertTriangle size={12} className="flex-shrink-0" />
|
||||
{expiryRemaining !== null
|
||||
? expiryRemaining <= 0
|
||||
? 'Links expired — refresh to get new ones'
|
||||
: `Expires in ${formatRemaining(expiryRemaining)} · Direct from Microsoft CDN`
|
||||
: 'Links expire in 24 hours · Direct from Microsoft CDN'
|
||||
}
|
||||
</div>
|
||||
{showRefresh && (
|
||||
<button
|
||||
onClick={handleRefreshLinks}
|
||||
disabled={isRefreshing}
|
||||
className="flex items-center gap-1.5 px-3 py-2.5 rounded-lg bg-blue-500/12 border border-blue-500/20 text-blue-400 text-[11px] font-medium hover:bg-blue-500/20 disabled:opacity-40 transition-all flex-shrink-0"
|
||||
>
|
||||
<motion.span
|
||||
animate={{ rotate: isRefreshing ? 360 : 0 }}
|
||||
transition={isRefreshing ? { duration: 1, repeat: Infinity, ease: 'linear' } : {}}
|
||||
className="inline-block"
|
||||
>
|
||||
<RefreshCw size={11} />
|
||||
</motion.span>
|
||||
Refresh
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{downloadLinks.map(link => {
|
||||
const filename = link.Uri.split('/').pop()?.split('?')[0] ?? 'download.iso'
|
||||
const arch = link.Architecture || archFromUri(link.Uri)
|
||||
const isCopied = copiedUri === link.Uri
|
||||
return (
|
||||
<div
|
||||
key={link.Uri}
|
||||
className="flex items-center justify-between gap-3 p-3.5 rounded-xl bg-white/3 border border-white/5 hover:border-white/9 transition-colors"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[12px] font-mono text-white truncate">{filename}</p>
|
||||
<p className="text-[11px] text-zinc-600 mt-0.5">{arch}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => handleCopy(link.Uri)}
|
||||
className="p-2 rounded-lg text-zinc-600 hover:text-white hover:bg-white/6 transition-all"
|
||||
title="Copy link"
|
||||
>
|
||||
{isCopied
|
||||
? <Check size={13} className="text-green-400" />
|
||||
: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8} className="w-3.5 h-3.5" aria-hidden="true">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||
</svg>
|
||||
}
|
||||
</button>
|
||||
<a
|
||||
href={link.Uri}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-blue-500/12 border border-blue-500/20 text-blue-400 text-[11px] font-medium hover:bg-blue-500/20 transition-all"
|
||||
>
|
||||
<Download size={11} />
|
||||
{arch}
|
||||
<ExternalLink size={10} className="opacity-50" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
|
@ -656,8 +722,8 @@ export default function ProductDetailPage() {
|
|||
productId={productId!}
|
||||
languageName={selectedSku?.LocalizedLanguage ?? ''}
|
||||
/>
|
||||
{/* Collapsed CLI only when highlighted card isn't already shown inside the card */}
|
||||
{!(error !== null && languages.length > 0) && (
|
||||
{/* Skip outer CLI card when already shown inside the main card (any stale or error) */}
|
||||
{linkStatus !== 'stale' && !(error !== null && languages.length > 0) && (
|
||||
selectedSku ? (
|
||||
<CliHandoff
|
||||
productId={productId!}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue