Add Tiptap enhancements and webinar date/time fields

- Add text alignment controls to Tiptap editor (left, center, right, justify)
- Add horizontal rule/spacer button to Tiptap toolbar
- Add event_start and duration_minutes fields to webinar products
- Add webinar status badges (Recording Available, Coming Soon, Ended)
- Install @tiptap/extension-text-align package

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
dwindown
2025-12-25 13:41:51 +07:00
parent fa274bd8cc
commit eea3a1f8d8
5 changed files with 125 additions and 4 deletions

14
package-lock.json generated
View File

@@ -45,6 +45,7 @@
"@tiptap/extension-table-cell": "^3.14.0", "@tiptap/extension-table-cell": "^3.14.0",
"@tiptap/extension-table-header": "^3.14.0", "@tiptap/extension-table-header": "^3.14.0",
"@tiptap/extension-table-row": "^3.14.0", "@tiptap/extension-table-row": "^3.14.0",
"@tiptap/extension-text-align": "^3.14.0",
"@tiptap/react": "^3.13.0", "@tiptap/react": "^3.13.0",
"@tiptap/starter-kit": "^3.13.0", "@tiptap/starter-kit": "^3.13.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
@@ -3267,6 +3268,19 @@
"@tiptap/core": "^3.13.0" "@tiptap/core": "^3.13.0"
} }
}, },
"node_modules/@tiptap/extension-text-align": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-align/-/extension-text-align-3.14.0.tgz",
"integrity": "sha512-CaxxlbAvfofZZ7KPL28Kg8xuMv8t4rvt5GPwZAqE+jd3rwrucpovpX/SdgclYDc75xs0t8qeoxDFe9HQmG5XZA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.14.0"
}
},
"node_modules/@tiptap/extension-underline": { "node_modules/@tiptap/extension-underline": {
"version": "3.13.0", "version": "3.13.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.13.0.tgz", "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.13.0.tgz",

View File

@@ -48,6 +48,7 @@
"@tiptap/extension-table-cell": "^3.14.0", "@tiptap/extension-table-cell": "^3.14.0",
"@tiptap/extension-table-header": "^3.14.0", "@tiptap/extension-table-header": "^3.14.0",
"@tiptap/extension-table-row": "^3.14.0", "@tiptap/extension-table-row": "^3.14.0",
"@tiptap/extension-text-align": "^3.14.0",
"@tiptap/react": "^3.13.0", "@tiptap/react": "^3.13.0",
"@tiptap/starter-kit": "^3.13.0", "@tiptap/starter-kit": "^3.13.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",

View File

@@ -3,12 +3,13 @@ import StarterKit from '@tiptap/starter-kit';
import Link from '@tiptap/extension-link'; import Link from '@tiptap/extension-link';
import Image from '@tiptap/extension-image'; import Image from '@tiptap/extension-image';
import Placeholder from '@tiptap/extension-placeholder'; import Placeholder from '@tiptap/extension-placeholder';
import TextAlign from '@tiptap/extension-text-align';
import { Node } from '@tiptap/core'; import { Node } from '@tiptap/core';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
Bold, Italic, List, ListOrdered, Quote, Link as LinkIcon, Bold, Italic, List, ListOrdered, Quote, Link as LinkIcon,
Image as ImageIcon, Heading1, Heading2, Undo, Redo, Image as ImageIcon, Heading1, Heading2, Undo, Redo,
Maximize2, Minimize2, MousePointer, Square Maximize2, Minimize2, MousePointer, Square, AlignLeft, AlignCenter, AlignRight, AlignJustify, MoreVertical, Minus
} from 'lucide-react'; } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
@@ -243,7 +244,15 @@ export function RichTextEditor({ content, onChange, placeholder = 'Tulis konten.
const editor = useEditor({ const editor = useEditor({
extensions: [ extensions: [
StarterKit, StarterKit.configure({
heading: {
levels: [1, 2, 3],
},
horizontalRule: true,
}),
TextAlign.configure({
types: ['heading', 'paragraph'],
}),
Link.configure({ Link.configure({
openOnClick: false, openOnClick: false,
HTMLAttributes: { HTMLAttributes: {
@@ -517,6 +526,63 @@ export function RichTextEditor({ content, onChange, placeholder = 'Tulis konten.
<LinkIcon className="w-4 h-4" /> <LinkIcon className="w-4 h-4" />
</Button> </Button>
{/* Text Align Separator */}
<div className="w-px h-6 bg-border mx-1" />
{/* Text Align Buttons */}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().setTextAlign('left').run()}
className={editor.isActive({ textAlign: 'left' }) ? 'bg-primary text-primary-foreground' : ''}
title="Align Left"
>
<AlignLeft className="w-4 h-4" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().setTextAlign('center').run()}
className={editor.isActive({ textAlign: 'center' }) ? 'bg-primary text-primary-foreground' : ''}
title="Align Center"
>
<AlignCenter className="w-4 h-4" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().setTextAlign('right').run()}
className={editor.isActive({ textAlign: 'right' }) ? 'bg-primary text-primary-foreground' : ''}
title="Align Right"
>
<AlignRight className="w-4 h-4" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().setTextAlign('justify').run()}
className={editor.isActive({ textAlign: 'justify' }) ? 'bg-primary text-primary-foreground' : ''}
title="Justify"
>
<AlignJustify className="w-4 h-4" />
</Button>
{/* Spacer/Separator */}
<div className="w-px h-6 bg-border mx-1" />
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().setHorizontalRule().run()}
title="Insert Spacer"
>
<Minus className="w-4 h-4" />
</Button>
{/* Email Components Separator */} {/* Email Components Separator */}
<div className="w-px h-6 bg-border mx-1" /> <div className="w-px h-6 bg-border mx-1" />
@@ -628,7 +694,7 @@ export function RichTextEditor({ content, onChange, placeholder = 'Tulis konten.
<div onPaste={handlePaste}> <div onPaste={handlePaste}>
<EditorContent <EditorContent
editor={editor} editor={editor}
className="prose prose-sm max-w-none p-4 min-h-[200px] focus:outline-none [&_.ProseMirror]:outline-none [&_.ProseMirror]:min-h-[180px] [&_img]:cursor-pointer [&_img.ProseMirror-selectednode]:ring-2 [&_img.ProseMirror-selectednode]:ring-primary [&_h1]:font-bold [&_h1]:text-2xl [&_h1]:mb-4 [&_h1]:mt-6 [&_h2]:font-bold [&_h2]:text-xl [&_h2]:mb-3 [&_h2]:mt-5 [&_blockquote]:border-l-4 [&_blockquote]:border-primary [&_blockquote]:pl-4 [&_blockquote]:italic [&_blockquote]:text-muted-foreground [&_blockquote]:my-4 [&_ul]:list-disc [&_ul]:pl-6 [&_ul]:space-y-1 [&_ol]:list-decimal [&_ol]:pl-6 [&_ol]:space-y-1 [&_li]:marker:text-primary" className="prose prose-sm max-w-none p-4 min-h-[200px] focus:outline-none [&_.ProseMirror]:outline-none [&_.ProseMirror]:min-h-[180px] [&_img]:cursor-pointer [&_img.ProseMirror-selectednode]:ring-2 [&_img.ProseMirror-selectednode]:ring-primary [&_h1]:font-bold [&_h1]:text-2xl [&_h1]:mb-4 [&_h1]:mt-6 [&_h2]:font-bold [&_h2]:text-xl [&_h2]:mb-3 [&_h2]:mt-5 [&_blockquote]:border-l-4 [&_blockquote]:border-primary [&_blockquote]:pl-4 [&_blockquote]:italic [&_blockquote]:text-muted-foreground [&_blockquote]:my-4 [&_ul]:list-disc [&_ul]:pl-6 [&_ul]:space-y-1 [&_ol]:list-decimal [&_ol]:pl-6 [&_ol]:space-y-1 [&_li]:marker:text-primary [&_hr]:border-border [&_hr]:my-4 [&_hr]:border-t-2"
/> />
</div> </div>
{uploading && ( {uploading && (

View File

@@ -345,8 +345,17 @@ export default function ProductDetail() {
<div className="flex flex-col md:flex-row md:items-start justify-between gap-4 mb-6"> <div className="flex flex-col md:flex-row md:items-start justify-between gap-4 mb-6">
<div> <div>
<h1 className="text-4xl font-bold mb-2">{product.title}</h1> <h1 className="text-4xl font-bold mb-2">{product.title}</h1>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 flex-wrap">
<Badge className="bg-primary text-primary-foreground capitalize">{product.type}</Badge> <Badge className="bg-primary text-primary-foreground capitalize">{product.type}</Badge>
{product.type === 'webinar' && product.recording_url && (
<Badge className="bg-secondary text-primary">Rekaman Tersedia</Badge>
)}
{product.type === 'webinar' && !product.recording_url && product.event_start && new Date(product.event_start) > new Date() && (
<Badge className="bg-brand-accent text-white">Segera Hadir</Badge>
)}
{product.type === 'webinar' && !product.recording_url && product.event_start && new Date(product.event_start) <= new Date() && (
<Badge className="bg-muted text-primary">Telah Selesai</Badge>
)}
{hasAccess && <Badge className="bg-primary text-primary-foreground">Anda memiliki akses</Badge>} {hasAccess && <Badge className="bg-primary text-primary-foreground">Anda memiliki akses</Badge>}
</div> </div>
</div> </div>

View File

@@ -29,6 +29,8 @@ interface Product {
content: string; content: string;
meeting_link: string | null; meeting_link: string | null;
recording_url: string | null; recording_url: string | null;
event_start: string | null;
duration_minutes: number | null;
price: number; price: number;
sale_price: number | null; sale_price: number | null;
is_active: boolean; is_active: boolean;
@@ -42,6 +44,8 @@ const emptyProduct = {
content: '', content: '',
meeting_link: '', meeting_link: '',
recording_url: '', recording_url: '',
event_start: null as string | null,
duration_minutes: null as number | null,
price: 0, price: 0,
sale_price: null as number | null, sale_price: null as number | null,
is_active: true, is_active: true,
@@ -84,6 +88,8 @@ export default function AdminProducts() {
content: product.content || '', content: product.content || '',
meeting_link: product.meeting_link || '', meeting_link: product.meeting_link || '',
recording_url: product.recording_url || '', recording_url: product.recording_url || '',
event_start: product.event_start,
duration_minutes: product.duration_minutes,
price: product.price, price: product.price,
sale_price: product.sale_price, sale_price: product.sale_price,
is_active: product.is_active, is_active: product.is_active,
@@ -113,6 +119,8 @@ export default function AdminProducts() {
content: form.content, content: form.content,
meeting_link: form.meeting_link || null, meeting_link: form.meeting_link || null,
recording_url: form.recording_url || null, recording_url: form.recording_url || null,
event_start: form.event_start || null,
duration_minutes: form.duration_minutes || null,
price: form.price, price: form.price,
sale_price: form.sale_price || null, sale_price: form.sale_price || null,
is_active: form.is_active, is_active: form.is_active,
@@ -322,6 +330,29 @@ export default function AdminProducts() {
<Input value={form.recording_url} onChange={(e) => setForm({ ...form, recording_url: e.target.value })} placeholder="https://youtube.com/..." className="border-2" /> <Input value={form.recording_url} onChange={(e) => setForm({ ...form, recording_url: e.target.value })} placeholder="https://youtube.com/..." className="border-2" />
</div> </div>
</div> </div>
{form.type === 'webinar' && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Tanggal & Waktu Webinar</Label>
<Input
type="datetime-local"
value={form.event_start || ''}
onChange={(e) => setForm({ ...form, event_start: e.target.value || null })}
className="border-2"
/>
</div>
<div className="space-y-2">
<Label>Durasi (menit)</Label>
<Input
type="number"
value={form.duration_minutes || ''}
onChange={(e) => setForm({ ...form, duration_minutes: e.target.value ? parseInt(e.target.value) : null })}
placeholder="60"
className="border-2"
/>
</div>
</div>
)}
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label>Harga *</Label> <Label>Harga *</Label>