Merge pull request #17 from starkSV/feat/ux-improvements
feat: expiry countdown, refresh links, CLI tabs, recently viewed
This commit is contained in:
commit
56549b7201
9 changed files with 399 additions and 30 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -51,3 +51,6 @@ logs/
|
|||
.cache/
|
||||
.temp/
|
||||
tmp/
|
||||
|
||||
# ── Claude Code internal ───────────────────────────────
|
||||
.claude/
|
||||
|
|
|
|||
13
CLAUDE.md
13
CLAUDE.md
|
|
@ -13,6 +13,7 @@ controlled by `CF_WORKER_URL` + `CF_WORKER_SECRET` env vars on the backend.
|
|||
- Any meaningful feature or multi-file change → feature branch → PR → merge
|
||||
- GitHub issues are for bugs and community contributions only — planned features tracked here and in PROGRESS.md
|
||||
- Commit messages follow conventional commits: `feat:`, `fix:`, `docs:`, `chore:`
|
||||
- **Always test locally before committing or pushing** — research feasibility before implementing (e.g. file size was planned but Microsoft CDN doesn't return it; caught after commit)
|
||||
|
||||
## Backlog (implement when ready, in priority order)
|
||||
|
||||
|
|
@ -28,13 +29,13 @@ negative cache (60s), dynamic TTL, stale-on-failure, jitter. Verified in product
|
|||
- [x] **README architecture section** — caching layer, `/metrics`, env vars documented.
|
||||
- [ ] **Per-IP / per-product rate limiter** — deferred; revisit after 1 month of production traffic data.
|
||||
|
||||
### Frontend UX improvements (next feature branch)
|
||||
### Frontend UX improvements — `feat/ux-improvements` (in progress)
|
||||
|
||||
- [ ] **Expiry countdown** — parse `se` param from signed URL, show live countdown. Under 6h remaining → show "Refresh links" button.
|
||||
- [ ] **Refresh links** — `?force=true` on `/proxy` bypasses cache, fetches fresh from Microsoft, resets countdown to ~24h.
|
||||
- [ ] **CLI command tabs** — aria2 / wget / curl tabs instead of showing all at once. Persist last selected tab in localStorage. Default: wget.
|
||||
- [ ] **Recently viewed** — localStorage only. Store product_id, name, version, se param. Show on homepage. Active links show countdown, expired links show "Expired" with re-fetch CTA.
|
||||
- [ ] **File size** — verify if Microsoft CDN response includes file size. If yes, show alongside each download link (e.g. `x64 · 5.4 GB`). Research first before implementing.
|
||||
- [x] **Expiry countdown** — parses `se` param from signed URL where available; falls back to "24 hours" for consumer links (Microsoft doesn't include `se` in consumer CDN URLs).
|
||||
- [x] **Refresh links** — `?force=true` on `/proxy` bypasses cache, fetches fresh from Microsoft. Button shown when under 6h remaining.
|
||||
- [x] **CLI command tabs** — wget / curl / aria2 tabs, persists selection in localStorage. Replaces old aria2-only tip. Applied to both consumer and eval pages.
|
||||
- [x] **Recently viewed** — localStorage only. Shows on homepage. Consumer + eval pages both tracked. Expired state shown when link expiry known.
|
||||
- ~~**File size**~~ — not feasible. Microsoft CDN does not return file size in the API response.
|
||||
|
||||
### Known bugs / open issues
|
||||
|
||||
|
|
|
|||
14
PROGRESS.md
14
PROGRESS.md
|
|
@ -67,15 +67,13 @@ CF Worker ensures Hetzner IP is never exposed to Microsoft — rate-limit block
|
|||
|
||||
---
|
||||
|
||||
## Planned — Frontend UX improvements
|
||||
## In progress — Frontend UX improvements (`feat/ux-improvements`)
|
||||
|
||||
| Feature | Notes |
|
||||
|---|---|
|
||||
| Expiry countdown | Parse `se` param from signed URL, live countdown. Under 6h → show Refresh button |
|
||||
| Refresh links | `?force=true` on `/proxy` bypasses cache, returns fresh URL, resets countdown |
|
||||
| CLI command tabs | aria2 / wget / curl tabs, persist selection in localStorage, default wget |
|
||||
| Recently viewed | localStorage only — active links show countdown, expired show re-fetch CTA |
|
||||
| File size | Research first — verify if Microsoft CDN response includes it |
|
||||
- **Expiry countdown** — shows on consumer links as static "24h" (Microsoft doesn't include `se` in consumer CDN URLs); would tick live if `se` present
|
||||
- **Refresh links** — `?force=true` on `/proxy`, button appears under 6h remaining
|
||||
- **CLI command tabs** — wget / curl / aria2, persists in localStorage, replaces old Aria2Tip on all pages
|
||||
- **Recently viewed** — localStorage, homepage row, both consumer and eval pages tracked
|
||||
- ~~**File size**~~ — not feasible, Microsoft CDN API does not return file size
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -790,21 +790,22 @@ func handleProxy(w http.ResponseWriter, r *http.Request) {
|
|||
atomic.AddInt64(&mLinkRequests, 1)
|
||||
cacheKey := productID + ":" + skuID
|
||||
negKey := "link:" + cacheKey
|
||||
forceRefresh := r.URL.Query().Get("force") == "true"
|
||||
|
||||
// 1. Check link cache (dynamic TTL, stale-on-failure)
|
||||
// 1. Check link cache (dynamic TTL, stale-on-failure) — skipped when force=true
|
||||
linkCacheMu.RLock()
|
||||
cached, hasCached := linkCache[cacheKey]
|
||||
linkCacheMu.RUnlock()
|
||||
|
||||
if hasCached && time.Now().Before(cached.ExpiresAt) {
|
||||
if !forceRefresh && hasCached && time.Now().Before(cached.ExpiresAt) {
|
||||
atomic.AddInt64(&mLinkCacheHits, 1)
|
||||
log.Printf("/proxy: product_id=%s sku_id=%s -> cache hit\n", productID, skuID)
|
||||
w.Write(cached.RawJSON)
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Check negative cache — but only if no stale data to fall back on
|
||||
if !hasCached {
|
||||
// 2. Check negative cache — but only if no stale data and not a force refresh
|
||||
if !forceRefresh && !hasCached {
|
||||
negCacheMu.RLock()
|
||||
neg, hasNeg := negCache[negKey]
|
||||
negCacheMu.RUnlock()
|
||||
|
|
@ -818,6 +819,9 @@ func handleProxy(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
// 3. Singleflight: one fetch per (product, sku) key
|
||||
sfKey := "link:" + cacheKey
|
||||
if forceRefresh {
|
||||
sfKey = "link:force:" + cacheKey
|
||||
}
|
||||
v, err, _ := sfGroup.Do(sfKey, func() (interface{}, error) {
|
||||
return fetchDownloadLinksFromMS(productID, skuID)
|
||||
})
|
||||
|
|
@ -879,7 +883,11 @@ func handleProxy(w http.ResponseWriter, r *http.Request) {
|
|||
linkCacheMu.Unlock()
|
||||
|
||||
atomic.AddInt64(&mLinkFetches, 1)
|
||||
log.Printf("/proxy: product_id=%s sku_id=%s -> fetched and cached until %s\n", productID, skuID, expiresAt.Format(time.RFC3339))
|
||||
if forceRefresh {
|
||||
log.Printf("/proxy: product_id=%s sku_id=%s -> force refresh, cached until %s\n", productID, skuID, expiresAt.Format(time.RFC3339))
|
||||
} else {
|
||||
log.Printf("/proxy: product_id=%s sku_id=%s -> fetched and cached until %s\n", productID, skuID, expiresAt.Format(time.RFC3339))
|
||||
}
|
||||
w.Write(raw)
|
||||
}
|
||||
|
||||
|
|
|
|||
130
frontend/src/components/CliCommand.tsx
Normal file
130
frontend/src/components/CliCommand.tsx
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
import { Terminal, ChevronRight, Copy, Check } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
type Tool = 'wget' | 'curl' | 'aria2'
|
||||
|
||||
const PREF_KEY = 'msdl-cli-tool'
|
||||
|
||||
const TOOLS: { id: Tool; label: string }[] = [
|
||||
{ id: 'wget', label: 'wget' },
|
||||
{ id: 'curl', label: 'curl' },
|
||||
{ id: 'aria2', label: 'aria2' },
|
||||
]
|
||||
|
||||
function buildCommand(tool: Tool, url: string, filename: string): string {
|
||||
switch (tool) {
|
||||
case 'wget':
|
||||
return `wget -O "${filename}" "${url}"`
|
||||
case 'curl':
|
||||
return `curl -L -o "${filename}" "${url}"`
|
||||
case 'aria2':
|
||||
return `aria2c -x 16 -s 16 --disable-ipv6=true -o "${filename}" "${url}"`
|
||||
}
|
||||
}
|
||||
|
||||
interface CliCommandProps {
|
||||
downloadUrl?: string
|
||||
filename?: string
|
||||
}
|
||||
|
||||
export default function CliCommand({ downloadUrl, filename }: CliCommandProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [tool, setTool] = useState<Tool>('wget')
|
||||
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem(PREF_KEY) as Tool | null
|
||||
if (saved && TOOLS.some(t => t.id === saved)) setTool(saved)
|
||||
}, [])
|
||||
|
||||
function selectTool(t: Tool) {
|
||||
setTool(t)
|
||||
localStorage.setItem(PREF_KEY, t)
|
||||
}
|
||||
|
||||
const url = downloadUrl || 'PASTE_YOUR_LINK_HERE'
|
||||
const file = filename || 'windows.iso'
|
||||
const command = buildCommand(tool, url, file)
|
||||
|
||||
function handleCopy() {
|
||||
navigator.clipboard.writeText(command)
|
||||
setCopied(true)
|
||||
toast.success('Command copied!')
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-3">
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex items-center gap-2 text-[12px] text-zinc-600 hover:text-zinc-400 transition-colors py-1"
|
||||
>
|
||||
<Terminal size={13} />
|
||||
<span>Download via terminal</span>
|
||||
<motion.span
|
||||
animate={{ rotate: open ? 90 : 0 }}
|
||||
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
|
||||
>
|
||||
<ChevronRight size={13} />
|
||||
</motion.span>
|
||||
</button>
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{open && (
|
||||
<motion.div
|
||||
key="cli-content"
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="mt-2 rounded-xl border border-white/7 bg-[#0d0d0f] overflow-hidden">
|
||||
{/* Tab bar */}
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-white/5">
|
||||
<div className="flex items-center gap-1">
|
||||
{TOOLS.map(t => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => selectTool(t.id)}
|
||||
className={`px-2.5 py-1 rounded-md text-[10px] font-mono font-medium transition-colors ${
|
||||
tool === t.id
|
||||
? 'bg-white/8 text-white'
|
||||
: 'text-zinc-600 hover:text-zinc-400'
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="flex items-center gap-1.5 text-[11px] text-zinc-500 hover:text-white transition-colors"
|
||||
>
|
||||
{copied ? <Check size={11} className="text-green-400" /> : <Copy size={11} />}
|
||||
<span>{copied ? 'Copied' : 'Copy'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Command */}
|
||||
<div className="px-4 py-3 overflow-x-auto">
|
||||
<code className="text-[12px] font-mono text-zinc-300 whitespace-nowrap">
|
||||
<span className="text-zinc-600 select-none">$ </span>
|
||||
{command}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
{!downloadUrl && (
|
||||
<p className="px-4 pb-3 text-[11px] text-zinc-600">
|
||||
Generate a link above, then the command will update automatically.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
115
frontend/src/components/RecentlyViewed.tsx
Normal file
115
frontend/src/components/RecentlyViewed.tsx
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Clock, ArrowRight } from 'lucide-react'
|
||||
|
||||
export interface RecentEntry {
|
||||
id: string
|
||||
name: string
|
||||
badge?: string
|
||||
visitedAt: number
|
||||
linkExpiresAt?: number
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'msdl-recent'
|
||||
const MAX_ENTRIES = 4
|
||||
|
||||
export function addRecentEntry(entry: Omit<RecentEntry, 'visitedAt'>) {
|
||||
try {
|
||||
const existing = getRecentEntries()
|
||||
const filtered = existing.filter(e => e.id !== entry.id)
|
||||
const updated: RecentEntry[] = [
|
||||
{ ...entry, visitedAt: Date.now() },
|
||||
...filtered,
|
||||
].slice(0, MAX_ENTRIES)
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated))
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function updateRecentExpiry(id: string, linkExpiresAt: number) {
|
||||
try {
|
||||
const existing = getRecentEntries()
|
||||
const updated = existing.map(e => e.id === id ? { ...e, linkExpiresAt } : e)
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated))
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function getRecentEntries(): RecentEntry[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return []
|
||||
return JSON.parse(raw) as RecentEntry[]
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function useCountdown(expiresAt?: number) {
|
||||
const [remaining, setRemaining] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!expiresAt) { setRemaining(null); return }
|
||||
const tick = () => setRemaining(expiresAt - Date.now())
|
||||
tick()
|
||||
const id = setInterval(tick, 60_000)
|
||||
return () => clearInterval(id)
|
||||
}, [expiresAt])
|
||||
|
||||
return remaining
|
||||
}
|
||||
|
||||
function formatRemaining(ms: number): string {
|
||||
if (ms <= 0) return 'Expired'
|
||||
const h = Math.floor(ms / 3_600_000)
|
||||
const m = Math.floor((ms % 3_600_000) / 60_000)
|
||||
if (h > 0) return `${h}h ${m}m`
|
||||
return `${m}m`
|
||||
}
|
||||
|
||||
function EntryCard({ entry }: { entry: RecentEntry }) {
|
||||
const remaining = useCountdown(entry.linkExpiresAt)
|
||||
const isExpired = remaining !== null && remaining <= 0
|
||||
const hasExpiry = remaining !== null
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={`/product/${entry.id}`}
|
||||
className="group flex items-center justify-between px-3.5 py-3 rounded-xl border border-white/6 bg-white/2 hover:border-white/10 hover:bg-white/4 transition-all"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm text-white/80 group-hover:text-white transition-colors truncate">{entry.name}</p>
|
||||
{hasExpiry && (
|
||||
<p className={`text-[11px] mt-0.5 ${isExpired ? 'text-red-400/70' : 'text-zinc-600'}`}>
|
||||
{isExpired ? 'Link expired' : `Link expires in ${formatRemaining(remaining!)}`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<ArrowRight size={13} className="text-zinc-700 group-hover:text-zinc-400 flex-shrink-0 ml-2 transition-colors" />
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export default function RecentlyViewed() {
|
||||
const [entries, setEntries] = useState<RecentEntry[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
setEntries(getRecentEntries())
|
||||
}, [])
|
||||
|
||||
if (entries.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Clock size={12} className="text-zinc-600" />
|
||||
<span className="text-[10px] font-mono font-semibold uppercase tracking-widest text-zinc-600">
|
||||
Recently viewed
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{entries.map(entry => (
|
||||
<EntryCard key={entry.id} entry={entry} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -4,9 +4,10 @@ import { motion, AnimatePresence } from 'motion/react'
|
|||
import { ArrowLeft, Download, ExternalLink, AlertTriangle, Copy, Check, Server, ChevronDown } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { evalProducts } from '../data/evalProducts'
|
||||
import Aria2Tip from '../components/Aria2Tip'
|
||||
import CliCommand from '../components/CliCommand'
|
||||
import RelatedReleases from '../components/RelatedReleases'
|
||||
import NotFoundPage from './NotFoundPage'
|
||||
import { addRecentEntry } from '../components/RecentlyViewed'
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL ?? 'http://localhost:3002'
|
||||
const SITE_URL = 'https://msdl.tech-latest.com'
|
||||
|
|
@ -55,6 +56,11 @@ export default function EvalDetailPage() {
|
|||
const [copiedUrl, setCopiedUrl] = useState<string | null>(null)
|
||||
const [moreOpen, setMoreOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!product) return
|
||||
addRecentEntry({ id: product.slug, name: product.name, badge: 'eval' })
|
||||
}, [product?.slug])
|
||||
|
||||
useEffect(() => {
|
||||
if (!product) return
|
||||
setIsLoading(true)
|
||||
|
|
@ -323,7 +329,7 @@ export default function EvalDetailPage() {
|
|||
</div>
|
||||
|
||||
{/* Aria2 tip — outside box, same as consumer page */}
|
||||
<Aria2Tip />
|
||||
<CliCommand />
|
||||
|
||||
{/* Also available — outside box, same as consumer page */}
|
||||
<RelatedReleases
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import StatsBar from '../components/StatsBar'
|
|||
import HowItWorks from '../components/HowItWorks'
|
||||
import ComparisonTable from '../components/ComparisonTable'
|
||||
import FAQAccordion from '../components/FAQAccordion'
|
||||
import RecentlyViewed from '../components/RecentlyViewed'
|
||||
|
||||
const SITE_URL = 'https://msdl.tech-latest.com'
|
||||
|
||||
|
|
@ -143,6 +144,9 @@ export default function HomePage() {
|
|||
<StatsBar />
|
||||
</div>
|
||||
|
||||
{/* Recently viewed */}
|
||||
<RecentlyViewed />
|
||||
|
||||
{/* Featured grid */}
|
||||
<motion.div
|
||||
className="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-8"
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom'
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
import * as Select from '@radix-ui/react-select'
|
||||
import { ArrowLeft, ChevronDown, Download, AlertTriangle, Check, ExternalLink, WifiOff } from 'lucide-react'
|
||||
import { ArrowLeft, ChevronDown, Download, AlertTriangle, Check, ExternalLink, WifiOff, RefreshCw } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import type { Sku, DownloadOption } from '../types'
|
||||
import SystemRequirements from '../components/SystemRequirements'
|
||||
import Aria2Tip from '../components/Aria2Tip'
|
||||
import CliCommand from '../components/CliCommand'
|
||||
import RelatedReleases from '../components/RelatedReleases'
|
||||
import { addRecentEntry, updateRecentExpiry } from '../components/RecentlyViewed'
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL ?? 'http://localhost:3002'
|
||||
const SITE_URL = 'https://msdl.tech-latest.com'
|
||||
|
|
@ -57,6 +58,10 @@ export default function ProductDetailPage() {
|
|||
const [isNotFound, setIsNotFound] = useState(false)
|
||||
const [isValidated, setIsValidated] = useState(false)
|
||||
const [hasCatalogError, setHasCatalogError] = useState(false)
|
||||
const [, setLinkExpiry] = useState<Date | null>(null)
|
||||
const [expiryRemaining, setExpiryRemaining] = useState<number | null>(null)
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
const expiryTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
const [meta, setMeta] = useState<{ badge: string; archs: string[]; active: boolean }>({ badge: '', archs: [], active: false })
|
||||
const [related, setRelated] = useState<{ id: string; label: string }[]>([])
|
||||
|
|
@ -136,6 +141,10 @@ export default function ProductDetailPage() {
|
|||
const isActive = product.active !== false
|
||||
setMeta({ badge: product.badge || '', archs: product.archs || [], active: isActive })
|
||||
|
||||
if (isActive) {
|
||||
addRecentEntry({ id: productId!, name, badge: product.badge })
|
||||
}
|
||||
|
||||
setRelated((product.related || []).map(rId => ({
|
||||
id: rId,
|
||||
label: data[rId]?.name || `Product ${rId}`
|
||||
|
|
@ -184,6 +193,9 @@ export default function ProductDetailPage() {
|
|||
setIsFetching(true)
|
||||
setError(null)
|
||||
setDownloadLinks([])
|
||||
setLinkExpiry(null)
|
||||
setExpiryRemaining(null)
|
||||
if (expiryTimerRef.current) clearInterval(expiryTimerRef.current)
|
||||
|
||||
fetch(`${API_BASE}/proxy?product_id=${productId}&sku_id=${selectedSku.Id}`)
|
||||
.then(r => r.json())
|
||||
|
|
@ -191,6 +203,12 @@ export default function ProductDetailPage() {
|
|||
if (data.error) throw new Error(data.error)
|
||||
if (!data.ProductDownloadOptions?.length) throw new Error('No download links returned.')
|
||||
setDownloadLinks(data.ProductDownloadOptions)
|
||||
const expiry = parseSeExpiry(data.ProductDownloadOptions[0].Uri)
|
||||
setLinkExpiry(expiry)
|
||||
if (expiry) {
|
||||
startExpiryCountdown(expiry)
|
||||
if (productId) updateRecentExpiry(productId, expiry.getTime())
|
||||
}
|
||||
})
|
||||
.catch(e => {
|
||||
setError(e.message)
|
||||
|
|
@ -206,7 +224,66 @@ export default function ProductDetailPage() {
|
|||
setTimeout(() => setCopiedUri(null), 2000)
|
||||
}
|
||||
|
||||
function parseSeExpiry(uri: string): Date | null {
|
||||
try {
|
||||
const se = new URL(uri).searchParams.get('se')
|
||||
if (!se) return null
|
||||
const d = new Date(se)
|
||||
return isNaN(d.getTime()) ? null : d
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function formatRemaining(ms: number): string {
|
||||
if (ms <= 0) return 'Expired'
|
||||
const h = Math.floor(ms / 3_600_000)
|
||||
const m = Math.floor((ms % 3_600_000) / 60_000)
|
||||
if (h > 0) return `${h}h ${m}m`
|
||||
return `${m}m`
|
||||
}
|
||||
|
||||
function startExpiryCountdown(expiry: Date) {
|
||||
if (expiryTimerRef.current) clearInterval(expiryTimerRef.current)
|
||||
const tick = () => setExpiryRemaining(expiry.getTime() - Date.now())
|
||||
tick()
|
||||
expiryTimerRef.current = setInterval(tick, 10_000)
|
||||
}
|
||||
|
||||
async function handleRefreshLinks() {
|
||||
if (!selectedSku || !productId) return
|
||||
setIsRefreshing(true)
|
||||
setError(null)
|
||||
try {
|
||||
const r = await fetch(`${API_BASE}/proxy?product_id=${productId}&sku_id=${selectedSku.Id}&force=true`)
|
||||
const data = await r.json()
|
||||
if (data.error) throw new Error(data.error)
|
||||
if (!data.ProductDownloadOptions?.length) throw new Error('No download links returned.')
|
||||
setDownloadLinks(data.ProductDownloadOptions)
|
||||
const expiry = parseSeExpiry(data.ProductDownloadOptions[0].Uri)
|
||||
setLinkExpiry(expiry)
|
||||
if (expiry) {
|
||||
startExpiryCountdown(expiry)
|
||||
if (productId) updateRecentExpiry(productId, expiry.getTime())
|
||||
}
|
||||
toast.success('Links refreshed!')
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : 'Refresh failed'
|
||||
setError(msg)
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setIsRefreshing(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup expiry timer on unmount
|
||||
useEffect(() => {
|
||||
return () => { if (expiryTimerRef.current) clearInterval(expiryTimerRef.current) }
|
||||
}, [])
|
||||
|
||||
const firstUri = downloadLinks[0]?.Uri
|
||||
const firstFilename = firstUri ? (firstUri.split('/').pop()?.split('?')[0] ?? 'windows.iso') : undefined
|
||||
const showRefresh = expiryRemaining !== null && expiryRemaining < 6 * 3_600_000
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -446,10 +523,37 @@ export default function ProductDetailPage() {
|
|||
transition={{ type: 'spring', stiffness: 300, damping: 28 }}
|
||||
>
|
||||
<div className="pt-4 border-t border-white/6 space-y-3">
|
||||
{/* Warning */}
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-amber-500/6 border border-amber-500/12 text-amber-400/80 text-[11px]">
|
||||
<AlertTriangle size={12} className="flex-shrink-0" />
|
||||
Links expire in 24 hours · IP-tied · Use a download manager for full speed
|
||||
{/* 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 */}
|
||||
|
|
@ -505,8 +609,8 @@ export default function ProductDetailPage() {
|
|||
{/* System requirements */}
|
||||
<SystemRequirements isWin11={isWin11(productId!)} />
|
||||
|
||||
{/* aria2 tip */}
|
||||
{meta.active && <Aria2Tip downloadUrl={firstUri} />}
|
||||
{/* CLI command */}
|
||||
{meta.active && <CliCommand downloadUrl={firstUri} filename={firstFilename} />}
|
||||
|
||||
{/* Related releases */}
|
||||
{related.length > 0 && (
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue