feat: add Enterprise & Server eval ISOs via Microsoft evalcenter
Adds /eval listing page and /product/<slug> detail pages for Windows Server 2025/2022/2019/2016 and Windows 11 Enterprise evaluation ISOs, sourced from Microsoft evalcenter without any session flow. Backend: new /evallinks endpoint fetches evalcenter page, extracts fwlinks, follows all redirects in parallel, detects arch + locale. 24h cache per product with startup warming so first hit is instant. Frontend: EvalPage listing at /eval, EvalDetailPage at /product/<slug> matching consumer structure with downloads, aria2 template, system requirements, and also-available section. Smart ProductRouter in App.tsx dispatches slug vs numeric productId. Dock highlights Products tab for /product/* and /eval routes. CTA banner on home page and footer link added. sitemap.xml updated with 6 new URLs. Closes #13 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
d6d782bc49
commit
a78059187f
9 changed files with 811 additions and 4 deletions
208
backend/main.go
208
backend/main.go
|
|
@ -4,13 +4,16 @@ import (
|
|||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
|
|
@ -39,6 +42,146 @@ var (
|
|||
validSkuID = regexp.MustCompile(`^[a-zA-Z0-9_\-]+$`)
|
||||
)
|
||||
|
||||
// --- Evalcenter (Enterprise / Server eval ISOs) ---
|
||||
|
||||
type EvalProduct struct {
|
||||
Name string
|
||||
EvalURL string
|
||||
}
|
||||
|
||||
type EvalLink struct {
|
||||
Arch string `json:"arch"`
|
||||
Lang string `json:"lang"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type EvalLinksResponse struct {
|
||||
Product string `json:"product"`
|
||||
Name string `json:"name"`
|
||||
Links []EvalLink `json:"links"`
|
||||
}
|
||||
|
||||
type evalCacheEntry struct {
|
||||
Links []EvalLink
|
||||
CachedAt time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
evalProductMap = map[string]EvalProduct{
|
||||
"server-2025": {Name: "Windows Server 2025", EvalURL: "https://www.microsoft.com/en-us/evalcenter/download-windows-server-2025"},
|
||||
"server-2022": {Name: "Windows Server 2022", EvalURL: "https://www.microsoft.com/en-us/evalcenter/download-windows-server-2022"},
|
||||
"server-2019": {Name: "Windows Server 2019", EvalURL: "https://www.microsoft.com/en-us/evalcenter/download-windows-server-2019"},
|
||||
"server-2016": {Name: "Windows Server 2016", EvalURL: "https://www.microsoft.com/en-us/evalcenter/download-windows-server-2016"},
|
||||
"win11-ent": {Name: "Windows 11 Enterprise", EvalURL: "https://www.microsoft.com/en-us/evalcenter/download-windows-11-enterprise"},
|
||||
}
|
||||
evalCache = make(map[string]evalCacheEntry)
|
||||
evalCacheMu sync.RWMutex
|
||||
evalCacheTTL = 24 * time.Hour
|
||||
fwlinkRe = regexp.MustCompile(`https://go\.microsoft\.com/fwlink/[^"'\s<>]+`)
|
||||
isoLangRe = regexp.MustCompile(`_([a-z]{2}-[a-z]{2})\.iso$`)
|
||||
)
|
||||
|
||||
func detectArch(rawURL string) string {
|
||||
lower := strings.ToLower(rawURL)
|
||||
if strings.Contains(lower, "arm64") {
|
||||
return "ARM64"
|
||||
}
|
||||
if strings.Contains(lower, "x64") {
|
||||
return "x64"
|
||||
}
|
||||
if strings.Contains(lower, "x86") {
|
||||
return "x86"
|
||||
}
|
||||
return "ISO"
|
||||
}
|
||||
|
||||
func detectLang(rawURL string) string {
|
||||
m := isoLangRe.FindStringSubmatch(strings.ToLower(rawURL))
|
||||
if len(m) > 1 {
|
||||
return m[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func fetchEvalLinks(evalURL string) ([]EvalLink, error) {
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
|
||||
// Step 1: Fetch the evalcenter page to extract fwlinks
|
||||
req, _ := http.NewRequest("GET", evalURL, nil)
|
||||
req.Header.Set("User-Agent", UA)
|
||||
req.Header.Set("Accept", "text/html,application/xhtml+xml")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetching evalcenter page: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
|
||||
// Step 2: Extract all fwlink URLs (unescape HTML entities like & → &)
|
||||
rawMatches := fwlinkRe.FindAllString(string(bodyBytes), -1)
|
||||
|
||||
// Deduplicate fwlinks
|
||||
seen := map[string]bool{}
|
||||
var fwlinks []string
|
||||
for _, raw := range rawMatches {
|
||||
fwlink := html.UnescapeString(raw)
|
||||
if !seen[fwlink] {
|
||||
seen[fwlink] = true
|
||||
fwlinks = append(fwlinks, fwlink)
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Follow all redirects in parallel
|
||||
type result struct {
|
||||
link EvalLink
|
||||
ok bool
|
||||
}
|
||||
results := make([]result, len(fwlinks))
|
||||
var wg sync.WaitGroup
|
||||
for i, fwlink := range fwlinks {
|
||||
wg.Add(1)
|
||||
go func(idx int, fw string) {
|
||||
defer wg.Done()
|
||||
fwReq, _ := http.NewRequest("GET", fw, nil)
|
||||
fwReq.Header.Set("User-Agent", UA)
|
||||
fwResp, err := client.Do(fwReq)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
fwResp.Body.Close()
|
||||
finalURL := fwResp.Request.URL.String()
|
||||
if !strings.Contains(strings.ToLower(finalURL), ".iso") {
|
||||
return
|
||||
}
|
||||
results[idx] = result{
|
||||
link: EvalLink{Arch: detectArch(finalURL), Lang: detectLang(finalURL), URL: finalURL},
|
||||
ok: true,
|
||||
}
|
||||
}(i, fwlink)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
var links []EvalLink
|
||||
for _, r := range results {
|
||||
if r.ok {
|
||||
links = append(links, r.link)
|
||||
}
|
||||
}
|
||||
|
||||
// Sort: en-us first, then alphabetically by lang
|
||||
sort.Slice(links, func(i, j int) bool {
|
||||
if links[i].Lang == "en-us" {
|
||||
return true
|
||||
}
|
||||
if links[j].Lang == "en-us" {
|
||||
return false
|
||||
}
|
||||
return links[i].Lang < links[j].Lang
|
||||
})
|
||||
|
||||
return links, nil
|
||||
}
|
||||
|
||||
func setWorkerSecret(req *http.Request) {
|
||||
if workerSecret != "" {
|
||||
req.Header.Set("X-Worker-Secret", workerSecret)
|
||||
|
|
@ -178,7 +321,7 @@ func enableCORS(next http.Handler) http.Handler {
|
|||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
origin := r.Header.Get("Origin")
|
||||
// Allowed origins
|
||||
if origin == "https://msdl.tech-latest.com" || origin == "http://localhost:5173" || origin == "http://localhost:3000" {
|
||||
if origin == "https://msdl.tech-latest.com" || strings.HasPrefix(origin, "http://localhost:") {
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
}
|
||||
|
||||
|
|
@ -440,14 +583,77 @@ func handleProxy(w http.ResponseWriter, r *http.Request) {
|
|||
w.Write(buf.Bytes())
|
||||
}
|
||||
|
||||
// --- /evallinks endpoint ---
|
||||
func handleEvalLinks(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
product := r.URL.Query().Get("product")
|
||||
if product == "" {
|
||||
respondJSONError(w, http.StatusBadRequest, "product query parameter is required")
|
||||
return
|
||||
}
|
||||
|
||||
evalProduct, ok := evalProductMap[product]
|
||||
if !ok {
|
||||
respondJSONError(w, http.StatusNotFound, "Unknown eval product: "+product)
|
||||
return
|
||||
}
|
||||
|
||||
// Check cache
|
||||
evalCacheMu.RLock()
|
||||
cached, exists := evalCache[product]
|
||||
evalCacheMu.RUnlock()
|
||||
|
||||
if exists && time.Since(cached.CachedAt) < evalCacheTTL {
|
||||
log.Printf("/evallinks: product=%s -> cache hit (%d links)\n", product, len(cached.Links))
|
||||
json.NewEncoder(w).Encode(EvalLinksResponse{Product: product, Name: evalProduct.Name, Links: cached.Links})
|
||||
return
|
||||
}
|
||||
|
||||
links, err := fetchEvalLinks(evalProduct.EvalURL)
|
||||
if err != nil {
|
||||
respondJSONError(w, http.StatusBadGateway, "Failed to fetch eval links: "+err.Error())
|
||||
return
|
||||
}
|
||||
if len(links) == 0 {
|
||||
respondJSONError(w, http.StatusNotFound, "No download links found for this eval product")
|
||||
return
|
||||
}
|
||||
|
||||
evalCacheMu.Lock()
|
||||
evalCache[product] = evalCacheEntry{Links: links, CachedAt: time.Now()}
|
||||
evalCacheMu.Unlock()
|
||||
|
||||
log.Printf("/evallinks: product=%s -> %d links\n", product, len(links))
|
||||
json.NewEncoder(w).Encode(EvalLinksResponse{Product: product, Name: evalProduct.Name, Links: links})
|
||||
}
|
||||
|
||||
// warmEvalCache pre-fetches all eval product links in the background at startup.
|
||||
func warmEvalCache() {
|
||||
for slug, product := range evalProductMap {
|
||||
go func(s string, p EvalProduct) {
|
||||
links, err := fetchEvalLinks(p.EvalURL)
|
||||
if err != nil || len(links) == 0 {
|
||||
log.Printf("eval warm: %s failed: %v\n", s, err)
|
||||
return
|
||||
}
|
||||
evalCacheMu.Lock()
|
||||
evalCache[s] = evalCacheEntry{Links: links, CachedAt: time.Now()}
|
||||
evalCacheMu.Unlock()
|
||||
log.Printf("eval warm: %s -> %d links cached\n", s, len(links))
|
||||
}(slug, product)
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
go cleanupSessions()
|
||||
go warmEvalCache()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// API Routing
|
||||
mux.HandleFunc("/skuinfo", handleSkuInfo)
|
||||
mux.HandleFunc("/proxy", handleProxy)
|
||||
mux.HandleFunc("/evallinks", handleEvalLinks)
|
||||
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"status":"ok"}`))
|
||||
|
|
|
|||
|
|
@ -32,6 +32,19 @@
|
|||
<changefreq>yearly</changefreq>
|
||||
</url>
|
||||
|
||||
<!-- Enterprise & Server -->
|
||||
<url>
|
||||
<loc>https://msdl.tech-latest.com/eval</loc>
|
||||
<lastmod>2026-05-18</lastmod>
|
||||
<priority>0.8</priority>
|
||||
<changefreq>monthly</changefreq>
|
||||
</url>
|
||||
<url><loc>https://msdl.tech-latest.com/product/server-2025</loc><lastmod>2026-05-18</lastmod><priority>0.8</priority><changefreq>monthly</changefreq></url>
|
||||
<url><loc>https://msdl.tech-latest.com/product/server-2022</loc><lastmod>2026-05-18</lastmod><priority>0.9</priority><changefreq>monthly</changefreq></url>
|
||||
<url><loc>https://msdl.tech-latest.com/product/server-2019</loc><lastmod>2026-05-18</lastmod><priority>0.8</priority><changefreq>monthly</changefreq></url>
|
||||
<url><loc>https://msdl.tech-latest.com/product/server-2016</loc><lastmod>2026-05-18</lastmod><priority>0.7</priority><changefreq>monthly</changefreq></url>
|
||||
<url><loc>https://msdl.tech-latest.com/product/win11-ent</loc><lastmod>2026-05-18</lastmod><priority>0.8</priority><changefreq>monthly</changefreq></url>
|
||||
|
||||
<!-- Product Pages -->
|
||||
<url><loc>https://msdl.tech-latest.com/product/48</loc><lastmod>2026-05-12</lastmod><priority>0.8</priority><changefreq>monthly</changefreq></url>
|
||||
<url><loc>https://msdl.tech-latest.com/product/52</loc><lastmod>2026-05-12</lastmod><priority>0.8</priority><changefreq>monthly</changefreq></url>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,17 @@ import ProductDetailPage from './pages/ProductDetailPage'
|
|||
import AboutPage from './pages/AboutPage'
|
||||
import PrivacyPolicyPage from './pages/PrivacyPolicyPage'
|
||||
import DisclaimerPage from './pages/DisclaimerPage'
|
||||
import EvalPage from './pages/EvalPage'
|
||||
import EvalDetailPage from './pages/EvalDetailPage'
|
||||
import NotFoundPage from './pages/NotFoundPage'
|
||||
import { evalSlugSet } from './data/evalProducts'
|
||||
import { useParams } from 'react-router-dom'
|
||||
|
||||
function ProductRouter() {
|
||||
const { productId } = useParams<{ productId: string }>()
|
||||
if (productId && evalSlugSet.has(productId)) return <EvalDetailPage />
|
||||
return <ProductDetailPage />
|
||||
}
|
||||
import ScrollToTop from './components/ScrollToTop'
|
||||
|
||||
export default function App() {
|
||||
|
|
@ -19,7 +29,8 @@ export default function App() {
|
|||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/products" element={<ProductsPage />} />
|
||||
<Route path="/product/:productId" element={<ProductDetailPage />} />
|
||||
<Route path="/product/:productId" element={<ProductRouter />} />
|
||||
<Route path="/eval" element={<EvalPage />} />
|
||||
<Route path="/about" element={<AboutPage />} />
|
||||
<Route path="/privacy-policy" element={<PrivacyPolicyPage />} />
|
||||
<Route path="/disclaimer" element={<DisclaimerPage />} />
|
||||
|
|
|
|||
|
|
@ -24,7 +24,12 @@ export default function Dock() {
|
|||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const idx = items.findIndex(item => item.path && item.path === location.pathname)
|
||||
// Exact match first
|
||||
let idx = items.findIndex(item => item.path && item.path === location.pathname)
|
||||
// /product/* and /eval* → highlight Products tab
|
||||
if (idx === -1 && (location.pathname.startsWith('/product/') || location.pathname.startsWith('/eval'))) {
|
||||
idx = items.findIndex(item => item.path === '/products')
|
||||
}
|
||||
if (idx !== -1) setActiveIndex(idx)
|
||||
}, [location.pathname])
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ export default function SiteFooter() {
|
|||
All ISO files are hosted on Microsoft's official CDN.
|
||||
</p>
|
||||
<div className="flex items-center justify-center gap-4 mt-2 text-[11px] text-zinc-700">
|
||||
<Link to="/eval" className="hover:text-zinc-500 transition-colors">Enterprise & Server</Link>
|
||||
<span>·</span>
|
||||
<Link to="/privacy-policy" className="hover:text-zinc-500 transition-colors">Privacy Policy</Link>
|
||||
<span>·</span>
|
||||
<Link to="/disclaimer" className="hover:text-zinc-500 transition-colors">Disclaimer</Link>
|
||||
|
|
|
|||
96
frontend/src/data/evalProducts.ts
Normal file
96
frontend/src/data/evalProducts.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
export interface EvalProductConfig {
|
||||
slug: string
|
||||
name: string
|
||||
version: string
|
||||
build: string
|
||||
description: string
|
||||
archs: string[]
|
||||
type: 'server' | 'enterprise'
|
||||
requirements: {
|
||||
cpu: string
|
||||
ram: string
|
||||
disk: string
|
||||
note?: string
|
||||
}
|
||||
}
|
||||
|
||||
export const evalProducts: EvalProductConfig[] = [
|
||||
{
|
||||
slug: 'server-2025',
|
||||
name: 'Windows Server 2025',
|
||||
version: 'Standard / Datacenter',
|
||||
build: 'Latest evaluation build',
|
||||
description:
|
||||
'The latest Windows Server release featuring enhanced security, hybrid cloud capabilities, and improved performance. Includes SMB over QUIC, credential guard improvements, and delegated managed service accounts.',
|
||||
archs: ['x64'],
|
||||
type: 'server',
|
||||
requirements: {
|
||||
cpu: '1.4 GHz 64-bit',
|
||||
ram: '512 MB / 2 GB GUI',
|
||||
disk: '32 GB',
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: 'server-2022',
|
||||
name: 'Windows Server 2022',
|
||||
version: 'Standard / Datacenter',
|
||||
build: 'Latest evaluation build',
|
||||
description:
|
||||
'Long-term servicing channel release with Secured-core server support, TLS 1.3 by default, and DNS-over-HTTPS. The recommended choice for new server infrastructure evaluation.',
|
||||
archs: ['x64'],
|
||||
type: 'server',
|
||||
requirements: {
|
||||
cpu: '1.4 GHz 64-bit',
|
||||
ram: '512 MB / 2 GB GUI',
|
||||
disk: '32 GB',
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: 'server-2019',
|
||||
name: 'Windows Server 2019',
|
||||
version: 'Standard / Datacenter',
|
||||
build: '17763',
|
||||
description:
|
||||
'Stable and widely deployed. Includes Windows Defender Advanced Threat Protection, Storage Migration Service, and System Insights. Ideal for existing infrastructure evaluation.',
|
||||
archs: ['x64'],
|
||||
type: 'server',
|
||||
requirements: {
|
||||
cpu: '1.4 GHz 64-bit',
|
||||
ram: '512 MB / 2 GB GUI',
|
||||
disk: '32 GB',
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: 'server-2016',
|
||||
name: 'Windows Server 2016',
|
||||
version: 'Standard / Datacenter',
|
||||
build: '14393',
|
||||
description:
|
||||
'Legacy server release with Nano Server, Storage Spaces Direct, and Windows Containers support. Suitable for older environment compatibility testing.',
|
||||
archs: ['x64'],
|
||||
type: 'server',
|
||||
requirements: {
|
||||
cpu: '1.4 GHz 64-bit',
|
||||
ram: '512 MB / 2 GB GUI',
|
||||
disk: '32 GB',
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: 'win11-ent',
|
||||
name: 'Windows 11 Enterprise',
|
||||
version: 'Evaluation',
|
||||
build: 'Latest evaluation build',
|
||||
description:
|
||||
'Full enterprise feature set including Windows Hello for Business, BitLocker, Microsoft Defender for Endpoint integration, and advanced management via Intune and Group Policy.',
|
||||
archs: ['x64'],
|
||||
type: 'enterprise',
|
||||
requirements: {
|
||||
cpu: '1 GHz, 2+ cores 64-bit',
|
||||
ram: '4 GB',
|
||||
disk: '64 GB',
|
||||
note: 'TPM 2.0 required',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
export const evalSlugSet = new Set(evalProducts.map(p => p.slug))
|
||||
325
frontend/src/pages/EvalDetailPage.tsx
Normal file
325
frontend/src/pages/EvalDetailPage.tsx
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
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 RelatedReleases from '../components/RelatedReleases'
|
||||
import NotFoundPage from './NotFoundPage'
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL ?? 'http://localhost:3002'
|
||||
const SITE_URL = 'https://msdl.tech-latest.com'
|
||||
|
||||
interface EvalLink {
|
||||
arch: string
|
||||
lang: string
|
||||
url: string
|
||||
}
|
||||
|
||||
const langNames: Record<string, string> = {
|
||||
'en-us': 'English',
|
||||
'zh-cn': 'Chinese (Simplified)',
|
||||
'zh-tw': 'Chinese (Traditional)',
|
||||
'fr-fr': 'French',
|
||||
'de-de': 'German',
|
||||
'it-it': 'Italian',
|
||||
'ja-jp': 'Japanese',
|
||||
'ko-kr': 'Korean',
|
||||
'pt-br': 'Portuguese (Brazil)',
|
||||
'ru-ru': 'Russian',
|
||||
'es-es': 'Spanish',
|
||||
'pl-pl': 'Polish',
|
||||
'nl-nl': 'Dutch',
|
||||
'sv-se': 'Swedish',
|
||||
'tr-tr': 'Turkish',
|
||||
}
|
||||
|
||||
function WinLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 88 88" className={className} fill="currentColor" aria-hidden="true">
|
||||
<path d="M0 12.402l35.687-4.86.016 34.423-35.67.203zm35.67 33.529l.028 34.453L.028 75.48.026 45.7zm4.326-39.025L87.314 0v41.527l-47.318.376zm47.329 39.349-.011 41.34-47.318-6.678-.066-34.739z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default function EvalDetailPage() {
|
||||
const { productId } = useParams<{ productId: string }>()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const product = evalProducts.find(p => p.slug === productId)
|
||||
|
||||
const [links, setLinks] = useState<EvalLink[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [copiedUrl, setCopiedUrl] = useState<string | null>(null)
|
||||
const [moreOpen, setMoreOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!product) return
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
fetch(`${API_BASE}/evallinks?product=${product.slug}`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.error) throw new Error(data.error)
|
||||
setLinks(data.links ?? [])
|
||||
})
|
||||
.catch(err => setError(err.message))
|
||||
.finally(() => setIsLoading(false))
|
||||
}, [product?.slug])
|
||||
|
||||
async function handleCopy(url: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url)
|
||||
setCopiedUrl(url)
|
||||
toast.success('Link copied to clipboard')
|
||||
setTimeout(() => setCopiedUrl(null), 2000)
|
||||
} catch {
|
||||
toast.error('Failed to copy link')
|
||||
}
|
||||
}
|
||||
|
||||
if (!product) return <NotFoundPage />
|
||||
|
||||
const primaryLink = links[0] ?? null
|
||||
const otherLinks = links.slice(1)
|
||||
|
||||
const pageTitle = `${product.name} ISO Download | Windows ISO Downloader`
|
||||
const pageDesc = `Download ${product.name} evaluation ISO directly from Microsoft. ${product.description}`
|
||||
const canonical = `${SITE_URL}/product/${product.slug}`
|
||||
|
||||
const breadcrumbJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{ '@type': 'ListItem', position: 1, name: 'Home', item: SITE_URL },
|
||||
{ '@type': 'ListItem', position: 2, name: 'Enterprise & Server', item: `${SITE_URL}/eval` },
|
||||
{ '@type': 'ListItem', position: 3, name: product.name, item: canonical },
|
||||
],
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>{pageTitle}</title>
|
||||
<meta name="description" content={pageDesc} />
|
||||
<link rel="canonical" href={canonical} />
|
||||
<meta property="og:title" content={pageTitle} />
|
||||
<meta property="og:description" content={pageDesc} />
|
||||
<meta property="og:url" content={canonical} />
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }} />
|
||||
|
||||
<div className="max-w-xl mx-auto px-5 pt-12 pb-10">
|
||||
{/* Back */}
|
||||
<button
|
||||
onClick={() => window.history.state?.idx ? navigate(-1) : navigate('/eval')}
|
||||
className="flex items-center gap-1.5 text-sm text-zinc-500 hover:text-zinc-300 mb-8 transition-colors"
|
||||
>
|
||||
<ArrowLeft size={15} />
|
||||
Enterprise & Server
|
||||
</button>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 14 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.35 }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-4 mb-6">
|
||||
<div className="w-12 h-12 rounded-xl bg-white/5 border border-white/8 flex items-center justify-center flex-shrink-0">
|
||||
{product.type === 'server'
|
||||
? <Server className="w-6 h-6 text-white/50" />
|
||||
: <WinLogo className="w-6 h-6 text-white/50" />
|
||||
}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-bold text-white leading-tight tracking-tight">
|
||||
{product.name}
|
||||
</h1>
|
||||
<p className="text-[12px] font-mono text-zinc-500 mt-0.5">{product.version}</p>
|
||||
<div className="flex gap-1.5 mt-2">
|
||||
{product.archs.map(arch => (
|
||||
<span key={arch} className="text-[10px] font-mono font-medium px-1.5 py-0.5 rounded bg-white/5 border border-white/8 text-zinc-500">
|
||||
{arch}
|
||||
</span>
|
||||
))}
|
||||
<span className="text-[10px] font-mono font-semibold px-2 py-0.5 rounded-full bg-amber-500/10 border border-amber-500/20 text-amber-400">
|
||||
EVAL
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Eval notice */}
|
||||
<div className="flex items-start gap-2.5 p-3.5 rounded-xl border border-amber-500/15 bg-amber-500/6 mb-5">
|
||||
<AlertTriangle size={14} className="text-amber-400/80 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-[11px] text-amber-400/70 leading-relaxed">
|
||||
<strong className="text-amber-400">180-day evaluation only.</strong> Not for production use. A valid license is required for activation beyond the trial period.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Download card */}
|
||||
<div className="rounded-2xl border border-white/7 bg-[#111113] p-6 mb-4">
|
||||
<p className="text-[10px] font-mono font-semibold uppercase tracking-widest text-zinc-600 mb-4">
|
||||
Download
|
||||
</p>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-2.5">
|
||||
<div className="h-11 bg-white/5 rounded-xl animate-pulse" />
|
||||
<div className="h-8 w-36 bg-white/4 rounded-lg animate-pulse" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="py-4 text-center">
|
||||
<p className="text-sm text-red-400 mb-1">Failed to load download links</p>
|
||||
<p className="text-xs text-zinc-600">{error}</p>
|
||||
</div>
|
||||
) : primaryLink ? (
|
||||
<div className="space-y-3">
|
||||
{/* Primary link */}
|
||||
<div className="flex items-center gap-2">
|
||||
<a
|
||||
href={primaryLink.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex-1 flex items-center gap-2 px-4 py-3 rounded-xl bg-blue-500/10 border border-blue-500/20 text-blue-400 hover:bg-blue-500/15 hover:border-blue-500/30 transition-all text-sm font-medium"
|
||||
>
|
||||
<Download size={14} />
|
||||
Download {primaryLink.arch}
|
||||
{primaryLink.lang && (
|
||||
<span className="text-blue-400/60 font-normal">
|
||||
· {langNames[primaryLink.lang] ?? primaryLink.lang}
|
||||
</span>
|
||||
)}
|
||||
<ExternalLink size={12} className="ml-auto opacity-60" />
|
||||
</a>
|
||||
<button
|
||||
onClick={() => handleCopy(primaryLink.url)}
|
||||
className={`p-3 rounded-xl border transition-all flex-shrink-0 ${
|
||||
copiedUrl === primaryLink.url
|
||||
? 'border-green-500/30 bg-green-500/10 text-green-400'
|
||||
: 'border-white/8 bg-white/4 text-zinc-500 hover:text-white hover:border-white/15'
|
||||
}`}
|
||||
title="Copy download link"
|
||||
>
|
||||
{copiedUrl === primaryLink.url ? <Check size={14} /> : <Copy size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* More languages */}
|
||||
{otherLinks.length > 0 && (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setMoreOpen(o => !o)}
|
||||
className="flex items-center gap-1.5 text-xs text-zinc-600 hover:text-zinc-400 transition-colors"
|
||||
>
|
||||
<ChevronDown
|
||||
size={12}
|
||||
className={`transition-transform duration-200 ${moreOpen ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
{otherLinks.length} more language{otherLinks.length > 1 ? 's' : ''}
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{moreOpen && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="space-y-1.5 mt-2.5">
|
||||
{otherLinks.map(link => (
|
||||
<div key={link.url} className="flex items-center gap-2">
|
||||
<a
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex-1 flex items-center gap-2 px-3 py-2 rounded-lg bg-white/4 border border-white/6 text-zinc-400 hover:bg-white/7 hover:border-white/10 hover:text-white transition-all text-xs"
|
||||
>
|
||||
<Download size={11} />
|
||||
{link.arch}
|
||||
{link.lang && (
|
||||
<span className="text-zinc-500">
|
||||
· {langNames[link.lang] ?? link.lang}
|
||||
</span>
|
||||
)}
|
||||
<ExternalLink size={10} className="ml-auto opacity-50" />
|
||||
</a>
|
||||
<button
|
||||
onClick={() => handleCopy(link.url)}
|
||||
className={`px-2.5 py-2 rounded-lg border text-[10px] font-mono transition-all flex-shrink-0 ${
|
||||
copiedUrl === link.url
|
||||
? 'border-green-500/30 bg-green-500/10 text-green-400'
|
||||
: 'border-white/8 bg-white/4 text-zinc-500 hover:text-white hover:border-white/15'
|
||||
}`}
|
||||
>
|
||||
{copiedUrl === link.url ? '✓' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-[10px] text-zinc-600 pt-1">
|
||||
Direct from Microsoft's CDN · Evaluation only — requires activation after 180 days
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-zinc-500 text-center py-4">No download links available.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* System requirements */}
|
||||
<div className="rounded-xl border border-white/7 bg-[#111113] p-4 mb-4">
|
||||
<p className="text-[10px] font-mono font-semibold uppercase tracking-widest text-zinc-600 mb-3">
|
||||
System Requirements
|
||||
</p>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-[11px] text-zinc-600">Processor</span>
|
||||
<span className="text-sm font-semibold text-white">{product.requirements.cpu}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-[11px] text-zinc-600">RAM</span>
|
||||
<span className="text-sm font-semibold text-white">{product.requirements.ram}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-[11px] text-zinc-600">Storage</span>
|
||||
<span className="text-sm font-semibold text-white">{product.requirements.disk}</span>
|
||||
</div>
|
||||
</div>
|
||||
{product.requirements.note && (
|
||||
<div className="mt-3 pt-3 border-t border-white/5 flex items-center gap-2">
|
||||
<AlertTriangle size={12} className="text-amber-500 flex-shrink-0" />
|
||||
<span className="text-[11px] text-amber-500/80">{product.requirements.note}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* About */}
|
||||
<div className="rounded-xl border border-white/7 bg-[#111113] p-4">
|
||||
<p className="text-[10px] font-mono font-semibold uppercase tracking-widest text-zinc-600 mb-2">
|
||||
About this release
|
||||
</p>
|
||||
<p className="text-sm text-zinc-400 leading-relaxed">{product.description}</p>
|
||||
</div>
|
||||
|
||||
{/* Aria2 tip — outside box, same as consumer page */}
|
||||
<Aria2Tip />
|
||||
|
||||
{/* Also available — outside box, same as consumer page */}
|
||||
<RelatedReleases
|
||||
current={product.slug}
|
||||
items={evalProducts.map(p => ({ id: p.slug, label: p.name }))}
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
121
frontend/src/pages/EvalPage.tsx
Normal file
121
frontend/src/pages/EvalPage.tsx
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import { Link } from 'react-router-dom'
|
||||
import { motion } from 'motion/react'
|
||||
import { ArrowRight, AlertTriangle, Server } from 'lucide-react'
|
||||
import { evalProducts } from '../data/evalProducts'
|
||||
|
||||
const SITE_URL = 'https://msdl.tech-latest.com'
|
||||
|
||||
const typeColors = {
|
||||
server: 'text-violet-400 bg-violet-500/10 border-violet-500/20',
|
||||
enterprise: 'text-blue-400 bg-blue-500/10 border-blue-500/20',
|
||||
} as const
|
||||
|
||||
const typeLabel = {
|
||||
server: 'Server',
|
||||
enterprise: 'Enterprise',
|
||||
} as const
|
||||
|
||||
const container = {
|
||||
hidden: {},
|
||||
show: { transition: { staggerChildren: 0.06 } },
|
||||
}
|
||||
|
||||
const cardVariant = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
show: { opacity: 1, y: 0, transition: { type: 'spring' as const, stiffness: 280, damping: 28 } },
|
||||
}
|
||||
|
||||
export default function EvalPage() {
|
||||
return (
|
||||
<>
|
||||
<title>Enterprise & Server ISOs | Windows ISO Downloader</title>
|
||||
<meta name="description" content="Download Windows Server 2025, 2022, 2019, 2016 and Windows 11 Enterprise evaluation ISOs directly from Microsoft's CDN." />
|
||||
<link rel="canonical" href={`${SITE_URL}/eval`} />
|
||||
<meta property="og:title" content="Enterprise & Server ISOs | Windows ISO Downloader" />
|
||||
<meta property="og:description" content="Download Windows Server and Enterprise evaluation ISOs directly from Microsoft's CDN." />
|
||||
<meta property="og:url" content={`${SITE_URL}/eval`} />
|
||||
|
||||
<div className="max-w-4xl mx-auto px-5 pt-12 pb-10">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<p className="text-[11px] font-mono font-semibold tracking-[0.2em] uppercase text-violet-400 mb-3">
|
||||
Enterprise & Server
|
||||
</p>
|
||||
<h1 className="text-3xl font-bold text-white mb-2">Evaluation ISOs</h1>
|
||||
<p className="text-zinc-400 text-sm leading-relaxed max-w-lg">
|
||||
Official evaluation editions from Microsoft's evalcenter. Direct links from Microsoft's CDN — no session, no registration.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Eval notice */}
|
||||
<div className="flex items-start gap-3 p-4 rounded-xl border border-amber-500/15 bg-amber-500/6 mb-8">
|
||||
<AlertTriangle size={15} className="text-amber-400/80 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-xs text-amber-400/70 leading-relaxed">
|
||||
<strong className="text-amber-400">Evaluation editions only.</strong> These ISOs are time-limited 180-day trials intended for testing and evaluation — not for production use. A valid license is required for activation beyond the trial period.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Product grid */}
|
||||
<motion.div
|
||||
className="grid grid-cols-1 sm:grid-cols-2 gap-3"
|
||||
variants={container}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
>
|
||||
{evalProducts.map(product => (
|
||||
<motion.div key={product.slug} variants={cardVariant}>
|
||||
<Link to={`/product/${product.slug}`} className="block outline-none group">
|
||||
<motion.div
|
||||
whileHover={{ y: -3, scale: 1.005 }}
|
||||
transition={{ type: 'spring', stiffness: 400, damping: 25 }}
|
||||
className="relative rounded-2xl border border-white/7 bg-[#111113] p-6
|
||||
hover:border-white/13 hover:shadow-2xl hover:shadow-black/40
|
||||
transition-colors duration-300"
|
||||
>
|
||||
{/* Header row */}
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="w-9 h-9 rounded-lg bg-white/5 border border-white/8 flex items-center justify-center">
|
||||
<Server className="w-4 h-4 text-white/50" />
|
||||
</div>
|
||||
<span className={`text-[10px] font-semibold px-2 py-0.5 rounded-full border ${typeColors[product.type]}`}>
|
||||
{typeLabel[product.type]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h2 className="text-base font-semibold text-white leading-tight">{product.name}</h2>
|
||||
<p className="text-[11px] font-mono text-zinc-500 mt-0.5 mb-3">{product.version}</p>
|
||||
<p className="text-sm text-zinc-400 leading-relaxed mb-4 line-clamp-2">{product.description}</p>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-1.5">
|
||||
{product.archs.map(arch => (
|
||||
<span key={arch} className="text-[10px] font-mono font-medium px-1.5 py-0.5 rounded bg-white/5 border border-white/8 text-zinc-500">
|
||||
{arch}
|
||||
</span>
|
||||
))}
|
||||
<span className="text-[10px] font-mono font-medium px-1.5 py-0.5 rounded bg-amber-500/8 border border-amber-500/20 text-amber-500/70">
|
||||
EVAL
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-xs text-zinc-600 group-hover:text-zinc-400 transition-colors">
|
||||
<span>Get links</span>
|
||||
<ArrowRight size={11} className="group-hover:translate-x-0.5 transition-transform" />
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</Link>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { motion } from 'motion/react'
|
||||
import { ArrowRight } from 'lucide-react'
|
||||
import { ArrowRight, Server } from 'lucide-react'
|
||||
import ProductCard from '../components/ProductCard'
|
||||
import StatsBar from '../components/StatsBar'
|
||||
import HowItWorks from '../components/HowItWorks'
|
||||
|
|
@ -161,6 +161,34 @@ export default function HomePage() {
|
|||
))}
|
||||
</motion.div>
|
||||
|
||||
{/* Enterprise & Server CTA */}
|
||||
<motion.div
|
||||
className="mb-4"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.45 }}
|
||||
>
|
||||
<Link
|
||||
to="/eval"
|
||||
className="group flex items-center justify-between w-full px-5 py-3.5 rounded-xl border border-violet-500/15 bg-violet-500/5 hover:border-violet-500/25 hover:bg-violet-500/8 transition-all duration-200"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-7 h-7 rounded-lg bg-violet-500/10 border border-violet-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<Server size={14} className="text-violet-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white/80 group-hover:text-white transition-colors leading-tight">
|
||||
Enterprise & Server ISOs
|
||||
</p>
|
||||
<p className="text-[11px] text-zinc-600 mt-0.5">
|
||||
Server 2025, 2022, 2019, 2016 · Win 11 Enterprise · Eval editions
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight size={14} className="text-zinc-600 group-hover:text-violet-400 group-hover:translate-x-0.5 transition-all" />
|
||||
</Link>
|
||||
</motion.div>
|
||||
|
||||
{/* Browse all CTA */}
|
||||
<motion.div
|
||||
className="text-center"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue