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

View File

@@ -3,12 +3,13 @@ import StarterKit from '@tiptap/starter-kit';
import Link from '@tiptap/extension-link';
import Image from '@tiptap/extension-image';
import Placeholder from '@tiptap/extension-placeholder';
import TextAlign from '@tiptap/extension-text-align';
import { Node } from '@tiptap/core';
import { Button } from '@/components/ui/button';
import {
Bold, Italic, List, ListOrdered, Quote, Link as LinkIcon,
Image as ImageIcon, Heading1, Heading2, Undo, Redo,
Maximize2, Minimize2, MousePointer, Square
Maximize2, Minimize2, MousePointer, Square, AlignLeft, AlignCenter, AlignRight, AlignJustify, MoreVertical, Minus
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useCallback, useEffect, useState } from 'react';
@@ -243,7 +244,15 @@ export function RichTextEditor({ content, onChange, placeholder = 'Tulis konten.
const editor = useEditor({
extensions: [
StarterKit,
StarterKit.configure({
heading: {
levels: [1, 2, 3],
},
horizontalRule: true,
}),
TextAlign.configure({
types: ['heading', 'paragraph'],
}),
Link.configure({
openOnClick: false,
HTMLAttributes: {
@@ -517,6 +526,63 @@ export function RichTextEditor({ content, onChange, placeholder = 'Tulis konten.
<LinkIcon className="w-4 h-4" />
</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 */}
<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}>
<EditorContent
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>
{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>
<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>
{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>}
</div>
</div>

View File

@@ -29,6 +29,8 @@ interface Product {
content: string;
meeting_link: string | null;
recording_url: string | null;
event_start: string | null;
duration_minutes: number | null;
price: number;
sale_price: number | null;
is_active: boolean;
@@ -42,6 +44,8 @@ const emptyProduct = {
content: '',
meeting_link: '',
recording_url: '',
event_start: null as string | null,
duration_minutes: null as number | null,
price: 0,
sale_price: null as number | null,
is_active: true,
@@ -84,6 +88,8 @@ export default function AdminProducts() {
content: product.content || '',
meeting_link: product.meeting_link || '',
recording_url: product.recording_url || '',
event_start: product.event_start,
duration_minutes: product.duration_minutes,
price: product.price,
sale_price: product.sale_price,
is_active: product.is_active,
@@ -113,6 +119,8 @@ export default function AdminProducts() {
content: form.content,
meeting_link: form.meeting_link || null,
recording_url: form.recording_url || null,
event_start: form.event_start || null,
duration_minutes: form.duration_minutes || null,
price: form.price,
sale_price: form.sale_price || null,
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" />
</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="space-y-2">
<Label>Harga *</Label>