mirror of
https://github.com/SamyRai/tercul-frontend.git
synced 2025-12-27 04:51:34 +00:00
Add a rich text editor component for creating formatted content
Implements a `RichTextEditor` component with formatting options using React, supporting text styling, lists, links, and image insertion. Replit-Commit-Author: Agent Replit-Commit-Session-Id: cbacfb18-842a-4116-a907-18c0105ad8ec Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/39b5c689-6e8a-4d5a-9792-69cc81a56534/d1468a00-d93a-40b6-8ef2-3b7ba89b0f76.jpg
This commit is contained in:
parent
92d1419642
commit
d3502eaa00
@ -0,0 +1,518 @@
|
|||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import {
|
||||||
|
Bold,
|
||||||
|
Italic,
|
||||||
|
Underline,
|
||||||
|
Heading1,
|
||||||
|
Heading2,
|
||||||
|
List,
|
||||||
|
ListOrdered,
|
||||||
|
Link as LinkIcon,
|
||||||
|
Quote,
|
||||||
|
Code,
|
||||||
|
Image as ImageIcon
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
|
export interface RichTextEditorProps {
|
||||||
|
/**
|
||||||
|
* Initial content value
|
||||||
|
*/
|
||||||
|
value?: string;
|
||||||
|
/**
|
||||||
|
* Event handler called when content changes
|
||||||
|
*/
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
/**
|
||||||
|
* Placeholder text when editor is empty
|
||||||
|
*/
|
||||||
|
placeholder?: string;
|
||||||
|
/**
|
||||||
|
* Height of the editor
|
||||||
|
*/
|
||||||
|
height?: string;
|
||||||
|
/**
|
||||||
|
* Whether to show the preview tab
|
||||||
|
*/
|
||||||
|
showPreview?: boolean;
|
||||||
|
/**
|
||||||
|
* CSS class to apply to the editor container
|
||||||
|
*/
|
||||||
|
className?: string;
|
||||||
|
/**
|
||||||
|
* Whether the editor is disabled
|
||||||
|
*/
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rich text editor component for content creation
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Basic formatting (bold, italic, underline)
|
||||||
|
* - Headings (h1, h2)
|
||||||
|
* - Lists (ordered, unordered)
|
||||||
|
* - Links
|
||||||
|
* - Images
|
||||||
|
* - Code blocks
|
||||||
|
* - Quotes
|
||||||
|
* - Preview mode
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <RichTextEditor
|
||||||
|
* value={content}
|
||||||
|
* onChange={setContent}
|
||||||
|
* placeholder="Write your content here..."
|
||||||
|
* height="min-h-[300px]"
|
||||||
|
* showPreview
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function RichTextEditor({
|
||||||
|
value = "",
|
||||||
|
onChange,
|
||||||
|
placeholder = "Write your content here...",
|
||||||
|
height = "min-h-[300px]",
|
||||||
|
showPreview = true,
|
||||||
|
className = "",
|
||||||
|
disabled = false,
|
||||||
|
}: RichTextEditorProps) {
|
||||||
|
const [content, setContent] = useState(value);
|
||||||
|
const [activeTab, setActiveTab] = useState<string>("write");
|
||||||
|
const [linkDialogOpen, setLinkDialogOpen] = useState(false);
|
||||||
|
const [imageDialogOpen, setImageDialogOpen] = useState(false);
|
||||||
|
const [linkUrl, setLinkUrl] = useState("");
|
||||||
|
const [linkText, setLinkText] = useState("");
|
||||||
|
const [imageUrl, setImageUrl] = useState("");
|
||||||
|
const [imageAlt, setImageAlt] = useState("");
|
||||||
|
const editorRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (value !== content) {
|
||||||
|
setContent(value);
|
||||||
|
}
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (onChange && content !== value) {
|
||||||
|
onChange(content);
|
||||||
|
}
|
||||||
|
}, [content, onChange, value]);
|
||||||
|
|
||||||
|
// Insert text at cursor position or replace selection
|
||||||
|
const insertTextAtCursor = (
|
||||||
|
textBefore: string,
|
||||||
|
textAfter: string = "",
|
||||||
|
defaultText: string = ""
|
||||||
|
) => {
|
||||||
|
if (!editorRef.current) return;
|
||||||
|
|
||||||
|
const textarea = editorRef.current;
|
||||||
|
const selectionStart = textarea.selectionStart;
|
||||||
|
const selectionEnd = textarea.selectionEnd;
|
||||||
|
const selectedText = textarea.value.substring(selectionStart, selectionEnd);
|
||||||
|
const textToInsert = selectedText || defaultText;
|
||||||
|
|
||||||
|
const newText =
|
||||||
|
textarea.value.substring(0, selectionStart) +
|
||||||
|
textBefore +
|
||||||
|
textToInsert +
|
||||||
|
textAfter +
|
||||||
|
textarea.value.substring(selectionEnd);
|
||||||
|
|
||||||
|
setContent(newText);
|
||||||
|
|
||||||
|
// Focus back on textarea and position cursor after inserted text
|
||||||
|
setTimeout(() => {
|
||||||
|
textarea.focus();
|
||||||
|
const newCursorPos = selectionStart + textBefore.length + textToInsert.length + textAfter.length;
|
||||||
|
textarea.setSelectionRange(newCursorPos, newCursorPos);
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format handlers
|
||||||
|
const handleBold = () => insertTextAtCursor("**", "**", "bold text");
|
||||||
|
const handleItalic = () => insertTextAtCursor("*", "*", "italic text");
|
||||||
|
const handleUnderline = () => insertTextAtCursor("<u>", "</u>", "underlined text");
|
||||||
|
const handleH1 = () => insertTextAtCursor("# ", "", "Heading 1");
|
||||||
|
const handleH2 = () => insertTextAtCursor("## ", "", "Heading 2");
|
||||||
|
const handleUl = () => insertTextAtCursor("- ", "", "List item");
|
||||||
|
const handleOl = () => insertTextAtCursor("1. ", "", "List item");
|
||||||
|
const handleQuote = () => insertTextAtCursor("> ", "", "Blockquote");
|
||||||
|
const handleCode = () => insertTextAtCursor("```\n", "\n```", "code");
|
||||||
|
|
||||||
|
const handleInsertLink = () => {
|
||||||
|
if (!linkUrl) return;
|
||||||
|
const linkMarkdown = `[${linkText || linkUrl}](${linkUrl})`;
|
||||||
|
insertTextAtCursor(linkMarkdown, "", "");
|
||||||
|
setLinkDialogOpen(false);
|
||||||
|
setLinkUrl("");
|
||||||
|
setLinkText("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInsertImage = () => {
|
||||||
|
if (!imageUrl) return;
|
||||||
|
const imageMarkdown = ``;
|
||||||
|
insertTextAtCursor(imageMarkdown, "", "");
|
||||||
|
setImageDialogOpen(false);
|
||||||
|
setImageUrl("");
|
||||||
|
setImageAlt("");
|
||||||
|
};
|
||||||
|
|
||||||
|
// Convert markdown to HTML for preview
|
||||||
|
const renderPreview = () => {
|
||||||
|
let html = content;
|
||||||
|
// Convert headings
|
||||||
|
html = html.replace(/^# (.*$)/gm, "<h1>$1</h1>");
|
||||||
|
html = html.replace(/^## (.*$)/gm, "<h2>$1</h2>");
|
||||||
|
// Convert bold
|
||||||
|
html = html.replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>");
|
||||||
|
// Convert italic
|
||||||
|
html = html.replace(/\*(.*?)\*/g, "<em>$1</em>");
|
||||||
|
// Convert links
|
||||||
|
html = html.replace(/\[(.*?)\]\((.*?)\)/g, '<a href="$2" class="text-primary hover:underline">$1</a>');
|
||||||
|
// Convert images
|
||||||
|
html = html.replace(/!\[(.*?)\]\((.*?)\)/g, '<img src="$2" alt="$1" class="max-w-full h-auto" />');
|
||||||
|
// Convert unordered lists
|
||||||
|
html = html.replace(/^- (.*$)/gm, "<li>$1</li>");
|
||||||
|
html = html.replace(/(<li>.*<\/li>)/g, "<ul>$1</ul>");
|
||||||
|
// Convert ordered lists
|
||||||
|
html = html.replace(/^\d+\. (.*$)/gm, "<li>$1</li>");
|
||||||
|
html = html.replace(/(<li>.*<\/li>)/g, "<ol>$1</ol>");
|
||||||
|
// Convert blockquotes
|
||||||
|
html = html.replace(/^> (.*$)/gm, "<blockquote class='pl-4 border-l-4 border-gray-300 italic'>$1</blockquote>");
|
||||||
|
// Convert code blocks
|
||||||
|
html = html.replace(/```([\s\S]*?)```/g, '<pre class="bg-gray-100 dark:bg-gray-800 p-4 rounded overflow-x-auto"><code>$1</code></pre>');
|
||||||
|
// Convert line breaks
|
||||||
|
html = html.replace(/\n/g, "<br />");
|
||||||
|
|
||||||
|
return html;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`border rounded-md ${className}`}>
|
||||||
|
{showPreview ? (
|
||||||
|
<Tabs defaultValue="write" value={activeTab} onValueChange={setActiveTab}>
|
||||||
|
<div className="border-b px-3 py-1">
|
||||||
|
<TabsList className="grid w-[200px] grid-cols-2">
|
||||||
|
<TabsTrigger value="write">Write</TabsTrigger>
|
||||||
|
<TabsTrigger value="preview">Preview</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TabsContent value="write" className="mt-0">
|
||||||
|
<EditorToolbar
|
||||||
|
onBold={handleBold}
|
||||||
|
onItalic={handleItalic}
|
||||||
|
onUnderline={handleUnderline}
|
||||||
|
onH1={handleH1}
|
||||||
|
onH2={handleH2}
|
||||||
|
onUl={handleUl}
|
||||||
|
onOl={handleOl}
|
||||||
|
onLink={() => setLinkDialogOpen(true)}
|
||||||
|
onQuote={handleQuote}
|
||||||
|
onCode={handleCode}
|
||||||
|
onImage={() => setImageDialogOpen(true)}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<Textarea
|
||||||
|
ref={editorRef}
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => setContent(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className={`resize-y border-0 focus-visible:ring-0 focus-visible:ring-transparent ${height}`}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="preview" className="mt-0">
|
||||||
|
{content ? (
|
||||||
|
<div
|
||||||
|
className={`p-3 prose prose-sm md:prose dark:prose-invert max-w-none ${height} overflow-auto`}
|
||||||
|
dangerouslySetInnerHTML={{ __html: renderPreview() }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className={`p-3 text-gray-400 italic ${height}`}>
|
||||||
|
Nothing to preview. Start writing in the "Write" tab.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<EditorToolbar
|
||||||
|
onBold={handleBold}
|
||||||
|
onItalic={handleItalic}
|
||||||
|
onUnderline={handleUnderline}
|
||||||
|
onH1={handleH1}
|
||||||
|
onH2={handleH2}
|
||||||
|
onUl={handleUl}
|
||||||
|
onOl={handleOl}
|
||||||
|
onLink={() => setLinkDialogOpen(true)}
|
||||||
|
onQuote={handleQuote}
|
||||||
|
onCode={handleCode}
|
||||||
|
onImage={() => setImageDialogOpen(true)}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<Textarea
|
||||||
|
ref={editorRef}
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => setContent(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className={`resize-y border-0 focus-visible:ring-0 focus-visible:ring-transparent ${height}`}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Link Dialog */}
|
||||||
|
<Dialog open={linkDialogOpen} onOpenChange={setLinkDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Insert Link</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Add the URL and text for your link
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="url">URL</Label>
|
||||||
|
<Input
|
||||||
|
id="url"
|
||||||
|
value={linkUrl}
|
||||||
|
onChange={(e) => setLinkUrl(e.target.value)}
|
||||||
|
placeholder="https://example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="text">Text</Label>
|
||||||
|
<Input
|
||||||
|
id="text"
|
||||||
|
value={linkText}
|
||||||
|
onChange={(e) => setLinkText(e.target.value)}
|
||||||
|
placeholder="Link text (optional)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setLinkDialogOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleInsertLink}>Insert Link</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Image Dialog */}
|
||||||
|
<Dialog open={imageDialogOpen} onOpenChange={setImageDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Insert Image</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Add the URL and alt text for your image
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="imageUrl">Image URL</Label>
|
||||||
|
<Input
|
||||||
|
id="imageUrl"
|
||||||
|
value={imageUrl}
|
||||||
|
onChange={(e) => setImageUrl(e.target.value)}
|
||||||
|
placeholder="https://example.com/image.jpg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="imageAlt">Alt Text</Label>
|
||||||
|
<Input
|
||||||
|
id="imageAlt"
|
||||||
|
value={imageAlt}
|
||||||
|
onChange={(e) => setImageAlt(e.target.value)}
|
||||||
|
placeholder="Image description (for accessibility)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setImageDialogOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleInsertImage}>Insert Image</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditorToolbarProps {
|
||||||
|
onBold: () => void;
|
||||||
|
onItalic: () => void;
|
||||||
|
onUnderline: () => void;
|
||||||
|
onH1: () => void;
|
||||||
|
onH2: () => void;
|
||||||
|
onUl: () => void;
|
||||||
|
onOl: () => void;
|
||||||
|
onLink: () => void;
|
||||||
|
onQuote: () => void;
|
||||||
|
onCode: () => void;
|
||||||
|
onImage: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditorToolbar({
|
||||||
|
onBold,
|
||||||
|
onItalic,
|
||||||
|
onUnderline,
|
||||||
|
onH1,
|
||||||
|
onH2,
|
||||||
|
onUl,
|
||||||
|
onOl,
|
||||||
|
onLink,
|
||||||
|
onQuote,
|
||||||
|
onCode,
|
||||||
|
onImage,
|
||||||
|
disabled = false,
|
||||||
|
}: EditorToolbarProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-1 border-b p-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="px-2 h-8 w-8"
|
||||||
|
onClick={onBold}
|
||||||
|
disabled={disabled}
|
||||||
|
title="Bold"
|
||||||
|
>
|
||||||
|
<Bold className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="px-2 h-8 w-8"
|
||||||
|
onClick={onItalic}
|
||||||
|
disabled={disabled}
|
||||||
|
title="Italic"
|
||||||
|
>
|
||||||
|
<Italic className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="px-2 h-8 w-8"
|
||||||
|
onClick={onUnderline}
|
||||||
|
disabled={disabled}
|
||||||
|
title="Underline"
|
||||||
|
>
|
||||||
|
<Underline className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<span className="w-px h-6 bg-gray-300 dark:bg-gray-600 mx-1 self-center" />
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="px-2 h-8 w-8"
|
||||||
|
onClick={onH1}
|
||||||
|
disabled={disabled}
|
||||||
|
title="Heading 1"
|
||||||
|
>
|
||||||
|
<Heading1 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="px-2 h-8 w-8"
|
||||||
|
onClick={onH2}
|
||||||
|
disabled={disabled}
|
||||||
|
title="Heading 2"
|
||||||
|
>
|
||||||
|
<Heading2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<span className="w-px h-6 bg-gray-300 dark:bg-gray-600 mx-1 self-center" />
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="px-2 h-8 w-8"
|
||||||
|
onClick={onUl}
|
||||||
|
disabled={disabled}
|
||||||
|
title="Bullet List"
|
||||||
|
>
|
||||||
|
<List className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="px-2 h-8 w-8"
|
||||||
|
onClick={onOl}
|
||||||
|
disabled={disabled}
|
||||||
|
title="Numbered List"
|
||||||
|
>
|
||||||
|
<ListOrdered className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<span className="w-px h-6 bg-gray-300 dark:bg-gray-600 mx-1 self-center" />
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="px-2 h-8 w-8"
|
||||||
|
onClick={onLink}
|
||||||
|
disabled={disabled}
|
||||||
|
title="Insert Link"
|
||||||
|
>
|
||||||
|
<LinkIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="px-2 h-8 w-8"
|
||||||
|
onClick={onImage}
|
||||||
|
disabled={disabled}
|
||||||
|
title="Insert Image"
|
||||||
|
>
|
||||||
|
<ImageIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="px-2 h-8 w-8"
|
||||||
|
onClick={onQuote}
|
||||||
|
disabled={disabled}
|
||||||
|
title="Blockquote"
|
||||||
|
>
|
||||||
|
<Quote className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="px-2 h-8 w-8"
|
||||||
|
onClick={onCode}
|
||||||
|
disabled={disabled}
|
||||||
|
title="Code Block"
|
||||||
|
>
|
||||||
|
<Code className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
client/src/pages/tests/ComponentTest.tsx
Normal file
1
client/src/pages/tests/ComponentTest.tsx
Normal file
@ -0,0 +1 @@
|
|||||||
|
null
|
||||||
Loading…
Reference in New Issue
Block a user