diff --git a/src/app/changelog/[slug]/page.tsx b/src/app/changelog/[slug]/page.tsx index f0c0335..54a69f3 100644 --- a/src/app/changelog/[slug]/page.tsx +++ b/src/app/changelog/[slug]/page.tsx @@ -10,16 +10,26 @@ import { GitCommit, CheckCircle2, AlertTriangle, + Download, + BookOpen, + ShieldAlert, } from "lucide-react"; import { CHANGELOG_ENTRIES, getChangelogEntryBySlug, getAdjacentEntries, + getLatestProductionRelease, isDeprecatedEntry, } from "@/lib/changelog"; -import { SITE_META } from "@/lib/constants"; +import { + SITE_META, + DOCS_RELEASE_NOTES_URL, + USER_GUIDE_URL, + GITHUB_REPO_URL, +} from "@/lib/constants"; import { ReleaseVisual } from "@/components/visuals/ReleaseVisual"; import { ScrollReveal } from "@/components/motion/ScrollReveal"; +import { formatDate } from "@passcodes/passalgo"; import { cn } from "@/lib/utils"; import type { Metadata } from "next"; @@ -61,19 +71,37 @@ export default function ChangelogEntryPage({ const { prev, next } = getAdjacentEntries(params.slug); const isDeprecated = isDeprecatedEntry(entry); + const latestProd = getLatestProductionRelease(); + const isCurrentProduction = latestProd?.slug === entry.slug; return (
{/* 1. Header & Release Metadata */} -
+
- +
@@ -104,17 +132,17 @@ export default function ChangelogEntryPage({ )} + {isCurrentProduction && ( + + + Current Production + + )} + - + @@ -126,7 +154,7 @@ export default function ChangelogEntryPage({ : "text-[var(--accent-light)]" )} > - + @@ -149,16 +177,35 @@ export default function ChangelogEntryPage({ {/* Action Links */}
+ {!isDeprecated && !entry.isYanked && !entry.isMilestone && ( + +
@@ -187,6 +234,15 @@ export default function ChangelogEntryPage({ {entry.deprecatedReason || "This release is no longer supported. Superseded by the newer database architecture introduced in later releases. Please use a current release instead."}

+
+ + View current production downloads +
)} @@ -194,13 +250,22 @@ export default function ChangelogEntryPage({ {entry.isYanked && (
- +

{entry.yankedReason || "This release has been explicitly yanked from active distribution. It is preserved here strictly for documentation and project history."}

+
+ + View current production downloads +
)} @@ -208,7 +273,7 @@ export default function ChangelogEntryPage({ {entry.internalDetails && (

- Build & Runtime Specifications + Build & Runtime Specifications

{entry.internalDetails.packageName && ( @@ -252,7 +317,6 @@ export default function ChangelogEntryPage({ )} - {/* 2. Article Content (Highlights + Release Sections) */}
@@ -260,7 +324,7 @@ export default function ChangelogEntryPage({ {entry.highlights && entry.highlights.length > 0 && (

- +

    @@ -304,6 +368,88 @@ export default function ChangelogEntryPage({ ))}
)} + + {/* Documentation & Resources Box */} +
+
+
+
+ + Official Docs Release Notes +
+
+ + {/* Next Steps Journey Card */} +
+
+
+ + Next Steps + +

+ Ready to run Passcodes? +

+

+ {isDeprecated || entry.isYanked + ? "This build is deprecated or yanked. Download the verified production release or review the setup guide." + : "Download the latest signed Android package, read our configuration walkthrough, or explore all releases."} +

+
+
+ +
+
+
@@ -319,7 +465,7 @@ export default function ChangelogEntryPage({ className="subtle-card group flex flex-col gap-1 text-left" > - + @@ -333,11 +479,11 @@ export default function ChangelogEntryPage({ {next ? ( - + Next Release - + {next.version} — {next.title} diff --git a/src/app/changelog/page.tsx b/src/app/changelog/page.tsx index cc38ac0..8cda867 100644 --- a/src/app/changelog/page.tsx +++ b/src/app/changelog/page.tsx @@ -10,70 +10,104 @@ import { Calendar, GitCommit, AlertTriangle, + Layers, + Filter, } from "lucide-react"; import { CHANGELOG_ENTRIES, CHANGELOG_CATEGORIES, + RELEASE_CHANNEL_FILTERS, isDeprecatedEntry, + getLatestProductionRelease, + getRecentUpdates, + getProjectMilestones, + matchesReleaseSearch, type CategoryFilter, + type ReleaseChannelFilter, } from "@/lib/changelog"; import { GITHUB_RELEASES_URL, DOCS_RELEASE_NOTES_URL } from "@/lib/constants"; import { ReleaseVisual } from "@/components/visuals/ReleaseVisual"; import { ScrollReveal } from "@/components/motion/ScrollReveal"; +import { CurrentReleaseHero } from "@/components/changelog/CurrentReleaseHero"; +import { RecentUpdatesGrid } from "@/components/changelog/RecentUpdatesGrid"; +import { MilestonesSection } from "@/components/changelog/MilestonesSection"; +import { formatDate } from "@passcodes/passalgo"; import { cn } from "@/lib/utils"; export default function ChangelogPage() { const [search, setSearch] = useState(""); const [activeCategory, setActiveCategory] = useState("All"); + const [activeChannel, setActiveChannel] = + useState("All"); + + const latestProduction = useMemo(() => getLatestProductionRelease(), []); + const recentUpdates = useMemo(() => getRecentUpdates(3), []); + const projectMilestones = useMemo(() => getProjectMilestones(), []); const filteredEntries = useMemo(() => { return CHANGELOG_ENTRIES.filter((entry) => { + // Category filter const matchesCategory = activeCategory === "All" || entry.category === activeCategory; - const searchLower = search.trim().toLowerCase(); - if (!searchLower) return matchesCategory; - - const matchesTitle = entry.title - .toLowerCase() - .includes(searchLower); - const matchesSummary = entry.summary - .toLowerCase() - .includes(searchLower); - const matchesVersion = entry.version - .toLowerCase() - .includes(searchLower); - const matchesTldr = entry.tldr?.toLowerCase().includes(searchLower); - const matchesHighlights = entry.highlights?.some((h) => - h.toLowerCase().includes(searchLower) - ); - const matchesSections = entry.sections?.some((s) => - s.items.some((item) => item.toLowerCase().includes(searchLower)) - ); - - return ( - matchesCategory && - (matchesTitle || - matchesSummary || - matchesVersion || - matchesTldr || - matchesHighlights || - matchesSections) - ); + if (!matchesCategory) return false; + + // Channel filter + if (activeChannel !== "All") { + const isDep = isDeprecatedEntry(entry); + const isYank = !!entry.isYanked; + const isMs = !!entry.isMilestone; + + if (activeChannel === "Milestones" && !isMs) return false; + if (activeChannel === "Deprecated" && !isDep) return false; + if (activeChannel === "Yanked" && !isYank) return false; + if ( + activeChannel === "Stable" && + (entry.releaseType !== "Stable" || isDep || isYank || isMs) + ) { + return false; + } + if ( + activeChannel === "Beta" && + (entry.releaseType !== "Beta" || isYank || isMs) + ) { + return false; + } + if ( + activeChannel === "Alpha" && + (entry.releaseType !== "Alpha" || isYank || isMs) + ) { + return false; + } + } + + // Search query across version, title, summary, highlights, technical details, release states + return matchesReleaseSearch(entry, search); }); - }, [search, activeCategory]); + }, [search, activeCategory, activeChannel]); + + const isFilterActive = + search.trim().length > 0 || + activeCategory !== "All" || + activeChannel !== "All"; + + const handleResetFilters = () => { + setSearch(""); + setActiveCategory("All"); + setActiveChannel("All"); + }; return ( -
+
{/* 1. Editorial Header */} -
+
Release Highlights

- Release Highlights & Milestones + Release Highlights & Milestones

Curated milestones, architecture transitions, and @@ -82,16 +116,16 @@ export default function ChangelogPage() { documentation and GitHub releases.

-
+
- +
+ + {/* Quick navigation anchor bar */} +
- {/* 2. Filter and Search Controls */} - -
-
- {CHANGELOG_CATEGORIES.map((cat) => { - const isSelected = activeCategory === cat; - return ( - - ); - })} + {/* 2. Featured Current Production Release */} + {latestProduction && ( +
+ + + +
+ )} + + {/* 3. Recent Updates */} + {recentUpdates.length > 0 && ( +
+ + + +
+ )} + + {/* 4. Major Architectural Milestones */} + {projectMilestones.length > 0 && ( +
+ + + +
+ )} + + {/* 5. Historical Release Archive & Timeline */} +
+ +
+ + Release Archive + +

+ Full Release History & Timeline +

+

+ Explore the complete chronological stream of production builds, preview releases, and archived tags. +

-
- - setSearch(e.target.value)} - className="w-full rounded-lg border border-[var(--border-light)] bg-[var(--card-bg)] py-1.5 pl-9 pr-8 text-sm text-[var(--text)] placeholder-[var(--text-dim)] outline-none transition-colors focus:border-[var(--accent-light)]" - aria-label="Search changelog entries" - /> - {search && ( - + )} +
+ +
+ + Showing {filteredEntries.length} of {CHANGELOG_ENTRIES.length} entries + + {isFilterActive && ( + + )} +
+
+ + {/* Middle row: Category Filters */} +
+
+ Categories: +
+
- - - )} + {CHANGELOG_CATEGORIES.map((cat) => { + const isSelected = activeCategory === cat; + return ( + + ); + })} +
+
+ + {/* Bottom row: Release Channel / State Filters */} +
+
+ Release State & Channel: +
+
+ {RELEASE_CHANNEL_FILTERS.map((channel) => { + const isSelected = activeChannel === channel; + return ( + + ); + })} +
+
-
-
+ - {/* 3. Release Timeline Stream */} - {filteredEntries.length === 0 ? ( -
-

- No updates match your filter -

-

- Try clearing your search query or selecting - "All" categories. -

- -
- ) : ( -
- {filteredEntries.map((entry, index) => { - const isLatest = - index === 0 && activeCategory === "All" && !search; + {/* 6. Release Timeline Stream */} + {filteredEntries.length === 0 ? ( +
+

+ No updates match your filter +

+

+ Try clearing your search query or selecting "All" categories. +

+ +
+ ) : ( +
+ {filteredEntries.map((entry, index) => { + const isLatest = + index === 0 && + activeCategory === "All" && + activeChannel === "All" && + !search; + + if (entry.isMilestone) { + return ( + +
+
+ + Architecture Milestone + + + {formatDate(entry.date)} + +
+ +

+ + {entry.title} + +

+ +

+ {entry.summary} +

+ + {entry.highlights && + entry.highlights.length > 0 && ( +
+ {entry.highlights.map( + (h, hIdx) => ( +
+ + + {h} + +
+ ) + )} +
+ )} + +
+ + + Read architectural overview + +
+
+
+ ); + } + + const isDeprecated = isDeprecatedEntry(entry); - if (entry.isMilestone) { return ( -
-
- - Architecture Milestone - - - May 2026 - -
- -

- - {entry.title} - -

- -

- {entry.summary} -

- - {entry.highlights && - entry.highlights.length > 0 && ( -
- {entry.highlights.map( - (h, hIdx) => ( -
- - - {h} - -
- ) +
+ {/* Left Column: Metadata & Category Node */} +
+
+
+ {isDeprecated && ( + + Deprecated + + )} + {entry.isYanked ? ( + + Yanked + + ) : ( + + {entry.releaseType} + + )} + {isLatest && ( + + Latest + + )} + {entry.isMajor && !isLatest && ( + + Major + )}
- )} -
- - - Read architectural overview - - - -
-
- - ); - } - - const isDeprecated = isDeprecatedEntry(entry); - - return ( - -
- {/* Left Column: Metadata & Category Node */} -
-
-
- {isDeprecated && ( - - Deprecated - - )} - {entry.isYanked ? ( - - Yanked - - ) : ( +
- {entry.releaseType} + {entry.version} - )} - {isLatest && ( - - Latest +
+ +
+
+ +
+ + + + {entry.category} + +
+ + {entry.internalDetails && ( +
+ {entry.internalDetails.expoSdk && ( +
Expo SDK {entry.internalDetails.expoSdk}
+ )} + {entry.internalDetails.minAndroid && ( +
Android {entry.internalDetails.minAndroid.split(" ")[0]}+
+ )} +
)} - {entry.isMajor && !isLatest && ( - - Major - + + {entry.githubUrl && ( +
+ + + GitHub Release + +
)}
+
-
- +

+ - {entry.version} - -

- -
- - -
- -
- - - - {entry.category} - - -
+ {entry.title} + + - {entry.internalDetails && ( -
- {entry.internalDetails.expoSdk && ( - Expo SDK {entry.internalDetails.expoSdk} - )} - {entry.internalDetails.minAndroid && ( - Android {entry.internalDetails.minAndroid.split(" ")[0]}+ - )} + {isDeprecated && ( +
+
+
+

+ {entry.deprecatedReason || + "This release is no longer supported. Superseded by the newer database architecture introduced in later releases. Please use a current release instead."} +

)} - {entry.githubUrl && ( -
- - - GitHub Release - - - + {entry.isYanked && entry.yankedReason && ( +
+ Release Notice: + {entry.yankedReason}
)} -
-
- - {/* Right Column: Editorial Summary & Highlights */} -
-

- - {entry.title} - -

- - {isDeprecated && ( -
-
-
-

- {entry.deprecatedReason || - "This release is no longer supported. Superseded by the newer database architecture introduced in later releases. Please use a current release instead."} -

-
- )} - {entry.isYanked && entry.yankedReason && ( -
- Release Notice: - {entry.yankedReason} -
- )} +

+ {entry.summary} +

-

- {entry.summary} -

- - {/* Highlights Bullet List */} - {entry.highlights && - entry.highlights.length > 0 && ( -
- {entry.highlights.map( - (highlight, hIdx) => ( -
- - - {highlight} - -
- ) - )} -
- )} + {/* Highlights Bullet List */} + {entry.highlights && + entry.highlights.length > 0 && ( +
+ {entry.highlights.map( + (highlight, hIdx) => ( +
+ + + {highlight} + +
+ ) + )} +
+ )} - {/* Link to detail page */} -
- - - Read full release notes - - - + {/* Link to detail page */} +
+ + + Read full release notes + +
-
-
-
- ); - })} -
- )} +
+ + ); + })} +
+ )} +
); } diff --git a/src/components/changelog/CurrentReleaseHero.tsx b/src/components/changelog/CurrentReleaseHero.tsx new file mode 100644 index 0000000..caec599 --- /dev/null +++ b/src/components/changelog/CurrentReleaseHero.tsx @@ -0,0 +1,156 @@ +import Link from "next/link"; +import { + Download, + ArrowRight, + ExternalLink, + Calendar, + CheckCircle2, + BookOpen, +} from "lucide-react"; +import type { ChangelogEntry } from "@/lib/changelog"; +import { formatDate } from "@passcodes/passalgo"; +import { USER_GUIDE_URL } from "@/lib/constants"; + +interface CurrentReleaseHeroProps { + release: ChangelogEntry; +} + +export function CurrentReleaseHero({ release }: CurrentReleaseHeroProps) { + const highlights = release.highlights?.slice(0, 4) || []; + + return ( +
+ {/* Ambient subtle glow */} +
+ ); +} diff --git a/src/components/changelog/MilestonesSection.tsx b/src/components/changelog/MilestonesSection.tsx new file mode 100644 index 0000000..22e5fec --- /dev/null +++ b/src/components/changelog/MilestonesSection.tsx @@ -0,0 +1,109 @@ +import Link from "next/link"; +import { ArrowRight, Layers, Milestone, GitCommit } from "lucide-react"; +import type { ChangelogEntry } from "@/lib/changelog"; +import { formatDate } from "@passcodes/passalgo"; +import { isDeprecatedEntry } from "@/lib/changelog"; + +interface MilestonesSectionProps { + milestones: ChangelogEntry[]; +} + +export function MilestonesSection({ milestones }: MilestonesSectionProps) { + if (milestones.length === 0) return null; + + return ( +
+
+ + Architecture & History + +

+ Major Architectural Milestones +

+

+ Documented architectural shifts and foundational transitions across Passcodes' open-source history. +

+
+ +
+ {milestones.map((milestone) => { + const isExplicitMilestone = milestone.isMilestone; + const isDeprecated = isDeprecatedEntry(milestone); + + return ( +
+
+
+ {isExplicitMilestone ? ( + + Architecture Milestone + + ) : ( + + Milestone + + )} + + {isDeprecated && ( + + Deprecated + + )} + + + {milestone.version} + + + + · {formatDate(milestone.date)} + +
+ +

+ + {milestone.title} + +

+ +

+ {milestone.summary} +

+ + {milestone.highlights && milestone.highlights.length > 0 && ( +
    + {milestone.highlights.slice(0, 2).map((item, idx) => ( +
  • + + {item} +
  • + ))} +
+ )} +
+ +
+ + Read details +
+
+ ); + })} +
+
+ ); +} diff --git a/src/components/changelog/RecentUpdatesGrid.tsx b/src/components/changelog/RecentUpdatesGrid.tsx new file mode 100644 index 0000000..499bf8e --- /dev/null +++ b/src/components/changelog/RecentUpdatesGrid.tsx @@ -0,0 +1,117 @@ +import Link from "next/link"; +import { ArrowRight, Calendar } from "lucide-react"; +import type { ChangelogEntry } from "@/lib/changelog"; +import { ReleaseVisual } from "@/components/visuals/ReleaseVisual"; +import { formatDate } from "@passcodes/passalgo"; +import { cn } from "@/lib/utils"; + +interface RecentUpdatesGridProps { + entries: ChangelogEntry[]; +} + +export function RecentUpdatesGrid({ entries }: RecentUpdatesGridProps) { + if (entries.length === 0) return null; + + return ( +
+
+ + What's New + +

+ Recent Updates +

+

+ The latest stable releases, iterative improvements, and maintenance builds. +

+
+ +
+ {entries.map((entry) => ( +
+
+ {/* Card Top: Channel, Date, Category */} +
+
+ + {entry.releaseType} + + + {entry.version} + +
+ +
+ +
+
+ + {/* Date */} +
+
+ + {/* Title */} +

+ + {entry.title} + +

+ + {/* Summary */} +

+ {entry.summary} +

+ + {/* Highlights Preview */} + {entry.highlights && entry.highlights.length > 0 && ( +
    + {entry.highlights.slice(0, 2).map((h, i) => ( +
  • + + {h} +
  • + ))} +
+ )} +
+ + {/* Card Footer Link */} +
+ + Read release notes +
+
+ ))} +
+
+ ); +} diff --git a/src/components/downloads/ArchDownload.tsx b/src/components/downloads/ArchDownload.tsx index 7b9f3c3..4e84a07 100644 --- a/src/components/downloads/ArchDownload.tsx +++ b/src/components/downloads/ArchDownload.tsx @@ -44,12 +44,12 @@ export function ArchDownload({ assets, variant = "full", className }: Props) { : "Best for most phones"; const note = isDetecting - ? "Detecting your device…" + ? "Detecting your device architecture…" : isAndroid && detected && detected !== primaryArch ? `No ${ARCH_META[detected].label} build in this release — ${ARCH_META[primaryArch].label} is the closest match.` : isAndroid && detected - ? `Detected Android · ${ARCH_META[detected].label} — showing the matching build first.` - : `Not on Android, or unsure? ${ARCH_META.arm64.label} fits most phones; ${ARCH_META.universal.label} works on any device.`; + ? `Detected Android (${ARCH_META[detected].label}) — recommended package for your hardware.` + : `Unsure which to pick? ${ARCH_META.arm64.label} fits almost all modern Android phones; ${ARCH_META.universal.label} works on any device.`; /* ---------- compact (release-history rows) ---------- */ if (variant === "compact") { @@ -122,7 +122,7 @@ export function ArchDownload({ assets, variant = "full", className }: Props) { {alts.length > 0 && (

- Or pick another build + Or pick another architecture build

{alts.map((v) => ( @@ -143,6 +143,7 @@ function Chip({ v }: { v: { key: ArchKey; asset: GithubReleaseAsset } }) {
+ {changelogEntry?.summary && ( +

+ {changelogEntry.summary} +

+ )} +
)} + {isYanked && ( +
+
+
+

+ This release was withdrawn from active distribution. Preserved for archive documentation and project history only. +

+
+ )} + {release.body && (
+ {isYanked ? ( +
+

+ Downloads are withdrawn for this release. Please use the recommended production build above. +

+
+ ) : ( + + )} + + {/* Discovery & Navigation Links */} +
+ + View in Project Updates +
); } diff --git a/src/components/downloads/DownloadsContent.tsx b/src/components/downloads/DownloadsContent.tsx index b20273d..1388f15 100644 --- a/src/components/downloads/DownloadsContent.tsx +++ b/src/components/downloads/DownloadsContent.tsx @@ -1,7 +1,7 @@ "use client"; import { useMemo, useState } from "react"; -import { Search, XCircle, AlertTriangle, Cpu, Layers } from "lucide-react"; +import { Search, XCircle, AlertTriangle, Cpu, Layers, ArrowRight } from "lucide-react"; import { SectionHeader } from "@/components/shared/SectionHeader"; import { DeviceWarning } from "@/components/downloads/DeviceWarning"; import { DownloadCard } from "@/components/downloads/DownloadCard"; @@ -13,23 +13,43 @@ import { isRateLimitError, getLatestStableRelease, classifyRelease, + isYankedRelease, + isDeprecatedRelease, } from "@/hooks/useGithubRelease"; import { KOMI_STORE_URL, KOMI_BADGE_SRC, KOMI_BADGE_WEBP, + USER_GUIDE_URL, } from "@/lib/constants"; import Link from "next/link"; -type FilterChannel = "all" | "stable" | "beta" | "alpha"; +type FilterChannel = + | "all" + | "stable" + | "beta" + | "alpha" + | "deprecated" + | "yanked"; const FILTER_CHANNELS: { id: FilterChannel; label: string }[] = [ { id: "all", label: "All Releases" }, { id: "stable", label: "Stable" }, { id: "beta", label: "Beta" }, { id: "alpha", label: "Alpha" }, + { id: "deprecated", label: "Deprecated" }, + { id: "yanked", label: "Yanked" }, ]; +const CHANNEL_EXPLANATIONS: Record = { + all: "Displaying all recorded releases across active, preview, and historical channels.", + stable: "Verified production releases thoroughly tested and recommended for general use.", + beta: "Pre-release builds for testing upcoming features and bug fixes before production.", + alpha: "Early preview builds containing experimental features and rapid architectural changes.", + deprecated: "Legacy releases (v1.x and v2.x) superseded by the modern v3 database architecture.", + yanked: "Releases withdrawn from active distribution due to known issues; preserved for historical records.", +}; + function KomiSection() { const [badgeFailed, setBadgeFailed] = useState(false); return ( @@ -97,13 +117,30 @@ export function DownloadsContent() { const filtered = useMemo(() => { const q = query.trim().toLowerCase(); return (releases ?? []).filter((r) => { + const isDep = isDeprecatedRelease(r); + const isYank = isYankedRelease(r); + const channel = classifyRelease(r); + const matchQ = !q || (r.name || "").toLowerCase().includes(q) || r.tag_name.toLowerCase().includes(q) || - (r.body || "").toLowerCase().includes(q); - const matchS = - status === "all" || classifyRelease(r) === status; + (r.body || "").toLowerCase().includes(q) || + channel.includes(q) || + (isDep && "deprecated".includes(q)) || + (isYank && "yanked".includes(q)); + + const matchS = (() => { + if (status === "all") return true; + if (status === "deprecated") return isDep; + if (status === "yanked") return isYank; + if (status === "stable") + return channel === "stable" && !isDep && !isYank; + if (status === "beta") return channel === "beta" && !isYank; + if (status === "alpha") return channel === "alpha" && !isYank; + return channel === status; + })(); + return matchQ && matchS; }); }, [releases, query, status]); @@ -121,7 +158,7 @@ export function DownloadsContent() { as="h1" badge="Verified Binaries" title="Downloads & Releases" - subtitle="Production builds engineered for Android devices with split ABI support. Always free and open source." + subtitle="Verified, privacy-first Android builds with optimized packages for your device architecture. Always free and open source." /> @@ -156,18 +193,37 @@ export function DownloadsContent() { latestRelease && (
-
+

+ + Release notes & milestones +
+

+ First time installing an Android APK? Follow our{" "} + + Installation & Setup Guide + + . +

) @@ -221,11 +277,16 @@ export function DownloadsContent() {
-
+
{FILTER_CHANNELS.map((item) => (
+

+ {CHANNEL_EXPLANATIONS[status]} +

+ {hasControls && (

Showing{" "} diff --git a/src/components/downloads/ReleaseList.tsx b/src/components/downloads/ReleaseList.tsx index 6078b85..cb1c87f 100644 --- a/src/components/downloads/ReleaseList.tsx +++ b/src/components/downloads/ReleaseList.tsx @@ -1,6 +1,13 @@ "use client"; -import { Download, Calendar, ExternalLink, AlertTriangle } from "lucide-react"; +import { + Download, + Calendar, + ExternalLink, + AlertTriangle, + Search, + ArrowRight, +} from "lucide-react"; import { ArchDownload } from "@/components/downloads/ArchDownload"; import { classifyRelease, @@ -9,7 +16,8 @@ import { } from "@/hooks/useGithubRelease"; import type { GithubRelease } from "@/types/github"; import Link from "next/link"; -import { formatDate } from "@passcodes/passalgo"; +import { formatDate, formatNumber } from "@passcodes/passalgo"; +import { getChangelogEntryByTagName } from "@/lib/changelog"; export function ReleaseList({ releases, @@ -21,17 +29,21 @@ export function ReleaseList({ if (releases.length === 0) { return (

-

+

No releases match your filter

-

- Try adjusting your search query or channel filter. +

+ Try adjusting your search query or channel filter to view available builds.

{onResetFilters && ( @@ -50,6 +62,7 @@ export function ReleaseList({ const channel = classifyRelease(release); const isYanked = isYankedRelease(release); const isDeprecated = isDeprecatedRelease(release); + const changelogEntry = getChangelogEntryByTagName(release.tag_name); return (
{isDeprecated && ( - deprecated + Deprecated )} {isYanked && ( - - yanked + + Yanked )} - {channel} + {channel === "stable" ? "Stable" : channel === "beta" ? "Beta" : "Alpha"}

@@ -91,7 +104,7 @@ export function ReleaseList({ className="mr-1 inline h-3 w-3" aria-hidden="true" /> - {totalDownloads} + {formatNumber(totalDownloads)}

@@ -113,21 +126,56 @@ export function ReleaseList({
)} -
- - - - GitHub Manifest - + {isYanked && ( +
+
+
+

+ This release was withdrawn from active distribution. + Preserved for documentation and project history only. +

+
+ )} + +
+ {isYanked ? ( + + Downloads withdrawn for this release + + ) : ( + + )} +
+ + Project Updates +
); diff --git a/src/components/home/Hero.tsx b/src/components/home/Hero.tsx index b3337c6..99563bf 100644 --- a/src/components/home/Hero.tsx +++ b/src/components/home/Hero.tsx @@ -17,14 +17,18 @@ import { GithubIcon, DiscordIcon } from "@/components/ui/BrandIcons"; import { ScrollReveal } from "@/components/motion/ScrollReveal"; import { useDownloadCount } from "@/hooks/useDownloadCount"; import { useLatestRelease } from "@/hooks/useGithubRelease"; -import { getLatestChangelogEntry } from "@/lib/changelog"; +import { + getLatestProductionRelease, + getLatestChangelogEntry, +} from "@/lib/changelog"; import { formatNumber } from "@passcodes/passalgo"; import { GITHUB_REPO_URL, DISCORD_URL, USER_GUIDE_URL } from "@/lib/constants"; export function Hero() { const { data: downloadStats } = useDownloadCount(); const { data: latestRelease } = useLatestRelease(); - const latestEntry = getLatestChangelogEntry(); + const latestEntry = + getLatestProductionRelease() || getLatestChangelogEntry(); const displayVersion = latestRelease ? `v${latestRelease.tag_name.replace(/^v/, "")}` @@ -72,7 +76,7 @@ export function Hero() { {displayVersion} - Passcodes {latestEntry.version} is live + Passcodes {displayVersion} is live entry.slug === slug); } +export function getChangelogEntryByTagName( + tagName: string +): ChangelogEntry | undefined { + const cleanTag = tagName.trim().toLowerCase(); + const tagWithoutV = cleanTag.startsWith("v") ? cleanTag.slice(1) : cleanTag; + const tagWithV = cleanTag.startsWith("v") ? cleanTag : `v${cleanTag}`; + + return CHANGELOG_ENTRIES.find((entry) => { + const v = entry.version.toLowerCase(); + return ( + v === cleanTag || + v === tagWithV || + v === tagWithoutV || + entry.slug === cleanTag || + entry.slug.startsWith(cleanTag) + ); + }); +} + export function getLatestChangelogEntry(): ChangelogEntry { return ( CHANGELOG_ENTRIES.find((e) => !e.isMilestone) || CHANGELOG_ENTRIES[0] @@ -106,3 +125,158 @@ export function isDeprecatedEntry(entry: ChangelogEntry): boolean { const major = parseInt(match[1], 10); return major === 1 || major === 2; } + +export type ReleaseState = + | "Stable" + | "Beta" + | "Alpha" + | "Deprecated" + | "Yanked" + | "Milestone"; + +/** + * Classify a changelog entry into its primary semantic release state. + */ +export function getReleaseState(entry: ChangelogEntry): ReleaseState { + if (entry.isMilestone) return "Milestone"; + if (entry.isYanked) return "Yanked"; + if (isDeprecatedEntry(entry)) return "Deprecated"; + return entry.releaseType; +} + +/** + * Get the current stable production release. + * Excludes milestones, yanked releases, deprecated releases, and non-stable channels. + */ +export function getLatestProductionRelease(): ChangelogEntry | undefined { + return CHANGELOG_ENTRIES.find( + (entry) => + !entry.isMilestone && + !entry.isYanked && + !isDeprecatedEntry(entry) && + entry.releaseType === "Stable" + ); +} + +/** + * Get the most recent meaningful updates (active, non-milestone releases). + */ +export function getRecentUpdates(count: number = 3): ChangelogEntry[] { + return CHANGELOG_ENTRIES.filter( + (entry) => + !entry.isMilestone && + !isDeprecatedEntry(entry) && + !entry.isYanked + ).slice(0, count); +} + +/** + * Get documented major architectural milestones. + * Includes the explicit architecture transition milestone and major version baseline releases. + */ +export function getProjectMilestones(): ChangelogEntry[] { + return CHANGELOG_ENTRIES.filter( + (entry) => + entry.isMilestone || + (entry.isMajor && + ["v3.0.0", "v2.0.0", "v1.0.0"].includes(entry.version)) + ); +} + +export const RELEASE_CHANNEL_FILTERS = [ + "All", + "Stable", + "Beta", + "Alpha", + "Milestones", + "Deprecated", + "Yanked", +] as const; + +export type ReleaseChannelFilter = (typeof RELEASE_CHANNEL_FILTERS)[number]; + +/** + * Search across version, title, summary, highlights, technical details, and release state. + */ +export function matchesReleaseSearch( + entry: ChangelogEntry, + query: string +): boolean { + const searchLower = query.trim().toLowerCase(); + if (!searchLower) return true; + + const state = getReleaseState(entry).toLowerCase(); + const releaseType = entry.releaseType.toLowerCase(); + const isDep = isDeprecatedEntry(entry) ? "deprecated" : ""; + const isYank = entry.isYanked ? "yanked" : ""; + const isMs = entry.isMilestone ? "milestone" : ""; + + // Version, title, summary, tldr + if (entry.version.toLowerCase().includes(searchLower)) return true; + if (entry.title.toLowerCase().includes(searchLower)) return true; + if (entry.summary.toLowerCase().includes(searchLower)) return true; + if (entry.tldr?.toLowerCase().includes(searchLower)) return true; + + // Highlights + if (entry.highlights?.some((h) => h.toLowerCase().includes(searchLower))) { + return true; + } + + // Sections + if ( + entry.sections?.some( + (s) => + s.title.toLowerCase().includes(searchLower) || + s.items.some((item) => + item.toLowerCase().includes(searchLower) + ) + ) + ) { + return true; + } + + // Release states & channels + if ( + state.includes(searchLower) || + releaseType.includes(searchLower) || + isDep.includes(searchLower) || + isYank.includes(searchLower) || + isMs.includes(searchLower) + ) { + return true; + } + + // Technical build & runtime details + if (entry.internalDetails) { + const details = entry.internalDetails; + if (details.packageName?.toLowerCase().includes(searchLower)) { + return true; + } + if (details.versionName?.toLowerCase().includes(searchLower)) { + return true; + } + if (details.masterDbVersion?.toLowerCase().includes(searchLower)) { + return true; + } + if (details.minAndroid?.toLowerCase().includes(searchLower)) { + return true; + } + if (details.maxAndroid?.toLowerCase().includes(searchLower)) { + return true; + } + if ( + details.expoSdk && + String(details.expoSdk).toLowerCase().includes(searchLower) + ) { + return true; + } + if ( + details.versionCode && + String(details.versionCode).includes(searchLower) + ) { + return true; + } + } + + return false; +} diff --git a/tests/functional/changelog-search.spec.ts b/tests/functional/changelog-search.spec.ts index e9ca501..c35d58a 100644 --- a/tests/functional/changelog-search.spec.ts +++ b/tests/functional/changelog-search.spec.ts @@ -44,6 +44,24 @@ test.describe("Changelog Search Functional Tests", () => { expect(restoredCount).toBeGreaterThan(5); }); + test("searching by release state filters to deprecated or yanked entries", async ({ page }) => { + const searchInput = page.getByLabel("Search changelog entries"); + + // Search for "yanked" + await searchInput.fill("yanked"); + const yankedArticles = page.locator("article").filter({ + has: page.locator(".tag").filter({ hasText: /yanked/i }), + }); + await expect(yankedArticles.first()).toBeVisible(); + + // Search for "deprecated" + await searchInput.fill("deprecated"); + const deprecatedArticles = page.locator("article").filter({ + has: page.locator(".tag.deprecated"), + }); + await expect(deprecatedArticles.first()).toBeVisible(); + }); + test("no results message appears on unmatched query and reset works", async ({ page }) => { const searchInput = page.getByLabel("Search changelog entries"); await searchInput.fill("nonexistent-release-query-xyz"); diff --git a/tests/functional/release-filters.spec.ts b/tests/functional/release-filters.spec.ts index 02904fd..af8928f 100644 --- a/tests/functional/release-filters.spec.ts +++ b/tests/functional/release-filters.spec.ts @@ -39,6 +39,20 @@ test.describe("Downloads Channel Filter & Search Functional Tests", () => { await expect(archiveCards.locator(".tag.alpha").first()).toBeVisible(); await expect(archiveCards.locator(".tag.stable")).toHaveCount(0); + // Filter by Deprecated + const deprecatedFilterBtn = page.getByRole("button", { name: "Deprecated", exact: true }); + await deprecatedFilterBtn.click(); + await expect(deprecatedFilterBtn).toHaveClass(/active/); + await expect(archiveCards.first()).toBeVisible(); + await expect(archiveCards.locator(".tag.deprecated").first()).toBeVisible(); + + // Filter by Yanked + const yankedFilterBtn = page.getByRole("button", { name: "Yanked", exact: true }); + await yankedFilterBtn.click(); + await expect(yankedFilterBtn).toHaveClass(/active/); + await expect(archiveCards.first()).toBeVisible(); + await expect(archiveCards.locator(".tag").filter({ hasText: /yanked/i }).first()).toBeVisible(); + // Reset to All await allFilterBtn.click(); await expect(allFilterBtn).toHaveClass(/active/); @@ -67,4 +81,20 @@ test.describe("Downloads Channel Filter & Search Functional Tests", () => { await expect(searchInput).toHaveValue(""); await expect(archiveCards.count()).resolves.toBeGreaterThan(1); }); + + test("unmatched query shows friendly empty state with working reset button", async ({ page }) => { + const searchInput = page.getByLabel("Search releases"); + await searchInput.fill("nonexistentquery9999"); + + const emptyMessage = page.getByText(/No releases match your/i); + await expect(emptyMessage).toBeVisible(); + + const resetBtn = page.getByRole("button", { name: "Reset filters" }); + await expect(resetBtn).toBeVisible(); + await resetBtn.click(); + + await expect(searchInput).toHaveValue(""); + const archiveCards = page.locator(".release-card:not(.latest)"); + await expect(archiveCards.first()).toBeVisible(); + }); }); diff --git a/tests/smoke/changelog.spec.ts b/tests/smoke/changelog.spec.ts index f3085b7..44c4ea7 100644 --- a/tests/smoke/changelog.spec.ts +++ b/tests/smoke/changelog.spec.ts @@ -7,7 +7,12 @@ test.describe("Changelog Smoke & Detail Page Tests", () => { test("changelog index loads and displays timeline stream", async ({ page }) => { await expect(page).toHaveTitle(/Passcodes/i); - await expect(page.getByRole("heading", { name: "Release Highlights & Milestones", level: 1 })).toBeVisible(); + await expect( + page.getByRole("heading", { + name: "Release Highlights & Milestones", + level: 1, + }) + ).toBeVisible(); // Articles/timeline entries render const articles = page.locator("article"); @@ -17,6 +22,59 @@ test.describe("Changelog Smoke & Detail Page Tests", () => { await expect(page.getByText("v3.2.1").first()).toBeVisible(); }); + test("current production release hero renders prominently with actions", async ({ page }) => { + // Current production badge + await expect(page.getByText(/Current Production Release/i)).toBeVisible(); + + // Top production heading + const heroHeading = page.locator("#current-release"); + await expect(heroHeading).toBeVisible(); + await expect(heroHeading.getByRole("heading", { level: 2 })).toContainText("v3.2.1"); + + // Action links + const downloadBtn = heroHeading.getByRole("link", { name: /Get v3\.2\.1 APK/i }); + await expect(downloadBtn).toBeVisible(); + await expect(downloadBtn).toHaveAttribute("href", /\/downloads\/?/); + + const detailsBtn = heroHeading.getByRole("link", { name: /Release Details/i }); + await expect(detailsBtn).toBeVisible(); + }); + + test("recent updates and architecture milestones sections render properly", async ({ page }) => { + // Recent Updates + await expect( + page.getByRole("heading", { name: "Recent Updates", level: 2 }) + ).toBeVisible(); + + // Major Architectural Milestones + await expect( + page.getByRole("heading", { + name: "Major Architectural Milestones", + level: 2, + }) + ).toBeVisible(); + await expect( + page.getByText(/End of Android-Only Codebase/i).first() + ).toBeVisible(); + }); + + test("release channel filter pills update visible timeline entries", async ({ page }) => { + const betaChannelBtn = page.getByRole("button", { name: "Beta", exact: true }); + await expect(betaChannelBtn).toBeVisible(); + await betaChannelBtn.click(); + + // Beta releases should be visible + const articles = page.locator("article"); + await expect(articles.first()).toBeVisible(); + await expect(articles.locator(".tag.beta").first()).toBeVisible(); + // Should not show active stable in beta filter + await expect(articles.locator(".tag.stable")).toHaveCount(0); + + // Reset by clicking All + const allChannelBtn = page.getByRole("button", { name: "All", exact: true }).filter({ hasText: /^All$/ }).last(); + await allChannelBtn.click(); + }); + test("historical releases display deprecated and yanked indicators appropriately", async ({ page }) => { // v2.1.1 should have Deprecated tag const v2Entry = page.locator("article").filter({ hasText: /v2\.1\.1/i }).first(); @@ -24,10 +82,15 @@ test.describe("Changelog Smoke & Detail Page Tests", () => { await expect(v2Entry.locator(".tag.deprecated")).toBeVisible(); // v1.2.0 should display Yanked tag and Deprecated tag - const v120Entry = page.locator("article").filter({ has: page.getByRole("heading", { name: /v1\.2\.0/i }) }).first(); + const v120Entry = page + .locator("article") + .filter({ has: page.getByRole("heading", { name: /v1\.2\.0/i }) }) + .first(); await expect(v120Entry).toBeVisible(); await expect(v120Entry.locator(".tag.deprecated")).toBeVisible(); - await expect(v120Entry.locator(".tag").filter({ hasText: /yanked/i })).toBeVisible(); + await expect( + v120Entry.locator(".tag").filter({ hasText: /yanked/i }) + ).toBeVisible(); }); test("release detail page loads with metadata and navigates back", async ({ page }) => { @@ -42,6 +105,22 @@ test.describe("Changelog Smoke & Detail Page Tests", () => { // Release Highlights or sections are visible await expect(page.getByText(/Release Highlights/i)).toBeVisible(); + // Documentation resources are visible + await expect(page.getByText(/Relevant Documentation/i)).toBeVisible(); + + // Breadcrumb navigation is visible + await expect(page.getByRole("navigation", { name: "Breadcrumb" })).toBeVisible(); + + // Next Steps journey card is visible + await expect(page.getByRole("heading", { name: "Ready to run Passcodes?" })).toBeVisible(); + await expect(page.getByRole("link", { name: "Download APK" })).toHaveAttribute("href", /\/downloads\/?/); + await expect(page.getByRole("link", { name: "All Updates", exact: true })).toHaveAttribute("href", /\/changelog\/?/); + + // Download APK button is visible for stable release + await expect( + page.getByRole("link", { name: /Get v3\.2\.1 APK/i }) + ).toBeVisible(); + // Back link works const backLink = page.getByRole("link", { name: /Back to all updates/i }); await expect(backLink).toBeVisible(); @@ -57,4 +136,16 @@ test.describe("Changelog Smoke & Detail Page Tests", () => { await expect(page.locator(".tag.deprecated")).toContainText(/Deprecated Release/i); await expect(page.getByText(/Deprecated Release Notice/i)).toBeVisible(); }); + + test("yanked release detail page displays yanked notice banner without download button", async ({ page }) => { + // Navigate to yanked release detail page + await page.goto("/changelog/v1-2-0-alpha-yanked/"); + + await expect(page).toHaveTitle(/v1\.2\.0.*Passcodes Changelog/i); + await expect(page.getByText(/Yanked Release Notice/i)).toBeVisible(); + // Should not offer a direct get APK button for yanked release + await expect( + page.getByRole("link", { name: /Get v1\.2\.0 APK/i }) + ).toHaveCount(0); + }); }); diff --git a/tests/smoke/downloads.spec.ts b/tests/smoke/downloads.spec.ts index ec41e53..d91d639 100644 --- a/tests/smoke/downloads.spec.ts +++ b/tests/smoke/downloads.spec.ts @@ -21,6 +21,23 @@ test.describe("Downloads Page Smoke & Regression Tests", () => { // Download controls are rendered const downloadOptions = page.locator(".arch-download-btn, .arch-dropdown, a[href*='.apk']"); await expect(downloadOptions.first()).toBeVisible(); + + // Link to release notes & milestones in /changelog + const releaseNotesLink = page.getByRole("link", { name: /Release notes & milestones/i }); + await expect(releaseNotesLink).toBeVisible(); + await expect(releaseNotesLink).toHaveAttribute("href", /\/changelog\/?/); + + // Download cards contain "View in Project Updates" link + const updatesLink = page.getByRole("link", { name: /View in Project Updates/i }).first(); + await expect(updatesLink).toBeVisible(); + await expect(updatesLink).toHaveAttribute("href", /\/changelog/); + + // Setup guide link prompt is visible below recommended build + const setupLink = page.getByRole("link", { name: "Installation & Setup Guide" }); + await expect(setupLink).toBeVisible(); + + // Channel explanation text is visible + await expect(page.getByText(/Displaying all recorded releases across active/i)).toBeVisible(); }); test("deprecated v1.x and v2.x releases display deprecation indicators while v3.x does not", async ({ page }) => { @@ -49,4 +66,16 @@ test.describe("Downloads Page Smoke & Regression Tests", () => { await expect(v120Card.locator(".tag.deprecated")).toBeVisible(); await expect(v120Card.locator(".tag").filter({ hasText: /yanked/i })).toBeVisible(); }); + + test("archive release item allows expanding architecture variants", async ({ page }) => { + const buildsToggle = page.locator(".release-card:not(.latest) button", { hasText: /builds/i }).first(); + if (await buildsToggle.isVisible()) { + await expect(buildsToggle).toHaveAttribute("aria-expanded", "false"); + await buildsToggle.click(); + await expect(buildsToggle).toHaveAttribute("aria-expanded", "true"); + // Expanded variants list should now be visible + const variantLinks = page.locator(".release-card:not(.latest) ul a[href*='.apk']"); + await expect(variantLinks.first()).toBeVisible(); + } + }); }); diff --git a/tests/smoke/mobile.spec.ts b/tests/smoke/mobile.spec.ts index 8ea8988..783504c 100644 --- a/tests/smoke/mobile.spec.ts +++ b/tests/smoke/mobile.spec.ts @@ -47,4 +47,28 @@ test.describe("Mobile Responsive Usability Tests", () => { expect(hasHorizontalOverflow, "Changelog has unexpected horizontal overflow").toBe(false); }); + + test("homepage does not horizontally overflow viewport on mobile", async ({ page, isMobile }) => { + test.skip(!isMobile, "Horizontal overflow check targeted for mobile viewports"); + + await page.goto("/"); + + const hasHorizontalOverflow = await page.evaluate(() => { + return document.documentElement.scrollWidth > window.innerWidth; + }); + + expect(hasHorizontalOverflow, "Homepage has unexpected horizontal overflow").toBe(false); + }); + + test("release detail page does not horizontally overflow viewport on mobile", async ({ page, isMobile }) => { + test.skip(!isMobile, "Horizontal overflow check targeted for mobile viewports"); + + await page.goto("/changelog/v3-2-1-stable/"); + + const hasHorizontalOverflow = await page.evaluate(() => { + return document.documentElement.scrollWidth > window.innerWidth; + }); + + expect(hasHorizontalOverflow, "Release detail page has unexpected horizontal overflow").toBe(false); + }); });