dialog-release-notes.tsx 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158
  1. import { createSignal, createEffect, onMount, onCleanup } from "solid-js"
  2. import { Dialog } from "@opencode-ai/ui/dialog"
  3. import { Button } from "@opencode-ai/ui/button"
  4. import { useDialog } from "@opencode-ai/ui/context/dialog"
  5. import { useSettings } from "@/context/settings"
  6. export type Highlight = {
  7. title: string
  8. description: string
  9. media?: {
  10. type: "image" | "video"
  11. src: string
  12. alt?: string
  13. }
  14. }
  15. export function DialogReleaseNotes(props: { highlights: Highlight[] }) {
  16. const dialog = useDialog()
  17. const settings = useSettings()
  18. const [index, setIndex] = createSignal(0)
  19. const total = () => props.highlights.length
  20. const last = () => Math.max(0, total() - 1)
  21. const feature = () => props.highlights[index()] ?? props.highlights[last()]
  22. const isFirst = () => index() === 0
  23. const isLast = () => index() >= last()
  24. const paged = () => total() > 1
  25. function handleNext() {
  26. if (isLast()) return
  27. setIndex(index() + 1)
  28. }
  29. function handleClose() {
  30. dialog.close()
  31. }
  32. function handleDisable() {
  33. settings.general.setReleaseNotes(false)
  34. handleClose()
  35. }
  36. let focusTrap: HTMLDivElement | undefined
  37. function handleKeyDown(e: KeyboardEvent) {
  38. if (e.key === "Escape") {
  39. e.preventDefault()
  40. handleClose()
  41. return
  42. }
  43. if (!paged()) return
  44. if (e.key === "ArrowLeft" && !isFirst()) {
  45. e.preventDefault()
  46. setIndex(index() - 1)
  47. }
  48. if (e.key === "ArrowRight" && !isLast()) {
  49. e.preventDefault()
  50. setIndex(index() + 1)
  51. }
  52. }
  53. onMount(() => {
  54. focusTrap?.focus()
  55. document.addEventListener("keydown", handleKeyDown)
  56. onCleanup(() => document.removeEventListener("keydown", handleKeyDown))
  57. })
  58. // Refocus the trap when index changes to ensure escape always works
  59. createEffect(() => {
  60. index() // track index
  61. focusTrap?.focus()
  62. })
  63. return (
  64. <Dialog
  65. size="large"
  66. fit
  67. class="w-[min(calc(100vw-40px),720px)] h-[min(calc(100vh-40px),400px)] -mt-20 min-h-0 overflow-hidden"
  68. >
  69. {/* Hidden element to capture initial focus and handle escape */}
  70. <div ref={focusTrap} tabindex="0" class="absolute opacity-0 pointer-events-none" />
  71. <div class="flex flex-1 min-w-0 min-h-0">
  72. {/* Left side - Text content */}
  73. <div class="flex flex-col flex-1 min-w-0 p-8">
  74. {/* Top section - feature content (fixed position from top) */}
  75. <div class="flex flex-col gap-2 pt-22">
  76. <div class="flex items-center gap-2">
  77. <h1 class="text-16-medium text-text-strong">{feature()?.title ?? ""}</h1>
  78. </div>
  79. <p class="text-14-regular text-text-base">{feature()?.description ?? ""}</p>
  80. </div>
  81. {/* Spacer to push buttons to bottom */}
  82. <div class="flex-1" />
  83. {/* Bottom section - buttons and indicators (fixed position) */}
  84. <div class="flex flex-col gap-12">
  85. <div class="flex flex-col items-start gap-3">
  86. {isLast() ? (
  87. <Button variant="primary" size="large" onClick={handleClose}>
  88. Get started
  89. </Button>
  90. ) : (
  91. <Button variant="secondary" size="large" onClick={handleNext}>
  92. Next
  93. </Button>
  94. )}
  95. <Button variant="ghost" size="small" onClick={handleDisable}>
  96. Don't show these in the future
  97. </Button>
  98. </div>
  99. {paged() && (
  100. <div class="flex items-center gap-1.5 -my-2.5">
  101. {props.highlights.map((_, i) => (
  102. <button
  103. type="button"
  104. class="h-6 flex items-center cursor-pointer bg-transparent border-none p-0 transition-all duration-200"
  105. classList={{
  106. "w-8": i === index(),
  107. "w-3": i !== index(),
  108. }}
  109. onClick={() => setIndex(i)}
  110. >
  111. <div
  112. class="w-full h-0.5 rounded-[1px] transition-colors duration-200"
  113. classList={{
  114. "bg-icon-strong-base": i === index(),
  115. "bg-icon-weak-base": i !== index(),
  116. }}
  117. />
  118. </button>
  119. ))}
  120. </div>
  121. )}
  122. </div>
  123. </div>
  124. {/* Right side - Media content (edge to edge) */}
  125. {feature()?.media && (
  126. <div class="flex-1 min-w-0 bg-surface-base overflow-hidden rounded-r-xl">
  127. {feature()!.media!.type === "image" ? (
  128. <img
  129. src={feature()!.media!.src}
  130. alt={feature()!.media!.alt ?? feature()?.title ?? "Release preview"}
  131. class="w-full h-full object-cover"
  132. />
  133. ) : (
  134. <video src={feature()!.media!.src} autoplay loop muted playsinline class="w-full h-full object-cover" />
  135. )}
  136. </div>
  137. )}
  138. </div>
  139. </Dialog>
  140. )
  141. }