feat: expiry countdown, refresh links, CLI tabs, recently viewed #17

Merged
starkSV merged 3 commits from feat/ux-improvements into main 2026-05-22 21:56:08 +05:30
9 changed files with 399 additions and 30 deletions

3
.gitignore vendored
View file

@ -51,3 +51,6 @@ logs/
.cache/
.temp/
tmp/
# ── Claude Code internal ───────────────────────────────
.claude/

View file

@ -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

View file

@ -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
---

View file

@ -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)
}

View 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>
)
}

View 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>
)
}

View file

@ -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

View file

@ -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"

View file

@ -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 && (