diff --git a/package.json b/package.json index addc395..cdc89a4 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,10 @@ "pre-commit": "lint-staged" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@radix-ui/react-alert-dialog": "^1.1.14", "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-dropdown-menu": "^2.1.15", @@ -25,6 +29,7 @@ "clsx": "^2.1.1", "dayjs": "^1.11.13", "lucide-react": "^0.513.0", + "nanoid": "^5.1.5", "next": "15.3.3", "next-auth": "5.0.0-beta.28", "next-themes": "^0.4.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b2de3ff..12fa18a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,18 @@ importers: .: dependencies: + '@dnd-kit/core': + specifier: ^6.3.1 + version: 6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@dnd-kit/modifiers': + specifier: ^9.0.0 + version: 9.0.0(@dnd-kit/core@6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0) + '@dnd-kit/sortable': + specifier: ^10.0.0 + version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0) + '@dnd-kit/utilities': + specifier: ^3.2.2 + version: 3.2.2(react@19.1.0) '@radix-ui/react-alert-dialog': specifier: ^1.1.14 version: 1.1.14(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -38,6 +50,9 @@ importers: lucide-react: specifier: ^0.513.0 version: 0.513.0(react@19.1.0) + nanoid: + specifier: ^5.1.5 + version: 5.1.5 next: specifier: 15.3.3 version: 15.3.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -133,6 +148,34 @@ packages: nodemailer: optional: true + '@dnd-kit/accessibility@3.1.1': + resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} + peerDependencies: + react: '>=16.8.0' + + '@dnd-kit/core@6.3.1': + resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@dnd-kit/modifiers@9.0.0': + resolution: {integrity: sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==} + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: '>=16.8.0' + + '@dnd-kit/sortable@10.0.0': + resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==} + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: '>=16.8.0' + + '@dnd-kit/utilities@3.2.2': + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} + peerDependencies: + react: '>=16.8.0' + '@emnapi/core@1.4.3': resolution: {integrity: sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g==} @@ -1973,6 +2016,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@5.1.5: + resolution: {integrity: sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==} + engines: {node: ^18 || >=20} + hasBin: true + napi-postinstall@0.2.4: resolution: {integrity: sha512-ZEzHJwBhZ8qQSbknHqYcdtQVr8zUgGyM/q6h6qAyhtyVMNrSgDhrC4disf03dYW0e+czXyLnZINnCTEkWy0eJg==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -2635,6 +2683,38 @@ snapshots: preact: 10.24.3 preact-render-to-string: 6.5.11(preact@10.24.3) + '@dnd-kit/accessibility@3.1.1(react@19.1.0)': + dependencies: + react: 19.1.0 + tslib: 2.8.1 + + '@dnd-kit/core@6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@dnd-kit/accessibility': 3.1.1(react@19.1.0) + '@dnd-kit/utilities': 3.2.2(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + tslib: 2.8.1 + + '@dnd-kit/modifiers@9.0.0(@dnd-kit/core@6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@dnd-kit/utilities': 3.2.2(react@19.1.0) + react: 19.1.0 + tslib: 2.8.1 + + '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@dnd-kit/utilities': 3.2.2(react@19.1.0) + react: 19.1.0 + tslib: 2.8.1 + + '@dnd-kit/utilities@3.2.2(react@19.1.0)': + dependencies: + react: 19.1.0 + tslib: 2.8.1 + '@emnapi/core@1.4.3': dependencies: '@emnapi/wasi-threads': 1.0.2 @@ -4546,6 +4626,8 @@ snapshots: nanoid@3.3.11: {} + nanoid@5.1.5: {} + napi-postinstall@0.2.4: {} natural-compare@1.4.0: {} diff --git a/src/app/(app)/_components/header/header.tsx b/src/app/(app)/_components/header/header.tsx index 4338f8a..0d10351 100644 --- a/src/app/(app)/_components/header/header.tsx +++ b/src/app/(app)/_components/header/header.tsx @@ -39,8 +39,9 @@ export default function Header() { )}
-
diff --git a/src/app/(app)/~/[repo]/new/_components/active-field-item.tsx b/src/app/(app)/~/[repo]/new/_components/active-field-item.tsx new file mode 100644 index 0000000..b81c197 --- /dev/null +++ b/src/app/(app)/~/[repo]/new/_components/active-field-item.tsx @@ -0,0 +1,46 @@ +import { cn } from '@/lib/utils'; +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { GripVertical, Trash } from 'lucide-react'; + +interface Props { + id: string; + label: string; + Icon: React.ComponentType<{ className: string }>; + onDelete: (id: string) => void; +} + +export default function ActiveFieldItem({ id, label, Icon, onDelete }: Props) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id, + }); + + const style: React.CSSProperties = { + transform: CSS.Transform.toString(transform), + transition, + zIndex: isDragging ? 1 : 0, + }; + + return ( +
+ + {label} + onDelete(id)} + /> + +
+ ); +} diff --git a/src/app/(app)/~/[repo]/new/_components/dnd/sortable.tsx b/src/app/(app)/~/[repo]/new/_components/dnd/sortable.tsx new file mode 100644 index 0000000..1ab5f8e --- /dev/null +++ b/src/app/(app)/~/[repo]/new/_components/dnd/sortable.tsx @@ -0,0 +1,40 @@ +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { HTMLAttributes } from 'react'; + +interface Props { + id: string; + children: (props: { gripProps: HTMLAttributes }) => React.ReactNode; +} + +export default function Sortable({ id, children }: Props) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id, + }); + + const style: React.CSSProperties = { + transform: CSS.Transform.toString(transform), + transition, + zIndex: isDragging ? 1 : 0, + }; + + return ( + // suppressing "...attributes" warning +
+ {children({ + gripProps: { + ...listeners, + className: `text-muted-foreground size-5 stroke-[1.5] ${ + isDragging ? 'cursor-grabbing' : 'cursor-grab' + }`, + }, + })} +
+ ); +} diff --git a/src/app/(app)/~/[repo]/new/_components/editor.tsx b/src/app/(app)/~/[repo]/new/_components/editor.tsx new file mode 100644 index 0000000..67c5018 --- /dev/null +++ b/src/app/(app)/~/[repo]/new/_components/editor.tsx @@ -0,0 +1,59 @@ +import { DndContext, DragEndEvent } from '@dnd-kit/core'; +import { restrictToVerticalAxis } from '@dnd-kit/modifiers'; +import { arrayMove, SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; +import { useActiveField } from '../_contexts/active-field.context'; +import { FieldId } from '../_types/field'; +import Sortable from './dnd/sortable'; +import TextInput from './fields/text-input'; + +export default function Editor() { + const { activeFields, setActiveFields } = useActiveField(); + + function getField(fieldId: FieldId) { + switch (fieldId) { + case 'text': + return TextInput; + } + } + + function handleDragEnd(e: DragEndEvent) { + const { active, over } = e; + + if (over && active.id !== over.id) { + setActiveFields((items) => { + const oldIndex = items.findIndex((item) => item.id === active.id); + const newIndex = items.findIndex((item) => item.id === over.id); + + const newArr = arrayMove(items, oldIndex, newIndex); + return newArr; + }); + } + } + + return ( +
+
+

Editor

+ + Arrange and edit frontmatter fields below + +
+ + +
+ {activeFields.map(({ id, fieldId }) => { + const field = getField(fieldId); + if (!field) return null; + + return ( + + {({ gripProps }) => field({ id, gripProps })} + + ); + })} +
+
+
+
+ ); +} diff --git a/src/app/(app)/~/[repo]/new/_components/field-item.tsx b/src/app/(app)/~/[repo]/new/_components/field-item.tsx new file mode 100644 index 0000000..59ccc84 --- /dev/null +++ b/src/app/(app)/~/[repo]/new/_components/field-item.tsx @@ -0,0 +1,20 @@ +import { FieldId } from '../_types/field'; + +interface Props { + id: FieldId; + label: string; + Icon: React.ComponentType<{ className: string }>; + addField: (id: FieldId) => void; +} + +export default function FieldItem({ id, label, Icon, addField }: Props) { + return ( + + ); +} diff --git a/src/app/(app)/~/[repo]/new/_components/fields/text-input.tsx b/src/app/(app)/~/[repo]/new/_components/fields/text-input.tsx new file mode 100644 index 0000000..62cffa5 --- /dev/null +++ b/src/app/(app)/~/[repo]/new/_components/fields/text-input.tsx @@ -0,0 +1,37 @@ +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { GripVertical, RotateCcw, Trash, Type } from 'lucide-react'; +import { HTMLAttributes } from 'react'; +import { useActiveField } from '../../_contexts/active-field.context'; + +interface Props { + id: string; + gripProps: HTMLAttributes; +} + +export default function TextInput({ id, gripProps }: Props) { + const { activeFields } = useActiveField(); + const field = activeFields.find((field) => field.id === id); + + if (!field) return null; + + return ( +
+ + + : + +
+ + + +
+
+ ); +} diff --git a/src/app/(app)/~/[repo]/new/_components/sidebar.tsx b/src/app/(app)/~/[repo]/new/_components/sidebar.tsx new file mode 100644 index 0000000..e9501a2 --- /dev/null +++ b/src/app/(app)/~/[repo]/new/_components/sidebar.tsx @@ -0,0 +1,30 @@ +import { nanoid } from 'nanoid'; +import { useActiveField } from '../_contexts/active-field.context'; +import { FieldId } from '../_types/field'; +import { FIELD } from '../constants'; +import FieldItem from './field-item'; + +export default function Sidebar() { + const { setActiveFields } = useActiveField(); + + function addField(fieldId: FieldId) { + setActiveFields((prev) => [ + ...prev, + { id: `${fieldId}-${nanoid()}`, fieldId: fieldId, key: '', value: '' }, + ]); + } + + return ( +
+
+

Fields

+ Click on a field below to add +
+
+ {Object.entries(FIELD).map(([id, { label, Icon }]) => ( + + ))} +
+
+ ); +} diff --git a/src/app/(app)/~/[repo]/new/_contexts/active-field.context.ts b/src/app/(app)/~/[repo]/new/_contexts/active-field.context.ts new file mode 100644 index 0000000..66ab3ce --- /dev/null +++ b/src/app/(app)/~/[repo]/new/_contexts/active-field.context.ts @@ -0,0 +1,15 @@ +import { createContext, useContext } from 'react'; +import { ActiveField } from '../_types/field'; + +export interface ActiveFieldContextValue { + activeFields: ActiveField[]; + setActiveFields: React.Dispatch>; +} + +export const ActiveFieldContext = createContext(null); +export function useActiveField(): ActiveFieldContextValue { + const context = useContext(ActiveFieldContext); + if (!context) throw new Error('useField must be used within FieldProvider'); + + return context; +} diff --git a/src/app/(app)/~/[repo]/new/_providers/active-field.provider.tsx b/src/app/(app)/~/[repo]/new/_providers/active-field.provider.tsx new file mode 100644 index 0000000..4fa5583 --- /dev/null +++ b/src/app/(app)/~/[repo]/new/_providers/active-field.provider.tsx @@ -0,0 +1,21 @@ +import { nanoid } from 'nanoid'; +import { useState } from 'react'; +import { ActiveFieldContext, ActiveFieldContextValue } from '../_contexts/active-field.context'; +import { ActiveField } from '../_types/field'; + +interface ActiveFieldProviderProps { + children: React.ReactNode; +} + +export function ActiveFieldProvider({ children }: ActiveFieldProviderProps) { + const [activeFields, setActiveFields] = useState([ + { id: `text-${nanoid()}`, fieldId: 'text', key: '', value: '' }, + ]); + + const contextValue: ActiveFieldContextValue = { + activeFields, + setActiveFields, + }; + + return {children}; +} diff --git a/src/app/(app)/~/[repo]/new/_types/field.ts b/src/app/(app)/~/[repo]/new/_types/field.ts new file mode 100644 index 0000000..7a371ce --- /dev/null +++ b/src/app/(app)/~/[repo]/new/_types/field.ts @@ -0,0 +1,15 @@ +export type FieldId = 'text' | 'slug'; +export type Field = Record< + FieldId, + { + label: string; + Icon: React.ComponentType<{ className: string }>; + } +>; + +export interface ActiveField { + id: string; + fieldId: FieldId; + key: string; + value: string; +} diff --git a/src/app/(app)/~/[repo]/new/constants.ts b/src/app/(app)/~/[repo]/new/constants.ts new file mode 100644 index 0000000..e767398 --- /dev/null +++ b/src/app/(app)/~/[repo]/new/constants.ts @@ -0,0 +1,13 @@ +import { TextCursorInput, Type } from 'lucide-react'; +import { Field } from './_types/field'; + +export const FIELD: Field = { + text: { + label: 'Text Input', + Icon: Type, + }, + slug: { + label: 'Slug', + Icon: TextCursorInput, + }, +} as const; diff --git a/src/app/(app)/~/[repo]/new/page.tsx b/src/app/(app)/~/[repo]/new/page.tsx new file mode 100644 index 0000000..cc64146 --- /dev/null +++ b/src/app/(app)/~/[repo]/new/page.tsx @@ -0,0 +1,18 @@ +'use client'; + +import Editor from './_components/editor'; +import Sidebar from './_components/sidebar'; +import { ActiveFieldProvider } from './_providers/active-field.provider'; + +export default function Page() { + return ( +
+ +
+ +
+ +
+
+ ); +}