diff --git a/README.md b/README.md index 868b1543..b5a54888 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Semhub +# SemHub ## Development @@ -84,7 +84,7 @@ To set up a GitHub App: - In terms of permissions: - Select the following read-only Repository permissions: Metadata (mandatory), Discussions, Issues, Pull Requests, Contents. (These should be tracked in code via `github-app.ts`.) - Select the following read-only User permissions: Emails (actually would've gotten the user's email from the login process) - - Select the following read-only Organization permissions: Members (to enable Semhub to work for users in the same organization after it has been installed by an admin) + - Select the following read-only Organization permissions: Members (to enable SemHub to work for users in the same organization after it has been installed by an admin) - Leave unchecked the box that says "Request user authorization (OAuth) during installation". Our app handles user login + creation. - Select redirect on update and use the frontend `/repos` page as the Setup URL - Local dev: `https://local.semhub.dev:3001/repos` diff --git a/bun.lockb b/bun.lockb index bd2507f2..11c56eb7 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/packages/core/migrations/0033_vector-plain-storage.sql b/packages/core/migrations/0033_vector-plain-storage.sql index bddbfb0e..85532f0f 100644 --- a/packages/core/migrations/0033_vector-plain-storage.sql +++ b/packages/core/migrations/0033_vector-plain-storage.sql @@ -1,7 +1,7 @@ /* pg (or pgvector?) uses EXTENDED storage by default for for all columns bigger than 2KB -Changes vector storage to PLAIN if vector operation is in hot path (which it is for Semhub): +Changes vector storage to PLAIN if vector operation is in hot path (which it is for SemHub): - this only works if your row size is within PG page limit of 8KB - you cannot increase page limit beyond 8KB for index diff --git a/packages/core/src/email/index.ts b/packages/core/src/email/index.ts index 6af52153..d4b4baaf 100644 --- a/packages/core/src/email/index.ts +++ b/packages/core/src/email/index.ts @@ -18,7 +18,7 @@ export async function sendEmail( envPrefix: string, ) { const { data, error } = await client.emails.send({ - from: `${envPrefix ? `${envPrefix} ` : ""}Semhub `, + from: `${envPrefix ? `${envPrefix} ` : ""}SemHub `, to, subject, html, diff --git a/packages/core/src/github/permission/github-app.ts b/packages/core/src/github/permission/github-app.ts index 228b9d03..54b6824a 100644 --- a/packages/core/src/github/permission/github-app.ts +++ b/packages/core/src/github/permission/github-app.ts @@ -1,6 +1,6 @@ import { type Permissions } from "../schema.webhook"; -// this file tracks the current permissions requested by Semhub Github App +// this file tracks the current permissions requested by SemHub Github App // however, the actual permissions are configured via UI on GitHub export const CURRENT_REQUESTED_PERMISSIONS: Permissions = { diff --git a/packages/core/src/repo.ts b/packages/core/src/repo.ts index 1b830324..113699df 100644 --- a/packages/core/src/repo.ts +++ b/packages/core/src/repo.ts @@ -392,4 +392,43 @@ export const Repo = { .set({ issuesLastUpdatedAt: result.lastUpdated }) .where(eq(repos.id, repoId)); }, + + readyForPublicSearch: async ({ + owner, + name, + db, + }: { + owner: string; + name: string; + db: DbClient; + }) => { + const [result] = await db + .select({ + initStatus: repos.initStatus, + syncStatus: repos.syncStatus, + lastSyncedAt: repos.lastSyncedAt, + issuesLastUpdatedAt: repos.issuesLastUpdatedAt, + avatarUrl: repos.ownerAvatarUrl, + }) + .from(repos) + .where( + and( + eq(repos.ownerLogin, owner), + eq(repos.name, name), + eq(repos.isPrivate, false), + ), + ); + + if (!result) { + return null; + } + + return { + initStatus: result.initStatus, + lastSyncedAt: result.lastSyncedAt, + issuesLastUpdatedAt: result.issuesLastUpdatedAt, + syncStatus: result.syncStatus, + avatarUrl: result.avatarUrl, + }; + }, }; diff --git a/packages/web/package.json b/packages/web/package.json index d0188c0b..9600087f 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -10,6 +10,7 @@ "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dropdown-menu": "^2.1.4", "@radix-ui/react-icons": "^1.3.0", + "@radix-ui/react-popover": "^1.1.4", "@radix-ui/react-select": "^2.1.4", "@radix-ui/react-slot": "^1.1.1", "@radix-ui/react-toast": "^1.2.4", diff --git a/packages/web/public/sounds/dark-mode.mp3 b/packages/web/public/sounds/dark-mode.mp3 new file mode 100644 index 00000000..da1aa08a Binary files /dev/null and b/packages/web/public/sounds/dark-mode.mp3 differ diff --git a/packages/web/src/components/navbar/DarkModeToggle.tsx b/packages/web/src/components/navbar/DarkModeToggle.tsx index 601917e4..b1e9d395 100644 --- a/packages/web/src/components/navbar/DarkModeToggle.tsx +++ b/packages/web/src/components/navbar/DarkModeToggle.tsx @@ -1,11 +1,20 @@ import { useThemeToggle } from "@/lib/hooks/useThemeToggle"; import { Button } from "@/components/ui/button"; -export function DarkModeToggle() { +interface DarkModeToggleProps { + onToggleCountChange: () => void; +} + +export function DarkModeToggle({ onToggleCountChange }: DarkModeToggleProps) { const { ThemeIcon, handleThemeChange } = useThemeToggle(); + const handleClick = () => { + onToggleCountChange(); + handleThemeChange(); + }; + return ( - diff --git a/packages/web/src/components/navbar/Navbar.tsx b/packages/web/src/components/navbar/Navbar.tsx index 9ef9d179..e79ab589 100644 --- a/packages/web/src/components/navbar/Navbar.tsx +++ b/packages/web/src/components/navbar/Navbar.tsx @@ -1,31 +1,73 @@ import { Link } from "@tanstack/react-router"; +import { useTheme } from "next-themes"; +import { useEffect, useRef, useState } from "react"; import { useSession } from "@/lib/hooks/useSession"; +import { useAudio } from "@/hooks/useAudio"; import { DarkModeToggle } from "@/components/navbar/DarkModeToggle"; import { LoginButton } from "@/components/navbar/LoginButton"; import { UserNav } from "@/components/navbar/UserNav"; export function Navbar() { const { isAuthenticated, user } = useSession(); + const [toggleCount, setToggleCount] = useState(0); + const { theme, systemTheme } = useTheme(); + const audio = useAudio("/sounds/dark-mode.mp3"); + const prevThemeRef = useRef(theme); + const prevSystemThemeRef = useRef(systemTheme); + const showEasterEgg = toggleCount >= 8; + useEffect(() => { + const wasInDarkMode = + prevThemeRef.current === "dark" || + (prevThemeRef.current === "system" && + prevSystemThemeRef.current === "dark"); + const isInDarkMode = + theme === "dark" || (theme === "system" && systemTheme === "dark"); + + if (!wasInDarkMode && isInDarkMode && showEasterEgg) { + audio.play().catch(console.error); + } + + prevThemeRef.current = theme; + prevSystemThemeRef.current = systemTheme; + }, [theme, systemTheme, audio, showEasterEgg]); + + const handleToggleCount = () => { + setToggleCount((prev) => prev + 1); + }; + + const standardLogo = ( + <> + Sem + Hub + _ + + ); + const lightModeLogo = ( +

