The recipes.blue monorepo recipes.blue
recipes appview atproto
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: start implementing new recipe form

+1016 -2
+7
apps/web/package.json
··· 12 12 "dependencies": { 13 13 "@atcute/client": "^2.0.6", 14 14 "@atcute/oauth-browser-client": "^1.0.7", 15 + "@dnd-kit/core": "^6.3.1", 16 + "@dnd-kit/modifiers": "^9.0.0", 17 + "@dnd-kit/sortable": "^10.0.0", 18 + "@dnd-kit/utilities": "^3.2.2", 19 + "@hookform/resolvers": "^3.9.1", 15 20 "@radix-ui/react-avatar": "^1.1.1", 16 21 "@radix-ui/react-collapsible": "^1.1.1", 17 22 "@radix-ui/react-dialog": "^1.1.4", 18 23 "@radix-ui/react-dropdown-menu": "^2.1.4", 24 + "@radix-ui/react-icons": "^1.3.2", 19 25 "@radix-ui/react-label": "^2.1.0", 20 26 "@radix-ui/react-separator": "^1.1.0", 21 27 "@radix-ui/react-slot": "^1.1.0", ··· 28 34 "clsx": "^2.1.1", 29 35 "lucide-react": "^0.464.0", 30 36 "react-dom": "19.0.0", 37 + "react-hook-form": "^7.54.1", 31 38 "tailwind-merge": "^2.5.5", 32 39 "tailwindcss-animate": "^1.0.7", 33 40 "zod": "^3.23.8"
+176
apps/web/src/components/ui/form.tsx
··· 1 + import * as React from "react" 2 + import * as LabelPrimitive from "@radix-ui/react-label" 3 + import { Slot } from "@radix-ui/react-slot" 4 + import { 5 + Controller, 6 + ControllerProps, 7 + FieldPath, 8 + FieldValues, 9 + FormProvider, 10 + useFormContext, 11 + } from "react-hook-form" 12 + 13 + import { cn } from "@/lib/utils" 14 + import { Label } from "@/components/ui/label" 15 + 16 + const Form = FormProvider 17 + 18 + type FormFieldContextValue< 19 + TFieldValues extends FieldValues = FieldValues, 20 + TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues> 21 + > = { 22 + name: TName 23 + } 24 + 25 + const FormFieldContext = React.createContext<FormFieldContextValue>( 26 + {} as FormFieldContextValue 27 + ) 28 + 29 + const FormField = < 30 + TFieldValues extends FieldValues = FieldValues, 31 + TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues> 32 + >({ 33 + ...props 34 + }: ControllerProps<TFieldValues, TName>) => { 35 + return ( 36 + <FormFieldContext.Provider value={{ name: props.name }}> 37 + <Controller {...props} /> 38 + </FormFieldContext.Provider> 39 + ) 40 + } 41 + 42 + const useFormField = () => { 43 + const fieldContext = React.useContext(FormFieldContext) 44 + const itemContext = React.useContext(FormItemContext) 45 + const { getFieldState, formState } = useFormContext() 46 + 47 + const fieldState = getFieldState(fieldContext.name, formState) 48 + 49 + if (!fieldContext) { 50 + throw new Error("useFormField should be used within <FormField>") 51 + } 52 + 53 + const { id } = itemContext 54 + 55 + return { 56 + id, 57 + name: fieldContext.name, 58 + formItemId: `${id}-form-item`, 59 + formDescriptionId: `${id}-form-item-description`, 60 + formMessageId: `${id}-form-item-message`, 61 + ...fieldState, 62 + } 63 + } 64 + 65 + type FormItemContextValue = { 66 + id: string 67 + } 68 + 69 + const FormItemContext = React.createContext<FormItemContextValue>( 70 + {} as FormItemContextValue 71 + ) 72 + 73 + const FormItem = React.forwardRef< 74 + HTMLDivElement, 75 + React.HTMLAttributes<HTMLDivElement> 76 + >(({ className, ...props }, ref) => { 77 + const id = React.useId() 78 + 79 + return ( 80 + <FormItemContext.Provider value={{ id }}> 81 + <div ref={ref} className={cn("space-y-2", className)} {...props} /> 82 + </FormItemContext.Provider> 83 + ) 84 + }) 85 + FormItem.displayName = "FormItem" 86 + 87 + const FormLabel = React.forwardRef< 88 + React.ElementRef<typeof LabelPrimitive.Root>, 89 + React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> 90 + >(({ className, ...props }, ref) => { 91 + const { error, formItemId } = useFormField() 92 + 93 + return ( 94 + <Label 95 + ref={ref} 96 + className={cn(error && "text-destructive", className)} 97 + htmlFor={formItemId} 98 + {...props} 99 + /> 100 + ) 101 + }) 102 + FormLabel.displayName = "FormLabel" 103 + 104 + const FormControl = React.forwardRef< 105 + React.ElementRef<typeof Slot>, 106 + React.ComponentPropsWithoutRef<typeof Slot> 107 + >(({ ...props }, ref) => { 108 + const { error, formItemId, formDescriptionId, formMessageId } = useFormField() 109 + 110 + return ( 111 + <Slot 112 + ref={ref} 113 + id={formItemId} 114 + aria-describedby={ 115 + !error 116 + ? `${formDescriptionId}` 117 + : `${formDescriptionId} ${formMessageId}` 118 + } 119 + aria-invalid={!!error} 120 + {...props} 121 + /> 122 + ) 123 + }) 124 + FormControl.displayName = "FormControl" 125 + 126 + const FormDescription = React.forwardRef< 127 + HTMLParagraphElement, 128 + React.HTMLAttributes<HTMLParagraphElement> 129 + >(({ className, ...props }, ref) => { 130 + const { formDescriptionId } = useFormField() 131 + 132 + return ( 133 + <p 134 + ref={ref} 135 + id={formDescriptionId} 136 + className={cn("text-[0.8rem] text-muted-foreground", className)} 137 + {...props} 138 + /> 139 + ) 140 + }) 141 + FormDescription.displayName = "FormDescription" 142 + 143 + const FormMessage = React.forwardRef< 144 + HTMLParagraphElement, 145 + React.HTMLAttributes<HTMLParagraphElement> 146 + >(({ className, children, ...props }, ref) => { 147 + const { error, formMessageId } = useFormField() 148 + const body = error ? String(error?.message) : children 149 + 150 + if (!body) { 151 + return null 152 + } 153 + 154 + return ( 155 + <p 156 + ref={ref} 157 + id={formMessageId} 158 + className={cn("text-[0.8rem] font-medium text-destructive", className)} 159 + {...props} 160 + > 161 + {body} 162 + </p> 163 + ) 164 + }) 165 + FormMessage.displayName = "FormMessage" 166 + 167 + export { 168 + useFormField, 169 + Form, 170 + FormItem, 171 + FormLabel, 172 + FormControl, 173 + FormDescription, 174 + FormMessage, 175 + FormField, 176 + }
+336
apps/web/src/components/ui/sortable.tsx
··· 1 + "use client" 2 + 3 + import * as React from "react" 4 + import type { 5 + DndContextProps, 6 + DraggableSyntheticListeners, 7 + DropAnimation, 8 + UniqueIdentifier, 9 + } from "@dnd-kit/core" 10 + import { 11 + closestCenter, 12 + defaultDropAnimationSideEffects, 13 + DndContext, 14 + DragOverlay, 15 + KeyboardSensor, 16 + MouseSensor, 17 + TouchSensor, 18 + useSensor, 19 + useSensors, 20 + } from "@dnd-kit/core" 21 + import { 22 + restrictToHorizontalAxis, 23 + restrictToParentElement, 24 + restrictToVerticalAxis, 25 + } from "@dnd-kit/modifiers" 26 + import { 27 + arrayMove, 28 + horizontalListSortingStrategy, 29 + SortableContext, 30 + useSortable, 31 + verticalListSortingStrategy, 32 + type SortableContextProps, 33 + } from "@dnd-kit/sortable" 34 + import { CSS } from "@dnd-kit/utilities" 35 + import { Slot, type SlotProps } from "@radix-ui/react-slot" 36 + 37 + import { composeRefs } from "@/lib/compose-refs" 38 + import { cn } from "@/lib/utils" 39 + import { Button, type ButtonProps } from "@/components/ui/button" 40 + 41 + const orientationConfig = { 42 + vertical: { 43 + modifiers: [restrictToVerticalAxis, restrictToParentElement], 44 + strategy: verticalListSortingStrategy, 45 + }, 46 + horizontal: { 47 + modifiers: [restrictToHorizontalAxis, restrictToParentElement], 48 + strategy: horizontalListSortingStrategy, 49 + }, 50 + mixed: { 51 + modifiers: [restrictToParentElement], 52 + strategy: undefined, 53 + }, 54 + } 55 + 56 + interface SortableProps<TData extends { id: UniqueIdentifier }> 57 + extends DndContextProps { 58 + /** 59 + * An array of data items that the sortable component will render. 60 + * @example 61 + * value={[ 62 + * { id: 1, name: 'Item 1' }, 63 + * { id: 2, name: 'Item 2' }, 64 + * ]} 65 + */ 66 + value: TData[] 67 + 68 + /** 69 + * An optional callback function that is called when the order of the data items changes. 70 + * It receives the new array of items as its argument. 71 + * @example 72 + * onValueChange={(items) => console.log(items)} 73 + */ 74 + onValueChange?: (items: TData[]) => void 75 + 76 + /** 77 + * An optional callback function that is called when an item is moved. 78 + * It receives an event object with `activeIndex` and `overIndex` properties, representing the original and new positions of the moved item. 79 + * This will override the default behavior of updating the order of the data items. 80 + * @type (event: { activeIndex: number; overIndex: number }) => void 81 + * @example 82 + * onMove={(event) => console.log(`Item moved from index ${event.activeIndex} to index ${event.overIndex}`)} 83 + */ 84 + onMove?: (event: { activeIndex: number; overIndex: number }) => void 85 + 86 + /** 87 + * A collision detection strategy that will be used to determine the closest sortable item. 88 + * @default closestCenter 89 + * @type DndContextProps["collisionDetection"] 90 + */ 91 + collisionDetection?: DndContextProps["collisionDetection"] 92 + 93 + /** 94 + * An array of modifiers that will be used to modify the behavior of the sortable component. 95 + * @default 96 + * [restrictToVerticalAxis, restrictToParentElement] 97 + * @type Modifier[] 98 + */ 99 + modifiers?: DndContextProps["modifiers"] 100 + 101 + /** 102 + * A sorting strategy that will be used to determine the new order of the data items. 103 + * @default verticalListSortingStrategy 104 + * @type SortableContextProps["strategy"] 105 + */ 106 + strategy?: SortableContextProps["strategy"] 107 + 108 + /** 109 + * Specifies the axis for the drag-and-drop operation. It can be "vertical", "horizontal", or "both". 110 + * @default "vertical" 111 + * @type "vertical" | "horizontal" | "mixed" 112 + */ 113 + orientation?: "vertical" | "horizontal" | "mixed" 114 + 115 + /** 116 + * An optional React node that is rendered on top of the sortable component. 117 + * It can be used to display additional information or controls. 118 + * @default null 119 + * @type React.ReactNode | null 120 + * @example 121 + * overlay={<Skeleton className="w-full h-8" />} 122 + */ 123 + overlay?: React.ReactNode | null 124 + } 125 + 126 + function Sortable<TData extends { id: UniqueIdentifier }>({ 127 + value, 128 + onValueChange, 129 + collisionDetection = closestCenter, 130 + modifiers, 131 + strategy, 132 + onMove, 133 + orientation = "vertical", 134 + overlay, 135 + children, 136 + ...props 137 + }: SortableProps<TData>) { 138 + const [activeId, setActiveId] = React.useState<UniqueIdentifier | null>(null) 139 + const sensors = useSensors( 140 + useSensor(MouseSensor), 141 + useSensor(TouchSensor), 142 + useSensor(KeyboardSensor) 143 + ) 144 + 145 + const config = orientationConfig[orientation] 146 + 147 + return ( 148 + <DndContext 149 + modifiers={modifiers ?? config.modifiers} 150 + sensors={sensors} 151 + onDragStart={({ active }) => setActiveId(active.id)} 152 + onDragEnd={({ active, over }) => { 153 + if (over && active.id !== over?.id) { 154 + const activeIndex = value.findIndex((item) => item.id === active.id) 155 + const overIndex = value.findIndex((item) => item.id === over.id) 156 + 157 + if (onMove) { 158 + onMove({ activeIndex, overIndex }) 159 + } else { 160 + onValueChange?.(arrayMove(value, activeIndex, overIndex)) 161 + } 162 + } 163 + setActiveId(null) 164 + }} 165 + onDragCancel={() => setActiveId(null)} 166 + collisionDetection={collisionDetection} 167 + {...props} 168 + > 169 + <SortableContext items={value} strategy={strategy ?? config.strategy}> 170 + {children} 171 + </SortableContext> 172 + {overlay ? ( 173 + <SortableOverlay activeId={activeId}>{overlay}</SortableOverlay> 174 + ) : null} 175 + </DndContext> 176 + ) 177 + } 178 + 179 + const dropAnimationOpts: DropAnimation = { 180 + sideEffects: defaultDropAnimationSideEffects({ 181 + styles: { 182 + active: { 183 + opacity: "0.4", 184 + }, 185 + }, 186 + }), 187 + } 188 + 189 + interface SortableOverlayProps 190 + extends React.ComponentPropsWithRef<typeof DragOverlay> { 191 + activeId?: UniqueIdentifier | null 192 + } 193 + 194 + const SortableOverlay = React.forwardRef<HTMLDivElement, SortableOverlayProps>( 195 + ( 196 + { activeId, dropAnimation = dropAnimationOpts, children, ...props }, 197 + ref 198 + ) => { 199 + return ( 200 + <DragOverlay dropAnimation={dropAnimation} {...props}> 201 + {activeId ? ( 202 + <SortableItem 203 + ref={ref} 204 + value={activeId} 205 + className="cursor-grabbing" 206 + asChild 207 + > 208 + {children} 209 + </SortableItem> 210 + ) : null} 211 + </DragOverlay> 212 + ) 213 + } 214 + ) 215 + SortableOverlay.displayName = "SortableOverlay" 216 + 217 + interface SortableItemContextProps { 218 + attributes: React.HTMLAttributes<HTMLElement> 219 + listeners: DraggableSyntheticListeners | undefined 220 + isDragging?: boolean 221 + } 222 + 223 + const SortableItemContext = React.createContext<SortableItemContextProps>({ 224 + attributes: {}, 225 + listeners: undefined, 226 + isDragging: false, 227 + }) 228 + 229 + function useSortableItem() { 230 + const context = React.useContext(SortableItemContext) 231 + 232 + if (!context) { 233 + throw new Error("useSortableItem must be used within a SortableItem") 234 + } 235 + 236 + return context 237 + } 238 + 239 + interface SortableItemProps extends SlotProps { 240 + /** 241 + * The unique identifier of the item. 242 + * @example "item-1" 243 + * @type UniqueIdentifier 244 + */ 245 + value: UniqueIdentifier 246 + 247 + /** 248 + * Specifies whether the item should act as a trigger for the drag-and-drop action. 249 + * @default false 250 + * @type boolean | undefined 251 + */ 252 + asTrigger?: boolean 253 + 254 + /** 255 + * Merges the item's props into its immediate child. 256 + * @default false 257 + * @type boolean | undefined 258 + */ 259 + asChild?: boolean 260 + } 261 + 262 + const SortableItem = React.forwardRef<HTMLDivElement, SortableItemProps>( 263 + ({ value, asTrigger, asChild, className, ...props }, ref) => { 264 + const { 265 + attributes, 266 + listeners, 267 + setNodeRef, 268 + transform, 269 + transition, 270 + isDragging, 271 + } = useSortable({ id: value }) 272 + 273 + const context = React.useMemo<SortableItemContextProps>( 274 + () => ({ 275 + attributes, 276 + listeners, 277 + isDragging, 278 + }), 279 + [attributes, listeners, isDragging] 280 + ) 281 + const style: React.CSSProperties = { 282 + opacity: isDragging ? 0.5 : 1, 283 + transform: CSS.Translate.toString(transform), 284 + transition, 285 + } 286 + 287 + const Comp = asChild ? Slot : "div" 288 + 289 + return ( 290 + <SortableItemContext.Provider value={context}> 291 + <Comp 292 + data-state={isDragging ? "dragging" : undefined} 293 + className={cn( 294 + "data-[state=dragging]:cursor-grabbing", 295 + { "cursor-grab": !isDragging && asTrigger }, 296 + className 297 + )} 298 + ref={composeRefs(ref, setNodeRef as React.Ref<HTMLDivElement>)} 299 + style={style} 300 + {...(asTrigger ? attributes : {})} 301 + {...(asTrigger ? listeners : {})} 302 + {...props} 303 + /> 304 + </SortableItemContext.Provider> 305 + ) 306 + } 307 + ) 308 + SortableItem.displayName = "SortableItem" 309 + 310 + interface SortableDragHandleProps extends ButtonProps { 311 + withHandle?: boolean 312 + } 313 + 314 + const SortableDragHandle = React.forwardRef< 315 + HTMLButtonElement, 316 + SortableDragHandleProps 317 + >(({ className, ...props }, ref) => { 318 + const { attributes, listeners, isDragging } = useSortableItem() 319 + 320 + return ( 321 + <Button 322 + ref={composeRefs(ref)} 323 + data-state={isDragging ? "dragging" : undefined} 324 + className={cn( 325 + "cursor-grab data-[state=dragging]:cursor-grabbing", 326 + className 327 + )} 328 + {...attributes} 329 + {...listeners} 330 + {...props} 331 + /> 332 + ) 333 + }) 334 + SortableDragHandle.displayName = "SortableDragHandle" 335 + 336 + export { Sortable, SortableDragHandle, SortableItem, SortableOverlay }
+22
apps/web/src/components/ui/textarea.tsx
··· 1 + import * as React from "react" 2 + 3 + import { cn } from "@/lib/utils" 4 + 5 + const Textarea = React.forwardRef< 6 + HTMLTextAreaElement, 7 + React.ComponentProps<"textarea"> 8 + >(({ className, ...props }, ref) => { 9 + return ( 10 + <textarea 11 + className={cn( 12 + "flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", 13 + className 14 + )} 15 + ref={ref} 16 + {...props} 17 + /> 18 + ) 19 + }) 20 + Textarea.displayName = "Textarea" 21 + 22 + export { Textarea }
+36
apps/web/src/lib/compose-refs.ts
··· 1 + // @see https://github.com/radix-ui/primitives/blob/main/packages/react/compose-refs/src/composeRefs.tsx 2 + 3 + import * as React from "react" 4 + 5 + type PossibleRef<T> = React.Ref<T> | undefined 6 + 7 + /** 8 + * Set a given ref to a given value 9 + * This utility takes care of different types of refs: callback refs and RefObject(s) 10 + */ 11 + function setRef<T>(ref: PossibleRef<T>, value: T) { 12 + if (typeof ref === "function") { 13 + ref(value) 14 + } else if (ref !== null && ref !== undefined) { 15 + ;(ref as React.MutableRefObject<T>).current = value 16 + } 17 + } 18 + 19 + /** 20 + * A utility to compose multiple refs together 21 + * Accepts callback refs and RefObject(s) 22 + */ 23 + function composeRefs<T>(...refs: PossibleRef<T>[]) { 24 + return (node: T) => refs.forEach((ref) => setRef(ref, node)) 25 + } 26 + 27 + /** 28 + * A custom hook that composes multiple refs 29 + * Accepts callback refs and RefObject(s) 30 + */ 31 + function useComposedRefs<T>(...refs: PossibleRef<T>[]) { 32 + // eslint-disable-next-line react-hooks/exhaustive-deps 33 + return React.useCallback(composeRefs(...refs), refs) 34 + } 35 + 36 + export { composeRefs, useComposedRefs }
+29 -2
apps/web/src/routeTree.gen.ts
··· 20 20 21 21 const appIndexLazyImport = createFileRoute('/_/(app)/')() 22 22 const authLoginLazyImport = createFileRoute('/_/(auth)/login')() 23 + const appRecipesNewLazyImport = createFileRoute('/_/(app)/recipes/new')() 23 24 24 25 // Create/Update Routes 25 26 ··· 44 45 } as any) 45 46 .lazy(() => import('./routes/_.(auth)/login.lazy').then((d) => d.Route)) 46 47 48 + const appRecipesNewLazyRoute = appRecipesNewLazyImport 49 + .update({ 50 + id: '/(app)/recipes/new', 51 + path: '/recipes/new', 52 + getParentRoute: () => Route, 53 + } as any) 54 + .lazy(() => import('./routes/_.(app)/recipes/new.lazy').then((d) => d.Route)) 55 + 47 56 const appRecipesAuthorRkeyRoute = appRecipesAuthorRkeyImport.update({ 48 57 id: '/(app)/recipes/$author/$rkey', 49 58 path: '/recipes/$author/$rkey', ··· 73 82 path: '/' 74 83 fullPath: '/' 75 84 preLoaderRoute: typeof appIndexLazyImport 85 + parentRoute: typeof rootRoute 86 + } 87 + '/_/(app)/recipes/new': { 88 + id: '/_/(app)/recipes/new' 89 + path: '/recipes/new' 90 + fullPath: '/recipes/new' 91 + preLoaderRoute: typeof appRecipesNewLazyImport 76 92 parentRoute: typeof rootRoute 77 93 } 78 94 '/_/(app)/recipes/$author/$rkey': { ··· 90 106 interface RouteChildren { 91 107 authLoginLazyRoute: typeof authLoginLazyRoute 92 108 appIndexLazyRoute: typeof appIndexLazyRoute 109 + appRecipesNewLazyRoute: typeof appRecipesNewLazyRoute 93 110 appRecipesAuthorRkeyRoute: typeof appRecipesAuthorRkeyRoute 94 111 } 95 112 96 113 const RouteChildren: RouteChildren = { 97 114 authLoginLazyRoute: authLoginLazyRoute, 98 115 appIndexLazyRoute: appIndexLazyRoute, 116 + appRecipesNewLazyRoute: appRecipesNewLazyRoute, 99 117 appRecipesAuthorRkeyRoute: appRecipesAuthorRkeyRoute, 100 118 } 101 119 ··· 105 123 '': typeof RouteWithChildren 106 124 '/login': typeof authLoginLazyRoute 107 125 '/': typeof appIndexLazyRoute 126 + '/recipes/new': typeof appRecipesNewLazyRoute 108 127 '/recipes/$author/$rkey': typeof appRecipesAuthorRkeyRoute 109 128 } 110 129 111 130 export interface FileRoutesByTo { 112 131 '/login': typeof authLoginLazyRoute 113 132 '/': typeof appIndexLazyRoute 133 + '/recipes/new': typeof appRecipesNewLazyRoute 114 134 '/recipes/$author/$rkey': typeof appRecipesAuthorRkeyRoute 115 135 } 116 136 ··· 119 139 '/_': typeof RouteWithChildren 120 140 '/_/(auth)/login': typeof authLoginLazyRoute 121 141 '/_/(app)/': typeof appIndexLazyRoute 142 + '/_/(app)/recipes/new': typeof appRecipesNewLazyRoute 122 143 '/_/(app)/recipes/$author/$rkey': typeof appRecipesAuthorRkeyRoute 123 144 } 124 145 125 146 export interface FileRouteTypes { 126 147 fileRoutesByFullPath: FileRoutesByFullPath 127 - fullPaths: '' | '/login' | '/' | '/recipes/$author/$rkey' 148 + fullPaths: '' | '/login' | '/' | '/recipes/new' | '/recipes/$author/$rkey' 128 149 fileRoutesByTo: FileRoutesByTo 129 - to: '/login' | '/' | '/recipes/$author/$rkey' 150 + to: '/login' | '/' | '/recipes/new' | '/recipes/$author/$rkey' 130 151 id: 131 152 | '__root__' 132 153 | '/_' 133 154 | '/_/(auth)/login' 134 155 | '/_/(app)/' 156 + | '/_/(app)/recipes/new' 135 157 | '/_/(app)/recipes/$author/$rkey' 136 158 fileRoutesById: FileRoutesById 137 159 } ··· 162 184 "children": [ 163 185 "/_/(auth)/login", 164 186 "/_/(app)/", 187 + "/_/(app)/recipes/new", 165 188 "/_/(app)/recipes/$author/$rkey" 166 189 ] 167 190 }, ··· 171 194 }, 172 195 "/_/(app)/": { 173 196 "filePath": "_.(app)/index.lazy.tsx", 197 + "parent": "/_" 198 + }, 199 + "/_/(app)/recipes/new": { 200 + "filePath": "_.(app)/recipes/new.lazy.tsx", 174 201 "parent": "/_" 175 202 }, 176 203 "/_/(app)/recipes/$author/$rkey": {
+301
apps/web/src/routes/_.(app)/recipes/new.lazy.tsx
··· 1 + import { createLazyFileRoute, Link } from '@tanstack/react-router' 2 + import { 3 + Breadcrumb, 4 + BreadcrumbItem, 5 + BreadcrumbLink, 6 + BreadcrumbList, 7 + BreadcrumbPage, 8 + BreadcrumbSeparator, 9 + } from '@/components/ui/breadcrumb' 10 + import { Separator } from '@/components/ui/separator' 11 + import { SidebarTrigger } from '@/components/ui/sidebar' 12 + import { useFieldArray, useForm } from 'react-hook-form' 13 + import { z } from 'zod'; 14 + import { zodResolver } from "@hookform/resolvers/zod" 15 + import { RecipeRecord } from '@cookware/lexicons' 16 + import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form' 17 + import { Button } from '@/components/ui/button' 18 + import { Input } from '@/components/ui/input' 19 + import { Textarea } from '@/components/ui/textarea' 20 + import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' 21 + import { Sortable, SortableDragHandle, SortableItem } from '@/components/ui/sortable' 22 + import { DragHandleDots2Icon } from '@radix-ui/react-icons' 23 + import { Label } from '@/components/ui/label' 24 + import { TrashIcon } from 'lucide-react' 25 + 26 + export const Route = createLazyFileRoute('/_/(app)/recipes/new')({ 27 + component: RouteComponent, 28 + }) 29 + 30 + const schema = RecipeRecord; 31 + 32 + function RouteComponent() { 33 + const form = useForm<z.infer<typeof schema>>({ 34 + resolver: zodResolver(schema), 35 + defaultValues: { 36 + title: '', 37 + description: '', 38 + ingredients: [ 39 + { name: '' }, 40 + ], 41 + steps: [ 42 + { text: '' }, 43 + ], 44 + }, 45 + }); 46 + 47 + const onSubmit = (values: z.infer<typeof schema>) => { 48 + console.log(values); 49 + }; 50 + 51 + const ingredients = useFieldArray({ 52 + control: form.control, 53 + name: "ingredients", 54 + }); 55 + 56 + const steps = useFieldArray({ 57 + control: form.control, 58 + name: "steps", 59 + }); 60 + 61 + return ( 62 + <> 63 + <Breadcrumbs /> 64 + <div className="flex-1 grid p-4 pt-0 max-w-xl w-full mx-auto"> 65 + <Card> 66 + <CardHeader> 67 + <CardTitle>New recipe</CardTitle> 68 + <CardDescription>Share your recipe with the world!</CardDescription> 69 + </CardHeader> 70 + <CardContent> 71 + <Form {...form}> 72 + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8"> 73 + 74 + <FormField 75 + name="title" 76 + control={form.control} 77 + render={({ field }) => ( 78 + <FormItem> 79 + <FormLabel>Title</FormLabel> 80 + <FormControl> 81 + <Input placeholder="My awesome recipe!" {...field} /> 82 + </FormControl> 83 + <FormDescription> 84 + This is your recipe's name. 85 + </FormDescription> 86 + <FormMessage /> 87 + </FormItem> 88 + )} 89 + /> 90 + 91 + <FormField 92 + name="description" 93 + control={form.control} 94 + render={({ field }) => ( 95 + <FormItem> 96 + <FormLabel>Description</FormLabel> 97 + <FormControl> 98 + <Textarea 99 + className="resize-none" 100 + {...field} 101 + /> 102 + </FormControl> 103 + <FormMessage /> 104 + </FormItem> 105 + )} 106 + /> 107 + 108 + <div className="grid gap-2"> 109 + <Label>Ingredients</Label> 110 + <Sortable 111 + value={ingredients.fields} 112 + onMove={({ activeIndex, overIndex }) => ingredients.move(activeIndex, overIndex)} 113 + > 114 + <div className="flex w-full flex-col gap-2"> 115 + {ingredients.fields.map((field, index) => ( 116 + <SortableItem 117 + key={field.id} 118 + value={field.id} 119 + asChild 120 + > 121 + <div className="grid grid-cols-[2rem_1fr_0.2fr_0.2fr_2rem] items-center gap-2"> 122 + <SortableDragHandle 123 + type="button" 124 + variant="outline" 125 + size="icon" 126 + className="size-8 shrink-0" 127 + > 128 + <DragHandleDots2Icon 129 + className="size-4" 130 + aria-hidden="true" 131 + /> 132 + </SortableDragHandle> 133 + 134 + <FormField 135 + control={form.control} 136 + name={`ingredients.${index}.name`} 137 + render={({ field }) => ( 138 + <FormItem> 139 + <FormControl> 140 + <Input 141 + placeholder="Ingredient" 142 + className="h-8" 143 + {...field} 144 + /> 145 + </FormControl> 146 + </FormItem> 147 + )} 148 + /> 149 + 150 + <FormField 151 + control={form.control} 152 + name={`ingredients.${index}.amount`} 153 + render={({ field: { value, ...field } }) => ( 154 + <FormItem> 155 + <FormControl> 156 + <Input 157 + type="number" 158 + placeholder="#" 159 + value={value || 0} 160 + className="h-8" 161 + {...field} 162 + /> 163 + </FormControl> 164 + </FormItem> 165 + )} 166 + /> 167 + 168 + <FormField 169 + control={form.control} 170 + name={`ingredients.${index}.unit`} 171 + render={({ field: { value, ...field } }) => ( 172 + <FormItem> 173 + <FormControl> 174 + <Input 175 + placeholder="Unit" 176 + className="h-8" 177 + value={value || ''} 178 + {...field} 179 + /> 180 + </FormControl> 181 + </FormItem> 182 + )} 183 + /> 184 + 185 + <Button 186 + type="button" 187 + variant="destructive" 188 + className="size-8" 189 + onClick={(e) => { 190 + e.preventDefault(); 191 + ingredients.remove(index); 192 + }} 193 + > 194 + <TrashIcon /> 195 + </Button> 196 + </div> 197 + </SortableItem> 198 + ))} 199 + </div> 200 + </Sortable> 201 + <Button 202 + type="button" 203 + onClick={(e) => { 204 + e.preventDefault(); 205 + ingredients.append({ name: '', amount: null, unit: null }); 206 + }} 207 + >Add</Button> 208 + </div> 209 + 210 + <div className="grid gap-2"> 211 + <Label>Steps</Label> 212 + <Sortable 213 + value={steps.fields} 214 + onMove={({ activeIndex, overIndex }) => steps.move(activeIndex, overIndex)} 215 + > 216 + <div className="flex w-full flex-col gap-2"> 217 + {steps.fields.map((field, index) => ( 218 + <SortableItem 219 + key={field.id} 220 + value={field.id} 221 + asChild 222 + > 223 + <div className="grid grid-cols-[2rem_auto] items-center gap-2"> 224 + <SortableDragHandle 225 + type="button" 226 + variant="outline" 227 + size="icon" 228 + className="size-8 shrink-0" 229 + > 230 + <DragHandleDots2Icon 231 + className="size-4" 232 + aria-hidden="true" 233 + /> 234 + </SortableDragHandle> 235 + <FormField 236 + control={form.control} 237 + name={`steps.${index}.text`} 238 + render={({ field }) => ( 239 + <FormItem> 240 + <FormControl> 241 + <Input className="h-8" {...field} /> 242 + </FormControl> 243 + </FormItem> 244 + )} 245 + /> 246 + </div> 247 + </SortableItem> 248 + ))} 249 + </div> 250 + </Sortable> 251 + <Button 252 + type="button" 253 + onClick={(e) => { 254 + e.preventDefault(); 255 + steps.append({ text: '' }) 256 + }} 257 + >Add</Button> 258 + </div> 259 + 260 + <Button 261 + type="button" 262 + > 263 + Submit 264 + </Button> 265 + 266 + </form> 267 + </Form> 268 + </CardContent> 269 + </Card> 270 + </div> 271 + </> 272 + ) 273 + } 274 + 275 + const Breadcrumbs = () => ( 276 + <header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-12"> 277 + <div className="flex items-center gap-2 px-4"> 278 + <SidebarTrigger className="-ml-1" /> 279 + <Separator orientation="vertical" className="mr-2 h-4" /> 280 + <Breadcrumb> 281 + <BreadcrumbList> 282 + <BreadcrumbItem className="hidden md:block"> 283 + <BreadcrumbLink asChild> 284 + <Link href="/">Home</Link> 285 + </BreadcrumbLink> 286 + </BreadcrumbItem> 287 + <BreadcrumbSeparator className="hidden md:block" /> 288 + <BreadcrumbItem className="hidden md:block"> 289 + <BreadcrumbLink asChild> 290 + <Link href="/recipes">Recipes</Link> 291 + </BreadcrumbLink> 292 + </BreadcrumbItem> 293 + <BreadcrumbSeparator className="hidden md:block" /> 294 + <BreadcrumbItem> 295 + <BreadcrumbPage>New</BreadcrumbPage> 296 + </BreadcrumbItem> 297 + </BreadcrumbList> 298 + </Breadcrumb> 299 + </div> 300 + </header> 301 + );
+109
pnpm-lock.yaml
··· 172 172 '@atcute/oauth-browser-client': 173 173 specifier: ^1.0.7 174 174 version: 1.0.7 175 + '@dnd-kit/core': 176 + specifier: ^6.3.1 177 + version: 6.3.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 178 + '@dnd-kit/modifiers': 179 + specifier: ^9.0.0 180 + version: 9.0.0(@dnd-kit/core@6.3.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0) 181 + '@dnd-kit/sortable': 182 + specifier: ^10.0.0 183 + version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0) 184 + '@dnd-kit/utilities': 185 + specifier: ^3.2.2 186 + version: 3.2.2(react@19.0.0) 187 + '@hookform/resolvers': 188 + specifier: ^3.9.1 189 + version: 3.9.1(react-hook-form@7.54.1(react@19.0.0)) 175 190 '@radix-ui/react-avatar': 176 191 specifier: ^1.1.1 177 192 version: 1.1.1(@types/react-dom@19.0.1)(@types/react@19.0.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) ··· 184 199 '@radix-ui/react-dropdown-menu': 185 200 specifier: ^2.1.4 186 201 version: 2.1.4(@types/react-dom@19.0.1)(@types/react@19.0.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 202 + '@radix-ui/react-icons': 203 + specifier: ^1.3.2 204 + version: 1.3.2(react@19.0.0) 187 205 '@radix-ui/react-label': 188 206 specifier: ^2.1.0 189 207 version: 2.1.0(@types/react-dom@19.0.1)(@types/react@19.0.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) ··· 220 238 react-dom: 221 239 specifier: 19.0.0 222 240 version: 19.0.0(react@19.0.0) 241 + react-hook-form: 242 + specifier: ^7.54.1 243 + version: 7.54.1(react@19.0.0) 223 244 tailwind-merge: 224 245 specifier: ^2.5.5 225 246 version: 2.5.5 ··· 523 544 resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} 524 545 engines: {node: '>=12'} 525 546 547 + '@dnd-kit/accessibility@3.1.1': 548 + resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} 549 + peerDependencies: 550 + react: '>=16.8.0' 551 + 552 + '@dnd-kit/core@6.3.1': 553 + resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==} 554 + peerDependencies: 555 + react: '>=16.8.0' 556 + react-dom: '>=16.8.0' 557 + 558 + '@dnd-kit/modifiers@9.0.0': 559 + resolution: {integrity: sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==} 560 + peerDependencies: 561 + '@dnd-kit/core': ^6.3.0 562 + react: '>=16.8.0' 563 + 564 + '@dnd-kit/sortable@10.0.0': 565 + resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==} 566 + peerDependencies: 567 + '@dnd-kit/core': ^6.3.0 568 + react: '>=16.8.0' 569 + 570 + '@dnd-kit/utilities@3.2.2': 571 + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} 572 + peerDependencies: 573 + react: '>=16.8.0' 574 + 526 575 '@drizzle-team/brocli@0.10.2': 527 576 resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} 528 577 ··· 1152 1201 peerDependencies: 1153 1202 hono: ^4 1154 1203 1204 + '@hookform/resolvers@3.9.1': 1205 + resolution: {integrity: sha512-ud2HqmGBM0P0IABqoskKWI6PEf6ZDDBZkFqe2Vnl+mTHCEHzr3ISjjZyCwTjC/qpL25JC9aIDkloQejvMeq0ug==} 1206 + peerDependencies: 1207 + react-hook-form: ^7.0.0 1208 + 1155 1209 '@humanfs/core@0.19.1': 1156 1210 resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} 1157 1211 engines: {node: '>=18.18.0'} ··· 1686 1740 '@types/react-dom': 1687 1741 optional: true 1688 1742 1743 + '@radix-ui/react-icons@1.3.2': 1744 + resolution: {integrity: sha512-fyQIhGDhzfc9pK2kH6Pl9c4BDJGfMkPqkyIgYDthyNYoNg3wVhoJMMh19WS4Up/1KMPFVpNsT2q3WmXn2N1m6g==} 1745 + peerDependencies: 1746 + react: ^16.x || ^17.x || ^18.x || ^19.0.0 || ^19.0.0-rc 1747 + 1689 1748 '@radix-ui/react-id@1.1.0': 1690 1749 resolution: {integrity: sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA==} 1691 1750 peerDependencies: ··· 3638 3697 peerDependencies: 3639 3698 react: ^19.0.0 3640 3699 3700 + react-hook-form@7.54.1: 3701 + resolution: {integrity: sha512-PUNzFwQeQ5oHiiTUO7GO/EJXGEtuun2Y1A59rLnZBBj+vNEOWt/3ERTiG1/zt7dVeJEM+4vDX/7XQ/qanuvPMg==} 3702 + engines: {node: '>=18.0.0'} 3703 + peerDependencies: 3704 + react: ^16.8.0 || ^17 || ^18 || ^19 3705 + 3641 3706 react-remove-scroll-bar@2.3.8: 3642 3707 resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} 3643 3708 engines: {node: '>=10'} ··· 4422 4487 dependencies: 4423 4488 '@jridgewell/trace-mapping': 0.3.9 4424 4489 4490 + '@dnd-kit/accessibility@3.1.1(react@19.0.0)': 4491 + dependencies: 4492 + react: 19.0.0 4493 + tslib: 2.8.1 4494 + 4495 + '@dnd-kit/core@6.3.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': 4496 + dependencies: 4497 + '@dnd-kit/accessibility': 3.1.1(react@19.0.0) 4498 + '@dnd-kit/utilities': 3.2.2(react@19.0.0) 4499 + react: 19.0.0 4500 + react-dom: 19.0.0(react@19.0.0) 4501 + tslib: 2.8.1 4502 + 4503 + '@dnd-kit/modifiers@9.0.0(@dnd-kit/core@6.3.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)': 4504 + dependencies: 4505 + '@dnd-kit/core': 6.3.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 4506 + '@dnd-kit/utilities': 3.2.2(react@19.0.0) 4507 + react: 19.0.0 4508 + tslib: 2.8.1 4509 + 4510 + '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)': 4511 + dependencies: 4512 + '@dnd-kit/core': 6.3.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 4513 + '@dnd-kit/utilities': 3.2.2(react@19.0.0) 4514 + react: 19.0.0 4515 + tslib: 2.8.1 4516 + 4517 + '@dnd-kit/utilities@3.2.2(react@19.0.0)': 4518 + dependencies: 4519 + react: 19.0.0 4520 + tslib: 2.8.1 4521 + 4425 4522 '@drizzle-team/brocli@0.10.2': {} 4426 4523 4427 4524 '@esbuild-kit/core-utils@3.3.2': ··· 4776 4873 '@hono/node-server@1.13.7(hono@4.6.12)': 4777 4874 dependencies: 4778 4875 hono: 4.6.12 4876 + 4877 + '@hookform/resolvers@3.9.1(react-hook-form@7.54.1(react@19.0.0))': 4878 + dependencies: 4879 + react-hook-form: 7.54.1(react@19.0.0) 4779 4880 4780 4881 '@humanfs/core@0.19.1': {} 4781 4882 ··· 5367 5468 optionalDependencies: 5368 5469 '@types/react': 19.0.1 5369 5470 '@types/react-dom': 19.0.1 5471 + 5472 + '@radix-ui/react-icons@1.3.2(react@19.0.0)': 5473 + dependencies: 5474 + react: 19.0.0 5370 5475 5371 5476 '@radix-ui/react-id@1.1.0(@types/react@19.0.1)(react@19.0.0)': 5372 5477 dependencies: ··· 7254 7359 dependencies: 7255 7360 react: 19.0.0 7256 7361 scheduler: 0.25.0 7362 + 7363 + react-hook-form@7.54.1(react@19.0.0): 7364 + dependencies: 7365 + react: 19.0.0 7257 7366 7258 7367 react-remove-scroll-bar@2.3.8(@types/react@19.0.1)(react@19.0.0): 7259 7368 dependencies: