create-starbase 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +94 -0
  2. package/dist/index.d.ts +2 -0
  3. package/package.json +54 -0
  4. package/template/.claude/commands/audit.md +14 -0
  5. package/template/.claude/commands/review.md +23 -0
  6. package/template/.editorconfig +14 -0
  7. package/template/.env +1 -0
  8. package/template/.env.example +1 -0
  9. package/template/.nvmrc +1 -0
  10. package/template/.prettierignore +12 -0
  11. package/template/.prettierrc.json +7 -0
  12. package/template/.vscode/extensions.json +8 -0
  13. package/template/.vscode/settings.json +48 -0
  14. package/template/CLAUDE.md +109 -0
  15. package/template/README.md +46 -0
  16. package/template/eslint.config.js +27 -0
  17. package/template/index.html +47 -0
  18. package/template/package.json +51 -0
  19. package/template/src/lib/queries/github.ts +13 -0
  20. package/template/src/lib/queries/index.ts +1 -0
  21. package/template/src/lib/theme/app.css +3 -0
  22. package/template/src/lib/theme/base.css +37 -0
  23. package/template/src/lib/theme/tailwind.css +167 -0
  24. package/template/src/lib/utils/cn.ts +16 -0
  25. package/template/src/lib/utils/darkMode.ts +44 -0
  26. package/template/src/lib/utils/index.ts +2 -0
  27. package/template/src/main.tsx +38 -0
  28. package/template/src/routeTree.gen.ts +77 -0
  29. package/template/src/routes/__root.tsx +49 -0
  30. package/template/src/routes/index.tsx +29 -0
  31. package/template/src/routes/liftoff.tsx +22 -0
  32. package/template/src/ui/atoms/Button.tsx +106 -0
  33. package/template/src/ui/atoms/Code.tsx +52 -0
  34. package/template/src/ui/atoms/Link.tsx +78 -0
  35. package/template/src/ui/atoms/StarbaseLogo.tsx +19 -0
  36. package/template/src/ui/molecules/DarkModeToggle.tsx +29 -0
  37. package/template/src/ui/molecules/PageHeader.tsx +35 -0
  38. package/template/src/ui/molecules/Stargazers.tsx +24 -0
  39. package/template/src/ui/organisms/.gitkeep +0 -0
  40. package/template/src/ui/templates/.gitkeep +0 -0
  41. package/template/tsconfig.app.json +39 -0
  42. package/template/tsconfig.json +7 -0
  43. package/template/tsconfig.node.json +26 -0
  44. package/template/vite.config.ts +27 -0
