feature/adds-admin-add-article #1

Merged
HideyoshiNakazone merged 21 commits from feature/adds-admin-add-article into main 2026-04-11 04:30:42 +00:00
10 changed files with 728 additions and 7 deletions
Showing only changes of commit 11acc19e64 - Show all commits

46
package-lock.json generated
View File

@@ -9,6 +9,7 @@
"version": "0.1.0",
"dependencies": {
"@clerk/nextjs": "^7.0.7",
"@hookform/resolvers": "^5.2.2",
"@tanstack/react-query": "^5.95.2",
"@vercel/analytics": "^2.0.1",
"class-variance-authority": "^0.7.1",
@@ -22,7 +23,9 @@
"radix-ui": "^1.4.3",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-hook-form": "^7.72.1",
"shadcn": "^4.1.1",
"slugify": "^1.6.9",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",
@@ -1281,6 +1284,18 @@
"hono": "^4"
}
},
"node_modules/@hookform/resolvers": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz",
"integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==",
"license": "MIT",
"dependencies": {
"@standard-schema/utils": "^0.3.0"
},
"peerDependencies": {
"react-hook-form": "^7.55.0"
}
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -4520,6 +4535,12 @@
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@@ -13796,6 +13817,22 @@
"react": "^19.2.4"
}
},
"node_modules/react-hook-form": {
"version": "7.72.1",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.72.1.tgz",
"integrity": "sha512-RhwBoy2ygeVZje+C+bwJ8g0NjTdBmDlJvAUHTxRjTmSUKPYsKfMphkS2sgEMotsY03bP358yEYlnUeZy//D9Ig==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/react-hook-form"
},
"peerDependencies": {
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@@ -14668,6 +14705,15 @@
"node": ">=8"
}
},
"node_modules/slugify": {
"version": "1.6.9",
"resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.9.tgz",
"integrity": "sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg==",
"license": "MIT",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/sonner": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",

View File

@@ -18,6 +18,7 @@
},
"dependencies": {
"@clerk/nextjs": "^7.0.7",
"@hookform/resolvers": "^5.2.2",
"@tanstack/react-query": "^5.95.2",
"@vercel/analytics": "^2.0.1",
"class-variance-authority": "^0.7.1",
@@ -31,7 +32,9 @@
"radix-ui": "^1.4.3",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-hook-form": "^7.72.1",
"shadcn": "^4.1.1",
"slugify": "^1.6.9",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",

View File

@@ -1,12 +1,14 @@
import { siteConfig } from '@/site.config';
import CreateArticleForm from '@/ui/components/internal/create-article-form';
const Home = async () => {
const AdminPage = async () => {
return (
<div className='flex flex-col items-center justify-center'>
<h1 className='mb-4 text-4xl font-bold'>Admin</h1>
<p className='text-lg'>Welcome {siteConfig.name}!</p>
<div className='container mx-auto py-10 min-h-3/4'>
<div className='h-full rounded-lg border border-border bg-card p-6'>
<h2 className='mb-6 text-2xl font-bold'>Create New Article</h2>
<CreateArticleForm />
</div>
</div>
);
};
export default Home;
export default AdminPage;

View File

@@ -0,0 +1,220 @@
'use client';
import { saveArticle } from '@/lib/feature/article/article.external';
import { Button } from '@/ui/components/shadcn/button';
import {
Field,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
} from '@/ui/components/shadcn/field';
import { Input } from '@/ui/components/shadcn/input';
import {
InputGroup,
InputGroupTextarea,
} from '@/ui/components/shadcn/input-group';
import { zodResolver } from '@hookform/resolvers/zod';
import { useCallback, useEffect, useRef } from 'react';
import { Controller, useForm, useWatch } from 'react-hook-form';
import slugify from 'slugify';
import { toast } from 'sonner';
import { z } from 'zod';
export const CreateArticleForm = () => {
const fileInputRef = useRef<HTMLInputElement>(null);
const formSchema = z.object({
title: z.string().min(3).max(255),
slug: z.string().min(3),
description: z.string().min(10),
coverImageUrl: z.url(),
content: z.instanceof(File),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
title: '',
slug: '',
description: '',
coverImageUrl: '',
content: undefined,
},
});
const title = useWatch({
control: form.control,
name: 'title',
});
useEffect(() => {
if (!title) return;
form.setValue('slug', slugify(title).toLowerCase());
}, [form, title]);
const handleFormSubmit = useCallback(
async (data: z.infer<typeof formSchema>) => {
try {
const result = await saveArticle({
...data,
content: await data.content.text(),
});
toast.success('Article created successfully!', {
description: `Article "${result.title}" has been created.`,
position: 'bottom-right',
});
form.reset();
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
} catch (error) {
toast.error('Failed to create article', {
description:
error instanceof Error
? error.message
: 'An error occurred',
position: 'bottom-right',
});
}
},
[form]
);
return (
<form
id='form-create-article'
// eslint-disable-next-line react-hooks/refs
onSubmit={form.handleSubmit(handleFormSubmit)}
>
<FieldGroup>
<Controller
name='title'
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor='form-create-article-title'>
Title
</FieldLabel>
<Input
{...field}
id='form-create-article-title'
aria-invalid={fieldState.invalid}
placeholder='Article title'
autoComplete='off'
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
<Controller
name='slug'
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor='form-create-article-slug'>
Slug
</FieldLabel>
<Input
{...field}
id='form-create-article-slug'
aria-invalid={fieldState.invalid}
placeholder='article-slug'
autoComplete='off'
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
<Controller
name='description'
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor='form-create-article-description'>
Description
</FieldLabel>
<InputGroup>
<InputGroupTextarea
{...field}
id='form-create-article-description'
placeholder='A simple but nice description of the article here.'
rows={3}
className='min-h-24 resize-none'
aria-invalid={fieldState.invalid}
/>
</InputGroup>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
<Controller
name='coverImageUrl'
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor='form-create-article-cover-image-url'>
Cover Image URL
</FieldLabel>
<Input
{...field}
id='form-create-article-cover-image-url'
aria-invalid={fieldState.invalid}
placeholder='https://example.com/image.jpg'
type='url'
autoComplete='off'
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
<Controller
name='content'
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor='form-create-article-content'>
Content (Markdown File)
</FieldLabel>
<Input
ref={fileInputRef}
id='form-create-article-content'
type='file'
accept='.md,.markdown'
aria-invalid={fieldState.invalid}
onChange={(event) =>
field.onChange(
event.target.files &&
event.target.files[0]
)
}
/>
<FieldDescription>
Select your article.
</FieldDescription>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
</FieldGroup>
<div className='flex w-full justify-end'>
<Button type='submit' className='mt-6'>
Create Article
</Button>
</div>
</form>
);
};
export default CreateArticleForm;

View File

@@ -24,7 +24,7 @@ const buttonVariants = cva(
'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3',
lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
icon: 'size-8',
'icon-xs':
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",

View File

@@ -0,0 +1,237 @@
'use client';
import { Label } from '@/ui/components/shadcn/label';
import { cn } from '@/ui/components/shadcn/lib/utils';
import { Separator } from '@/ui/components/shadcn/separator';
import { cva, type VariantProps } from 'class-variance-authority';
import { useMemo } from 'react';
function FieldSet({ className, ...props }: React.ComponentProps<'fieldset'>) {
return (
<fieldset
data-slot='field-set'
className={cn(
'flex flex-col gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3',
className
)}
{...props}
/>
);
}
function FieldLegend({
className,
variant = 'legend',
...props
}: React.ComponentProps<'legend'> & { variant?: 'legend' | 'label' }) {
return (
<legend
data-slot='field-legend'
data-variant={variant}
className={cn(
'mb-1.5 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base',
className
)}
{...props}
/>
);
}
function FieldGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='field-group'
className={cn(
'group/field-group @container/field-group flex w-full flex-col gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4',
className
)}
{...props}
/>
);
}
const fieldVariants = cva(
'group/field flex w-full gap-2 data-[invalid=true]:text-destructive',
{
variants: {
orientation: {
vertical: 'flex-col *:w-full [&>.sr-only]:w-auto',
horizontal:
'flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px',
responsive:
'flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px',
},
},
defaultVariants: {
orientation: 'vertical',
},
}
);
function Field({
className,
orientation = 'vertical',
...props
}: React.ComponentProps<'div'> & VariantProps<typeof fieldVariants>) {
return (
<div
role='group'
data-slot='field'
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
);
}
function FieldContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='field-content'
className={cn(
'group/field-content flex flex-1 flex-col gap-0.5 leading-snug',
className
)}
{...props}
/>
);
}
function FieldLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot='field-label'
className={cn(
'group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-primary/30 has-data-checked:bg-primary/5 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border *:data-[slot=field]:p-2.5 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10',
'has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col',
className
)}
{...props}
/>
);
}
function FieldTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='field-label'
className={cn(
'flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50',
className
)}
{...props}
/>
);
}
function FieldDescription({ className, ...props }: React.ComponentProps<'p'>) {
return (
<p
data-slot='field-description'
className={cn(
'text-left text-sm leading-normal font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5',
'last:mt-0 nth-last-2:-mt-1',
'[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary',
className
)}
{...props}
/>
);
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<'div'> & {
children?: React.ReactNode;
}) {
return (
<div
data-slot='field-separator'
data-content={!!children}
className={cn(
'relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2',
className
)}
{...props}
>
<Separator className='absolute inset-0 top-1/2' />
{children && (
<span
className='relative mx-auto block w-fit bg-background px-2 text-muted-foreground'
data-slot='field-separator-content'
>
{children}
</span>
)}
</div>
);
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<'div'> & {
errors?: Array<{ message?: string } | undefined>;
}) {
const content = useMemo(() => {
if (children) {
return children;
}
if (!errors?.length) {
return null;
}
const uniqueErrors = [
...new Map(errors.map((error) => [error?.message, error])).values(),
];
if (uniqueErrors?.length == 1) {
return uniqueErrors[0]?.message;
}
return (
<ul className='ml-4 flex list-disc flex-col gap-1'>
{uniqueErrors.map(
(error, index) =>
error?.message && <li key={index}>{error.message}</li>
)}
</ul>
);
}, [children, errors]);
if (!content) {
return null;
}
return (
<div
role='alert'
data-slot='field-error'
className={cn('text-sm font-normal text-destructive', className)}
{...props}
>
{content}
</div>
);
}
export {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
};

