diff options
| author | Ryan Vogel <[email protected]> | 2026-01-25 21:18:26 -0500 |
|---|---|---|
| committer | Dax Raad <[email protected]> | 2026-01-25 21:26:56 -0500 |
| commit | cc0085676b193ea77fd18bff08fcb4987a32af79 (patch) | |
| tree | 32c89cc6b7046714f31a588c9ff55cf3f354574a /packages/console/app/src | |
| parent | eaad75b1765745865c7d9d0b4826e3b30463882e (diff) | |
| download | opencode-cc0085676b193ea77fd18bff08fcb4987a32af79.tar.gz opencode-cc0085676b193ea77fd18bff08fcb4987a32af79.zip | |
Add collapsible sections, sticky version header, and style refinements for changelog highlights
Diffstat (limited to 'packages/console/app/src')
| -rw-r--r-- | packages/console/app/src/routes/changelog.json.ts | 54 | ||||
| -rw-r--r-- | packages/console/app/src/routes/changelog/index.css | 114 | ||||
| -rw-r--r-- | packages/console/app/src/routes/changelog/index.tsx | 148 |
3 files changed, 223 insertions, 93 deletions
diff --git a/packages/console/app/src/routes/changelog.json.ts b/packages/console/app/src/routes/changelog.json.ts index b668229f8..a23f20503 100644 --- a/packages/console/app/src/routes/changelog.json.ts +++ b/packages/console/app/src/routes/changelog.json.ts @@ -6,21 +6,22 @@ type Release = { html_url: string } -type Highlight = { - source: string +type HighlightMedia = { type: "video"; src: string } | { type: "image"; src: string; width: string; height: string } + +type HighlightItem = { title: string description: string shortDescription?: string - image?: { - src: string - width: string - height: string - } - video?: string + media: HighlightMedia +} + +type HighlightGroup = { + source: string + items: HighlightItem[] } -function parseHighlights(body: string): Highlight[] { - const highlights: Highlight[] = [] +function parseHighlights(body: string): HighlightGroup[] { + const groups = new Map<string, HighlightItem[]>() const regex = /<highlight\s+source="([^"]+)">([\s\S]*?)<\/highlight>/g let match @@ -30,29 +31,32 @@ function parseHighlights(body: string): Highlight[] { const titleMatch = content.match(/<h2>([^<]+)<\/h2>/) const pMatch = content.match(/<p(?:\s+short="([^"]*)")?>([^<]+)<\/p>/) - const imgMatch = content.match(/<img\s+width="([^"]+)"\s+height="([^"]+)"\s+alt="([^"]*)"\s+src="([^"]+)"/) - // Match standalone GitHub asset URLs (videos) + const imgMatch = content.match(/<img\s+width="([^"]+)"\s+height="([^"]+)"\s+alt="[^"]*"\s+src="([^"]+)"/) const videoMatch = content.match(/^\s*(https:\/\/github\.com\/user-attachments\/assets\/[a-f0-9-]+)\s*$/m) - if (titleMatch) { - highlights.push({ - source, + let media: HighlightMedia | undefined + if (videoMatch) { + 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], description: pMatch?.[2] || "", shortDescription: pMatch?.[1], - image: imgMatch - ? { - width: imgMatch[1], - height: imgMatch[2], - src: imgMatch[4], - } - : undefined, - video: videoMatch?.[1], - }) + media, + } + + if (!groups.has(source)) { + groups.set(source, []) + } + groups.get(source)!.push(item) } } - return highlights + return Array.from(groups.entries()).map(([source, items]) => ({ source, items })) } function parseMarkdown(body: string) { diff --git a/packages/console/app/src/routes/changelog/index.css b/packages/console/app/src/routes/changelog/index.css index a06fb0055..233d85cc0 100644 --- a/packages/console/app/src/routes/changelog/index.css +++ b/packages/console/app/src/routes/changelog/index.css @@ -367,11 +367,18 @@ display: flex; flex-direction: column; gap: 4px; + position: sticky; + top: 80px; + align-self: start; + background: var(--color-background); + padding: 8px 0; @media (max-width: 50rem) { + position: static; flex-direction: row; align-items: center; gap: 12px; + padding: 0; } [data-slot="version"] { @@ -402,24 +409,26 @@ [data-component="section"] { h3 { - font-size: 14px; + font-size: 13px; font-weight: 600; color: var(--color-text-strong); - margin-bottom: 8px; + margin-bottom: 6px; } ul { list-style: none; padding: 0; margin: 0; + padding-left: 16px; display: flex; flex-direction: column; - gap: 6px; + gap: 4px; li { color: var(--color-text); + font-size: 13px; line-height: 1.5; - padding-left: 16px; + padding-left: 12px; position: relative; &::before { @@ -431,7 +440,7 @@ [data-slot="author"] { color: var(--color-text-weak); - font-size: 13px; + font-size: 12px; margin-left: 4px; text-decoration: none; @@ -473,6 +482,72 @@ 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"] { h4 { font-size: 14px; @@ -481,16 +556,29 @@ margin-bottom: 8px; } - p[data-slot="title"] { - font-weight: 500; - color: var(--color-text-strong); - margin-bottom: 4px; + hr { + border: none; + border-top: 1px solid var(--color-border-weak); + margin-bottom: 16px; } - p { - color: var(--color-text); - line-height: 1.5; - margin-bottom: 12px; + [data-slot="highlight-item"] { + margin-bottom: 24px; + + &: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, diff --git a/packages/console/app/src/routes/changelog/index.tsx b/packages/console/app/src/routes/changelog/index.tsx index 34fd5f83b..87e021ec8 100644 --- a/packages/console/app/src/routes/changelog/index.tsx +++ b/packages/console/app/src/routes/changelog/index.tsx @@ -5,7 +5,7 @@ import { Header } from "~/component/header" import { Footer } from "~/component/footer" import { Legal } from "~/component/legal" import { config } from "~/config" -import { For, Show } from "solid-js" +import { For, Show, createSignal } from "solid-js" type Release = { tag_name: string @@ -40,21 +40,22 @@ function formatDate(dateString: string) { }) } -type Highlight = { - source: string +type HighlightMedia = { type: "video"; src: string } | { type: "image"; src: string; width: string; height: string } + +type HighlightItem = { title: string description: string shortDescription?: string - image?: { - src: string - width: string - height: string - } - video?: string + media: HighlightMedia } -function parseHighlights(body: string): Highlight[] { - const highlights: Highlight[] = [] +type HighlightGroup = { + source: string + items: HighlightItem[] +} + +function parseHighlights(body: string): HighlightGroup[] { + const groups = new Map<string, HighlightItem[]>() const regex = /<highlight\s+source="([^"]+)">([\s\S]*?)<\/highlight>/g let match @@ -64,33 +65,32 @@ function parseHighlights(body: string): Highlight[] { const titleMatch = content.match(/<h2>([^<]+)<\/h2>/) const pMatch = content.match(/<p(?:\s+short="([^"]*)")?>([^<]+)<\/p>/) - const imgMatch = content.match(/<img\s+width="([^"]+)"\s+height="([^"]+)"\s+alt="([^"]*)"\s+src="([^"]+)"/) - // Match standalone GitHub asset URLs (videos) + const imgMatch = content.match(/<img\s+width="([^"]+)"\s+height="([^"]+)"\s+alt="[^"]*"\s+src="([^"]+)"/) const videoMatch = content.match(/^\s*(https:\/\/github\.com\/user-attachments\/assets\/[a-f0-9-]+)\s*$/m) - if (titleMatch) { - highlights.push({ - source, + let media: HighlightMedia | undefined + if (videoMatch) { + 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], description: pMatch?.[2] || "", shortDescription: pMatch?.[1], - image: imgMatch - ? { - width: imgMatch[1], - height: imgMatch[2], - src: imgMatch[4], - } - : undefined, - video: videoMatch?.[1], - }) + media, + } + + if (!groups.has(source)) { + groups.set(source, []) + } + groups.get(source)!.push(item) } } - return highlights -} - -function toTitleCase(str: string): string { - return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase() + return Array.from(groups.entries()).map(([source, items]) => ({ source, items })) } function parseMarkdown(body: string) { @@ -142,27 +142,60 @@ function ReleaseItem(props: { item: string }) { ) } -function HighlightCard(props: { highlight: Highlight }) { +function HighlightSection(props: { group: HighlightGroup }) { return ( <div data-component="highlight"> - <h4>{props.highlight.source}</h4> - <p data-slot="title">{props.highlight.title}</p> - <p>{props.highlight.description}</p> - <Show when={props.highlight.video}> - <video src={props.highlight.video} controls autoplay loop muted playsinline /> - </Show> - <Show when={props.highlight.image && !props.highlight.video}> - <img - src={props.highlight.image!.src} - alt={props.highlight.title} - width={props.highlight.image!.width} - height={props.highlight.image!.height} - /> + <h4>{props.group.source}</h4> + <hr /> + <For each={props.group.items}> + {(item) => ( + <div data-slot="highlight-item"> + <p data-slot="title">{item.title}</p> + <p>{item.description}</p> + <Show when={item.media.type === "video"}> + <video src={item.media.src} controls autoplay loop muted playsinline /> + </Show> + <Show when={item.media.type === "image"}> + <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> </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() { const releases = createAsync(() => getReleases()) @@ -198,19 +231,24 @@ export default function Changelog() { <div data-slot="content"> <Show when={parsed().highlights.length > 0}> <div data-component="highlights"> - <For each={parsed().highlights}>{(highlight) => <HighlightCard highlight={highlight} />}</For> + <For each={parsed().highlights}>{(group) => <HighlightSection group={group} />}</For> </div> </Show> - <For each={parsed().sections}> - {(section) => ( - <div data-component="section"> - <h3>{section.title}</h3> - <ul> - <For each={section.items}>{(item) => <ReleaseItem item={item} />}</For> - </ul> - </div> - )} - </For> + <Show when={parsed().highlights.length > 0 && parsed().sections.length > 0}> + <CollapsibleSections sections={parsed().sections} /> + </Show> + <Show when={parsed().highlights.length === 0}> + <For each={parsed().sections}> + {(section) => ( + <div data-component="section"> + <h3>{section.title}</h3> + <ul> + <For each={section.items}>{(item) => <ReleaseItem item={item} />}</For> + </ul> + </div> + )} + </For> + </Show> </div> </article> ) |