+ {standardLogo} +

+ ); + const darkModeLogo = ( +

+ {standardLogo} +

+ ); + const darkModeEasterEggLogo = ( +

+ Sem + + Hub + +

+ ); return (
-

- S - e - m - H - u - b -

-

- Sem - - Hub - -

+ {lightModeLogo} + {showEasterEgg ? darkModeEasterEggLogo : darkModeLogo}
diff --git a/packages/web/src/components/search/MeSearchBars.tsx b/packages/web/src/components/search/MeSearchBars.tsx index 22f78951..5a677585 100644 --- a/packages/web/src/components/search/MeSearchBars.tsx +++ b/packages/web/src/components/search/MeSearchBars.tsx @@ -29,7 +29,9 @@ export function MyReposResultsSearchBar({ handleBlur, commandValue, setCommandValue, - } = useSearchBar(initialQuery); + } = useSearchBar({ + initialQuery, + }); const { handleSearch } = useMeSearch(setQuery); return ( @@ -104,7 +106,7 @@ export function MyReposSearchBar() { commandValue, setCommandValue, setQuery, - } = useSearchBar(); + } = useSearchBar({}); const { handleSearch } = useMeSearch(setQuery); return ( diff --git a/packages/web/src/components/search/PublicSearchBars.tsx b/packages/web/src/components/search/PublicSearchBars.tsx index a0b70022..6f095998 100644 --- a/packages/web/src/components/search/PublicSearchBars.tsx +++ b/packages/web/src/components/search/PublicSearchBars.tsx @@ -220,6 +220,7 @@ function SearchFilters({ export function ResultsSearchBar({ query: initialQuery }: { query: string }) { const [selectedOrg, setSelectedOrg] = useState("all"); const [selectedRepo, setSelectedRepo] = useState("all"); + const removedOperators = ["collection"] as SearchOperator[]; const { query, inputRef, @@ -238,8 +239,11 @@ export function ResultsSearchBar({ query: initialQuery }: { query: string }) { commandValue, setCommandValue, setQuery, - } = useSearchBar(initialQuery); - const { handleSearch } = usePublicSearch(setQuery); + } = useSearchBar({ + initialQuery, + removedOperators, + }); + const { handleSearch } = usePublicSearch({ mode: "search", setQuery }); const handleOrgChange = (org: string) => { setSelectedOrg(org); @@ -306,6 +310,7 @@ export function ResultsSearchBar({ query: initialQuery }: { query: string }) { handleValueSelect={handleValueSelect} commandValue={commandValue} setCommandValue={setCommandValue} + removedOperators={removedOperators} />
)} @@ -318,6 +323,7 @@ export function HomepageSearchBar() { const { theme } = useTheme(); const [selectedOrg, setSelectedOrg] = useState("all"); const [selectedRepo, setSelectedRepo] = useState("all"); + const removedOperators = ["collection"] as SearchOperator[]; const { query, inputRef, @@ -336,8 +342,11 @@ export function HomepageSearchBar() { commandValue, setCommandValue, setQuery, - } = useSearchBar(); - const { handleSearch, handleLuckySearch } = usePublicSearch(setQuery); + } = useSearchBar({ removedOperators }); + const { handleSearch, handleLuckySearch } = usePublicSearch({ + mode: "search", + setQuery, + }); const placeholderText = usePlaceholderAnimation(); const handleOrgChange = (org: string) => { @@ -401,6 +410,7 @@ export function HomepageSearchBar() { handleValueSelect={handleValueSelect} commandValue={commandValue} setCommandValue={setCommandValue} + removedOperators={removedOperators} /> )} diff --git a/packages/web/src/components/search/RepoSearchBar.tsx b/packages/web/src/components/search/RepoSearchBar.tsx new file mode 100644 index 00000000..4b799110 --- /dev/null +++ b/packages/web/src/components/search/RepoSearchBar.tsx @@ -0,0 +1,113 @@ +import { SearchIcon, XIcon } from "lucide-react"; +import { useTheme } from "next-themes"; + +import { SearchOperator } from "@/core/constants/search.constant"; +import { usePublicSearch } from "@/hooks/usePublicSearch"; +import { useSearchBar } from "@/hooks/useSearchBar"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { SearchDropdownMenu } from "@/components/search/SearchDropdownMenu"; + +interface RepoSearchBarProps { + owner: string; + repo: string; +} + +export function RepoSearchBar({ owner, repo }: RepoSearchBarProps) { + const { theme } = useTheme(); + const removedOperators = ["org", "repo", "collection"] as SearchOperator[]; + const { + query, + inputRef, + commandInputRef, + commandRef, + commandInputValue, + subMenu, + shouldShowDropdown, + handleClear, + handleInputChange, + handleOperatorSelect, + handleValueSelect, + handleKeyDown, + handleFocus, + handleBlur, + commandValue, + setCommandValue, + setQuery, + } = useSearchBar({ + removedOperators, + }); + const { handleSearch, handleLuckySearch } = usePublicSearch({ + mode: "repo_search", + setQuery, + owner, + repo, + }); + + return ( +
handleSearch(e, query)}> +
+
+ + + {query && ( + + )} +
+ {shouldShowDropdown && ( +
+ +
+ )} +
+ + +
+
+
+ ); +} diff --git a/packages/web/src/components/search/SearchDropdownMenu.tsx b/packages/web/src/components/search/SearchDropdownMenu.tsx index 9aa1346f..4a17ed02 100644 --- a/packages/web/src/components/search/SearchDropdownMenu.tsx +++ b/packages/web/src/components/search/SearchDropdownMenu.tsx @@ -23,6 +23,7 @@ import { } from "@/components/ui/command"; interface SearchDropdownMenuProps { + removedOperators?: SearchOperator[]; commandRef: React.RefObject; commandInputRef: React.RefObject; commandInputValue: string; @@ -41,11 +42,13 @@ const preventDefault = (e: React.MouseEvent | React.TouchEvent) => { function OperatorItems({ commandInputValue, onSelect, + removedOperators, }: { commandInputValue: string; onSelect: (operator: OperatorWithIcon) => void; + removedOperators: SearchOperator[]; }) { - return getFilteredOperators(commandInputValue).map((o) => ( + return getFilteredOperators(commandInputValue, removedOperators).map((o) => ( onSelect(o)} @@ -89,6 +92,7 @@ export function SearchDropdownMenu({ handleValueSelect, commandValue, setCommandValue, + removedOperators = [], }: SearchDropdownMenuProps) { return ( )} {subMenu === "state" && handleValueSelect && ( @@ -192,11 +197,15 @@ export const OPERATOR_SUBMENU_VALUES = new Map([ ], ]); -export function getFilteredOperators(word: string) { +export function getFilteredOperators( + word: string, + removedOperators: SearchOperator[], +) { return OPERATORS_WITH_ICONS.filter( (o) => - o.operator.toLowerCase().startsWith(word.toLowerCase()) || - o.name.toLowerCase().startsWith(word.toLowerCase()), + !removedOperators.includes(o.operator) && + (o.operator.toLowerCase().startsWith(word.toLowerCase()) || + o.name.toLowerCase().startsWith(word.toLowerCase())), ); } diff --git a/packages/web/src/components/search/SuggestedSearchCard.tsx b/packages/web/src/components/search/SuggestedSearchCard.tsx index 804de0f2..ffd4ddb2 100644 --- a/packages/web/src/components/search/SuggestedSearchCard.tsx +++ b/packages/web/src/components/search/SuggestedSearchCard.tsx @@ -34,7 +34,7 @@ interface SuggestedSearchCardProps { } export function SuggestedSearchCard({ search }: SuggestedSearchCardProps) { - const { handleSearch } = usePublicSearch(); + const { handleSearch } = usePublicSearch({ mode: "suggested" }); return ( + + +
+
+
Add to your repo
+

+ Add semantic search to your repository by embedding this badge in + your README and loading your repo into SemHub. +

+
+ + Search with SemHub + +
+
+
+
+                  {embedCode}
+                
+ +
+
+
+
+
+ + ); +} diff --git a/packages/web/src/components/ui/breadcrumb.tsx b/packages/web/src/components/ui/breadcrumb.tsx new file mode 100644 index 00000000..1fd4b2c0 --- /dev/null +++ b/packages/web/src/components/ui/breadcrumb.tsx @@ -0,0 +1,114 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cn } from "@/lib/utils" +import { ChevronRightIcon, DotsHorizontalIcon } from "@radix-ui/react-icons" + +const Breadcrumb = React.forwardRef< + HTMLElement, + React.ComponentPropsWithoutRef<"nav"> & { + separator?: React.ReactNode + } +>(({ ...props }, ref) =>