@@ -0,0 +1,167 @@
1
+ /*
2
+ Theme Prefix: "sb-" (starbase)
3
+ To customize for your project, find/replace "sb-" with your own prefix.
4
+ */
5
+
6
+ @import 'tailwindcss';
7
+
8
+ @custom-variant dark (&:where(.dark, .dark *));
9
+ @custom-variant is-active (&:not(:disabled):hover, &:not(:disabled):focus-visible);
10
+
11
+ @theme {
12
+ /* Fonts */
13
+ --font-sans: 'Nunito', sans-serif;
14
+ --font-display: 'Bricolage Grotesque', serif;
15
+ --font-mono: 'Roboto Mono', monospace;
16
+
17
+ /* Text Variant: h1 - Bold display headings */
18
+ /* 22px → 30px */
19
+ --text-h1: clamp(1.375rem, 1.125rem + 0.75vw, 1.875rem);
20
+ --text-h1--font-weight: 600;
21
+ --text-h1--line-height: 1.15;
22
+ --text-h1--letter-spacing: -0.01em;
23
+
24
+ /* Text Variant: h2 */
25
+ /* 20px → 24px */
26
+ --text-h2: clamp(1.25rem, 1.1rem + 0.45vw, 1.5rem);
27
+ --text-h2--font-weight: 600;
28
+ --text-h2--line-height: 1.2;
29
+ --text-h2--letter-spacing: -0.005em;
30
+
31
+ /* Text Variant: h3 */
32
+ /* 18px → 22px */
33
+ --text-h3: clamp(1.125rem, 1rem + 0.4vw, 1.375rem);
34
+ --text-h3--font-weight: 600;
35
+ --text-h3--line-height: 1.25;
36
+ --text-h3--letter-spacing: -0.005em;
37
+
38
+ /* Text Variant: h4 */
39
+ /* 17px → 20px */
40
+ --text-h4: clamp(1.0625rem, 0.975rem + 0.275vw, 1.25rem);
41
+ --text-h4--font-weight: 600;
42
+ --text-h4--line-height: 1.3;
43
+ --text-h4--letter-spacing: 0;
44
+
45
+ /* Text Variant: h5 */
46
+ /* 16px → 18px */
47
+ --text-h5: clamp(1rem, 0.95rem + 0.15vw, 1.125rem);
48
+ --text-h5--font-weight: 500;
49
+ --text-h5--line-height: 1.35;
50
+ --text-h5--letter-spacing: 0;
51
+
52
+ /* Text Variant: base - 16px to 17px */
53
+ --text-base: clamp(1rem, 0.974rem + 0.098vw, 1.063rem);
54
+ --text-base--font-weight: 400;
55
+ --text-base--line-height: 1.55;
56
+ --text-base--letter-spacing: 0.005em;
57
+
58
+ /* Text Variant: sm - 15px to 16px */
59
+ --text-sm: clamp(0.9375rem, 0.912rem + 0.098vw, 1rem);
60
+ --text-sm--font-weight: 400;
61
+ --text-sm--line-height: 1.5;
62
+ --text-sm--letter-spacing: 0.005em;
63
+
64
+ /* Text Variant: xs - 14px to 15px */
65
+ --text-xs: clamp(0.875rem, 0.849rem + 0.098vw, 0.9375rem);
66
+ --text-xs--font-weight: 400;
67
+ --text-xs--line-height: 1.45;
68
+ --text-xs--letter-spacing: 0.01em;
69
+
70
+ /* Spacing (padding, margins, height, width, etc) */
71
+ --spacing-wrapper-page-full-x: clamp(0.75rem, 0.25rem + 1.5vw, 1rem);
72
+
73
+ /* List Style Types */
74
+ --list-style-type-circle: circle;
75
+ --list-style-type-roman: lower-roman;
76
+ }
77
+
78
+ /*
79
+ Theme Color Semantics
80
+ Mapping primitives to functional roles.
81
+ */
82
+ @layer base {
83
+ :root {
84
+ /* Light Mode Mappings */
85
+
86
+ /* Surfaces */
87
+ --sb-surface: var(--color-stone-100);
88
+ --sb-surface-raised: var(--color-stone-50);
89
+ --sb-canvas: var(--color-stone-200);
90
+
91
+ /* Text */
92
+ --sb-fg: var(--color-stone-700);
93
+ --sb-fg-subtle: var(--color-stone-600);
94
+ --sb-fg-title: var(--color-stone-800);
95
+
96
+ /* Borders */
97
+ --sb-divider: var(--color-stone-300);
98
+
99
+ /* Action */
100
+ --sb-action: var(--color-red-700);
101
+ --sb-action-active: var(--color-red-800);
102
+
103
+ /* Navigation */
104
+ --sb-anchor: var(--color-red-700);
105
+ --sb-anchor-active: var(--color-red-800);
106
+ }
107
+
108
+ ::selection {
109
+ background-color: var(--color-stone-300);
110
+ color: var(--color-stone-900);
111
+ }
112
+
113
+ .dark {
114
+ /* Dark Mode Mappings */
115
+
116
+ /* Surfaces */
117
+ --sb-surface: var(--color-zinc-900);
118
+ --sb-surface-raised: var(--color-zinc-800);
119
+ --sb-canvas: var(--color-zinc-950);
120
+
121
+ /* Text */
122
+ --sb-fg: var(--color-zinc-300);
123
+ --sb-fg-subtle: var(--color-zinc-400);
124
+ --sb-fg-title: var(--color-zinc-200);
125
+
126
+ /* Borders */
127
+ --sb-divider: var(--color-zinc-800);
128
+
129
+ /* Action */
130
+ --sb-action: var(--color-red-500);
131
+ --sb-action-active: var(--color-red-400);
132
+
133
+ /* Navigation */
134
+ --sb-anchor: var(--color-red-400);
135
+ --sb-anchor-active: var(--color-red-300);
136
+ }
137
+
138
+ .dark ::selection {
139
+ background-color: var(--color-zinc-700);
140
+ color: var(--color-zinc-100);
141
+ }
142
+ }
143
+
144
+ /*
145
+ Color Tokens
146
+ Exposing semantic variables as Tailwind utilities.
147
+ */
148
+ @theme {
149
+ /* Backgrounds */
150
+ --color-sb-surface: var(--sb-surface);
151
+ --color-sb-surface-raised: var(--sb-surface-raised);
152
+ --color-sb-canvas: var(--sb-canvas);
153
+
154
+ /* Text / Foreground */
155
+ --color-sb-fg: var(--sb-fg);
156
+ --color-sb-fg-subtle: var(--sb-fg-subtle);
157
+ --color-sb-fg-title: var(--sb-fg-title);
158
+
159
+ /* Borders */
160
+ --color-sb-divider: var(--sb-divider);
161
+
162
+ /* UI Elements */
163
+ --color-sb-action: var(--sb-action);
164
+ --color-sb-action-active: var(--sb-action-active);
165
+ --color-sb-anchor: var(--sb-anchor);
166
+ --color-sb-anchor-active: var(--sb-anchor-active);
167
+ }
@@ -0,0 +1,16 @@
1
+ import { type ClassValue, clsx } from 'clsx';
2
+ import { extendTailwindMerge } from 'tailwind-merge';
3
+
4
+ const twMerge = extendTailwindMerge({
5
+ extend: {
6
+ theme: {
7
+ spacing: ['wrapper-page-full-x'],
8
+ text: ['h1', 'h2', 'h3', 'h4', 'h5', 'base', 'sm', 'xs'],
9
+ },
10
+ classGroups: {
11
+ 'list-style-type': [{ list: ['circle', 'roman'] }],
12
+ },
13
+ },
14
+ });
15
+
16
+ export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs));
@@ -0,0 +1,44 @@
1
+ import Cookies from 'js-cookie';
2
+
3
+ const DARK_MODE_COOKIE = 'theme-preference';
4
+ const DARK_CLASS = 'dark';
5
+
6
+ export type ThemePreference = 'light' | 'dark' | 'system';
7
+
8
+ export function getThemePreference(): ThemePreference {
9
+ const value = Cookies.get(DARK_MODE_COOKIE);
10
+
11
+ if (value === 'light' || value === 'dark') {
12
+ return value;
13
+ }
14
+
15
+ return 'system';
16
+ }
17
+
18
+ export function setThemePreference(preference: ThemePreference): void {
19
+ Cookies.set(DARK_MODE_COOKIE, preference, {
20
+ expires: 365,
21
+ sameSite: 'lax',
22
+ });
23
+ }
24
+
25
+ export function getEffectiveTheme(
26
+ preference: ThemePreference,
27
+ ): 'light' | 'dark' {
28
+ if (preference === 'system') {
29
+ return window.matchMedia('(prefers-color-scheme: dark)').matches
30
+ ? 'dark'
31
+ : 'light';
32
+ }
33
+ return preference;
34
+ }
35
+
36
+ export function applyTheme(theme: 'light' | 'dark'): void {
37
+ if (theme === 'dark') {
38
+ document.documentElement.classList.add(DARK_CLASS);
39
+ document.documentElement.style.backgroundColor = 'var(--sb-canvas)';
40
+ } else {
41
+ document.documentElement.classList.remove(DARK_CLASS);
42
+ document.documentElement.style.backgroundColor = 'var(--sb-surface)';
43
+ }
44
+ }
@@ -0,0 +1,2 @@
1
+ export * from './cn';
2
+ export * as darkMode from './darkMode';
@@ -0,0 +1,38 @@
1
+ import { StrictMode } from 'react';
2
+ import { createRoot } from 'react-dom/client';
3
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
4
+ import { RouterProvider, createRouter } from '@tanstack/react-router';
5
+
6
+ import '@fontsource/bricolage-grotesque/400.css';
7
+ import '@fontsource/bricolage-grotesque/500.css';
8
+ import '@fontsource/bricolage-grotesque/600.css';
9
+ import '@fontsource/nunito/400.css';
10
+ import '@fontsource/nunito/500.css';
11
+ import '@fontsource/nunito/600.css';
12
+ import '@fontsource/roboto-mono/400.css';
13
+
14
+ import './lib/theme/app.css';
15
+
16
+ import { routeTree } from './routeTree.gen';
17
+
18
+ const queryClient = new QueryClient();
19
+
20
+ const router = createRouter({
21
+ routeTree,
22
+ defaultPreload: 'intent',
23
+ context: { queryClient },
24
+ });
25
+
26
+ declare module '@tanstack/react-router' {
27
+ interface Register {
28
+ router: typeof router;
29
+ }
30
+ }
31
+
32
+ createRoot(document.getElementById('root')!).render(
33
+ <StrictMode>
34
+ <QueryClientProvider client={queryClient}>
35
+ <RouterProvider router={router} />
36
+ </QueryClientProvider>
37
+ </StrictMode>,
38
+ );
@@ -0,0 +1,77 @@
1
+ /* eslint-disable */
2
+
3
+ // @ts-nocheck
4
+
5
+ // noinspection JSUnusedGlobalSymbols
6
+
7
+ // This file was automatically generated by TanStack Router.
8
+ // You should NOT make any changes in this file as it will be overwritten.
9
+ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
10
+
11
+ import { Route as rootRouteImport } from './routes/__root'
12
+ import { Route as LiftoffRouteImport } from './routes/liftoff'
13
+ import { Route as IndexRouteImport } from './routes/index'
14
+
15
+ const LiftoffRoute = LiftoffRouteImport.update({
16
+ id: '/liftoff',
17
+ path: '/liftoff',
18
+ getParentRoute: () => rootRouteImport,
19
+ } as any)
20
+ const IndexRoute = IndexRouteImport.update({
21
+ id: '/',
22
+ path: '/',
23
+ getParentRoute: () => rootRouteImport,
24
+ } as any)
25
+
26
+ export interface FileRoutesByFullPath {
27
+ '/': typeof IndexRoute
28
+ '/liftoff': typeof LiftoffRoute
29
+ }
30
+ export interface FileRoutesByTo {
31
+ '/': typeof IndexRoute
32
+ '/liftoff': typeof LiftoffRoute
33
+ }
34
+ export interface FileRoutesById {
35
+ __root__: typeof rootRouteImport
36
+ '/': typeof IndexRoute
37
+ '/liftoff': typeof LiftoffRoute
38
+ }
39
+ export interface FileRouteTypes {
40
+ fileRoutesByFullPath: FileRoutesByFullPath
41
+ fullPaths: '/' | '/liftoff'
42
+ fileRoutesByTo: FileRoutesByTo
43
+ to: '/' | '/liftoff'
44
+ id: '__root__' | '/' | '/liftoff'
45
+ fileRoutesById: FileRoutesById
46
+ }
47
+ export interface RootRouteChildren {
48
+ IndexRoute: typeof IndexRoute
49
+ LiftoffRoute: typeof LiftoffRoute
50
+ }
51
+
52
+ declare module '@tanstack/react-router' {
53
+ interface FileRoutesByPath {
54
+ '/liftoff': {
55
+ id: '/liftoff'
56
+ path: '/liftoff'
57
+ fullPath: '/liftoff'
58
+ preLoaderRoute: typeof LiftoffRouteImport
59
+ parentRoute: typeof rootRouteImport
60
+ }
61
+ '/': {
62
+ id: '/'
63
+ path: '/'
64
+ fullPath: '/'
65
+ preLoaderRoute: typeof IndexRouteImport
66
+ parentRoute: typeof rootRouteImport
67
+ }
68
+ }
69
+ }
70
+
71
+ const rootRouteChildren: RootRouteChildren = {
72
+ IndexRoute: IndexRoute,
73
+ LiftoffRoute: LiftoffRoute,
74
+ }
75
+ export const routeTree = rootRouteImport
76
+ ._addFileChildren(rootRouteChildren)
77
+ ._addFileTypes<FileRouteTypes>()
@@ -0,0 +1,49 @@
1
+ import React from 'react';
2
+ import type { QueryClient } from '@tanstack/react-query';
3
+ import {
4
+ HeadContent,
5
+ createRootRouteWithContext,
6
+ Outlet,
7
+ } from '@tanstack/react-router';
8
+ import { DarkModeToggle } from 'molecules/DarkModeToggle';
9
+ import { Stargazers } from 'molecules/Stargazers';
10
+
11
+ export interface RouterContext {
12
+ queryClient: QueryClient;
13
+ }
14
+
15
+ const devtools =
16
+ !import.meta.env.PROD && import.meta.env.VITE_DEVTOOLS === 'true';
17
+
18
+ const TanStackRouterDevtools = devtools
19
+ ? React.lazy(() =>
20
+ import('@tanstack/react-router-devtools').then((res) => ({
21
+ default: res.TanStackRouterDevtools,
22
+ })),
23
+ )
24
+ : () => null;
25
+
26
+ const ReactQueryDevtools = devtools
27
+ ? React.lazy(() =>
28
+ import('@tanstack/react-query-devtools').then((res) => ({
29
+ default: res.ReactQueryDevtools,
30
+ })),
31
+ )
32
+ : () => null;
33
+
34
+ export const Route = createRootRouteWithContext<RouterContext>()({
35
+ component: () => (
36
+ <>
37
+ <HeadContent />
38
+ <main className="flex min-h-screen flex-col items-center justify-center gap-6 p-8">
39
+ <Outlet />
40
+ <footer className="flex items-center gap-3">
41
+ <Stargazers />
42
+ <DarkModeToggle />
43
+ </footer>
44
+ </main>
45
+ <TanStackRouterDevtools />
46
+ <ReactQueryDevtools />
47
+ </>
48
+ ),
49
+ });
@@ -0,0 +1,29 @@
1
+ import { createFileRoute } from '@tanstack/react-router';
2
+ import { Code } from 'atoms/Code';
3
+ import { RouterLink } from 'atoms/Link';
4
+ import { PageHeader } from 'molecules/PageHeader';
5
+
6
+ export const Route = createFileRoute('/')({
7
+ component: Index,
8
+ head: () => ({
9
+ meta: [{ title: 'Starbase' }],
10
+ }),
11
+ });
12
+
13
+ function Index() {
14
+ return (
15
+ <div className="flex flex-col items-center gap-6">
16
+ <PageHeader title="Starbase" />
17
+ <p className="text-sb-fg-subtle max-w-md text-center text-balance">
18
+ A launchpad for modern React apps, built on Vite, TypeScript, Tailwind
19
+ CSS, TanStack Router, and TanStack Query. Start your mission today:
20
+ </p>
21
+
22
+ <Code>npm create starbase@latest</Code>
23
+
24
+ <RouterLink to="/liftoff" className="text-sm">
25
+ Ready for liftoff?
26
+ </RouterLink>
27
+ </div>
28
+ );
29
+ }
@@ -0,0 +1,22 @@
1
+ import { createFileRoute } from '@tanstack/react-router';
2
+ import { RouterLink } from 'atoms/Link';
3
+ import { PageHeader } from 'molecules/PageHeader';
4
+
5
+ export const Route = createFileRoute('/liftoff')({
6
+ component: Liftoff,
7
+ head: () => ({
8
+ meta: [{ title: 'Liftoff — Starbase' }],
9
+ }),
10
+ });
11
+
12
+ function Liftoff() {
13
+ return (
14
+ <div className="flex flex-col items-center gap-6">
15
+ <PageHeader title="Liftoff" />
16
+ <p className="text-sb-fg-subtle">More details coming soon.</p>
17
+ <RouterLink to="/" className="text-sm">
18
+ Back to home
19
+ </RouterLink>
20
+ </div>
21
+ );
22
+ }
@@ -0,0 +1,106 @@
1
+ import * as React from 'react';
2
+ import { createLink, type LinkComponent } from '@tanstack/react-router';
3
+ import { cn } from 'utils';
4
+
5
+ export type ButtonVariant = 'anchor' | 'outline' | 'ghost';
6
+
7
+ type ButtonSize = 'sm' | 'md' | 'lg';
8
+
9
+ export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
10
+ variant?: ButtonVariant;
11
+ iconOnly?: boolean;
12
+ size?: ButtonSize;
13
+ ref?: React.Ref<HTMLButtonElement>;
14
+ }
15
+
16
+ interface ButtonLinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
17
+ variant?: ButtonVariant;
18
+ iconOnly?: boolean;
19
+ size?: ButtonSize;
20
+ ref?: React.Ref<HTMLAnchorElement>;
21
+ }
22
+
23
+ const makeButtonClasses = (
24
+ variant?: ButtonVariant,
25
+ iconOnly?: boolean,
26
+ size: ButtonSize = 'md',
27
+ className?: string,
28
+ ) =>
29
+ cn(
30
+ // Base styles
31
+ 'inline-flex items-center justify-center',
32
+ 'font-sans font-semibold rounded-md border border-transparent outline-none focus-visible:outline-sb-action cursor-pointer',
33
+ 'transition-all duration-150 ease-out',
34
+ 'disabled:opacity-50 disabled:cursor-not-allowed disabled:shadow-none',
35
+ {
36
+ /* iconOnly sizes (square buttons) */
37
+ 'shrink-0 p-0 size-8': iconOnly && size === 'sm',
38
+ 'shrink-0 p-0 size-9': iconOnly && size === 'md',
39
+ 'shrink-0 p-0 size-11 rounded-lg': iconOnly && size === 'lg',
40
+
41
+ /* text button sizes */
42
+ 'text-sm px-3 py-1.5': !iconOnly && size === 'sm',
43
+ 'text-base px-4 py-2': !iconOnly && size === 'md',
44
+ 'text-lg px-5 py-2.5': !iconOnly && size === 'lg',
45
+
46
+ /* Variant: anchor */
47
+ 'bg-sb-anchor border-sb-anchor text-sb-surface-raised shadow-sm is-active:bg-sb-anchor-active is-active:border-sb-anchor-active is-active:shadow-md':
48
+ variant === 'anchor',
49
+
50
+ /* Variant: outline */
51
+ 'bg-transparent border-sb-divider text-sb-fg is-active:bg-sb-canvas is-active:border-sb-fg-subtle/30':
52
+ variant === 'outline',
53
+
54
+ /* Variant: ghost */
55
+ 'bg-transparent text-sb-fg is-active:bg-sb-fg/5': variant === 'ghost',
56
+ },
57
+ className,
58
+ );
59
+
60
+ export const ButtonLink = ({
61
+ children,
62
+ className,
63
+ variant = 'anchor',
64
+ iconOnly,
65
+ size,
66
+ ref,
67
+ ...rest
68
+ }: ButtonLinkProps) => {
69
+ return (
70
+ <a
71
+ {...rest}
72
+ ref={ref}
73
+ className={makeButtonClasses(variant, iconOnly, size, className)}
74
+ >
75
+ {children}
76
+ </a>
77
+ );
78
+ };
79
+
80
+ export const Button = ({
81
+ children,
82
+ className,
83
+ variant = 'anchor',
84
+ iconOnly,
85
+ size,
86
+ type = 'button',
87
+ ref,
88
+ ...rest
89
+ }: ButtonProps) => {
90
+ return (
91
+ <button
92
+ {...rest}
93
+ ref={ref}
94
+ type={type}
95
+ className={makeButtonClasses(variant, iconOnly, size, className)}
96
+ >
97
+ {children}
98
+ </button>
99
+ );
100
+ };
101
+
102
+ const CreatedLinkComponent = createLink(ButtonLink);
103
+
104
+ export const RouterButtonLink: LinkComponent<typeof ButtonLink> = (props) => {
105
+ return <CreatedLinkComponent preload={'intent'} {...props} />;
106
+ };
@@ -0,0 +1,52 @@
1
+ import { type HTMLAttributes, type Ref, useEffect, useState } from 'react';
2
+ import { useCopyToClipboard } from 'usehooks-ts';
3
+ import { LuCopy, LuCheck } from 'react-icons/lu';
4
+ import { Button } from 'atoms/Button';
5
+ import { cn } from 'utils';
6
+
7
+ export interface CodeProps extends HTMLAttributes<HTMLElement> {
8
+ children: string;
9
+ ref?: Ref<HTMLElement>;
10
+ }
11
+
12
+ export const Code = ({ children, className, ref, ...rest }: CodeProps) => {
13
+ const [, copy] = useCopyToClipboard();
14
+ const [copied, setCopied] = useState(false);
15
+
16
+ useEffect(() => {
17
+ if (!copied) return;
18
+ const id = setTimeout(() => setCopied(false), 2000);
19
+ return () => clearTimeout(id);
20
+ }, [copied]);
21
+
22
+ const handleCopy = () => {
23
+ copy(children).then(() => setCopied(true));
24
+ };
25
+
26
+ return (
27
+ <code
28
+ {...rest}
29
+ ref={ref}
30
+ className={cn(
31
+ 'inline-flex items-center gap-2 font-mono',
32
+ 'bg-sb-canvas text-sb-fg px-4 py-2 rounded-lg',
33
+ className,
34
+ )}
35
+ >
36
+ {children}
37
+ <Button
38
+ variant="ghost"
39
+ iconOnly
40
+ size="sm"
41
+ onClick={handleCopy}
42
+ aria-label={copied ? 'Copied' : 'Copy to clipboard'}
43
+ >
44
+ {copied ? (
45
+ <LuCheck className="size-4" />
46
+ ) : (
47
+ <LuCopy className="size-4" />
48
+ )}
49
+ </Button>
50
+ </code>
51
+ );
52
+ };
@@ -0,0 +1,78 @@
1
+ import {
2
+ type AnchorHTMLAttributes,
3
+ type ButtonHTMLAttributes,
4
+ type Ref,
5
+ } from 'react';
6
+ import { createLink, type LinkComponent } from '@tanstack/react-router';
7
+ import { cn } from 'utils';
8
+
9
+ type LinkVariant = 'anchor' | 'fg' | 'fg-subtle';
10
+
11
+ type LinkProps = AnchorHTMLAttributes<HTMLAnchorElement> & {
12
+ variant?: LinkVariant;
13
+ ref?: Ref<HTMLAnchorElement>;
14
+ };
15
+
16
+ type LinkButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
17
+ variant?: LinkVariant;
18
+ ref?: Ref<HTMLButtonElement>;
19
+ };
20
+
21
+ const makeLinkClasses = (variant?: LinkVariant, className?: string) => {
22
+ return cn(
23
+ 'outline-none focus-visible:outline-sb-action transition-colors duration-100',
24
+ 'underline decoration-current/30 underline-offset-4',
25
+ 'hover:decoration-current focus-visible:decoration-current',
26
+ {
27
+ 'text-sb-anchor hover:text-sb-anchor-active': variant === 'anchor',
28
+ 'text-sb-fg hover:text-sb-anchor': variant === 'fg',
29
+ 'text-sb-fg-subtle hover:text-sb-fg': variant === 'fg-subtle',
30
+ },
31
+ className,
32
+ );
33
+ };
34
+
35
+ export const Link = ({
36
+ children,
37
+ className,
38
+ variant = 'anchor',
39
+ ref,
40
+ ...rest
41
+ }: LinkProps) => {
42
+ return (
43
+ <a {...rest} ref={ref} className={makeLinkClasses(variant, className)}>
44
+ {children}
45
+ </a>
46
+ );
47
+ };
48
+
49
+ const CreatedLinkComponent = createLink(Link);
50
+
51
+ export const RouterLink: LinkComponent<typeof Link> = (props) => {
52
+ return <CreatedLinkComponent preload={'intent'} {...props} />;
53
+ };
54
+
55
+ export const LinkButton = ({
56
+ children,
57
+ className,
58
+ type = 'button',
59
+ variant = 'anchor',
60
+ disabled,
61
+ ref,
62
+ ...rest
63
+ }: LinkButtonProps) => {
64
+ return (
65
+ <button
66
+ {...rest}
67
+ ref={ref}
68
+ type={type}
69
+ disabled={disabled}
70
+ className={cn(
71
+ makeLinkClasses(variant, className),
72
+ disabled ? 'cursor-not-allowed' : 'cursor-pointer',
73
+ )}
74
+ >
75
+ {children}
76
+ </button>
77
+ );
78
+ };