feat: web auth routes done for mvp

pull/715/head
Travis Fischer 2025-06-16 10:13:33 +07:00
rodzic eaa0e3aa33
commit 222b1d43f6
13 zmienionych plików z 421 dodań i 272 usunięć

Wyświetl plik

@ -0,0 +1,4 @@
## TODO
- better auth error handling
- display validation errors in auth forms

Wyświetl plik

@ -35,14 +35,24 @@ function SuccessPage({
throw new Error('Missing code or challenge')
}
if (!ctx) {
return
}
// TODO: make generic using `provider`
const authSession = await ctx!.api.exchangeOAuthCodeWithGitHub({
code
})
try {
const authSession = await ctx.api.exchangeOAuthCodeWithGitHub({
code
})
console.log('AUTH SUCCESS', { authSession })
console.log('AUTH SUCCESS', { authSession })
} catch (err) {
console.error('AUTH ERROR', err)
redirect('/app', RedirectType.replace)
return redirect('/login', RedirectType.replace)
}
return redirect('/app', RedirectType.replace)
})()
}, [code, ctx])

Wyświetl plik

@ -177,43 +177,6 @@ a:hover .link {
transition-duration: 300ms;
}
h1 {
font-size: 2.5rem;
font-weight: 600;
@apply mb-8;
}
h2 {
font-size: 2rem;
font-weight: 500;
@apply mb-7;
}
h3 {
font-size: 1.5rem;
font-weight: 450;
margin-bottom: 1em;
@apply mb-6;
}
h4 {
font-size: 1.3rem;
font-weight: 400;
@apply mb-5;
}
h5 {
font-size: 1.2rem;
font-weight: 350;
@apply mb-4;
}
h6 {
font-size: 1rem;
font-weight: 350;
@apply mb-2;
}
main section {
width: 100%;
display: flex;

Wyświetl plik

@ -3,6 +3,7 @@
import type { PasswordLoginError } from '@agentic/openauth/provider/password'
import { isValidEmail, isValidPassword } from '@agentic/platform-validators'
import { useForm } from '@tanstack/react-form'
import { Loader2Icon } from 'lucide-react'
import { redirect, RedirectType } from 'next/navigation'
import { useCallback, useState } from 'react'
import { z } from 'zod'
@ -35,7 +36,7 @@ export default function LoginPage() {
password: ''
},
validators: {
onBlurAsync: z.object({
onChange: z.object({
email: z
.string()
.email()
@ -50,13 +51,14 @@ export default function LoginPage() {
password: value.password
})
// TODO
console.log('login success', res)
redirect('/app', RedirectType.push)
} catch (err) {
// TODO
console.error('login error', err)
throw err
}
return redirect('/app', RedirectType.push)
}
})
@ -68,129 +70,127 @@ export default function LoginPage() {
redirect(url, RedirectType.push)
}, [ctx])
// TODO:
if (!ctx) return null
return (
<>
<section>
<h1 className='my-0! text-center text-balance leading-snug md:leading-none'>
Log In
</h1>
<div className='flex-col flex-1 items-center justify-center w-full max-w-xs'>
<form
className={cn('flex flex-col gap-6 w-full')}
onSubmit={(e) => {
e.preventDefault()
void form.handleSubmit()
}}
>
<div className='flex flex-col items-center gap-2 text-center'>
<h1 className='text-2xl font-bold'>Login to your account</h1>
<p className='text-muted-foreground text-sm text-balance'>
Enter your email below to login to your account
</p>
</div>
<div className='flex flex-1 items-center justify-center'>
<div className='w-full max-w-xs'>
<form
className={cn('flex flex-col gap-6')}
onSubmit={(e) => {
e.preventDefault()
void form.handleSubmit()
}}
>
<div className='flex flex-col items-center gap-2 text-center'>
<h1 className='text-2xl font-bold'>Login to your account</h1>
<p className='text-muted-foreground text-sm text-balance'>
Enter your email below to login to your account
</p>
</div>
<div className='grid gap-6'>
<form.Field
name='email'
children={(field) => (
<div className='grid gap-3'>
<Label htmlFor={field.name}>Email</Label>
<div className='grid gap-6'>
<form.Field
name='email'
children={(field) => (
<div className='grid gap-3'>
<Label htmlFor={field.name}>Email</Label>
<Input
id={field.name}
name={field.name}
type='email'
required
placeholder='Email'
autoFocus={!error}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e: any) =>
field.handleChange(e.target.value)
}
/>
</div>
)}
/>
<form.Field
name='password'
children={(field) => (
<div className='grid gap-3'>
<div className='flex items-center'>
<Label htmlFor={field.name}>Password</Label>
<a
href='/forgot-password'
className='ml-auto text-sm underline-offset-4 hover:underline'
>
Forgot your password?
</a>
<Input
id={field.name}
name={field.name}
type='password'
required
placeholder='Password'
autoFocus={error?.type === 'invalid_password'}
autoComplete='current-password'
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
</div>
</div>
)}
/>
<form.Subscribe
selector={(state) => [state.canSubmit, state.isSubmitting]}
children={([canSubmit, isSubmitting]) => (
<Button
type='submit'
disabled={!canSubmit || !ctx}
className='w-full'
>
{isSubmitting ? '...' : 'Login'}
</Button>
)}
/>
<div className='after:border-border relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t'>
<span className='bg-background text-muted-foreground relative z-10 px-2'>
Or continue with
</span>
</div>
<Button
variant='outline'
className='w-full'
onClick={onAuthWithGitHub}
>
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'>
<path
d='M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12'
fill='currentColor'
<Input
id={field.name}
name={field.name}
type='email'
required
placeholder='Email'
autoComplete='email'
autoFocus={!error}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e: any) => field.handleChange(e.target.value)}
/>
</svg>
Login with GitHub
</Button>
</div>
)}
/>
<form.Field
name='password'
children={(field) => (
<div className='grid gap-3'>
<div className='flex items-center'>
<Label htmlFor={field.name}>Password</Label>
<a
href='/forgot-password'
className='ml-auto text-xs underline-offset-4 hover:underline'
tabIndex={-1}
>
Forgot your password?
</a>
</div>
<Input
id={field.name}
name={field.name}
type='password'
required
placeholder='Password'
autoFocus={error?.type === 'invalid_password'}
autoComplete='current-password'
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
</div>
)}
/>
<form.Subscribe
selector={(state) => [
state.canSubmit,
state.isSubmitting,
state.isTouched
]}
children={([canSubmit, isSubmitting, isTouched]) => (
<Button
type='submit'
disabled={!(isTouched && canSubmit && ctx)}
className='w-full'
>
{isSubmitting && <Loader2Icon className='animate-spin' />}
<span>Login</span>
</Button>
)}
/>
<div className='after:border-border relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t'>
<span className='bg-background text-muted-foreground relative z-10 px-2'>
Or continue with
</span>
</div>
<div className='text-center text-sm'>
Don&apos;t have an account?{' '}
<a href='/signup' className='underline underline-offset-4'>
Sign up
</a>
</div>
</form>
</div>
<Button
variant='outline'
className='w-full'
onClick={onAuthWithGitHub}
>
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'>
<path
d='M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12'
fill='currentColor'
/>
</svg>
<span>Login with GitHub</span>
</Button>
</div>
<div className='text-center text-xs'>
Don&apos;t have an account?{' '}
<a href='/signup' className='underline underline-offset-4'>
Sign up
</a>
</div>
</form>
</div>
</section>
</>

