Skip to content

Commit 3d994c5

Browse files
what's new
1 parent c04b9f1 commit 3d994c5

3 files changed

Lines changed: 208 additions & 193 deletions

File tree

packages/web/src/app/(app)/@sidebar/components/sidebarBase.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import { ReactNode, useEffect, useMemo, useRef, useState } from "react";
4343
import { KeyboardShortcutHint } from "@/app/components/keyboardShortcutHint";
4444
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
4545
import { Separator } from "@/components/ui/separator";
46+
import { WhatsNewSidebarButton } from "./whatsNewSidebarButton";
4647

4748
interface SidebarBaseProps {
4849
session: Session | null;
@@ -86,6 +87,7 @@ export function SidebarBase({ session, collapsible = "icon", headerContent, chil
8687
</SidebarContent>
8788
<SidebarFooter className="border-t border-sidebar-border">
8889
{collapsible !== "none" && <CollapseSidebarButton />}
90+
<WhatsNewSidebarButton />
8991
{session ? (
9092
<MeControlDropdownMenu session={session} />
9193
) : (
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
"use client"
2+
3+
import {
4+
SidebarMenu,
5+
SidebarMenuBadge,
6+
SidebarMenuButton,
7+
SidebarMenuItem,
8+
useSidebar,
9+
} from "@/components/ui/sidebar"
10+
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
11+
import { Separator } from "@/components/ui/separator"
12+
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
13+
import { Button } from "@/components/ui/button"
14+
import { newsData } from "@/lib/newsData"
15+
import { NewsItem } from "@/lib/types"
16+
import { env, SOURCEBOT_VERSION } from "@sourcebot/shared/client"
17+
import { Compass, Mail, MailOpen } from "lucide-react"
18+
import Link from "next/link"
19+
import { useCallback, useEffect, useState } from "react"
20+
import { useHotkeys } from "react-hotkeys-hook"
21+
import { KeyboardShortcutHint } from "@/app/components/keyboardShortcutHint"
22+
23+
const COOKIE_NAME = "whats-new-read-items"
24+
25+
const getReadItems = (): string[] => {
26+
if (typeof document === "undefined") {
27+
return []
28+
}
29+
30+
const cookies = document.cookie.split(';').map(cookie => cookie.trim())
31+
const targetCookie = cookies.find(cookie => cookie.startsWith(`${COOKIE_NAME}=`))
32+
33+
if (!targetCookie) {
34+
return []
35+
}
36+
37+
try {
38+
const cookieValue = targetCookie.substring(`${COOKIE_NAME}=`.length)
39+
return JSON.parse(decodeURIComponent(cookieValue))
40+
} catch {
41+
return []
42+
}
43+
}
44+
45+
const setReadItems = (readItems: string[]) => {
46+
if (typeof document === "undefined") {
47+
return
48+
}
49+
50+
try {
51+
const expires = new Date()
52+
expires.setFullYear(expires.getFullYear() + 1)
53+
const cookieValue = encodeURIComponent(JSON.stringify(readItems))
54+
document.cookie = `${COOKIE_NAME}=${cookieValue}; expires=${expires.toUTCString()}; path=/; SameSite=Lax`
55+
} catch {
56+
// ignore
57+
}
58+
}
59+
60+
export function WhatsNewSidebarButton() {
61+
const { state } = useSidebar()
62+
const [isOpen, setIsOpen] = useState(false)
63+
const [readItems, setReadItemsState] = useState<string[]>([])
64+
const [isInitialized, setIsInitialized] = useState(false)
65+
66+
const toggleOpen = useCallback(() => {
67+
setIsOpen(prev => !prev)
68+
}, [])
69+
70+
useHotkeys("shift+?", (e) => {
71+
e.preventDefault();
72+
toggleOpen();
73+
});
74+
75+
useEffect(() => {
76+
const items = getReadItems()
77+
setReadItemsState(items)
78+
setIsInitialized(true)
79+
}, [])
80+
81+
useEffect(() => {
82+
if (isInitialized) {
83+
setReadItems(readItems)
84+
}
85+
}, [readItems, isInitialized])
86+
87+
const newsItemsWithReadState = newsData.map((item) => ({
88+
...item,
89+
read: readItems.includes(item.unique_id),
90+
}))
91+
92+
const unreadCount = newsItemsWithReadState.filter((item) => !item.read).length
93+
94+
const markAsRead = (itemId: string) => {
95+
setReadItemsState((prev) => {
96+
if (!prev.includes(itemId)) {
97+
return [...prev, itemId]
98+
}
99+
return prev
100+
})
101+
}
102+
103+
const markAllAsRead = () => {
104+
const allIds = newsData.map((item) => item.unique_id)
105+
setReadItemsState(allIds)
106+
}
107+
108+
const handleNewsItemClick = (item: NewsItem) => {
109+
window.open(item.url, "_blank", "noopener,noreferrer")
110+
markAsRead(item.unique_id)
111+
}
112+
113+
return (
114+
<SidebarMenu>
115+
<SidebarMenuItem>
116+
<Popover open={isOpen} onOpenChange={setIsOpen}>
117+
<Tooltip open={state === "expanded" ? false : undefined}>
118+
<TooltipTrigger asChild>
119+
<PopoverTrigger asChild>
120+
<SidebarMenuButton>
121+
<Compass className="h-4 w-4" />
122+
<span>{"What's new"}</span>
123+
</SidebarMenuButton>
124+
</PopoverTrigger>
125+
</TooltipTrigger>
126+
<TooltipContent side="right" className="flex items-center gap-2">
127+
<KeyboardShortcutHint shortcut="?" />
128+
<Separator orientation="vertical" className="h-4" />
129+
<span>{"What's new"}</span>
130+
</TooltipContent>
131+
</Tooltip>
132+
{isInitialized && unreadCount > 0 && (
133+
<SidebarMenuBadge className="rounded-full bg-blue-500 text-white peer-hover/menu-button:text-white px-1.5">
134+
{unreadCount > 9 ? "9+" : unreadCount}
135+
</SidebarMenuBadge>
136+
)}
137+
<PopoverContent className="w-80 p-0" side="right" align="end" sideOffset={8}>
138+
<div className="border-b p-4">
139+
<div className="flex items-center justify-between">
140+
<div>
141+
<h3 className="font-semibold text-sm">{"What's New"}</h3>
142+
<p className="text-xs text-muted-foreground mt-1">
143+
{unreadCount > 0 ? `${unreadCount} unread update${unreadCount === 1 ? "" : "s"}` : "All caught up"}
144+
</p>
145+
</div>
146+
{unreadCount > 0 && (
147+
<Button variant="ghost" size="sm" onClick={markAllAsRead} className="text-xs h-7">
148+
Mark all read
149+
</Button>
150+
)}
151+
</div>
152+
</div>
153+
<div className="max-h-[32rem] overflow-y-auto scrollbar-thin scrollbar-thumb-muted-foreground/20 scrollbar-track-transparent">
154+
{newsItemsWithReadState.length === 0 ? (
155+
<div className="p-4 text-center text-sm text-muted-foreground">No recent updates</div>
156+
) : (
157+
<div className="space-y-1 p-2">
158+
{newsItemsWithReadState.map((item, index) => (
159+
<div
160+
key={item.unique_id}
161+
className={`relative rounded-md transition-colors ${item.read ? "opacity-60" : ""} ${index !== newsItemsWithReadState.length - 1 ? "border-b border-border/50" : ""}`}
162+
>
163+
{!item.read && <div className="absolute left-2 top-3 h-2 w-2 bg-blue-500 rounded-full" />}
164+
<button
165+
onClick={() => handleNewsItemClick(item)}
166+
className="w-full text-left p-3 pl-6 rounded-md hover:bg-muted transition-colors group"
167+
>
168+
<div className="flex items-start justify-between gap-2">
169+
<div className="flex-1 min-w-0">
170+
<h4 className={`font-medium text-sm leading-tight group-hover:text-primary ${item.read ? "text-muted-foreground" : ""}`}>
171+
{item.header}
172+
</h4>
173+
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">{item.sub_header}</p>
174+
</div>
175+
<div className="flex items-center gap-1 flex-shrink-0">
176+
{item.read ? (
177+
<MailOpen className="h-3 w-3 text-muted-foreground group-hover:text-primary" />
178+
) : (
179+
<Mail className="h-3 w-3 text-muted-foreground group-hover:text-primary" />
180+
)}
181+
</div>
182+
</div>
183+
</button>
184+
</div>
185+
))}
186+
</div>
187+
)}
188+
</div>
189+
<Separator />
190+
<div className="px-2 py-2 text-xs text-muted-foreground">
191+
Current version: {SOURCEBOT_VERSION}
192+
{env.NEXT_PUBLIC_BUILD_COMMIT_SHA && (
193+
<Link
194+
className="ml-1 font-mono"
195+
href={`https://github.com/sourcebot-dev/sourcebot/commit/${env.NEXT_PUBLIC_BUILD_COMMIT_SHA}`}
196+
>
197+
(<span className="hover:underline">{env.NEXT_PUBLIC_BUILD_COMMIT_SHA.substring(0, 7)}</span>)
198+
</Link>
199+
)}
200+
</div>
201+
</PopoverContent>
202+
</Popover>
203+
</SidebarMenuItem>
204+
</SidebarMenu>
205+
)
206+
}

0 commit comments

Comments
 (0)