Add collapsible sections, sticky version header, and style refinements for changelog highlights
This commit is contained in:
@@ -6,21 +6,22 @@ type Release = {
|
|||||||
html_url: string
|
html_url: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Highlight = {
|
type HighlightMedia = { type: "video"; src: string } | { type: "image"; src: string; width: string; height: string }
|
||||||
source: string
|
|
||||||
|
type HighlightItem = {
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
shortDescription?: string
|
shortDescription?: string
|
||||||
image?: {
|
media: HighlightMedia
|
||||||
src: string
|
|
||||||
width: string
|
|
||||||
height: string
|
|
||||||
}
|
|
||||||
video?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseHighlights(body: string): Highlight[] {
|
type HighlightGroup = {
|
||||||
const highlights: Highlight[] = []
|
source: string
|
||||||
|
items: HighlightItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHighlights(body: string): HighlightGroup[] {
|
||||||
|
const groups = new Map<string, HighlightItem[]>()
|
||||||
const regex = /<highlight\s+source="([^"]+)">([\s\S]*?)<\/highlight>/g
|
const regex = /<highlight\s+source="([^"]+)">([\s\S]*?)<\/highlight>/g
|
||||||
let match
|
let match
|
||||||
|
|
||||||
@@ -30,29 +31,32 @@ function parseHighlights(body: string): Highlight[] {
|
|||||||
|
|
||||||
const titleMatch = content.match(/<h2>([^<]+)<\/h2>/)
|
const titleMatch = content.match(/<h2>([^<]+)<\/h2>/)
|
||||||
const pMatch = content.match(/<p(?:\s+short="([^"]*)")?>([^<]+)<\/p>/)
|
const pMatch = content.match(/<p(?:\s+short="([^"]*)")?>([^<]+)<\/p>/)
|
||||||
const imgMatch = content.match(/<img\s+width="([^"]+)"\s+height="([^"]+)"\s+alt="([^"]*)"\s+src="([^"]+)"/)
|
const imgMatch = content.match(/<img\s+width="([^"]+)"\s+height="([^"]+)"\s+alt="[^"]*"\s+src="([^"]+)"/)
|
||||||
// Match standalone GitHub asset URLs (videos)
|
|
||||||
const videoMatch = content.match(/^\s*(https:\/\/github\.com\/user-attachments\/assets\/[a-f0-9-]+)\s*$/m)
|
const videoMatch = content.match(/^\s*(https:\/\/github\.com\/user-attachments\/assets\/[a-f0-9-]+)\s*$/m)
|
||||||
|
|
||||||
if (titleMatch) {
|
let media: HighlightMedia | undefined
|
||||||
highlights.push({
|
if (videoMatch) {
|
||||||
source,
|
media = { type: "video", src: videoMatch[1] }
|
||||||
|
} else if (imgMatch) {
|
||||||
|
media = { type: "image", src: imgMatch[3], width: imgMatch[1], height: imgMatch[2] }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (titleMatch && media) {
|
||||||
|
const item: HighlightItem = {
|
||||||
title: titleMatch[1],
|
title: titleMatch[1],
|
||||||
description: pMatch?.[2] || "",
|
description: pMatch?.[2] || "",
|
||||||
shortDescription: pMatch?.[1],
|
shortDescription: pMatch?.[1],
|
||||||
image: imgMatch
|
media,
|
||||||
? {
|
}
|
||||||
width: imgMatch[1],
|
|
||||||
height: imgMatch[2],
|
if (!groups.has(source)) {
|
||||||
src: imgMatch[4],
|
groups.set(source, [])
|
||||||
}
|
}
|
||||||
: undefined,
|
groups.get(source)!.push(item)
|
||||||
video: videoMatch?.[1],
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return highlights
|
return Array.from(groups.entries()).map(([source, items]) => ({ source, items }))
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseMarkdown(body: string) {
|
function parseMarkdown(body: string) {
|
||||||
|
|||||||
@@ -367,11 +367,18 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
|
position: sticky;
|
||||||
|
top: 80px;
|
||||||
|
align-self: start;
|
||||||
|
background: var(--color-background);
|
||||||
|
padding: 8px 0;
|
||||||
|
|
||||||
@media (max-width: 50rem) {
|
@media (max-width: 50rem) {
|
||||||
|
position: static;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="version"] {
|
[data-slot="version"] {
|
||||||
@@ -402,24 +409,26 @@
|
|||||||
|
|
||||||
[data-component="section"] {
|
[data-component="section"] {
|
||||||
h3 {
|
h3 {
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--color-text-strong);
|
color: var(--color-text-strong);
|
||||||
margin-bottom: 8px;
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
ul {
|
ul {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
padding-left: 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 4px;
|
||||||
|
|
||||||
li {
|
li {
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
|
font-size: 13px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
padding-left: 16px;
|
padding-left: 12px;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
@@ -431,7 +440,7 @@
|
|||||||
|
|
||||||
[data-slot="author"] {
|
[data-slot="author"] {
|
||||||
color: var(--color-text-weak);
|
color: var(--color-text-weak);
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
margin-left: 4px;
|
margin-left: 4px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
|
||||||
@@ -473,6 +482,72 @@
|
|||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-component="collapsible-sections"] {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-component="collapsible-section"] {
|
||||||
|
[data-slot="toggle"] {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 6px 0;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-weak);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="icon"] {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 16px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
|
||||||
|
li {
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
padding-left: 12px;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: "-";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
color: var(--color-text-weak);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="author"] {
|
||||||
|
color: var(--color-text-weak);
|
||||||
|
font-size: 12px;
|
||||||
|
margin-left: 4px;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
text-decoration-thickness: 1px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[data-component="highlight"] {
|
[data-component="highlight"] {
|
||||||
h4 {
|
h4 {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@@ -481,16 +556,29 @@
|
|||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
p[data-slot="title"] {
|
hr {
|
||||||
font-weight: 500;
|
border: none;
|
||||||
color: var(--color-text-strong);
|
border-top: 1px solid var(--color-border-weak);
|
||||||
margin-bottom: 4px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
[data-slot="highlight-item"] {
|
||||||
color: var(--color-text);
|
margin-bottom: 24px;
|
||||||
line-height: 1.5;
|
|
||||||
margin-bottom: 12px;
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
p[data-slot="title"] {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
img,
|
img,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Header } from "~/component/header"
|
|||||||
import { Footer } from "~/component/footer"
|
import { Footer } from "~/component/footer"
|
||||||
import { Legal } from "~/component/legal"
|
import { Legal } from "~/component/legal"
|
||||||
import { config } from "~/config"
|
import { config } from "~/config"
|
||||||
import { For, Show } from "solid-js"
|
import { For, Show, createSignal } from "solid-js"
|
||||||
|
|
||||||
type Release = {
|
type Release = {
|
||||||
tag_name: string
|
tag_name: string
|
||||||
@@ -40,21 +40,22 @@ function formatDate(dateString: string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
type Highlight = {
|
type HighlightMedia = { type: "video"; src: string } | { type: "image"; src: string; width: string; height: string }
|
||||||
source: string
|
|
||||||
|
type HighlightItem = {
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
shortDescription?: string
|
shortDescription?: string
|
||||||
image?: {
|
media: HighlightMedia
|
||||||
src: string
|
|
||||||
width: string
|
|
||||||
height: string
|
|
||||||
}
|
|
||||||
video?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseHighlights(body: string): Highlight[] {
|
type HighlightGroup = {
|
||||||
const highlights: Highlight[] = []
|
source: string
|
||||||
|
items: HighlightItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHighlights(body: string): HighlightGroup[] {
|
||||||
|
const groups = new Map<string, HighlightItem[]>()
|
||||||
const regex = /<highlight\s+source="([^"]+)">([\s\S]*?)<\/highlight>/g
|
const regex = /<highlight\s+source="([^"]+)">([\s\S]*?)<\/highlight>/g
|
||||||
let match
|
let match
|
||||||
|
|
||||||
@@ -64,33 +65,32 @@ function parseHighlights(body: string): Highlight[] {
|
|||||||
|
|
||||||
const titleMatch = content.match(/<h2>([^<]+)<\/h2>/)
|
const titleMatch = content.match(/<h2>([^<]+)<\/h2>/)
|
||||||
const pMatch = content.match(/<p(?:\s+short="([^"]*)")?>([^<]+)<\/p>/)
|
const pMatch = content.match(/<p(?:\s+short="([^"]*)")?>([^<]+)<\/p>/)
|
||||||
const imgMatch = content.match(/<img\s+width="([^"]+)"\s+height="([^"]+)"\s+alt="([^"]*)"\s+src="([^"]+)"/)
|
const imgMatch = content.match(/<img\s+width="([^"]+)"\s+height="([^"]+)"\s+alt="[^"]*"\s+src="([^"]+)"/)
|
||||||
// Match standalone GitHub asset URLs (videos)
|
|
||||||
const videoMatch = content.match(/^\s*(https:\/\/github\.com\/user-attachments\/assets\/[a-f0-9-]+)\s*$/m)
|
const videoMatch = content.match(/^\s*(https:\/\/github\.com\/user-attachments\/assets\/[a-f0-9-]+)\s*$/m)
|
||||||
|
|
||||||
if (titleMatch) {
|
let media: HighlightMedia | undefined
|
||||||
highlights.push({
|
if (videoMatch) {
|
||||||
source,
|
media = { type: "video", src: videoMatch[1] }
|
||||||
|
} else if (imgMatch) {
|
||||||
|
media = { type: "image", src: imgMatch[3], width: imgMatch[1], height: imgMatch[2] }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (titleMatch && media) {
|
||||||
|
const item: HighlightItem = {
|
||||||
title: titleMatch[1],
|
title: titleMatch[1],
|
||||||
description: pMatch?.[2] || "",
|
description: pMatch?.[2] || "",
|
||||||
shortDescription: pMatch?.[1],
|
shortDescription: pMatch?.[1],
|
||||||
image: imgMatch
|
media,
|
||||||
? {
|
}
|
||||||
width: imgMatch[1],
|
|
||||||
height: imgMatch[2],
|
if (!groups.has(source)) {
|
||||||
src: imgMatch[4],
|
groups.set(source, [])
|
||||||
}
|
}
|
||||||
: undefined,
|
groups.get(source)!.push(item)
|
||||||
video: videoMatch?.[1],
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return highlights
|
return Array.from(groups.entries()).map(([source, items]) => ({ source, items }))
|
||||||
}
|
|
||||||
|
|
||||||
function toTitleCase(str: string): string {
|
|
||||||
return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseMarkdown(body: string) {
|
function parseMarkdown(body: string) {
|
||||||
@@ -142,27 +142,60 @@ function ReleaseItem(props: { item: string }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function HighlightCard(props: { highlight: Highlight }) {
|
function HighlightSection(props: { group: HighlightGroup }) {
|
||||||
return (
|
return (
|
||||||
<div data-component="highlight">
|
<div data-component="highlight">
|
||||||
<h4>{props.highlight.source}</h4>
|
<h4>{props.group.source}</h4>
|
||||||
<p data-slot="title">{props.highlight.title}</p>
|
<hr />
|
||||||
<p>{props.highlight.description}</p>
|
<For each={props.group.items}>
|
||||||
<Show when={props.highlight.video}>
|
{(item) => (
|
||||||
<video src={props.highlight.video} controls autoplay loop muted playsinline />
|
<div data-slot="highlight-item">
|
||||||
</Show>
|
<p data-slot="title">{item.title}</p>
|
||||||
<Show when={props.highlight.image && !props.highlight.video}>
|
<p>{item.description}</p>
|
||||||
<img
|
<Show when={item.media.type === "video"}>
|
||||||
src={props.highlight.image!.src}
|
<video src={item.media.src} controls autoplay loop muted playsinline />
|
||||||
alt={props.highlight.title}
|
</Show>
|
||||||
width={props.highlight.image!.width}
|
<Show when={item.media.type === "image"}>
|
||||||
height={props.highlight.image!.height}
|
<img
|
||||||
/>
|
src={item.media.src}
|
||||||
|
alt={item.title}
|
||||||
|
width={(item.media as { width: string }).width}
|
||||||
|
height={(item.media as { height: string }).height}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapsibleSection(props: { section: { title: string; items: string[] } }) {
|
||||||
|
const [open, setOpen] = createSignal(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-component="collapsible-section">
|
||||||
|
<button data-slot="toggle" onClick={() => setOpen(!open())}>
|
||||||
|
<span data-slot="icon">{open() ? "▾" : "▸"}</span>
|
||||||
|
<span>{props.section.title}</span>
|
||||||
|
</button>
|
||||||
|
<Show when={open()}>
|
||||||
|
<ul>
|
||||||
|
<For each={props.section.items}>{(item) => <ReleaseItem item={item} />}</For>
|
||||||
|
</ul>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CollapsibleSections(props: { sections: { title: string; items: string[] }[] }) {
|
||||||
|
return (
|
||||||
|
<div data-component="collapsible-sections">
|
||||||
|
<For each={props.sections}>{(section) => <CollapsibleSection section={section} />}</For>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function Changelog() {
|
export default function Changelog() {
|
||||||
const releases = createAsync(() => getReleases())
|
const releases = createAsync(() => getReleases())
|
||||||
|
|
||||||
@@ -198,19 +231,24 @@ export default function Changelog() {
|
|||||||
<div data-slot="content">
|
<div data-slot="content">
|
||||||
<Show when={parsed().highlights.length > 0}>
|
<Show when={parsed().highlights.length > 0}>
|
||||||
<div data-component="highlights">
|
<div data-component="highlights">
|
||||||
<For each={parsed().highlights}>{(highlight) => <HighlightCard highlight={highlight} />}</For>
|
<For each={parsed().highlights}>{(group) => <HighlightSection group={group} />}</For>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<For each={parsed().sections}>
|
<Show when={parsed().highlights.length > 0 && parsed().sections.length > 0}>
|
||||||
{(section) => (
|
<CollapsibleSections sections={parsed().sections} />
|
||||||
<div data-component="section">
|
</Show>
|
||||||
<h3>{section.title}</h3>
|
<Show when={parsed().highlights.length === 0}>
|
||||||
<ul>
|
<For each={parsed().sections}>
|
||||||
<For each={section.items}>{(item) => <ReleaseItem item={item} />}</For>
|
{(section) => (
|
||||||
</ul>
|
<div data-component="section">
|
||||||
</div>
|
<h3>{section.title}</h3>
|
||||||
)}
|
<ul>
|
||||||
</For>
|
<For each={section.items}>{(item) => <ReleaseItem item={item} />}</For>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user