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 (
+ addField(id)}
+ >
+
+ {label}
+
+ );
+}
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 (
+
+ );
+}