diff --git a/src/components/common.tsx b/src/components/common.tsx index 2ef79cb..8fe22a9 100644 --- a/src/components/common.tsx +++ b/src/components/common.tsx @@ -1,6 +1,8 @@ import clsx from 'clsx'; import { Ban, + ChevronLeft, + ChevronRight, CircleDot, GitMerge, GitPullRequest, @@ -8,6 +10,7 @@ import { LoaderCircle, UserRound, } from 'lucide-solid'; +import { A } from '@solidjs/router'; import { Match, Show, Switch, createEffect, createMemo, createSignal, type Component } from 'solid-js'; import { createQuery } from '@tanstack/solid-query'; import { resolveAvatarUrl } from '../lib/api'; @@ -37,6 +40,104 @@ export const ToggleButton: Component<{ ); +export const PaginationControls: Component<{ + basePath?: string; + offset: number; + limit: number; + totalCount?: number; + hasNext?: boolean; + query?: Record; + hrefForOffset?: (offset: number) => string; +}> = (props) => { + const safeLimit = createMemo(() => Math.max(1, props.limit)); + const hasExactTotal = createMemo(() => typeof props.totalCount === 'number'); + const lastOffset = createMemo(() => Math.floor(Math.max((props.totalCount ?? 0) - 1, 0) / safeLimit()) * safeLimit()); + const previousOffset = createMemo(() => Math.max(0, props.offset - safeLimit())); + const nextOffset = createMemo(() => props.offset + safeLimit()); + const hasNextPage = createMemo(() => + hasExactTotal() ? nextOffset() < (props.totalCount ?? 0) : Boolean(props.hasNext), + ); + const pageNumber = (offset: number) => Math.floor(offset / safeLimit()) + 1; + const hrefForOffset = (offset: number) => { + if (props.hrefForOffset) { + return props.hrefForOffset(Math.max(0, offset)); + } + + const params = new URLSearchParams(); + + for (const [key, value] of Object.entries(props.query ?? {})) { + if (value) { + params.set(key, value); + } + } + + params.set('offset', String(Math.max(0, offset))); + params.set('limit', String(safeLimit())); + return `${props.basePath ?? ''}?${params.toString()}`; + }; + const showNextNumber = createMemo(() => hasExactTotal() ? nextOffset() < lastOffset() : hasNextPage()); + const showGap = createMemo(() => hasExactTotal() && nextOffset() < (props.totalCount ?? 0) - safeLimit() * 2); + + const linkClass = 'flex items-center gap-1 no-underline hover:no-underline dark:text-white text-sm'; + + return ( + 0 || hasNextPage()}> +
+ 0} + fallback={ + + + prev + + } + > + + + prev + + + + 0}> + 1 + + 0}> + {pageNumber(previousOffset())} + + + + {pageNumber(props.offset)} + + + + {pageNumber(nextOffset())} + + + -- + + + {pageNumber(lastOffset())} + + + + next + + + } + > + + next + + + +
+
+ ); +}; + export const LoadingState: Component<{ label: string }> = (props) => (
diff --git a/src/components/repo.tsx b/src/components/repo.tsx index 595d264..b49c1f6 100644 --- a/src/components/repo.tsx +++ b/src/components/repo.tsx @@ -14,7 +14,6 @@ import { PanelRightClose, PanelRightOpen, UnfoldVertical, - ChevronLeft, } from 'lucide-solid'; import { marked } from 'marked'; import { A } from '@solidjs/router'; @@ -55,97 +54,6 @@ export const RepoTabLink: Component<{ ); -export const PaginationControls: Component<{ - basePath: string; - offset: number; - limit: number; - totalCount?: number; - hasNext?: boolean; - query?: Record; -}> = (props) => { - const safeLimit = createMemo(() => Math.max(1, props.limit)); - const hasExactTotal = createMemo(() => typeof props.totalCount === 'number'); - const lastOffset = createMemo(() => Math.floor(Math.max((props.totalCount ?? 0) - 1, 0) / safeLimit()) * safeLimit()); - const previousOffset = createMemo(() => Math.max(0, props.offset - safeLimit())); - const nextOffset = createMemo(() => props.offset + safeLimit()); - const hasNextPage = createMemo(() => - hasExactTotal() ? nextOffset() < (props.totalCount ?? 0) : Boolean(props.hasNext), - ); - const pageNumber = (offset: number) => Math.floor(offset / safeLimit()) + 1; - const hrefForOffset = (offset: number) => { - const params = new URLSearchParams(); - - for (const [key, value] of Object.entries(props.query ?? {})) { - if (value) { - params.set(key, value); - } - } - - params.set('offset', String(Math.max(0, offset))); - params.set('limit', String(safeLimit())); - return `${props.basePath}?${params.toString()}`; - }; - - const linkClass = 'flex items-center gap-1 no-underline hover:no-underline dark:text-white text-sm'; - - return ( - 0 || hasNextPage()}> -
- 0} - fallback={ - - - prev - - } - > - - - prev - - - - 0}> - 1 - - 0}> - {pageNumber(previousOffset())} - - - - {pageNumber(props.offset)} - - - - {pageNumber(nextOffset())} - - - - - - - {pageNumber(lastOffset())} - - - - next - - - } - > - - next - - - -
-
- ); -}; - const skeletonRows = Array.from({ length: 7 }); const listSkeletonRows = Array.from({ length: 10 }); const skeletonCodeLines = Array.from({ length: 18 }); diff --git a/src/index.css b/src/index.css index 4956181..8990c7a 100644 --- a/src/index.css +++ b/src/index.css @@ -914,6 +914,70 @@ details[open] > .untangled-language-bar { min-height: 140px; } +.untangled-topbar-search-box { + position: relative; + display: flex; + max-height: 30px; + width: 20rem; + align-items: center; + gap: 0.5rem; + border: 1px solid rgb(229 231 235); + border-radius: 0.25rem; + background: rgb(255 255 255); + padding: 1rem 0.375rem 1rem 0.5rem; +} + +.untangled-topbar-search-box:focus-within { + z-index: 51; + outline: 2px solid rgb(209 213 219); + outline-offset: 1px; +} + +.untangled-topbar-search-input { + min-width: 0; + flex: 1; + border: 0; + border-color: transparent; + border-radius: 0; + background: transparent; + padding: 0; + color: rgb(31 41 55); + font-size: 0.875rem; + line-height: 1.25rem; + outline: none; +} + +.untangled-topbar-search-input:focus { + border: 0; + border-color: transparent; + box-shadow: none; + outline: none; +} + +.untangled-topbar-search-input::placeholder { + color: rgb(156 163 175); +} + +.untangled-topbar-search-kbd { + display: flex; + align-items: center; + border: 1px solid rgb(229 231 235); + border-bottom-width: 2px; + border-radius: 0.25rem; + padding: 0 0.25rem; + color: rgb(156 163 175); + font-family: inherit; + font-size: 0.75rem; + line-height: 1.25rem; + pointer-events: none; +} + +.untangled-topbar-search-input:focus + .untangled-topbar-search-kbd, +.untangled-topbar-search-box:focus-within .untangled-topbar-search-kbd, +.untangled-topbar-search-input:not(:placeholder-shown) + .untangled-topbar-search-kbd { + display: none; +} + .untangled-file-row { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); @@ -939,6 +1003,160 @@ details[open] > .untangled-language-bar { gap: 0.5rem; } +.untangled-repo-filter-search { + position: relative; + display: flex; + min-width: 0; +} + +.untangled-repo-filter-input { + min-width: 0; + flex: 1; + border: 1px solid rgb(209 213 219); + border-radius: 0.25rem 0 0 0.25rem; + background: rgb(255 255 255); + padding: 0.25rem 2.5rem 0.25rem 0.5rem; + color: rgb(17 24 39); + font-size: 0.875rem; + line-height: 1.5rem; + outline: none; +} + +.untangled-repo-filter-input:focus { + border-color: rgb(156 163 175); + box-shadow: 0 0 0 1px rgb(156 163 175); + z-index: 1; +} + +.untangled-repo-filter-input::placeholder { + color: rgb(156 163 175); +} + +.untangled-repo-filter-reset { + position: absolute; + right: 2.75rem; + top: 50%; + display: none; + color: rgb(156 163 175); + transform: translateY(-50%); +} + +.untangled-repo-filter-input:not(:placeholder-shown) + .untangled-repo-filter-reset { + display: block; +} + +.untangled-repo-filter-reset:hover { + color: rgb(75 85 99); +} + +.untangled-repo-filter-submit { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.5rem; + border: 1px solid rgb(209 213 219); + border-left: 0; + border-radius: 0 0.25rem 0.25rem 0; + background: rgb(255 255 255); + color: rgb(156 163 175); +} + +.untangled-repo-filter-submit:hover { + color: rgb(31 41 55); +} + +.untangled-search-grid { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 1rem; +} + +.untangled-search-main { + display: flex; + min-width: 0; + flex-direction: column; + gap: 1rem; +} + +.untangled-search-sidebar { + display: none; +} + +.untangled-search-page-input { + min-width: 0; + flex: 1; + border: 1px solid rgb(209 213 219); + border-radius: 0.25rem 0 0 0.25rem; + background: rgb(255 255 255); + margin-right: -1px; + padding: 0.25rem 2.5rem 0.25rem 0.5rem; + color: rgb(17 24 39); + outline: none; +} + +.untangled-search-page-input::placeholder { + color: rgb(156 163 175); +} + +.untangled-search-page-input:focus { + border-color: rgb(156 163 175); + box-shadow: 0 0 0 1px rgb(156 163 175); + z-index: 1; +} + +.untangled-search-page-submit { + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid rgb(209 213 219); + border-radius: 0 0.25rem 0.25rem 0; + padding: 0.5rem; + background: rgb(255 255 255); + color: rgb(156 163 175); +} + +.untangled-search-page-submit:hover { + color: rgb(75 85 99); +} + +.untangled-search-type-dot { + width: 0.5rem; + height: 0.5rem; + flex-shrink: 0; + border-radius: 9999px; + background: radial-gradient(circle at 35% 35%, rgb(156 163 175), rgb(107 114 128) 30%, rgb(75 85 99)); +} + +.untangled-search-description { + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +@media (min-width: 1024px) { + .untangled-topbar-search-box { + width: 24rem; + } +} + +@media (min-width: 768px) { + .untangled-search-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .untangled-search-main { + grid-column: span 3 / span 3; + } + + .untangled-search-sidebar { + position: sticky; + top: 0.5rem; + display: block; + align-self: start; + } +} + .untangled-repo-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -1601,6 +1819,54 @@ details[open] > .untangled-language-bar { background: rgb(31 41 55); } + .untangled-topbar-search-box, + .untangled-search-page-input, + .untangled-search-page-submit, + .untangled-repo-filter-input, + .untangled-repo-filter-submit { + border-color: rgb(55 65 81); + background: rgb(17 24 39); + } + + .untangled-topbar-search-box:focus-within { + outline-color: rgb(75 85 99); + } + + .untangled-topbar-search-input, + .untangled-search-page-input, + .untangled-repo-filter-input { + color: rgb(229 231 235); + } + + .untangled-topbar-search-input::placeholder, + .untangled-search-page-input::placeholder, + .untangled-repo-filter-input::placeholder { + color: rgb(75 85 99); + } + + .untangled-topbar-search-kbd { + border-color: rgb(75 85 99); + color: rgb(107 114 128); + } + + .untangled-search-page-input:focus, + .untangled-repo-filter-input:focus { + border-color: rgb(75 85 99); + box-shadow: 0 0 0 1px rgb(75 85 99); + } + + .untangled-search-page-submit, + .untangled-repo-filter-reset, + .untangled-repo-filter-submit { + color: rgb(156 163 175); + } + + .untangled-search-page-submit:hover, + .untangled-repo-filter-reset:hover, + .untangled-repo-filter-submit:hover { + color: rgb(229 231 235); + } + .untangled-blob-commit-panel { border-bottom-color: rgb(55 65 81); } @@ -1618,6 +1884,10 @@ details[open] > .untangled-language-bar { grid-template-columns: minmax(0, 1fr); } + .untangled-repo-filter-search { + grid-row: 1; + } + .untangled-file-row { grid-template-columns: minmax(0, 1fr); gap: 0.125rem; diff --git a/src/layout.tsx b/src/layout.tsx index 8419317..1cccc94 100644 --- a/src/layout.tsx +++ b/src/layout.tsx @@ -1,6 +1,6 @@ import clsx from 'clsx'; -import { Bell, LogOut, RotateCcw, Save, Settings } from 'lucide-solid'; -import { A } from '@solidjs/router'; +import { Bell, LogOut, RotateCcw, Save, Search, Settings } from 'lucide-solid'; +import { A, useLocation, useNavigate } from '@solidjs/router'; import { createQuery, useQueryClient } from '@tanstack/solid-query'; import { For, Show, createEffect, createSignal, onCleanup, onMount, type Component, type JSX } from 'solid-js'; @@ -242,6 +242,43 @@ const UserProfileMenu: Component = () => { ); }; +const TopbarSearch: Component = () => { + const navigate = useNavigate(); + const location = useLocation(); + const [query, setQuery] = createSignal(''); + + createEffect(() => { + if (location.pathname !== '/search') { + setQuery(''); + return; + } + + const params = new URLSearchParams(location.search); + setQuery(params.get('q') ?? ''); + }); + + const onSubmit = (event: SubmitEvent) => { + event.preventDefault(); + const trimmed = query().trim(); + navigate(trimmed ? `/search?q=${encodeURIComponent(trimmed)}` : '/search'); + }; + + return ( + + ); +}; + const Topbar: Component = () => { const auth = useAuth(); let topbar: HTMLElement | undefined; @@ -265,14 +302,18 @@ const Topbar: Component = () => { return (