feat(whats-new): adapt modal layout to release size
Small releases keep the compact single-column list; releases with more than 20 bullets across 2+ sections switch to a wider two-pane layout with a section nav rail, so no pane is a marathon scroll. The threshold lives in RAIL_THRESHOLD; single-section entries always stay single-column.
This commit is contained in:
@@ -2,10 +2,30 @@ import { useEffect, useState, type ReactNode } from 'react'
|
|||||||
import { AnimatePresence, motion } from 'framer-motion'
|
import { AnimatePresence, motion } from 'framer-motion'
|
||||||
import { invoke } from '@tauri-apps/api/core'
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
import { useGalleryStore } from '../store'
|
import { useGalleryStore } from '../store'
|
||||||
import { getChangelogForVersion, type ChangelogSection } from '../changelog'
|
import {
|
||||||
|
getChangelogForVersion,
|
||||||
|
type ChangelogEntry,
|
||||||
|
type ChangelogItem,
|
||||||
|
type ChangelogSection,
|
||||||
|
} from '../changelog'
|
||||||
import { Tooltip } from './Tooltip'
|
import { Tooltip } from './Tooltip'
|
||||||
import { ChevronRightIcon, CloseIcon } from './icons'
|
import { ChevronRightIcon, CloseIcon } from './icons'
|
||||||
|
|
||||||
|
// The modal adapts its layout to the size of the release: small releases keep
|
||||||
|
// the compact single-column list, while big ones switch to a two-pane layout
|
||||||
|
// with a section nav rail so no one pane is a marathon scroll. Preview both in
|
||||||
|
// UI Lab via `?changelog=unreleased` + the demo panel's "Open What's new modal".
|
||||||
|
type WhatsNewLayout = 'single' | 'rail'
|
||||||
|
|
||||||
|
// Above this many total bullets, the single column gets unpleasantly long.
|
||||||
|
const RAIL_THRESHOLD = 20
|
||||||
|
|
||||||
|
function chooseLayout(entry: ChangelogEntry | null): WhatsNewLayout {
|
||||||
|
if (!entry || entry.sections.length < 2) return 'single'
|
||||||
|
const total = entry.sections.reduce((sum, section) => sum + section.items.length, 0)
|
||||||
|
return total > RAIL_THRESHOLD ? 'rail' : 'single'
|
||||||
|
}
|
||||||
|
|
||||||
// Shown in the tooltip so the user can see the link's destination before
|
// Shown in the tooltip so the user can see the link's destination before
|
||||||
// clicking. The actual navigation is handled by the open_changelog_url command.
|
// clicking. The actual navigation is handled by the open_changelog_url command.
|
||||||
const CHANGELOG_URL = 'https://github.com/JezzWTF/phokus/blob/main/CHANGELOG.md'
|
const CHANGELOG_URL = 'https://github.com/JezzWTF/phokus/blob/main/CHANGELOG.md'
|
||||||
@@ -25,6 +45,10 @@ const SECTION_STYLES: Record<string, { label: string; dot: string }> = {
|
|||||||
|
|
||||||
const NEUTRAL_STYLE = { label: 'text-gray-300', dot: 'bg-gray-400/70' }
|
const NEUTRAL_STYLE = { label: 'text-gray-300', dot: 'bg-gray-400/70' }
|
||||||
|
|
||||||
|
function sectionStyle(title: string) {
|
||||||
|
return SECTION_STYLES[title] ?? NEUTRAL_STYLE
|
||||||
|
}
|
||||||
|
|
||||||
// Render the small amount of inline markdown the changelog uses: **bold** and
|
// Render the small amount of inline markdown the changelog uses: **bold** and
|
||||||
// `code`. Anything else passes through as plain text.
|
// `code`. Anything else passes through as plain text.
|
||||||
function renderInline(text: string): ReactNode[] {
|
function renderInline(text: string): ReactNode[] {
|
||||||
@@ -58,8 +82,44 @@ function renderInline(text: string): ReactNode[] {
|
|||||||
return nodes
|
return nodes
|
||||||
}
|
}
|
||||||
|
|
||||||
function Section({ section }: { section: ChangelogSection }) {
|
function Item({ item, dot }: { item: ChangelogItem; dot: string }) {
|
||||||
const style = SECTION_STYLES[section.title] ?? NEUTRAL_STYLE
|
return (
|
||||||
|
<li className="flex gap-3">
|
||||||
|
<span className={`mt-[7px] h-1.5 w-1.5 shrink-0 rounded-full ${dot}`} />
|
||||||
|
<p className="text-[13px] leading-relaxed text-gray-400">
|
||||||
|
{item.lead ? (
|
||||||
|
<>
|
||||||
|
<span className="font-semibold text-gray-100">{item.lead}</span>
|
||||||
|
{item.body ? <span> — {renderInline(item.body)}</span> : null}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
renderInline(item.body)
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemList({
|
||||||
|
items,
|
||||||
|
dot,
|
||||||
|
className = '',
|
||||||
|
}: {
|
||||||
|
items: ChangelogItem[]
|
||||||
|
dot: string
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ul className={`space-y-2.5 ${className}`}>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<Item key={index} item={item} dot={dot} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapsibleSection({ section }: { section: ChangelogSection }) {
|
||||||
|
const style = sectionStyle(section.title)
|
||||||
const [open, setOpen] = useState(true)
|
const [open, setOpen] = useState(true)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -88,23 +148,7 @@ function Section({ section }: { section: ChangelogSection }) {
|
|||||||
transition={{ duration: 0.18 }}
|
transition={{ duration: 0.18 }}
|
||||||
className="overflow-hidden"
|
className="overflow-hidden"
|
||||||
>
|
>
|
||||||
<ul className="mt-2.5 space-y-2.5 pl-5">
|
<ItemList items={section.items} dot={style.dot} className="mt-2.5 pl-5" />
|
||||||
{section.items.map((item, index) => (
|
|
||||||
<li key={index} className="flex gap-3">
|
|
||||||
<span className={`mt-[7px] h-1.5 w-1.5 shrink-0 rounded-full ${style.dot}`} />
|
|
||||||
<p className="text-[13px] leading-relaxed text-gray-400">
|
|
||||||
{item.lead ? (
|
|
||||||
<>
|
|
||||||
<span className="font-semibold text-gray-100">{item.lead}</span>
|
|
||||||
{item.body ? <span> — {renderInline(item.body)}</span> : null}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
renderInline(item.body)
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
) : null}
|
) : null}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
@@ -112,12 +156,61 @@ function Section({ section }: { section: ChangelogSection }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 'rail' layout — a section nav rail on the left, the selected section's items
|
||||||
|
// on the right.
|
||||||
|
function RailSections({ sections }: { sections: ChangelogSection[] }) {
|
||||||
|
const [active, setActive] = useState(0)
|
||||||
|
const section = sections[Math.min(active, sections.length - 1)]
|
||||||
|
const style = sectionStyle(section.title)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-0 flex-1">
|
||||||
|
<nav className="flex w-44 shrink-0 flex-col gap-1 border-r border-white/[0.07] px-3 py-4">
|
||||||
|
{sections.map((s, index) => {
|
||||||
|
const navStyle = sectionStyle(s.title)
|
||||||
|
const isActive = index === active
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={s.title}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActive(index)}
|
||||||
|
className={`flex items-center gap-2.5 rounded-md px-3 py-2 text-left text-[12px] font-medium transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'bg-white/[0.07] text-white'
|
||||||
|
: 'text-gray-500 hover:bg-white/[0.04] hover:text-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${navStyle.dot}`} />
|
||||||
|
<span className="flex-1">{s.title}</span>
|
||||||
|
<span className="text-[11px] font-medium text-gray-600">{s.items.length}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-5">
|
||||||
|
<p
|
||||||
|
className={`mb-3 text-[11px] font-semibold tracking-[0.1em] uppercase ${style.label}`}
|
||||||
|
>
|
||||||
|
{section.title}
|
||||||
|
</p>
|
||||||
|
<ItemList items={section.items} dot={style.dot} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const MODAL_WIDTHS: Record<WhatsNewLayout, string> = {
|
||||||
|
single: 'w-[min(88vw,560px)]',
|
||||||
|
rail: 'w-[min(90vw,780px)]',
|
||||||
|
}
|
||||||
|
|
||||||
export function WhatsNewModal() {
|
export function WhatsNewModal() {
|
||||||
const whatsNewOpen = useGalleryStore((s) => s.whatsNewOpen)
|
const whatsNewOpen = useGalleryStore((s) => s.whatsNewOpen)
|
||||||
const closeWhatsNew = useGalleryStore((s) => s.closeWhatsNew)
|
const closeWhatsNew = useGalleryStore((s) => s.closeWhatsNew)
|
||||||
const appVersion = useGalleryStore((s) => s.appVersion)
|
const appVersion = useGalleryStore((s) => s.appVersion)
|
||||||
|
|
||||||
const entry = getChangelogForVersion(appVersion)
|
const entry = getChangelogForVersion(appVersion)
|
||||||
|
const layout = chooseLayout(entry)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!whatsNewOpen) return
|
if (!whatsNewOpen) return
|
||||||
@@ -128,6 +221,8 @@ export function WhatsNewModal() {
|
|||||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
}, [whatsNewOpen, closeWhatsNew])
|
}, [whatsNewOpen, closeWhatsNew])
|
||||||
|
|
||||||
|
const hasSections = entry !== null && entry.sections.length > 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{whatsNewOpen ? (
|
{whatsNewOpen ? (
|
||||||
@@ -140,7 +235,7 @@ export function WhatsNewModal() {
|
|||||||
transition={{ duration: 0.15 }}
|
transition={{ duration: 0.15 }}
|
||||||
>
|
>
|
||||||
<motion.div
|
<motion.div
|
||||||
className="relative flex max-h-[min(82vh,760px)] w-[min(88vw,560px)] flex-col overflow-hidden rounded-lg border border-white/10 bg-gray-950 shadow-2xl shadow-black/60"
|
className={`relative flex max-h-[min(82vh,760px)] ${MODAL_WIDTHS[layout]} flex-col overflow-hidden rounded-lg border border-white/10 bg-gray-950 shadow-2xl shadow-black/60`}
|
||||||
onClick={(event) => event.stopPropagation()}
|
onClick={(event) => event.stopPropagation()}
|
||||||
initial={{ opacity: 0, scale: 0.97, y: 8 }}
|
initial={{ opacity: 0, scale: 0.97, y: 8 }}
|
||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
@@ -169,16 +264,22 @@ export function WhatsNewModal() {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 space-y-6 overflow-y-auto px-6 py-5">
|
{hasSections && layout === 'rail' ? (
|
||||||
{entry && entry.sections.length > 0 ? (
|
<RailSections sections={entry.sections} />
|
||||||
entry.sections.map((section) => <Section key={section.title} section={section} />)
|
) : (
|
||||||
) : (
|
<div className="min-h-0 flex-1 space-y-6 overflow-y-auto px-6 py-5">
|
||||||
<p className="text-sm text-gray-500">
|
{hasSections ? (
|
||||||
Release notes for this version aren't available in-app. See the full changelog on
|
entry.sections.map((section) => (
|
||||||
GitHub.
|
<CollapsibleSection key={section.title} section={section} />
|
||||||
</p>
|
))
|
||||||
)}
|
) : (
|
||||||
</div>
|
<p className="text-sm text-gray-500">
|
||||||
|
Release notes for this version aren't available in-app. See the full changelog
|
||||||
|
on GitHub.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-4 border-t border-white/[0.07] px-6 py-4">
|
<div className="flex items-center justify-between gap-4 border-t border-white/[0.07] px-6 py-4">
|
||||||
<Tooltip label={CHANGELOG_URL} side="top" align="start">
|
<Tooltip label={CHANGELOG_URL} side="top" align="start">
|
||||||
|
|||||||
Reference in New Issue
Block a user