Преглед изворни кода

Add collapsible sections, sticky version header, and style refinements for changelog highlights

Ryan Vogel пре 3 недеља
родитељ
комит
cc0085676b

+ 29 - 25
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) {

+ 101 - 13
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,

+ 93 - 55
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>
                 )