View File

@@ -0,0 +1,155 @@
'use client';
import { Button } from '@/ui/components/shadcn/button';
import { Input } from '@/ui/components/shadcn/input';
import { cn } from '@/ui/components/shadcn/lib/utils';
import { Textarea } from '@/ui/components/shadcn/textarea';
import { cva, type VariantProps } from 'class-variance-authority';
import * as React from 'react';
function InputGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='input-group'
role='group'
className={cn(
'group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5',
className
)}
{...props}
/>
);
}
const inputGroupAddonVariants = cva(
"flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
{
variants: {
align: {
'inline-start':
'order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]',
'inline-end':
'order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]',
'block-start':
'order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2',
'block-end':
'order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2',
},
},
defaultVariants: {
align: 'inline-start',
},
}
);
function InputGroupAddon({
className,
align = 'inline-start',
...props
}: React.ComponentProps<'div'> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role='group'
data-slot='input-group-addon'
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest('button')) {
return;
}
e.currentTarget.parentElement?.querySelector('input')?.focus();
}}
{...props}
/>
);
}
const inputGroupButtonVariants = cva(
'flex items-center gap-2 text-sm shadow-none',
{
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
sm: '',
'icon-xs':
'size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0',
'icon-sm': 'size-8 p-0 has-[>svg]:p-0',
},
},
defaultVariants: {
size: 'xs',
},
}
);
function InputGroupButton({
className,
type = 'button',
variant = 'ghost',
size = 'xs',
...props
}: Omit<React.ComponentProps<typeof Button>, 'size'> &
VariantProps<typeof inputGroupButtonVariants>) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
);
}
function InputGroupText({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
className={cn(
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
);
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<'input'>) {
return (
<Input
data-slot='input-group-control'
className={cn(
'flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent',
className
)}
{...props}
/>
);
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<'textarea'>) {
return (
<Textarea
data-slot='input-group-control'
className={cn(
'flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent',
className
)}
{...props}
/>
);
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
};

View File

@@ -0,0 +1,18 @@
import { cn } from '@/ui/components/shadcn/lib/utils';
import * as React from 'react';
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
return (
<input
type={type}
data-slot='input'
className={cn(
'h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40',
className
)}
{...props}
/>
);
}
export { Input };

View File

@@ -0,0 +1,23 @@
'use client';
import { cn } from '@/ui/components/shadcn/lib/utils';
import { Label as LabelPrimitive } from 'radix-ui';
import * as React from 'react';
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 };

View File

@@ -0,0 +1,17 @@
import { cn } from '@/ui/components/shadcn/lib/utils';
import * as React from 'react';
function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
return (
<textarea
data-slot='textarea'
className={cn(
'flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40',
className
)}
{...props}
/>
);
}
export { Textarea };