feat: replace rich text editor with custom Tiptap implementation and add HTML formatting utilities
This commit is contained in:
@@ -21,7 +21,7 @@ import {
|
|||||||
} from "@/app/actions/questions";
|
} from "@/app/actions/questions";
|
||||||
import { Plus, Trash, ArrowUp, ArrowDown, Loader2, Save, Image as ImageIcon, X } from "lucide-react";
|
import { Plus, Trash, ArrowUp, ArrowDown, Loader2, Save, Image as ImageIcon, X } from "lucide-react";
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { RichTextEditor } from "@/components/ui/rich-text-editor";
|
import { RichTextEditor } from "@/components/rich-text-editor";
|
||||||
|
|
||||||
interface Question {
|
interface Question {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import {
|
|||||||
import { Copy, Check, Users, Calendar, Clock } from "lucide-react";
|
import { Copy, Check, Users, Calendar, Clock } from "lucide-react";
|
||||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
|
import { stripHtml } from "@/lib/utils";
|
||||||
|
import { HtmlDisplay } from "@/components/html-display";
|
||||||
|
|
||||||
interface Question {
|
interface Question {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -112,9 +114,9 @@ export default function SubmissionDetailClient({
|
|||||||
>
|
>
|
||||||
<CardHeader className="pb-3 flex flex-row items-start justify-between space-y-0 gap-4">
|
<CardHeader className="pb-3 flex flex-row items-start justify-between space-y-0 gap-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-base font-semibold leading-normal">
|
<div className="text-base font-semibold leading-normal">
|
||||||
{q.label}
|
<HtmlDisplay html={q.label} />
|
||||||
</Label>
|
</div>
|
||||||
{wasEdited && (
|
{wasEdited && (
|
||||||
<p className="text-xs text-muted-foreground italic flex items-center gap-1">
|
<p className="text-xs text-muted-foreground italic flex items-center gap-1">
|
||||||
edited
|
edited
|
||||||
@@ -173,7 +175,7 @@ export default function SubmissionDetailClient({
|
|||||||
History
|
History
|
||||||
</ContextMenuLabel>
|
</ContextMenuLabel>
|
||||||
<ContextMenuLabel className="text-sm font-medium text-foreground truncate pb-2">
|
<ContextMenuLabel className="text-sm font-medium text-foreground truncate pb-2">
|
||||||
{q.label}
|
{stripHtml(q.label)}
|
||||||
</ContextMenuLabel>
|
</ContextMenuLabel>
|
||||||
|
|
||||||
<ContextMenuSeparator />
|
<ContextMenuSeparator />
|
||||||
|
|||||||
+4
-41
@@ -9,6 +9,9 @@ import { Textarea } from "@/components/ui/textarea";
|
|||||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { cn, stripHtml } from "@/lib/utils";
|
||||||
|
import { HtmlDisplay } from "@/components/html-display";
|
||||||
|
import { FormattedText } from "@/components/formatted-text";
|
||||||
import { Dialog, DialogContent, DialogTrigger, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogTrigger, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
|
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -28,40 +31,7 @@ import {
|
|||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
|
|
||||||
function FormattedText({ text }: { text: string }) {
|
|
||||||
const lines = text.split('\n');
|
|
||||||
const urlRegex = /(https?:\/\/[^\s]+)/g;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{lines.map((line, i) => {
|
|
||||||
const parts = line.split(urlRegex);
|
|
||||||
return (
|
|
||||||
<React.Fragment key={i}>
|
|
||||||
{i > 0 && <br />}
|
|
||||||
{parts.map((part, j) => {
|
|
||||||
if (part.match(urlRegex)) {
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
key={j}
|
|
||||||
href={part}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-primary hover:underline underline-offset-4"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{part}
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return part;
|
|
||||||
})}
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FormLayoutClient({
|
export function FormLayoutClient({
|
||||||
userName,
|
userName,
|
||||||
@@ -119,10 +89,6 @@ interface FormFillerClientProps {
|
|||||||
const LS_KEY = (formId: string) => `form-draft-${formId}`;
|
const LS_KEY = (formId: string) => `form-draft-${formId}`;
|
||||||
const QUESTIONS_PER_STEP = 3;
|
const QUESTIONS_PER_STEP = 3;
|
||||||
|
|
||||||
const stripHtml = (html: string) => {
|
|
||||||
return (html || "").replace(/<[^>]*>?/gm, '').trim();
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildSchema = (questions: Question[]) => {
|
const buildSchema = (questions: Question[]) => {
|
||||||
const shape: Record<string, z.ZodTypeAny> = {};
|
const shape: Record<string, z.ZodTypeAny> = {};
|
||||||
questions.forEach(q => {
|
questions.forEach(q => {
|
||||||
@@ -315,10 +281,7 @@ export function FormFillerClient({ form, questions, initialAnswers = {} }: FormF
|
|||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div className="text-base leading-normal mt-1 flex-1">
|
<div className="text-base leading-normal mt-1 flex-1">
|
||||||
<div
|
<HtmlDisplay html={q.label} />
|
||||||
className="prose prose-sm dark:prose-invert max-w-none [&>p]:m-0"
|
|
||||||
dangerouslySetInnerHTML={{ __html: q.label || "" }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-start gap-4 shrink-0">
|
<div className="flex items-start gap-4 shrink-0">
|
||||||
{q.imageUrl && (
|
{q.imageUrl && (
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
export function FormattedText({ text }: { text: string }) {
|
||||||
|
const lines = text.split('\n');
|
||||||
|
const urlRegex = /(https?:\/\/[^\s]+)/g;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{lines.map((line, i) => {
|
||||||
|
const parts = line.split(urlRegex);
|
||||||
|
return (
|
||||||
|
<React.Fragment key={i}>
|
||||||
|
{i > 0 && <br />}
|
||||||
|
{parts.map((part, j) => {
|
||||||
|
if (part.match(urlRegex)) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={j}
|
||||||
|
href={part}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary hover:underline underline-offset-4"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{part}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return part;
|
||||||
|
})}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
export interface HtmlDisplayProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
html?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HtmlDisplay({ html, className, ...props }: HtmlDisplayProps) {
|
||||||
|
if (!html) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn("prose prose-sm dark:prose-invert max-w-none [&>p]:m-0", className)}
|
||||||
|
dangerouslySetInnerHTML={{ __html: html }}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,3 +4,7 @@ import { twMerge } from "tailwind-merge"
|
|||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const stripHtml = (html: string) => {
|
||||||
|
return (html || "").replace(/<[^>]*>?/gm, '').trim();
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user