Wyświetl plik

@ -11,7 +11,7 @@ export default function LogoutPage() {
useEffect(() => {
;(async () => {
if (ctx) {
await ctx.api.logout()
ctx.logout()
redirect('/', RedirectType.replace)
}
})()

Wyświetl plik

@ -1,120 +1,227 @@
'use client'
import type {
PasswordRegisterError,
PasswordRegisterState
} from '@agentic/openauth/provider/password'
import { useState } from 'react'
import type { PasswordLoginError } from '@agentic/openauth/provider/password'
import {
isValidEmail,
isValidPassword,
isValidUsername
} from '@agentic/platform-validators'
import { useForm } from '@tanstack/react-form'
import { Loader2Icon } from 'lucide-react'
import { redirect, RedirectType } from 'next/navigation'
import { useCallback, useState } from 'react'
import { toast } from 'sonner'
import { z } from 'zod'
import { authCopy } from '@/lib/auth-copy'
import { useUnauthenticatedAgentic } from '@/components/agentic-provider'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { GitHubIcon } from '@/icons/github'
import { cn } from '@/lib/utils'
export default function SignupPage() {
// TODO
const [error, setError] = useState<PasswordRegisterError | undefined>(
undefined
)
const [state, setState] = useState<PasswordRegisterState>({ type: 'start' })
const [form, setForm] = useState<FormData | undefined>(undefined)
export default function LoginPage() {
const [error] = useState<PasswordLoginError | undefined>(undefined)
const ctx = useUnauthenticatedAgentic()
const emailError = ['invalid_email', 'email_taken'].includes(
error?.type || ''
)
const form = useForm({
defaultValues: {
email: '',
username: '',
password: '',
repeat: ''
},
validators: {
onChange: z.object({
email: z
.string()
.email()
.refine((email) => isValidEmail(email)),
username: z.string().refine((username) => isValidUsername(username)),
password: z.string().refine((password) => isValidPassword(password)),
repeat: z.string().refine((password) => isValidPassword(password))
})
},
onSubmit: async ({ value }) => {
try {
if (value.password !== value.repeat) {
toast.error('Passwords do not match')
return
}
const passwordError = [
'invalid_password',
'password_mismatch',
'validation_error'
].includes(error?.type || '')
const res = await ctx!.api.signUpWithPassword({
email: value.email,
username: value.username,
password: value.password
})
console.log('signup success', res)
} catch (err: any) {
console.error('Signup error', err.message)
toast.error('Signup error', err.message)
return
}
return redirect('/app', RedirectType.push)
}
})
const onAuthWithGitHub = useCallback(async () => {
const url = await ctx!.api.initAuthFlowWithGitHub({
redirectUri: `${globalThis.location.origin}/auth/github/success`
})
redirect(url, RedirectType.push)
}, [ctx])
return (
<>
<section>
<h1 className='my-0! text-center text-balance leading-snug md:leading-none'>
{authCopy.register}
</h1>
<div className='flex-col flex-1 items-center justify-center w-full max-w-xs'>
<form
className={cn('flex flex-col gap-6 w-full')}
onSubmit={(e) => {
e.preventDefault()
void form.handleSubmit()
}}
>
<div className='flex flex-col items-center gap-2 text-center'>
<h1 className='text-2xl font-bold'>Create an account</h1>
<p className='text-muted-foreground text-sm text-balance'>
Enter your info below to create an account
</p>
</div>
<form data-component='form' method='post'>
{/* <FormAlert
message={
error?.type
? error.type === 'validation_error'
? (error.message ?? authCopy?.[`error_${error.type}`])
: authCopy?.[`error_${error.type}`]
: undefined
}
/> */}
{state.type === 'start' && (
<>
<input type='hidden' name='action' value='register' />
<input
data-component='input'
autoFocus={!error || emailError}
type='email'
<div className='grid gap-6'>
<form.Field
name='email'
value={!emailError ? form?.get('email')?.toString() : ''}
required
placeholder={authCopy.input_email}
children={(field) => (
<div className='grid gap-3'>
<Label htmlFor={field.name}>Email</Label>
<Input
id={field.name}
name={field.name}
type='email'
required
placeholder='Email'
autoComplete='email'
autoFocus={!error}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e: any) => field.handleChange(e.target.value)}
/>
</div>
)}
/>
<input
data-component='input'
autoFocus={passwordError}
type='password'
<form.Field
name='username'
children={(field) => (
<div className='grid gap-3'>
<Label htmlFor={field.name}>Username</Label>
<Input
id={field.name}
name={field.name}
type='text'
required
placeholder='Username'
autoComplete='username'
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e: any) => field.handleChange(e.target.value)}
/>
</div>
)}
/>
<form.Field
name='password'
placeholder={authCopy.input_password}
required
value={!passwordError ? form?.get('password')?.toString() : ''}
autoComplete='new-password'
children={(field) => (
<div className='grid gap-3'>
<Label htmlFor={field.name}>Password</Label>
<Input
id={field.name}
name={field.name}
type='password'
required
placeholder='Password'
autoFocus={error?.type === 'invalid_password'}
autoComplete='new-password'
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
</div>
)}
/>
<input
data-component='input'
type='password'
<form.Field
name='repeat'
required
autoFocus={passwordError}
placeholder={authCopy.input_repeat}
autoComplete='new-password'
children={(field) => (
<div className='grid gap-3'>
<Label htmlFor={field.name}>Repeat password</Label>
<Input
id={field.name}
name={field.name}
type='password'
required
placeholder='Password'
autoComplete='new-password'
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
</div>
)}
/>
<button data-component='button'>
{authCopy.button_continue}
</button>
<form.Subscribe
selector={(state) => [
state.canSubmit,
state.isSubmitting,
state.isTouched
]}
children={([canSubmit, isSubmitting, isTouched]) => (
<Button
type='submit'
disabled={!(isTouched && canSubmit && ctx)}
className='w-full'
>
{isSubmitting && <Loader2Icon className='animate-spin' />}
<span>Sign up</span>
</Button>
)}
/>
<div data-component='form-footer'>
<span>
{authCopy.login_prompt}{' '}
<a data-component='link' href='/login'>
{authCopy.login}
</a>
<div className='after:border-border relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t'>
<span className='bg-background text-muted-foreground relative z-10 px-2'>
Or continue with
</span>
</div>
</>
)}
{state.type === 'code' && (
<>
<input type='hidden' name='action' value='verify' />
<Button
variant='outline'
className='w-full'
onClick={onAuthWithGitHub}
>
<GitHubIcon />
<input
data-component='input'
autoFocus
name='code'
minLength={6}
maxLength={6}
required
placeholder={authCopy.input_code}
autoComplete='one-time-code'
/>
<span>Sign up with GitHub</span>
</Button>
</div>
<button data-component='button'>
{authCopy.button_continue}
</button>
</>
)}
</form>
<div className='text-center text-xs'>
Already have an account?{' '}
<a href='/login' className='underline underline-offset-4'>
Login
</a>
</div>
</form>
</div>
</section>
</>
)

Wyświetl plik

@ -8,6 +8,7 @@ import { redirect, RedirectType } from 'next/navigation'
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useRef,
@ -17,17 +18,22 @@ import { useLocalStorage } from 'react-use'
import * as config from '@/lib/config'
type AgenticContext = {
type AgenticContextType = {
api: AgenticApiClient
logout: () => void
}
const AgenticContext = createContext<AgenticContext | undefined>(undefined)
const AgenticContext = createContext<AgenticContextType | undefined>(undefined)
export function AgenticProvider({ children }: { children: ReactNode }) {
const [authSession, setAuthSession] = useLocalStorage<AuthSession>(
const [authSession, setAuthSession] = useLocalStorage<AuthSession | null>(
'agentic-auth-session'
)
const agenticContext = useRef<AgenticContext>({
const logout = useCallback(() => {
setAuthSession(null)
}, [setAuthSession])
const agenticContext = useRef<AgenticContextType>({
api: new AgenticApiClient({
apiBaseUrl: config.apiBaseUrl,
onUpdateAuth: (updatedAuthSession) => {
@ -43,12 +49,19 @@ export function AgenticProvider({ children }: { children: ReactNode }) {
console.log('auth session not updated')
}
}
})
}),
logout
})
useEffect(() => {
console.log('setting session from localStorage', authSession?.token)
agenticContext.current.api.authSession = authSession
console.log('updating session from localStorage', authSession?.token)
if (agenticContext.current) {
if (authSession) {
agenticContext.current.api.authSession = authSession
} else {
agenticContext.current.api.authSession = undefined
}
}
}, [agenticContext, authSession])
return (
@ -58,7 +71,7 @@ export function AgenticProvider({ children }: { children: ReactNode }) {
)
}
export function useAgentic(): AgenticContext {
export function useAgentic(): AgenticContextType {
const ctx = useContext(AgenticContext)
if (!ctx) {
@ -68,7 +81,7 @@ export function useAgentic(): AgenticContext {
return ctx
}
export function useUnauthenticatedAgentic(): AgenticContext | undefined {
export function useUnauthenticatedAgentic(): AgenticContextType | undefined {
const ctx = useAgentic()
const [isMounted, setIsMounted] = useState(false)
@ -86,7 +99,7 @@ export function useUnauthenticatedAgentic(): AgenticContext | undefined {
return isMounted ? ctx : undefined
}
export function useAuthenticatedAgentic(): AgenticContext | undefined {
export function useAuthenticatedAgentic(): AgenticContextType | undefined {
const ctx = useAgentic()
const [isMounted, setIsMounted] = useState(false)
@ -97,7 +110,7 @@ export function useAuthenticatedAgentic(): AgenticContext | undefined {
}
if (!ctx.api.isAuthenticated) {
redirect('/', RedirectType.replace)
redirect('/login', RedirectType.replace)
}
}, [isMounted, setIsMounted, ctx])

Wyświetl plik

@ -1,8 +1,8 @@
import Link from 'next/link'
import { ActiveLink } from '@/components/active-link'
import { GitHub } from '@/icons/github'
import { Twitter } from '@/icons/twitter'
import { GitHubIcon } from '@/icons/github'
import { TwitterIcon } from '@/icons/twitter'
import { copyright, githubUrl, twitterUrl } from '@/lib/config'
export function Footer() {
@ -54,7 +54,8 @@ export function Footer() {
target='_blank'
rel='noopener noreferrer'
>
<Twitter className='h-4 w-4' />
<TwitterIcon className='h-4 w-4' />
<span>Twitter</span>
</Link>
@ -64,7 +65,8 @@ export function Footer() {
target='_blank'
rel='noopener noreferrer'
>
<GitHub className='h-4 w-4' />
<GitHubIcon className='h-4 w-4' />
<span>GitHub</span>
</Link>
</nav>

Wyświetl plik

@ -5,7 +5,7 @@ import * as React from 'react'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
"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",
"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 cursor-pointer",
{
variants: {
variant: {

Wyświetl plik

@ -0,0 +1,21 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
return (
<input
type={type}
data-slot='input'
className={cn(
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'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',
className
)}
{...props}
/>
)
}
export { Input }

Wyświetl plik

@ -0,0 +1,24 @@
'use client'
import * as LabelPrimitive from '@radix-ui/react-label'
import * as React from 'react'
import { cn } from '@/lib/utils'
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot='label'
className={cn(
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
className
)}
{...props}
/>
)
}
export { Label }

Wyświetl plik

@ -1,7 +1,12 @@
export function GitHub({ className }: { className?: string }) {
export function GitHubIcon({ className }: { className?: string }) {
return (
<svg viewBox='0 0 24 24' fill='currentcolor' className={className}>
<path d='M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22'></path>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 24 24'
fill='currentColor'
className={className}
>
<path d='M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12' />
</svg>
)
}

Wyświetl plik

@ -1,4 +1,4 @@
export function Twitter({ className }: { className?: string }) {
export function TwitterIcon({ className }: { className?: string }) {
return (
<svg
role='img'