summaryrefslogtreecommitdiffhomepage
path: root/packages/console/app/src
diff options
context:
space:
mode:
authorRyan Vogel <[email protected]>2026-01-25 21:18:26 -0500
committerDax Raad <[email protected]>2026-01-25 21:26:56 -0500
commitcc0085676b193ea77fd18bff08fcb4987a32af79 (patch)
tree32c89cc6b7046714f31a588c9ff55cf3f354574a /packages/console/app/src
parenteaad75b1765745865c7d9d0b4826e3b30463882e (diff)
downloadopencode-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.ts54
-rw-r--r--packages/console/app/src/routes/changelog/index.css114
-rw-r--r--packages/console/app/src/routes/changelog/index.tsx148
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>
)