diff --git a/src/components/Autocomplete.tsx b/src/components/Autocomplete.tsx index 8ebe2e8..7eeb2d4 100644 --- a/src/components/Autocomplete.tsx +++ b/src/components/Autocomplete.tsx @@ -1,26 +1,59 @@ 'use client'; -import {useState} from 'react'; +import {useId, useState} from 'react'; import {Input} from '@/components/ui/input'; +import {Label} from '@/components/ui/label'; import {cn} from '@/lib/utils'; function Autocomplete({ + emptyMessage, + id, + label, onChange, + onSelect, options = [], value = '', ...props -}: React.ComponentProps<'input'> & {isLoading?: boolean; options: string[]}) { - const [query, setQuery] = useState(String(value)); +}: Omit, 'onSelect'> & { + emptyMessage?: string; + isLoading?: boolean; + label: string; + onSelect?: (value: string) => void; + options: string[]; +}) { + const generatedId = useId(); + const inputId = id ?? generatedId; + const listboxId = `${inputId}-listbox`; + const optionId = (index: number) => `${listboxId}-option-${index}`; + const [selectedIndex, setSelectedIndex] = useState(-1); const [isFocused, setIsFocused] = useState(false); + // keep list open on no matches so it can say so.. + const showEmpty = + emptyMessage !== undefined && + options.length === 0 && + String(value).trim().length > 0; + const isOpen = isFocused && (options.length > 0 || showEmpty); + + let announcement = ''; + if (isOpen) { + announcement = showEmpty + ? (emptyMessage ?? '') + : `${options.length} ${options.length === 1 ? 'suggestion' : 'suggestions'} available.`; + } + const handleInputChange = (e: React.ChangeEvent) => { - setQuery(e.target.value); onChange?.(e); setSelectedIndex(-1); }; + const selectOption = (suggestion: string) => { + setSelectedIndex(-1); + onSelect?.(suggestion); + }; + const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'ArrowDown') { e.preventDefault(); @@ -29,10 +62,9 @@ function Autocomplete({ e.preventDefault(); setSelectedIndex(prev => (prev > 0 ? prev - 1 : -1)); } else if (e.key === 'Enter' && selectedIndex >= 0) { - handleInputChange({ - target: {name: props.name || '', value: options[selectedIndex]}, - } as React.ChangeEvent); - setSelectedIndex(-1); + // otherwise form submits term the suggestion replaces + e.preventDefault(); + selectOption(options[selectedIndex]); } else if (e.key === 'Escape') { setSelectedIndex(-1); } @@ -43,58 +75,68 @@ function Autocomplete({ }; const handleBlur = () => { - setTimeout(() => { - setIsFocused(false); - setSelectedIndex(-1); - }, 200); + setIsFocused(false); + setSelectedIndex(-1); }; return ( -
- 0} - onBlur={handleBlur} - onChange={handleInputChange} - onFocus={handleFocus} - onKeyDown={handleKeyDown} - value={query} - {...props} - /> - {options.length > 0 && isFocused && ( -
    - {options.map((suggestion, index) => ( - // biome-ignore lint/a11y/useKeyWithClickEvents: handled by parent input's keydown - // biome-ignore lint/a11y/useAriaPropsSupportedByRole: listbox role inferred by aria-live -
  • - handleInputChange({ - target: { - name: props.name || '', - type: 'click', - value: suggestion, - }, - } as React.ChangeEvent) - } - > - {suggestion} -
  • - ))} -
- )} +
+ +
+ = 0 ? optionId(selectedIndex) : undefined + } + aria-autocomplete="list" + aria-controls={listboxId} + aria-expanded={isOpen} + id={inputId} + onBlur={handleBlur} + onChange={handleInputChange} + onFocus={handleFocus} + onKeyDown={handleKeyDown} + role="combobox" + value={value} + {...props} + /> + {isOpen && ( +
    + {showEmpty && ( +
  • + {emptyMessage} +
  • + )} + {options.map((suggestion, index) => ( +
  • { + e.preventDefault(); + selectOption(suggestion); + }} + role="option" + > + {suggestion} +
  • + ))} +
+ )} + + {announcement} + +
); } diff --git a/src/components/CopyButton.tsx b/src/components/CopyButton.tsx index 7c518c2..59bacdb 100644 --- a/src/components/CopyButton.tsx +++ b/src/components/CopyButton.tsx @@ -16,6 +16,12 @@ export type CopyButtonProps = Omit< type CopyState = 'error' | 'idle' | 'success'; +const COPY_STATUS_MESSAGES: Record = { + error: 'Copy failed. Select the text and copy it manually.', + idle: '', + success: 'Copied to clipboard', +}; + export function CopyButton(props: CopyButtonProps) { const {className, text, ...rest} = props; const [, copy] = useCopyToClipboard(); @@ -24,27 +30,34 @@ export function CopyButton(props: CopyButtonProps) { useTimeout(() => setCopyState('idle'), copyState !== 'idle' ? 2000 : null); return ( - + <> + + + {COPY_STATUS_MESSAGES[copyState]} + + ); } diff --git a/src/components/EmptyState.tsx b/src/components/EmptyState.tsx new file mode 100644 index 0000000..33bccb3 --- /dev/null +++ b/src/components/EmptyState.tsx @@ -0,0 +1,29 @@ +import {Button} from '@/components/ui/button'; + +export function EmptyState({ + action, + description, + onAction, + title, +}: { + action: string; + description: string; + onAction: () => void; + title: string; +}) { + return ( +
+

{title}

+

{description}

+ +
+ ); +} diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..f1879ad --- /dev/null +++ b/src/components/ErrorBoundary.tsx @@ -0,0 +1,83 @@ +import { + type ErrorComponentProps, + Link, + useRouter, +} from '@tanstack/react-router'; +import {Loader2, SearchX} from 'lucide-react'; + +import {ErrorState} from '@/components/ErrorState'; +import {PageMain} from '@/components/PageMain'; +import {Button} from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; + +export function RouteError({error, reset}: ErrorComponentProps) { + const router = useRouter(); + + const retry = () => { + reset(); + router.invalidate(); + }; + + return ( + + + + +
+ + +
+
+
+
+ ); +} + +export function RouteNotFound() { + return ( + + + + + + Page not found + + + This address does not match any page. The package may have been + dropped from the repositories, or the link may be mistyped. + + + + + + + + ); +} + +export function RoutePending() { + return ( + + + ); +} diff --git a/src/components/ErrorState.tsx b/src/components/ErrorState.tsx new file mode 100644 index 0000000..dd81807 --- /dev/null +++ b/src/components/ErrorState.tsx @@ -0,0 +1,24 @@ +import {AlertCircle} from 'lucide-react'; + +import {Alert, AlertDescription, AlertTitle} from '@/components/ui/alert'; + +export function ErrorState({ + actions, + message, + title, +}: { + actions?: React.ReactNode; + message: string; + title: string; +}) { + return ( + + + {title} + + {message} + {actions} + + + ); +} diff --git a/src/components/MirrorslistTable.tsx b/src/components/MirrorslistTable.tsx index dac79f4..9a47ba3 100644 --- a/src/components/MirrorslistTable.tsx +++ b/src/components/MirrorslistTable.tsx @@ -200,7 +200,7 @@ export default function MirrorslistTable({ diff --git a/src/components/PackageFiles.tsx b/src/components/PackageFiles.tsx index 2fff436..8c0251f 100644 --- a/src/components/PackageFiles.tsx +++ b/src/components/PackageFiles.tsx @@ -4,6 +4,8 @@ import {useQuery} from '@tanstack/react-query'; import {useState} from 'react'; import {CopyButton} from '@/components/CopyButton'; +import {EmptyState} from '@/components/EmptyState'; +import {ErrorState} from '@/components/ErrorState'; import {Button} from '@/components/ui/button'; import {PackageDetailFilesResponseSchema, PackageRepo} from '@/lib/types'; import {cn} from '@/lib/utils'; @@ -64,14 +66,39 @@ export function PackageFiles({arch, pkgname, repo}: PackageFilesProps) { if (query.isError) { return ( - {query.error.message} + { + query.refetch(); + }} + size="sm" + type="button" + variant="outline" + > + Try again + + } + message={query.error.message} + title="Package files could not be loaded" + /> ); } const files = query.data ?? []; if (files.length === 0) { - return No files found.; + return ( + { + query.refetch(); + }} + title="No files listed" + /> + ); } return ( diff --git a/src/components/PackageSearch.tsx b/src/components/PackageSearch.tsx index 973148f..fbeeebe 100644 --- a/src/components/PackageSearch.tsx +++ b/src/components/PackageSearch.tsx @@ -6,16 +6,16 @@ import { useQueryClient, } from '@tanstack/react-query'; import {useNavigate, useSearch} from '@tanstack/react-router'; -import {AlertCircle} from 'lucide-react'; import {useCallback, useEffect, useMemo} from 'react'; import {useSessionStorage} from 'usehooks-ts'; import {SEARCH_BACK_PATH} from '@/components/BackLink'; +import {EmptyState} from '@/components/EmptyState'; +import {ErrorState} from '@/components/ErrorState'; import PackageSearchForm from '@/components/PackageSearchForm'; import PackageSearchSkeleton from '@/components/PackageSearchSkeleton'; import PackageTable from '@/components/PackageTable'; import {PackageTablePagination} from '@/components/PackageTablePagination'; -import {Alert, AlertDescription, AlertTitle} from '@/components/ui/alert'; import {searchQueryFn} from '@/lib/query-actions'; import {PAGE_SIZE, type PackagesSearchQueryParams} from '@/lib/types'; import {INTL_LOCALE} from '@/lib/utils'; @@ -101,19 +101,17 @@ export default function PackageSearch() { onSubmit={onFormSubmit} /> - {error && ( - - - Error - {error.message} - - )} + {error && } {isPending && !data && } {data && (
-

+

Found {data.total_packages.toLocaleString(INTL_LOCALE)} packages. Page {parsedParams.current_page.toLocaleString(INTL_LOCALE)} of{' '} {data.total_pages.toLocaleString(INTL_LOCALE)}. @@ -169,9 +167,12 @@ export default function PackageSearch() { /> ) : ( -

- No packages found matching your criteria. -

+ )}
)} diff --git a/src/components/PackageSearchForm.tsx b/src/components/PackageSearchForm.tsx index 8c8dbf0..5db0ca0 100644 --- a/src/components/PackageSearchForm.tsx +++ b/src/components/PackageSearchForm.tsx @@ -4,6 +4,7 @@ import {keepPreviousData, useQuery} from '@tanstack/react-query'; import {Loader2} from 'lucide-react'; import {useCallback, useRef, useState} from 'react'; import {useDebounceValue} from 'usehooks-ts'; +import {z} from 'zod'; import {Autocomplete} from '@/components/Autocomplete'; import {Button} from '@/components/ui/button'; @@ -29,6 +30,18 @@ interface PackageSearchFormProps { onSubmit: (params: PackagesSearchQueryParams) => void; } +const SEARCH_ERROR_ID = 'search-error'; +const MAX_SEARCH_LENGTH = 128; + +// checked on submit +const SearchTermSchema = z + .string() + .max(MAX_SEARCH_LENGTH, `Use ${MAX_SEARCH_LENGTH} characters or fewer.`) + .refine( + value => value.length === 0 || value.trim().length > 0, + 'Enter a package name or description, or clear the field to browse all packages.' + ); + export default function PackageSearchForm({ initialParams, isLoading, @@ -37,6 +50,7 @@ export default function PackageSearchForm({ }: PackageSearchFormProps) { const [params, setParams] = useState(initialParams); + const [searchError, setSearchError] = useState(null); const [searchSuggest] = useDebounceValue(params.search, 300); const {data: [, options] = [searchSuggest, []]} = useQuery({ @@ -59,15 +73,22 @@ export default function PackageSearchForm({ const onInputChange = ( e: | React.ChangeEvent - | {target: {name: string; type?: string; value: string}} + | {target: {name: string; value: string}} ) => { - const {name, type, value} = e.target; + const {name, value} = e.target; setParams(prev => ({...prev, [name]: value})); - if (type === 'click') { - onSubmit({...params, [name]: value}); + if (name === 'search') { + setSearchError(null); } }; + // picked suggestion is already a valid term + const onSuggestionSelect = (search: string) => { + setParams(prev => ({...prev, search})); + setSearchError(null); + onSubmit({...params, search}); + }; + const handleSelectionChange = (name: 'arch' | 'repo', value: string) => { const currentValues = params[name] ? params[name].split(',') : []; const newValues = currentValues.includes(value) @@ -79,11 +100,20 @@ export default function PackageSearchForm({ const handleSubmit = (e: React.SubmitEvent) => { e.preventDefault(); + + const result = SearchTermSchema.safeParse(params.search); + if (!result.success) { + setSearchError(result.error.issues[0].message); + primarySearchFilterInputRef.current?.focus(); + return; + } + onSubmit(params); }; const handleReset = (e: React.SyntheticEvent) => { e.preventDefault(); + setSearchError(null); onReset(); }; @@ -94,18 +124,30 @@ export default function PackageSearchForm({
- + {searchError && ( + + )}
@@ -113,6 +155,8 @@ export default function PackageSearchForm({ + + + {isLoading ? 'Searching packages…' : ''} +
); diff --git a/src/components/PackageTablePagination.tsx b/src/components/PackageTablePagination.tsx index 5efd87a..d7649cc 100644 --- a/src/components/PackageTablePagination.tsx +++ b/src/components/PackageTablePagination.tsx @@ -59,7 +59,7 @@ export function PackageTablePagination({ size="sm" variant="ghost" > - + Previous {pages.map((page, index) => { @@ -100,7 +100,7 @@ export function PackageTablePagination({ variant="ghost" > Next - +
{' '}
diff --git a/src/components/PageMain.tsx b/src/components/PageMain.tsx new file mode 100644 index 0000000..b537a85 --- /dev/null +++ b/src/components/PageMain.tsx @@ -0,0 +1,16 @@ +import {cn} from '@/lib/utils'; + +export function PageMain({ + children, + className, + ...props +}: React.ComponentProps<'main'>) { + return ( +
+ {children} +
+ ); +} diff --git a/src/components/theme-provider.tsx b/src/components/theme-provider.tsx index e7822e7..97d76df 100644 --- a/src/components/theme-provider.tsx +++ b/src/components/theme-provider.tsx @@ -1,11 +1,49 @@ 'use client'; -import {ThemeProvider as NextThemesProvider} from 'next-themes'; -import type {ComponentProps} from 'react'; +import {ThemeProvider as NextThemesProvider, useTheme} from 'next-themes'; +import {type ComponentProps, useEffect} from 'react'; export function ThemeProvider({ children, ...props }: ComponentProps) { - return {children}; + return ( + + + {children} + + ); +} + +function ThemeHotkey() { + const {resolvedTheme, setTheme} = useTheme(); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key.toLowerCase() !== 'd') return; + if (event.ctrlKey || event.metaKey || event.altKey) return; + + const target = event.target; + if ( + target instanceof HTMLElement && + (target.tagName === 'INPUT' || + target.tagName === 'TEXTAREA' || + target.tagName === 'SELECT' || + target.isContentEditable) + ) { + return; + } + + event.preventDefault(); + setTheme(resolvedTheme === 'dark' ? 'light' : 'dark'); + }; + + window.addEventListener('keydown', handleKeyDown); + + return () => { + window.removeEventListener('keydown', handleKeyDown); + }; + }, [resolvedTheme, setTheme]); + + return null; } diff --git a/src/components/theme-toggle.tsx b/src/components/theme-toggle.tsx index ca71c58..7d08cc7 100644 --- a/src/components/theme-toggle.tsx +++ b/src/components/theme-toggle.tsx @@ -17,10 +17,10 @@ export function ThemeToggle() { return ( - diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index f1e568b..da6bf01 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -5,7 +5,7 @@ import type * as React from 'react'; import {cn} from '@/lib/utils'; const buttonVariants = cva( - "hover:cursor-pointer inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + "hover:cursor-pointer inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 [&_svg[data-icon='inline-start']]:-ms-0.5 [&_svg[data-icon='inline-end']]:-me-0.5 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", { defaultVariants: { size: 'default', diff --git a/src/hooks/use-keyboard-shortcut-listener.ts b/src/hooks/use-keyboard-shortcut-listener.ts index 0a4c969..8ebeb53 100644 --- a/src/hooks/use-keyboard-shortcut-listener.ts +++ b/src/hooks/use-keyboard-shortcut-listener.ts @@ -2,21 +2,31 @@ import {useEffect} from 'react'; +const EDITABLE_TAGS = new Set(['INPUT', 'SELECT', 'TEXTAREA']); + // using from CachyOS/builder-dashboard ;) export function useGenericShortcutListener( key: string, callback: () => void, - ignoreModifiers = false + bareKey = false ) { useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { + if (event.key.toLowerCase() !== key.toLowerCase()) return; + + // don't steal keys.. + const target = event.target; if ( - (event.ctrlKey || event.metaKey || ignoreModifiers) && - event.key.toLowerCase() === key.toLowerCase() + target instanceof HTMLElement && + (EDITABLE_TAGS.has(target.tagName) || target.isContentEditable) ) { - event.preventDefault(); - callback(); + return; } + const hasModifier = event.ctrlKey || event.metaKey || event.altKey; + if (bareKey === hasModifier) return; + + event.preventDefault(); + callback(); }; window.addEventListener('keydown', handleKeyDown); @@ -24,5 +34,5 @@ export function useGenericShortcutListener( return () => { window.removeEventListener('keydown', handleKeyDown); }; - }); + }, [bareKey, callback, key]); } diff --git a/src/lib/site.ts b/src/lib/site.ts new file mode 100644 index 0000000..5aced72 --- /dev/null +++ b/src/lib/site.ts @@ -0,0 +1,53 @@ +/** Canonical public origin, used by Open Graph metadata and the SEO routes. */ +export const SITE_ORIGIN = 'https://dashboard.cachyos.org'; + +/** Site name for `og:site_name`. */ +export const SITE_NAME = 'CachyOS Package Dashboard'; + +/** Default description, used by the root route and the package search page. */ +export const SITE_DESCRIPTION = + 'Search and view packages across all CachyOS repositories.'; + +/** Brand prefix every page title carries, e.g. `CachyOS | Mirrors List`. */ +const TITLE_PREFIX = 'CachyOS'; + +type LinkTag = React.JSX.IntrinsicElements['link']; +type MetaTag = React.JSX.IntrinsicElements['meta']; + +type PageHeadOptions = { + description: string; + /** Site-relative path, already URL-encoded where it contains params. */ + path: string; + /** e.g. `'noindex, follow'` for filtered or stub pages. */ + robots?: string; + /** Page title without the brand prefix, e.g. `'Mirrors List'`. */ + title: string; +}; + +/** Absolute URL for a site-relative path. */ +export function canonicalUrl(path: string): string { + return new URL(path, SITE_ORIGIN).href; +} + +export function pageHead(options: PageHeadOptions): { + links: LinkTag[]; + meta: MetaTag[]; +} { + const {description, path, robots} = options; + const title = `${TITLE_PREFIX} | ${options.title}`; + const url = canonicalUrl(path); + + return { + links: [{href: url, rel: 'canonical'}], + meta: [ + {title}, + {content: description, name: 'description'}, + {content: title, property: 'og:title'}, + {content: description, property: 'og:description'}, + {content: url, property: 'og:url'}, + {content: title, name: 'twitter:title'}, + {content: description, name: 'twitter:description'}, + ...(robots ? [{content: robots, name: 'robots'}] : []), + ], + }; +} diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 5c24e28..fc1344d 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -9,12 +9,18 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' +import { Route as RobotsDottxtRouteImport } from './routes/robots[.]txt' import { Route as MirrorsRouteImport } from './routes/mirrors' import { Route as IndexRouteImport } from './routes/index' import { Route as ApiSearchRouteImport } from './routes/api/search' import { Route as PackageRepoArchPkgnameRouteImport } from './routes/package.$repo.$arch.$pkgname' import { Route as ApiPackageRepoArchPkgnameFilesRouteImport } from './routes/api/package.$repo.$arch.$pkgname.files' +const RobotsDottxtRoute = RobotsDottxtRouteImport.update({ + id: '/robots.txt', + path: '/robots.txt', + getParentRoute: () => rootRouteImport, +} as any) const MirrorsRoute = MirrorsRouteImport.update({ id: '/mirrors', path: '/mirrors', @@ -45,6 +51,7 @@ const ApiPackageRepoArchPkgnameFilesRoute = export interface FileRoutesByFullPath { '/': typeof IndexRoute '/mirrors': typeof MirrorsRoute + '/robots.txt': typeof RobotsDottxtRoute '/api/search': typeof ApiSearchRoute '/package/$repo/$arch/$pkgname': typeof PackageRepoArchPkgnameRoute '/api/package/$repo/$arch/$pkgname/files': typeof ApiPackageRepoArchPkgnameFilesRoute @@ -52,6 +59,7 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/': typeof IndexRoute '/mirrors': typeof MirrorsRoute + '/robots.txt': typeof RobotsDottxtRoute '/api/search': typeof ApiSearchRoute '/package/$repo/$arch/$pkgname': typeof PackageRepoArchPkgnameRoute '/api/package/$repo/$arch/$pkgname/files': typeof ApiPackageRepoArchPkgnameFilesRoute @@ -60,6 +68,7 @@ export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/mirrors': typeof MirrorsRoute + '/robots.txt': typeof RobotsDottxtRoute '/api/search': typeof ApiSearchRoute '/package/$repo/$arch/$pkgname': typeof PackageRepoArchPkgnameRoute '/api/package/$repo/$arch/$pkgname/files': typeof ApiPackageRepoArchPkgnameFilesRoute @@ -69,6 +78,7 @@ export interface FileRouteTypes { fullPaths: | '/' | '/mirrors' + | '/robots.txt' | '/api/search' | '/package/$repo/$arch/$pkgname' | '/api/package/$repo/$arch/$pkgname/files' @@ -76,6 +86,7 @@ export interface FileRouteTypes { to: | '/' | '/mirrors' + | '/robots.txt' | '/api/search' | '/package/$repo/$arch/$pkgname' | '/api/package/$repo/$arch/$pkgname/files' @@ -83,6 +94,7 @@ export interface FileRouteTypes { | '__root__' | '/' | '/mirrors' + | '/robots.txt' | '/api/search' | '/package/$repo/$arch/$pkgname' | '/api/package/$repo/$arch/$pkgname/files' @@ -91,6 +103,7 @@ export interface FileRouteTypes { export interface RootRouteChildren { IndexRoute: typeof IndexRoute MirrorsRoute: typeof MirrorsRoute + RobotsDottxtRoute: typeof RobotsDottxtRoute ApiSearchRoute: typeof ApiSearchRoute PackageRepoArchPkgnameRoute: typeof PackageRepoArchPkgnameRoute ApiPackageRepoArchPkgnameFilesRoute: typeof ApiPackageRepoArchPkgnameFilesRoute @@ -98,6 +111,13 @@ export interface RootRouteChildren { declare module '@tanstack/react-router' { interface FileRoutesByPath { + '/robots.txt': { + id: '/robots.txt' + path: '/robots.txt' + fullPath: '/robots.txt' + preLoaderRoute: typeof RobotsDottxtRouteImport + parentRoute: typeof rootRouteImport + } '/mirrors': { id: '/mirrors' path: '/mirrors' @@ -139,6 +159,7 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, MirrorsRoute: MirrorsRoute, + RobotsDottxtRoute: RobotsDottxtRoute, ApiSearchRoute: ApiSearchRoute, PackageRepoArchPkgnameRoute: PackageRepoArchPkgnameRoute, ApiPackageRepoArchPkgnameFilesRoute: ApiPackageRepoArchPkgnameFilesRoute, diff --git a/src/router.tsx b/src/router.tsx index 5e3fe60..0193893 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -1,6 +1,11 @@ import {QueryClient} from '@tanstack/react-query'; import {createRouter} from '@tanstack/react-router'; import {setupRouterSsrQueryIntegration} from '@tanstack/react-router-ssr-query'; +import { + RouteError, + RouteNotFound, + RoutePending, +} from '@/components/ErrorBoundary'; import {routeTree} from './routeTree.gen'; export function getRouter() { @@ -8,6 +13,9 @@ export function getRouter() { const router = createRouter({ context: {queryClient}, + defaultErrorComponent: RouteError, + defaultNotFoundComponent: RouteNotFound, + defaultPendingComponent: RoutePending, defaultPreload: 'intent', defaultPreloadStaleTime: 0, routeTree, diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 3993832..7ddf619 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -2,18 +2,22 @@ import {TanStackDevtools} from '@tanstack/react-devtools'; import type {QueryClient} from '@tanstack/react-query'; import {ReactQueryDevtoolsPanel} from '@tanstack/react-query-devtools'; import { + CatchBoundary, createRootRouteWithContext, HeadContent, Scripts, } from '@tanstack/react-router'; import {TanStackRouterDevtoolsPanel} from '@tanstack/react-router-devtools'; -import {ThemeProvider} from 'next-themes'; +import {RouteError} from '@/components/ErrorBoundary'; +import {ThemeProvider} from '@/components/theme-provider'; +import {SITE_DESCRIPTION, SITE_NAME, SITE_ORIGIN} from '@/lib/site'; import favicon from '../assets/icon.svg'; import appCss from '../styles/globals.css?url'; export const Route = createRootRouteWithContext<{ queryClient: QueryClient; }>()({ + errorComponent: RouteError, head: () => ({ links: [ {href: appCss, rel: 'stylesheet'}, @@ -26,12 +30,13 @@ export const Route = createRootRouteWithContext<{ content: import.meta.env.VITE_APP_VERSION || 'development', name: 'version', }, + {content: SITE_DESCRIPTION, name: 'description'}, { content: 'CachyOS Package Repository Dashboard', property: 'og:title', }, { - content: 'Search and view packages across all CachyOS repositories.', + content: SITE_DESCRIPTION, property: 'og:description', }, { @@ -39,13 +44,21 @@ export const Route = createRootRouteWithContext<{ property: 'og:type', }, { - content: 'https://dashboard.cachyos.org/', - property: 'og:url', + content: SITE_NAME, + property: 'og:site_name', }, { - content: 'https://dashboard.cachyos.org/icon.svg', + content: `${SITE_ORIGIN}/icon.svg`, property: 'og:image', }, + { + content: 'summary_large_image', + name: 'twitter:card', + }, + { + content: `${SITE_ORIGIN}/icon.svg`, + name: 'twitter:image', + }, {title: 'CachyOS'}, ], }), @@ -60,7 +73,9 @@ function RootDocument({children}: {children: React.ReactNode}) { - {children} + 'root'}> + {children} + ({meta: [{title: 'CachyOS | Package Search'}]}), + head: ({match}) => + pageHead({ + description: SITE_DESCRIPTION, + path: '/', + robots: + Object.keys(match.search).length > 0 ? 'noindex, follow' : undefined, + title: 'Package Search', + }), }); function HomePage() { return ( -
+ -
+ ); } diff --git a/src/routes/mirrors.tsx b/src/routes/mirrors.tsx index 1321ffa..f7d71c6 100644 --- a/src/routes/mirrors.tsx +++ b/src/routes/mirrors.tsx @@ -1,20 +1,27 @@ import {createFileRoute} from '@tanstack/react-router'; import MirrorslistTable from '@/components/MirrorslistTable'; +import {PageMain} from '@/components/PageMain'; import {SiteCardHeader} from '@/components/SiteCardHeader'; import {Card, CardContent} from '@/components/ui/card'; import {getMirrorsData} from '@/lib/server/actions'; +import {pageHead} from '@/lib/site'; export const Route = createFileRoute('/mirrors')({ component: MirrorsPage, loader: () => getMirrorsData(), - head: () => ({meta: [{title: 'CachyOS | Mirrors List'}]}), + head: () => + pageHead({ + description: 'List of CachyOS package repository mirrors.', + path: '/mirrors', + title: 'Mirrors List', + }), }); function MirrorsPage() { const {baselines, mirrors} = Route.useLoaderData(); return ( -
+ -
+ ); } diff --git a/src/routes/package.$repo.$arch.$pkgname.tsx b/src/routes/package.$repo.$arch.$pkgname.tsx index 6017c90..fdfdfcc 100644 --- a/src/routes/package.$repo.$arch.$pkgname.tsx +++ b/src/routes/package.$repo.$arch.$pkgname.tsx @@ -8,6 +8,7 @@ import { getSourceUrl, getSplitPackages, } from '@/lib/server/actions'; +import {pageHead} from '@/lib/site'; import { type BriefPackage, type PackageArch, @@ -112,13 +113,30 @@ export const Route = createFileRoute('/package/$repo/$arch/$pkgname')({ const pkgname = decodeURIComponent(params.pkgname); const repo = decodeURIComponent(params.repo); const title = `${pkgname} - ${repo} (${arch})`; + // the canonical URL needs the encoded form that was requested + const path = `/package/${params.repo}/${params.arch}/${params.pkgname}`; const pkg = (loaderData as LoaderData | undefined)?.package; - if (!pkg) return {meta: [{title: `CachyOS | ${title}`}]}; + + // fallback to list of splits + if (!pkg) { + return pageHead({ + description: `Split packages for ${pkgname} in ${repo} (${arch}).`, + path, + robots: 'noindex, follow', + title, + }); + } + const description = pkg.pkg_desc || `Details for ${pkgname}`; + const head = pageHead({ + description, + path, + title, + }); return { + ...head, meta: [ - {title: `CachyOS | ${title}`}, - {content: description, name: 'description'}, + ...head.meta, { content: [ pkg.pkg_name, @@ -132,9 +150,6 @@ export const Route = createFileRoute('/package/$repo/$arch/$pkgname')({ .join(', '), name: 'keywords', }, - {content: title, property: 'og:title'}, - {content: description, property: 'og:description'}, - {content: 'website', property: 'og:type'}, ], }; }, diff --git a/src/routes/robots[.]txt.ts b/src/routes/robots[.]txt.ts new file mode 100644 index 0000000..66e0adc --- /dev/null +++ b/src/routes/robots[.]txt.ts @@ -0,0 +1,23 @@ +import {createFileRoute} from '@tanstack/react-router'; + +import {SITE_ORIGIN} from '@/lib/site'; + +const ROBOTS_BODY = `User-agent: * +Allow: / + +Sitemap: ${SITE_ORIGIN}/sitemap.xml +`; + +export const Route = createFileRoute('/robots.txt')({ + server: { + handlers: { + GET: () => + new Response(ROBOTS_BODY, { + headers: { + 'cache-control': 'public, max-age=3600', + 'content-type': 'text/plain; charset=utf-8', + }, + }), + }, + }, +}); diff --git a/src/styles/globals.css b/src/styles/globals.css index 290718f..98752b4 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -176,4 +176,24 @@ body { @apply bg-background text-foreground; } + + @media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto !important; + } + + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } + + /* A spinner is the only in-progress signal, so slow it rather than stop it. */ + .animate-spin { + animation-duration: 1.5s !important; + animation-iteration-count: infinite !important; + } + } } diff --git a/vite.config.ts b/vite.config.ts index f58862c..147f599 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,8 +4,24 @@ import {tanstackStart} from '@tanstack/react-start/plugin/vite'; import viteReact from '@vitejs/plugin-react'; import {defineConfig} from 'vite'; +import {SITE_ORIGIN} from './src/lib/site'; + +// Crawlable entry points for sitemap.xml. Package pages are unbounded +const SITEMAP_PAGES = [ + {path: '/', sitemap: {changefreq: 'daily', priority: 1}}, + {path: '/mirrors', sitemap: {changefreq: 'hourly', priority: 0.8}}, +] as const; + const config = defineConfig({ - plugins: [devtools(), tailwindcss(), tanstackStart(), viteReact()], + plugins: [ + devtools(), + tailwindcss(), + tanstackStart({ + pages: [...SITEMAP_PAGES], + sitemap: {enabled: true, host: SITE_ORIGIN}, + }), + viteReact(), + ], resolve: {tsconfigPaths: true}, });