refactor: reorganize imports across multiple components for improved clarity and consistency

This commit is contained in:
Yoga Pangestu 2026-04-23 15:02:59 +07:00
parent 24da9d5333
commit 221f796696
109 changed files with 1528 additions and 765 deletions

View File

@ -1,5 +1,5 @@
import { NavUser } from '@/components/nav-user';
import { Breadcrumbs } from '@/components/breadcrumbs'; import { Breadcrumbs } from '@/components/breadcrumbs';
import { NavUser } from '@/components/nav-user';
import { SidebarTrigger } from '@/components/ui/sidebar'; import { SidebarTrigger } from '@/components/ui/sidebar';
import type { BreadcrumbItem as BreadcrumbItemType } from '@/types'; import type { BreadcrumbItem as BreadcrumbItemType } from '@/types';

View File

@ -13,14 +13,14 @@ import {
import { dashboard } from '@/routes'; import { dashboard } from '@/routes';
import category from '@/routes/category'; import category from '@/routes/category';
import type { NavItem } from '@/types';
import product from '@/routes/product';
import expense from '@/routes/expense'; import expense from '@/routes/expense';
import payroll from '@/routes/payroll';
import user from '@/routes/user';
import system from '@/routes/system';
import purchase from '@/routes/purchase';
import order from '@/routes/order'; import order from '@/routes/order';
import payroll from '@/routes/payroll';
import product from '@/routes/product';
import purchase from '@/routes/purchase';
import system from '@/routes/system';
import user from '@/routes/user';
import type { NavItem } from '@/types';
const mainNavItems: NavItem[] = [ const mainNavItems: NavItem[] = [
{ {

View File

@ -1,9 +1,9 @@
import { cn } from '@/lib/utils';
import { formatCurrency, formatNumber } from '@/lib/formatters';
import { StatItem } from '@/types';
import { TrendingUp, TrendingDown } from 'lucide-react'; import { TrendingUp, TrendingDown } from 'lucide-react';
import { Card, CardHeader, CardTitle, CardDescription, CardAction, CardFooter } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Card, CardHeader, CardTitle, CardDescription, CardAction, CardFooter } from '@/components/ui/card';
import { formatCurrency, formatNumber } from '@/lib/formatters';
import { cn } from '@/lib/utils';
import type { StatItem } from '@/types';
export interface StatCardProps { export interface StatCardProps {
title: string; title: string;

View File

@ -1,5 +1,5 @@
import { formatTime, formatDate } from '@/lib/formatters';
import { Sun, Moon, Cloud, Trees, Stars, Bird } from 'lucide-react'; import { Sun, Moon, Cloud, Trees, Stars, Bird } from 'lucide-react';
import { formatTime, formatDate } from '@/lib/formatters';
const DynamicScene = ({ hour }: { hour: number }) => { const DynamicScene = ({ hour }: { hour: number }) => {
if (hour >= 5 && hour < 11) { if (hour >= 5 && hour < 11) {
@ -11,6 +11,7 @@ const DynamicScene = ({ hour }: { hour: number }) => {
</div> </div>
); );
} }
if (hour >= 11 && hour < 15) { if (hour >= 11 && hour < 15) {
return ( return (
<div className="relative h-24 w-32 overflow-hidden"> <div className="relative h-24 w-32 overflow-hidden">
@ -20,6 +21,7 @@ const DynamicScene = ({ hour }: { hour: number }) => {
</div> </div>
); );
} }
if (hour >= 15 && hour < 19) { if (hour >= 15 && hour < 19) {
return ( return (
<div className="relative h-24 w-32 overflow-hidden"> <div className="relative h-24 w-32 overflow-hidden">
@ -29,6 +31,7 @@ const DynamicScene = ({ hour }: { hour: number }) => {
</div> </div>
); );
} }
return ( return (
<div className="relative h-24 w-32 overflow-hidden"> <div className="relative h-24 w-32 overflow-hidden">
<Moon className="absolute top-2 left-6 h-14 w-14 rotate-12 text-indigo-200" fill="currentColor" /> <Moon className="absolute top-2 left-6 h-14 w-14 rotate-12 text-indigo-200" fill="currentColor" />

View File

@ -13,9 +13,10 @@ import {
import { import {
ChartContainer, ChartContainer,
ChartTooltip, ChartTooltip,
ChartTooltipContent, ChartTooltipContent
type ChartConfig,
} from "@/components/ui/chart" } from "@/components/ui/chart"
import type {ChartConfig} from "@/components/ui/chart";
export const CustomBarChart = React.memo(function CustomBarChart({ export const CustomBarChart = React.memo(function CustomBarChart({
title, title,
@ -44,6 +45,7 @@ export const CustomBarChart = React.memo(function CustomBarChart({
label: String(item.name), label: String(item.name),
color: `hsl(${hue}, 60%, 65%)`, color: `hsl(${hue}, 60%, 65%)`,
} }
return { return {
...item, ...item,
fill: `var(--color-${key})`, fill: `var(--color-${key})`,
@ -51,11 +53,15 @@ export const CustomBarChart = React.memo(function CustomBarChart({
total: Number(item.total) total: Number(item.total)
} }
}) })
return { config: cfg, chartData: formattedData } return { config: cfg, chartData: formattedData }
}, [data, colorOffset]) }, [data, colorOffset])
const formatValue = (val: any) => { const formatValue = (val: any) => {
if (!isCurrency) return val; if (!isCurrency) {
return val;
}
return new Intl.NumberFormat('id-ID', { style: 'currency', currency: 'IDR', maximumFractionDigits: 0 }).format(val); return new Intl.NumberFormat('id-ID', { style: 'currency', currency: 'IDR', maximumFractionDigits: 0 }).format(val);
}; };

View File

@ -13,9 +13,10 @@ import {
import { import {
ChartContainer, ChartContainer,
ChartTooltip, ChartTooltip,
ChartTooltipContent, ChartTooltipContent
type ChartConfig,
} from "@/components/ui/chart" } from "@/components/ui/chart"
import type {ChartConfig} from "@/components/ui/chart";
export const CustomPieChart = React.memo(function CustomPieChart({ export const CustomPieChart = React.memo(function CustomPieChart({
title, title,
@ -44,12 +45,14 @@ export const CustomPieChart = React.memo(function CustomPieChart({
label: String(item.name), label: String(item.name),
color: `hsl(${hue}, 70%, 50%)`, color: `hsl(${hue}, 70%, 50%)`,
} }
return { return {
...item, ...item,
fill: `var(--color-${key})`, fill: `var(--color-${key})`,
name: String(item.name), name: String(item.name),
} }
}) })
return { config: cfg, chartData: formattedData } return { config: cfg, chartData: formattedData }
}, [data, colorOffset]) }, [data, colorOffset])

View File

@ -14,9 +14,10 @@ import {
ChartLegend, ChartLegend,
ChartLegendContent, ChartLegendContent,
ChartTooltip, ChartTooltip,
ChartTooltipContent, ChartTooltipContent
type ChartConfig,
} from "@/components/ui/chart" } from "@/components/ui/chart"
import type {ChartConfig} from "@/components/ui/chart";
export const description = "Menampilkan aktivitas toko berdasarkan pesanan yang masuk." export const description = "Menampilkan aktivitas toko berdasarkan pesanan yang masuk."

View File

@ -1,8 +1,8 @@
import { type Column } from "@tanstack/react-table" import type {Column} from "@tanstack/react-table";
import { ArrowDown, ArrowUp, ChevronsUpDown } from "lucide-react" import { ArrowDown, ArrowUp, ChevronsUpDown } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
interface DataTableColumnHeaderProps<TData, TValue> interface DataTableColumnHeaderProps<TData, TValue>
extends React.HTMLAttributes<HTMLDivElement> { extends React.HTMLAttributes<HTMLDivElement> {

View File

@ -1,26 +1,20 @@
import { import type {
ColumnDef, ColumnDef,
SortingState,
ColumnFiltersState,
VisibilityState} from "@tanstack/react-table";
import {
flexRender, flexRender,
getCoreRowModel, getCoreRowModel,
useReactTable, useReactTable,
getPaginationRowModel, getPaginationRowModel,
SortingState,
getSortedRowModel, getSortedRowModel,
ColumnFiltersState, getFilteredRowModel
getFilteredRowModel,
VisibilityState,
} from "@tanstack/react-table"; } from "@tanstack/react-table";
import { import { Settings2, ChevronDown, ListFilter, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, X } from "lucide-react";
Table, import React from "react";
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "./ui/button";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuCheckboxItem, DropdownMenuCheckboxItem,
@ -33,16 +27,11 @@ import {
DropdownMenuSubTrigger, DropdownMenuSubTrigger,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import React from "react";
import { Input } from "./ui/input";
import { Settings2, ChevronDown, ListFilter, Trash2, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, List, X } from "lucide-react";
import { import {
Empty, Empty,
EmptyContent,
EmptyDescription, EmptyDescription,
EmptyHeader, EmptyHeader,
EmptyMedia,
EmptyTitle, EmptyTitle,
} from "@/components/ui/empty" } from "@/components/ui/empty"
import { import {
@ -52,7 +41,17 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select" } from "@/components/ui/select"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Badge } from "./ui/badge"; import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
interface DataTableFilterOption { interface DataTableFilterOption {
columnId: string; columnId: string;
@ -245,7 +244,10 @@ export function DataTable<TData, TValue>({
<DropdownMenuSeparator /> <DropdownMenuSeparator />
{filters.map((filter) => { {filters.map((filter) => {
const column = table.getColumn(filter.columnId); const column = table.getColumn(filter.columnId);
if (!column) return null;
if (!column) {
return null;
}
return ( return (
<DropdownMenuSub key={filter.columnId}> <DropdownMenuSub key={filter.columnId}>
@ -265,9 +267,13 @@ export function DataTable<TData, TValue>({
key={option.value} key={option.value}
onSelect={(e) => e.preventDefault()} onSelect={(e) => e.preventDefault()}
onClick={() => { onClick={() => {
if (option.value === "true") column.setFilterValue(true); if (option.value === "true") {
else if (option.value === "false") column.setFilterValue(false); column.setFilterValue(true);
else column.setFilterValue(option.value); } else if (option.value === "false") {
column.setFilterValue(false);
} else {
column.setFilterValue(option.value);
}
}} }}
> >
{option.label} {option.label}

View File

@ -1,6 +1,6 @@
import type { HTMLAttributes } from 'react'; import type { HTMLAttributes } from 'react';
import { cn } from '@/lib/utils';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { cn } from '@/lib/utils';
interface InputErrorProps extends HTMLAttributes<HTMLParagraphElement> { interface InputErrorProps extends HTMLAttributes<HTMLParagraphElement> {
message?: string; message?: string;
@ -14,8 +14,13 @@ export default function InputError({
...props ...props
}: InputErrorProps) { }: InputErrorProps) {
const formattedError = useMemo(() => { const formattedError = useMemo(() => {
if (!message) return null; if (!message) {
if (!label) return message; return null;
}
if (!label) {
return message;
}
// Preserve all-caps labels (like NIK), otherwise capitalize first letter and lowercase the rest // Preserve all-caps labels (like NIK), otherwise capitalize first letter and lowercase the rest
const isAllOptionsCaps = label === label.toUpperCase() && label.length > 1; const isAllOptionsCaps = label === label.toUpperCase() && label.length > 1;
@ -33,6 +38,7 @@ export default function InputError({
// Find the first occurrence of a verb // Find the first occurrence of a verb
let verbIndex = -1; let verbIndex = -1;
for (let i = 0; i < words.length; i++) { for (let i = 0; i < words.length; i++) {
if (verbs.includes(words[i].toLowerCase())) { if (verbs.includes(words[i].toLowerCase())) {
verbIndex = i; verbIndex = i;

View File

@ -1,4 +1,5 @@
import { Trash2, X } from 'lucide-react'; import { Trash2, X } from 'lucide-react';
import React from 'react';
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@ -10,7 +11,6 @@ import {
AlertDialogMedia, AlertDialogMedia,
AlertDialogTitle, AlertDialogTitle,
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import React from 'react';
interface DeleteConfirmationProps { interface DeleteConfirmationProps {
isOpen: boolean; isOpen: boolean;

View File

@ -2,9 +2,9 @@ interface ImagePreviewDialogProps {
imageUrl: string | null; imageUrl: string | null;
onClose: () => void; onClose: () => void;
} }
import { Dialog, DialogContent, DialogTitle, DialogDescription } from "@/components/ui/dialog"
import { VisuallyHidden } from "@radix-ui/react-visually-hidden" import { VisuallyHidden } from "@radix-ui/react-visually-hidden"
import { X } from 'lucide-react'; import { X } from 'lucide-react';
import { Dialog, DialogContent, DialogTitle, DialogDescription } from "@/components/ui/dialog"
export function ImagePreviewDialog({ imageUrl, onClose }: ImagePreviewDialogProps) { export function ImagePreviewDialog({ imageUrl, onClose }: ImagePreviewDialogProps) {
return ( return (

View File

@ -34,10 +34,13 @@ function getToggleColor(
targets: NodeWithPos[], targets: NodeWithPos[],
inputColor: string inputColor: string
): string | null { ): string | null {
if (targets.length === 0) return null if (targets.length === 0) {
return null
}
for (const target of targets) { for (const target of targets) {
const currentColor = target.node.attrs?.backgroundColor ?? null const currentColor = target.node.attrs?.backgroundColor ?? null
if (currentColor !== inputColor) { if (currentColor !== inputColor) {
return inputColor return inputColor
} }
@ -75,15 +78,22 @@ export const NodeBackground = Extension.create<NodeBackgroundOptions>({
parseHTML: (element: HTMLElement) => { parseHTML: (element: HTMLElement) => {
const styleColor = element.style?.backgroundColor const styleColor = element.style?.backgroundColor
if (styleColor) return styleColor
if (styleColor) {
return styleColor
}
const dataColor = element.getAttribute("data-background-color") const dataColor = element.getAttribute("data-background-color")
return dataColor || null return dataColor || null
}, },
renderHTML: (attributes) => { renderHTML: (attributes) => {
const color = attributes.backgroundColor as string | null const color = attributes.backgroundColor as string | null
if (!color) return {}
if (!color) {
return {}
}
if (this.options.useStyle) { if (this.options.useStyle) {
return { return {
@ -118,7 +128,9 @@ export const NodeBackground = Extension.create<NodeBackgroundOptions>({
this.options.types this.options.types
) )
if (targets.length === 0) return false if (targets.length === 0) {
return false
}
const targetColor = getTargetColor(targets, inputColor) const targetColor = getTargetColor(targets, inputColor)

View File

@ -1,5 +1,5 @@
import { mergeAttributes } from "@tiptap/react"
import TiptapHorizontalRule from "@tiptap/extension-horizontal-rule" import TiptapHorizontalRule from "@tiptap/extension-horizontal-rule"
import { mergeAttributes } from "@tiptap/react"
export const HorizontalRule = TiptapHorizontalRule.extend({ export const HorizontalRule = TiptapHorizontalRule.extend({
renderHTML() { renderHTML() {

View File

@ -1,7 +1,7 @@
import type { NodeType } from "@tiptap/pm/model"
import { mergeAttributes, Node } from "@tiptap/react" import { mergeAttributes, Node } from "@tiptap/react"
import { ReactNodeViewRenderer } from "@tiptap/react" import { ReactNodeViewRenderer } from "@tiptap/react"
import { ImageUploadNode as ImageUploadNodeComponent } from "@/components/tiptap-node/image-upload-node/image-upload-node" import { ImageUploadNode as ImageUploadNodeComponent } from "@/components/tiptap-node/image-upload-node/image-upload-node"
import type { NodeType } from "@tiptap/pm/model"
export type UploadFunction = ( export type UploadFunction = (
file: File, file: File,
@ -47,7 +47,7 @@ export interface ImageUploadNodeOptions {
* @default {} * @default {}
* @example { class: 'foo' } * @example { class: 'foo' }
*/ */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
HTMLAttributes: Record<string, any> HTMLAttributes: Record<string, any>
} }
@ -144,15 +144,19 @@ export const ImageUploadNode = Node.create<ImageUploadNodeOptions>({
editor.isActive("imageUpload") editor.isActive("imageUpload")
) { ) {
const nodeEl = editor.view.nodeDOM(selection.$from.pos) const nodeEl = editor.view.nodeDOM(selection.$from.pos)
if (nodeEl && nodeEl instanceof HTMLElement) { if (nodeEl && nodeEl instanceof HTMLElement) {
// Since NodeViewWrapper is wrapped with a div, we need to click the first child // Since NodeViewWrapper is wrapped with a div, we need to click the first child
const firstChild = nodeEl.firstChild const firstChild = nodeEl.firstChild
if (firstChild && firstChild instanceof HTMLElement) { if (firstChild && firstChild instanceof HTMLElement) {
firstChild.click() firstChild.click()
return true return true
} }
} }
} }
return false return false
}, },
} }

View File

@ -1,10 +1,10 @@
"use client" "use client"
import { useRef, useState } from "react"
import type { NodeViewProps } from "@tiptap/react" import type { NodeViewProps } from "@tiptap/react"
import { NodeViewWrapper } from "@tiptap/react" import { NodeViewWrapper } from "@tiptap/react"
import { Button } from "@/components/tiptap-ui-primitive/button" import { useRef, useState } from "react"
import { CloseIcon } from "@/components/tiptap-icons/close-icon" import { CloseIcon } from "@/components/tiptap-icons/close-icon"
import { Button } from "@/components/tiptap-ui-primitive/button"
import "@/components/tiptap-node/image-upload-node/image-upload-node.scss" import "@/components/tiptap-node/image-upload-node/image-upload-node.scss"
import { focusNextNode, isValidPosition } from "@/lib/tiptap-utils" import { focusNextNode, isValidPosition } from "@/lib/tiptap-utils"
@ -91,6 +91,7 @@ function useFileUpload(options: UploadOptions) {
`File size exceeds maximum allowed (${options.maxSize / 1024 / 1024}MB)` `File size exceeds maximum allowed (${options.maxSize / 1024 / 1024}MB)`
) )
options.onError?.(error) options.onError?.(error)
return null return null
} }
@ -124,7 +125,9 @@ function useFileUpload(options: UploadOptions) {
abortController.signal abortController.signal
) )
if (!url) throw new Error("Upload failed: No URL returned") if (!url) {
throw new Error("Upload failed: No URL returned")
}
if (!abortController.signal.aborted) { if (!abortController.signal.aborted) {
setFileItems((prev) => setFileItems((prev) =>
@ -135,6 +138,7 @@ function useFileUpload(options: UploadOptions) {
) )
) )
options.onSuccess?.(url) options.onSuccess?.(url)
return url return url
} }
@ -152,6 +156,7 @@ function useFileUpload(options: UploadOptions) {
error instanceof Error ? error : new Error("Upload failed") error instanceof Error ? error : new Error("Upload failed")
) )
} }
return null return null
} }
} }
@ -159,6 +164,7 @@ function useFileUpload(options: UploadOptions) {
const uploadFiles = async (files: File[]): Promise<string[]> => { const uploadFiles = async (files: File[]): Promise<string[]> => {
if (!files || files.length === 0) { if (!files || files.length === 0) {
options.onError?.(new Error("No files to upload")) options.onError?.(new Error("No files to upload"))
return [] return []
} }
@ -168,6 +174,7 @@ function useFileUpload(options: UploadOptions) {
`Maximum ${options.limit} file${options.limit === 1 ? "" : "s"} allowed` `Maximum ${options.limit} file${options.limit === 1 ? "" : "s"} allowed`
) )
) )
return [] return []
} }
@ -182,12 +189,15 @@ function useFileUpload(options: UploadOptions) {
const removeFileItem = (fileId: string) => { const removeFileItem = (fileId: string) => {
setFileItems((prev) => { setFileItems((prev) => {
const fileToRemove = prev.find((item) => item.id === fileId) const fileToRemove = prev.find((item) => item.id === fileId)
if (fileToRemove?.abortController) { if (fileToRemove?.abortController) {
fileToRemove.abortController.abort() fileToRemove.abortController.abort()
} }
if (fileToRemove?.url) { if (fileToRemove?.url) {
URL.revokeObjectURL(fileToRemove.url) URL.revokeObjectURL(fileToRemove.url)
} }
return prev.filter((item) => item.id !== fileId) return prev.filter((item) => item.id !== fileId)
}) })
} }
@ -197,6 +207,7 @@ function useFileUpload(options: UploadOptions) {
if (item.abortController) { if (item.abortController) {
item.abortController.abort() item.abortController.abort()
} }
if (item.url) { if (item.url) {
URL.revokeObjectURL(item.url) URL.revokeObjectURL(item.url)
} }
@ -300,6 +311,7 @@ const ImageUploadDragArea: React.FC<ImageUploadDragAreaProps> = ({
const handleDragLeave = (e: React.DragEvent) => { const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
if (!e.currentTarget.contains(e.relatedTarget as Node)) { if (!e.currentTarget.contains(e.relatedTarget as Node)) {
setIsDragActive(false) setIsDragActive(false)
setIsDragOver(false) setIsDragOver(false)
@ -319,6 +331,7 @@ const ImageUploadDragArea: React.FC<ImageUploadDragAreaProps> = ({
setIsDragOver(false) setIsDragOver(false)
const files = Array.from(e.dataTransfer.files) const files = Array.from(e.dataTransfer.files)
if (files.length > 0) { if (files.length > 0) {
onFile(files) onFile(files)
} }
@ -356,10 +369,14 @@ const ImageUploadPreview: React.FC<ImageUploadPreviewProps> = ({
onRemove, onRemove,
}) => { }) => {
const formatFileSize = (bytes: number) => { const formatFileSize = (bytes: number) => {
if (bytes === 0) return "0 Bytes" if (bytes === 0) {
return "0 Bytes"
}
const k = 1024 const k = 1024
const sizes = ["Bytes", "KB", "MB", "GB"] const sizes = ["Bytes", "KB", "MB", "GB"]
const i = Math.floor(Math.log(bytes) / Math.log(k)) const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}` return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`
} }
@ -460,6 +477,7 @@ export const ImageUploadNode: React.FC<NodeViewProps> = (props) => {
const imageNodes = urls.map((url, index) => { const imageNodes = urls.map((url, index) => {
const filename = const filename =
files[index]?.name.replace(/\.[^/.]+$/, "") || "unknown" files[index]?.name.replace(/\.[^/.]+$/, "") || "unknown"
return { return {
type: extension.options.type, type: extension.options.type,
attrs: { attrs: {
@ -485,10 +503,13 @@ export const ImageUploadNode: React.FC<NodeViewProps> = (props) => {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files const files = e.target.files
if (!files || files.length === 0) { if (!files || files.length === 0) {
extension.options.onError?.(new Error("No file selected")) extension.options.onError?.(new Error("No file selected"))
return return
} }
handleUpload(Array.from(files)) handleUpload(Array.from(files))
} }

View File

@ -1,18 +1,18 @@
"use client" "use client"
import { useEffect, useRef, useState } from "react"
import { EditorContent, EditorContext, useEditor } from "@tiptap/react"
// --- Tiptap Core Extensions --- // --- Tiptap Core Extensions ---
import { StarterKit } from "@tiptap/starter-kit" import { Highlight } from "@tiptap/extension-highlight"
import { Image } from "@tiptap/extension-image" import { Image } from "@tiptap/extension-image"
import { TaskItem, TaskList } from "@tiptap/extension-list" import { TaskItem, TaskList } from "@tiptap/extension-list"
import { TextAlign } from "@tiptap/extension-text-align"
import { Typography } from "@tiptap/extension-typography"
import { Highlight } from "@tiptap/extension-highlight"
import { Subscript } from "@tiptap/extension-subscript" import { Subscript } from "@tiptap/extension-subscript"
import { Superscript } from "@tiptap/extension-superscript" import { Superscript } from "@tiptap/extension-superscript"
import { TextAlign } from "@tiptap/extension-text-align"
import { Typography } from "@tiptap/extension-typography"
import { Placeholder, Selection } from "@tiptap/extensions" import { Placeholder, Selection } from "@tiptap/extensions"
import { EditorContent, EditorContext, useEditor } from "@tiptap/react"
import { StarterKit } from "@tiptap/starter-kit"
import { useEffect, useRef, useState } from "react"
// --- UI Primitives --- // --- UI Primitives ---
import { Button } from "@/components/tiptap-ui-primitive/button" import { Button } from "@/components/tiptap-ui-primitive/button"
@ -35,9 +35,11 @@ import "@/components/tiptap-node/heading-node/heading-node.scss"
import "@/components/tiptap-node/paragraph-node/paragraph-node.scss" import "@/components/tiptap-node/paragraph-node/paragraph-node.scss"
// --- Tiptap UI --- // --- Tiptap UI ---
import { HeadingDropdownMenu } from "@/components/tiptap-ui/heading-dropdown-menu"
import { ImageUploadButton } from "@/components/tiptap-ui/image-upload-button" // --- Icons ---
import { ListDropdownMenu } from "@/components/tiptap-ui/list-dropdown-menu" import { ArrowLeftIcon } from "@/components/tiptap-icons/arrow-left-icon"
import { HighlighterIcon } from "@/components/tiptap-icons/highlighter-icon"
import { LinkIcon } from "@/components/tiptap-icons/link-icon"
import { BlockquoteButton } from "@/components/tiptap-ui/blockquote-button" import { BlockquoteButton } from "@/components/tiptap-ui/blockquote-button"
import { CodeBlockButton } from "@/components/tiptap-ui/code-block-button" import { CodeBlockButton } from "@/components/tiptap-ui/code-block-button"
import { import {
@ -45,20 +47,18 @@ import {
ColorHighlightPopoverContent, ColorHighlightPopoverContent,
ColorHighlightPopoverButton, ColorHighlightPopoverButton,
} from "@/components/tiptap-ui/color-highlight-popover" } from "@/components/tiptap-ui/color-highlight-popover"
import { HeadingDropdownMenu } from "@/components/tiptap-ui/heading-dropdown-menu"
import { ImageUploadButton } from "@/components/tiptap-ui/image-upload-button"
import { import {
LinkPopover, LinkPopover,
LinkContent, LinkContent,
LinkButton, LinkButton,
} from "@/components/tiptap-ui/link-popover" } from "@/components/tiptap-ui/link-popover"
import { ListDropdownMenu } from "@/components/tiptap-ui/list-dropdown-menu"
import { MarkButton } from "@/components/tiptap-ui/mark-button" import { MarkButton } from "@/components/tiptap-ui/mark-button"
import { TextAlignButton } from "@/components/tiptap-ui/text-align-button" import { TextAlignButton } from "@/components/tiptap-ui/text-align-button"
import { UndoRedoButton } from "@/components/tiptap-ui/undo-redo-button" import { UndoRedoButton } from "@/components/tiptap-ui/undo-redo-button"
// --- Icons ---
import { ArrowLeftIcon } from "@/components/tiptap-icons/arrow-left-icon"
import { HighlighterIcon } from "@/components/tiptap-icons/highlighter-icon"
import { LinkIcon } from "@/components/tiptap-icons/link-icon"
// --- Hooks --- // --- Hooks ---
import { useIsBreakpoint } from "@/hooks/use-is-breakpoint" import { useIsBreakpoint } from "@/hooks/use-is-breakpoint"

View File

@ -1,9 +1,9 @@
import { useEffect, useState } from "react"
import { MoonStarIcon } from "@/components/tiptap-icons/moon-star-icon"
import { SunIcon } from "@/components/tiptap-icons/sun-icon"
import { Button } from "@/components/tiptap-ui-primitive/button" import { Button } from "@/components/tiptap-ui-primitive/button"
// --- Icons --- // --- Icons ---
import { MoonStarIcon } from "@/components/tiptap-icons/moon-star-icon"
import { SunIcon } from "@/components/tiptap-icons/sun-icon"
import { useEffect, useState } from "react"
export function ThemeToggle() { export function ThemeToggle() {
const [isDarkMode, setIsDarkMode] = useState<boolean>(false) const [isDarkMode, setIsDarkMode] = useState<boolean>(false)
@ -12,6 +12,7 @@ export function ThemeToggle() {
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)") const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
const handleChange = () => setIsDarkMode(mediaQuery.matches) const handleChange = () => setIsDarkMode(mediaQuery.matches)
mediaQuery.addEventListener("change", handleChange) mediaQuery.addEventListener("change", handleChange)
return () => mediaQuery.removeEventListener("change", handleChange) return () => mediaQuery.removeEventListener("change", handleChange)
}, []) }, [])

View File

@ -1,8 +1,9 @@
import { mergeProps } from "@base-ui/react/merge-props" import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render" import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority" import { cva } from "class-variance-authority"
import { cn } from "@/lib/tiptap-utils" import type {VariantProps} from "class-variance-authority";
import { Separator } from "@/components/tiptap-ui-primitive/separator" import { Separator } from "@/components/tiptap-ui-primitive/separator"
import { cn } from "@/lib/tiptap-utils"
import "./button-group.scss" import "./button-group.scss"
const buttonGroupVariants = cva("tiptap-button-group", { const buttonGroupVariants = cva("tiptap-button-group", {

View File

@ -27,7 +27,9 @@ export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElemen
export const ShortcutDisplay: React.FC<{ shortcuts: string[] }> = ({ export const ShortcutDisplay: React.FC<{ shortcuts: string[] }> = ({
shortcuts, shortcuts,
}) => { }) => {
if (shortcuts.length === 0) return null if (shortcuts.length === 0) {
return null
}
return ( return (
<div> <div>

View File

@ -1,6 +1,6 @@
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { cn } from "@/lib/tiptap-utils"
import { CheckIcon } from "@/components/tiptap-icons/check-icon" import { CheckIcon } from "@/components/tiptap-icons/check-icon"
import { cn } from "@/lib/tiptap-utils"
import "@/components/tiptap-ui-primitive/dropdown-menu/dropdown-menu.scss" import "@/components/tiptap-ui-primitive/dropdown-menu/dropdown-menu.scss"

View File

@ -1,9 +1,9 @@
import { forwardRef, useCallback, useEffect, useRef, useState } from "react" import { forwardRef, useCallback, useEffect, useRef, useState } from "react"
import { Separator } from "@/components/tiptap-ui-primitive/separator" import { Separator } from "@/components/tiptap-ui-primitive/separator"
import "@/components/tiptap-ui-primitive/toolbar/toolbar.scss" import "@/components/tiptap-ui-primitive/toolbar/toolbar.scss"
import { cn } from "@/lib/tiptap-utils"
import { useMenuNavigation } from "@/hooks/use-menu-navigation"
import { useComposedRef } from "@/hooks/use-composed-ref" import { useComposedRef } from "@/hooks/use-composed-ref"
import { useMenuNavigation } from "@/hooks/use-menu-navigation"
import { cn } from "@/lib/tiptap-utils"
type BaseProps = React.HTMLAttributes<HTMLDivElement> type BaseProps = React.HTMLAttributes<HTMLDivElement>
@ -17,7 +17,10 @@ const useToolbarNavigation = (
const [items, setItems] = useState<HTMLElement[]>([]) const [items, setItems] = useState<HTMLElement[]>([])
const collectItems = useCallback(() => { const collectItems = useCallback(() => {
if (!toolbarRef.current) return [] if (!toolbarRef.current) {
return []
}
return Array.from( return Array.from(
toolbarRef.current.querySelectorAll<HTMLElement>( toolbarRef.current.querySelectorAll<HTMLElement>(
'button:not([disabled]), [role="button"]:not([disabled]), [tabindex="0"]:not([disabled])' 'button:not([disabled]), [role="button"]:not([disabled]), [tabindex="0"]:not([disabled])'
@ -27,7 +30,10 @@ const useToolbarNavigation = (
useEffect(() => { useEffect(() => {
const toolbar = toolbarRef.current const toolbar = toolbarRef.current
if (!toolbar) return
if (!toolbar) {
return
}
const updateItems = () => setItems(collectItems()) const updateItems = () => setItems(collectItems())
@ -48,17 +54,25 @@ const useToolbarNavigation = (
useEffect(() => { useEffect(() => {
const toolbar = toolbarRef.current const toolbar = toolbarRef.current
if (!toolbar) return
if (!toolbar) {
return
}
const handleFocus = (e: FocusEvent) => { const handleFocus = (e: FocusEvent) => {
const target = e.target as HTMLElement const target = e.target as HTMLElement
if (toolbar.contains(target))
target.setAttribute("data-focus-visible", "true") if (toolbar.contains(target)) {
target.setAttribute("data-focus-visible", "true")
}
} }
const handleBlur = (e: FocusEvent) => { const handleBlur = (e: FocusEvent) => {
const target = e.target as HTMLElement const target = e.target as HTMLElement
if (toolbar.contains(target)) target.removeAttribute("data-focus-visible")
if (toolbar.contains(target)) {
target.removeAttribute("data-focus-visible")
}
} }
toolbar.addEventListener("focus", handleFocus, true) toolbar.addEventListener("focus", handleFocus, true)

View File

@ -1,15 +1,5 @@
"use client" "use client"
import {
cloneElement,
createContext,
forwardRef,
isValidElement,
useContext,
useMemo,
useState,
version,
} from "react"
import { import {
useFloating, useFloating,
autoUpdate, autoUpdate,
@ -23,11 +13,22 @@ import {
useInteractions, useInteractions,
useMergeRefs, useMergeRefs,
FloatingPortal, FloatingPortal,
type Placement,
type UseFloatingReturn,
type ReferenceType,
FloatingDelayGroup, FloatingDelayGroup
} from "@floating-ui/react" } from "@floating-ui/react"
import type {Placement, UseFloatingReturn, ReferenceType} from "@floating-ui/react";
import {
cloneElement,
createContext,
forwardRef,
isValidElement,
useContext,
useMemo,
useState,
version,
} from "react"
import "@/components/tiptap-ui-primitive/tooltip/tooltip.scss" import "@/components/tiptap-ui-primitive/tooltip/tooltip.scss"
interface TooltipProviderProps { interface TooltipProviderProps {
@ -165,9 +166,9 @@ export const TooltipTrigger = forwardRef<HTMLElement, TooltipTriggerProps>(
const context = useTooltipContext() const context = useTooltipContext()
const childrenRef = isValidElement(children) const childrenRef = isValidElement(children)
? parseInt(version, 10) >= 19 ? parseInt(version, 10) >= 19
? // eslint-disable-next-line @typescript-eslint/no-explicit-any ?
(children as { props: { ref?: React.Ref<any> } }).props.ref (children as { props: { ref?: React.Ref<any> } }).props.ref
: // eslint-disable-next-line @typescript-eslint/no-explicit-any :
(children as any).ref (children as any).ref
: undefined : undefined
const ref = useMergeRefs([context.refs.setReference, propRef, childrenRef]) const ref = useMergeRefs([context.refs.setReference, propRef, childrenRef])
@ -208,7 +209,9 @@ export const TooltipContent = forwardRef<HTMLDivElement, TooltipContentProps>(
const context = useTooltipContext() const context = useTooltipContext()
const ref = useMergeRefs([context.refs.setFloating, propRef]) const ref = useMergeRefs([context.refs.setFloating, propRef])
if (!context.open) return null if (!context.open) {
return null
}
const content = ( const content = (
<div <div

View File

@ -8,15 +8,15 @@ import {
} from "@/components/tiptap-ui/blockquote-button" } from "@/components/tiptap-ui/blockquote-button"
// --- Hooks --- // --- Hooks ---
import { Badge } from "@/components/tiptap-ui-primitive/badge"
import type { ButtonProps } from "@/components/tiptap-ui-primitive/button"
import { Button } from "@/components/tiptap-ui-primitive/button"
import { useTiptapEditor } from "@/hooks/use-tiptap-editor" import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
// --- Lib --- // --- Lib ---
import { parseShortcutKeys } from "@/lib/tiptap-utils" import { parseShortcutKeys } from "@/lib/tiptap-utils"
// --- UI Primitives --- // --- UI Primitives ---
import type { ButtonProps } from "@/components/tiptap-ui-primitive/button"
import { Button } from "@/components/tiptap-ui-primitive/button"
import { Badge } from "@/components/tiptap-ui-primitive/badge"
export interface BlockquoteButtonProps export interface BlockquoteButtonProps
extends Omit<ButtonProps, "type">, UseBlockquoteConfig { extends Omit<ButtonProps, "type">, UseBlockquoteConfig {
@ -79,7 +79,11 @@ export const BlockquoteButton = forwardRef<
const handleClick = useCallback( const handleClick = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => { (event: React.MouseEvent<HTMLButtonElement>) => {
onClick?.(event) onClick?.(event)
if (event.defaultPrevented) return
if (event.defaultPrevented) {
return
}
handleToggle() handleToggle()
}, },
[handleToggle, onClick] [handleToggle, onClick]

View File

@ -1,14 +1,14 @@
"use client" "use client"
import { useCallback, useEffect, useState } from "react"
import type { Editor } from "@tiptap/react"
import { NodeSelection, TextSelection } from "@tiptap/pm/state" import { NodeSelection, TextSelection } from "@tiptap/pm/state"
import type { Editor } from "@tiptap/react"
import { useCallback, useEffect, useState } from "react"
// --- Hooks --- // --- Hooks ---
import { BlockquoteIcon } from "@/components/tiptap-icons/blockquote-icon"
import { useTiptapEditor } from "@/hooks/use-tiptap-editor" import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
// --- Icons --- // --- Icons ---
import { BlockquoteIcon } from "@/components/tiptap-icons/blockquote-icon"
// --- UI Utils --- // --- UI Utils ---
import { import {
@ -48,12 +48,16 @@ export function canToggleBlockquote(
editor: Editor | null, editor: Editor | null,
turnInto: boolean = true turnInto: boolean = true
): boolean { ): boolean {
if (!editor || !editor.isEditable) return false if (!editor || !editor.isEditable) {
return false
}
if ( if (
!isNodeInSchema("blockquote", editor) || !isNodeInSchema("blockquote", editor) ||
isNodeTypeSelected(editor, ["image"]) isNodeTypeSelected(editor, ["image"])
) ) {
return false return false
}
if (!turnInto) { if (!turnInto) {
return editor.can().toggleWrap("blockquote") return editor.can().toggleWrap("blockquote")
@ -70,8 +74,9 @@ export function canToggleBlockquote(
"blockquote", "blockquote",
"codeBlock", "codeBlock",
]) ])
) ) {
return false return false
}
// Either we can wrap in blockquote directly on the selection, // Either we can wrap in blockquote directly on the selection,
// or we can clear formatting/nodes to arrive at a blockquote. // or we can clear formatting/nodes to arrive at a blockquote.
@ -82,8 +87,13 @@ export function canToggleBlockquote(
* Toggles blockquote formatting for a specific node or the current selection * Toggles blockquote formatting for a specific node or the current selection
*/ */
export function toggleBlockquote(editor: Editor | null): boolean { export function toggleBlockquote(editor: Editor | null): boolean {
if (!editor || !editor.isEditable) return false if (!editor || !editor.isEditable) {
if (!canToggleBlockquote(editor)) return false return false
}
if (!canToggleBlockquote(editor)) {
return false
}
try { try {
const view = editor.view const view = editor.view
@ -115,7 +125,10 @@ export function toggleBlockquote(editor: Editor | null): boolean {
editor, editor,
node: state.selection.$anchor.node(1), node: state.selection.$anchor.node(1),
})?.pos })?.pos
if (!isValidPosition(pos)) return false
if (!isValidPosition(pos)) {
return false
}
tr = tr.setSelection(NodeSelection.create(state.doc, pos)) tr = tr.setSelection(NodeSelection.create(state.doc, pos))
view.dispatch(tr) view.dispatch(tr)
@ -170,15 +183,21 @@ export function shouldShowButton(props: {
}): boolean { }): boolean {
const { editor, hideWhenUnavailable } = props const { editor, hideWhenUnavailable } = props
if (!editor) return false if (!editor) {
return false
}
if (!hideWhenUnavailable) { if (!hideWhenUnavailable) {
return true return true
} }
if (!editor.isEditable) return false if (!editor.isEditable) {
return false
}
if (!isNodeInSchema("blockquote", editor)) return false if (!isNodeInSchema("blockquote", editor)) {
return false
}
if (!editor.isActive("code")) { if (!editor.isActive("code")) {
return canToggleBlockquote(editor) return canToggleBlockquote(editor)
@ -236,7 +255,9 @@ export function useBlockquote(config?: UseBlockquoteConfig) {
const isActive = editor?.isActive("blockquote") || false const isActive = editor?.isActive("blockquote") || false
useEffect(() => { useEffect(() => {
if (!editor) return if (!editor) {
return
}
const handleSelectionUpdate = () => { const handleSelectionUpdate = () => {
setIsVisible(shouldShowButton({ editor, hideWhenUnavailable })) setIsVisible(shouldShowButton({ editor, hideWhenUnavailable }))
@ -252,12 +273,16 @@ export function useBlockquote(config?: UseBlockquoteConfig) {
}, [editor, hideWhenUnavailable]) }, [editor, hideWhenUnavailable])
const handleToggle = useCallback(() => { const handleToggle = useCallback(() => {
if (!editor) return false if (!editor) {
return false
}
const success = toggleBlockquote(editor) const success = toggleBlockquote(editor)
if (success) { if (success) {
onToggled?.() onToggled?.()
} }
return success return success
}, [editor, onToggled]) }, [editor, onToggled])

View File

@ -1,10 +1,8 @@
import { forwardRef, useCallback } from "react" import { forwardRef, useCallback } from "react"
// --- Hooks --- // --- Hooks ---
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
// --- Lib --- // --- Lib ---
import { parseShortcutKeys } from "@/lib/tiptap-utils"
// --- Tiptap UI --- // --- Tiptap UI ---
import type { UseCodeBlockConfig } from "@/components/tiptap-ui/code-block-button" import type { UseCodeBlockConfig } from "@/components/tiptap-ui/code-block-button"
@ -14,9 +12,11 @@ import {
} from "@/components/tiptap-ui/code-block-button" } from "@/components/tiptap-ui/code-block-button"
// --- UI Primitives --- // --- UI Primitives ---
import { Badge } from "@/components/tiptap-ui-primitive/badge"
import type { ButtonProps } from "@/components/tiptap-ui-primitive/button" import type { ButtonProps } from "@/components/tiptap-ui-primitive/button"
import { Button } from "@/components/tiptap-ui-primitive/button" import { Button } from "@/components/tiptap-ui-primitive/button"
import { Badge } from "@/components/tiptap-ui-primitive/badge" import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
import { parseShortcutKeys } from "@/lib/tiptap-utils"
export interface CodeBlockButtonProps export interface CodeBlockButtonProps
extends Omit<ButtonProps, "type">, UseCodeBlockConfig { extends Omit<ButtonProps, "type">, UseCodeBlockConfig {
@ -79,7 +79,11 @@ export const CodeBlockButton = forwardRef<
const handleClick = useCallback( const handleClick = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => { (event: React.MouseEvent<HTMLButtonElement>) => {
onClick?.(event) onClick?.(event)
if (event.defaultPrevented) return
if (event.defaultPrevented) {
return
}
handleToggle() handleToggle()
}, },
[handleToggle, onClick] [handleToggle, onClick]

View File

@ -1,10 +1,11 @@
"use client" "use client"
import { useCallback, useEffect, useState } from "react"
import { type Editor } from "@tiptap/react"
import { NodeSelection, TextSelection } from "@tiptap/pm/state" import { NodeSelection, TextSelection } from "@tiptap/pm/state"
import type {Editor} from "@tiptap/react";
import { useCallback, useEffect, useState } from "react"
// --- Hooks --- // --- Hooks ---
import { CodeBlockIcon } from "@/components/tiptap-icons/code-block-icon"
import { useTiptapEditor } from "@/hooks/use-tiptap-editor" import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
// --- Lib --- // --- Lib ---
@ -18,7 +19,6 @@ import {
} from "@/lib/tiptap-utils" } from "@/lib/tiptap-utils"
// --- Icons --- // --- Icons ---
import { CodeBlockIcon } from "@/components/tiptap-icons/code-block-icon"
export const CODE_BLOCK_SHORTCUT_KEY = "mod+alt+c" export const CODE_BLOCK_SHORTCUT_KEY = "mod+alt+c"
@ -48,12 +48,16 @@ export function canToggle(
editor: Editor | null, editor: Editor | null,
turnInto: boolean = true turnInto: boolean = true
): boolean { ): boolean {
if (!editor || !editor.isEditable) return false if (!editor || !editor.isEditable) {
return false
}
if ( if (
!isNodeInSchema("codeBlock", editor) || !isNodeInSchema("codeBlock", editor) ||
isNodeTypeSelected(editor, ["image"]) isNodeTypeSelected(editor, ["image"])
) ) {
return false return false
}
if (!turnInto) { if (!turnInto) {
return editor.can().toggleNode("codeBlock", "paragraph") return editor.can().toggleNode("codeBlock", "paragraph")
@ -70,8 +74,9 @@ export function canToggle(
"blockquote", "blockquote",
"codeBlock", "codeBlock",
]) ])
) ) {
return false return false
}
// Either we can toggle code block directly on the selection, // Either we can toggle code block directly on the selection,
// or we can clear formatting/nodes to arrive at a code block. // or we can clear formatting/nodes to arrive at a code block.
@ -85,8 +90,13 @@ export function canToggle(
* Toggles code block in the editor * Toggles code block in the editor
*/ */
export function toggleCodeBlock(editor: Editor | null): boolean { export function toggleCodeBlock(editor: Editor | null): boolean {
if (!editor || !editor.isEditable) return false if (!editor || !editor.isEditable) {
if (!canToggle(editor)) return false return false
}
if (!canToggle(editor)) {
return false
}
try { try {
const view = editor.view const view = editor.view
@ -118,7 +128,10 @@ export function toggleCodeBlock(editor: Editor | null): boolean {
editor, editor,
node: state.selection.$anchor.node(1), node: state.selection.$anchor.node(1),
})?.pos })?.pos
if (!isValidPosition(pos)) return false
if (!isValidPosition(pos)) {
return false
}
tr = tr.setSelection(NodeSelection.create(state.doc, pos)) tr = tr.setSelection(NodeSelection.create(state.doc, pos))
view.dispatch(tr) view.dispatch(tr)
@ -173,15 +186,21 @@ export function shouldShowButton(props: {
}): boolean { }): boolean {
const { editor, hideWhenUnavailable } = props const { editor, hideWhenUnavailable } = props
if (!editor) return false if (!editor) {
return false
}
if (!hideWhenUnavailable) { if (!hideWhenUnavailable) {
return true return true
} }
if (!editor.isEditable) return false if (!editor.isEditable) {
return false
}
if (!isNodeInSchema("codeBlock", editor)) return false if (!isNodeInSchema("codeBlock", editor)) {
return false
}
if (!editor.isActive("code")) { if (!editor.isActive("code")) {
return canToggle(editor) return canToggle(editor)
@ -246,7 +265,9 @@ export function useCodeBlock(config?: UseCodeBlockConfig) {
const isActive = editor?.isActive("codeBlock") || false const isActive = editor?.isActive("codeBlock") || false
useEffect(() => { useEffect(() => {
if (!editor) return if (!editor) {
return
}
const handleSelectionUpdate = () => { const handleSelectionUpdate = () => {
setIsVisible(shouldShowButton({ editor, hideWhenUnavailable })) setIsVisible(shouldShowButton({ editor, hideWhenUnavailable }))
@ -262,12 +283,16 @@ export function useCodeBlock(config?: UseCodeBlockConfig) {
}, [editor, hideWhenUnavailable]) }, [editor, hideWhenUnavailable])
const handleToggle = useCallback(() => { const handleToggle = useCallback(() => {
if (!editor) return false if (!editor) {
return false
}
const success = toggleCodeBlock(editor) const success = toggleCodeBlock(editor)
if (success) { if (success) {
onToggled?.() onToggled?.()
} }
return success return success
}, [editor, onToggled]) }, [editor, onToggled])

View File

@ -1,10 +1,8 @@
import { forwardRef, useCallback, useMemo } from "react" import { forwardRef, useCallback, useMemo } from "react"
// --- Lib --- // --- Lib ---
import { parseShortcutKeys } from "@/lib/tiptap-utils"
// --- Hooks --- // --- Hooks ---
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
// --- Tiptap UI --- // --- Tiptap UI ---
import type { UseColorHighlightConfig } from "@/components/tiptap-ui/color-highlight-button" import type { UseColorHighlightConfig } from "@/components/tiptap-ui/color-highlight-button"
@ -14,9 +12,11 @@ import {
} from "@/components/tiptap-ui/color-highlight-button" } from "@/components/tiptap-ui/color-highlight-button"
// --- UI Primitives --- // --- UI Primitives ---
import { Badge } from "@/components/tiptap-ui-primitive/badge"
import type { ButtonProps } from "@/components/tiptap-ui-primitive/button" import type { ButtonProps } from "@/components/tiptap-ui-primitive/button"
import { Button } from "@/components/tiptap-ui-primitive/button" import { Button } from "@/components/tiptap-ui-primitive/button"
import { Badge } from "@/components/tiptap-ui-primitive/badge" import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
import { parseShortcutKeys } from "@/lib/tiptap-utils"
// --- Styles --- // --- Styles ---
import "@/components/tiptap-ui/color-highlight-button/color-highlight-button.scss" import "@/components/tiptap-ui/color-highlight-button/color-highlight-button.scss"
@ -112,7 +112,11 @@ export const ColorHighlightButton = forwardRef<
const handleClick = useCallback( const handleClick = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => { (event: React.MouseEvent<HTMLButtonElement>) => {
onClick?.(event) onClick?.(event)
if (event.defaultPrevented) return
if (event.defaultPrevented) {
return
}
handleColorHighlight() handleColorHighlight()
}, },
[handleColorHighlight, onClick] [handleColorHighlight, onClick]

View File

@ -1,12 +1,13 @@
"use client" "use client"
import type {Editor} from "@tiptap/react";
import { useCallback, useEffect, useState } from "react" import { useCallback, useEffect, useState } from "react"
import { type Editor } from "@tiptap/react"
import { useHotkeys } from "react-hotkeys-hook" import { useHotkeys } from "react-hotkeys-hook"
// --- Hooks --- // --- Hooks ---
import { useTiptapEditor } from "@/hooks/use-tiptap-editor" import { HighlighterIcon } from "@/components/tiptap-icons/highlighter-icon"
import { useIsBreakpoint } from "@/hooks/use-is-breakpoint" import { useIsBreakpoint } from "@/hooks/use-is-breakpoint"
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
// --- Lib --- // --- Lib ---
import { import {
@ -16,7 +17,6 @@ import {
} from "@/lib/tiptap-utils" } from "@/lib/tiptap-utils"
// --- Icons --- // --- Icons ---
import { HighlighterIcon } from "@/components/tiptap-icons/highlighter-icon"
export const COLOR_HIGHLIGHT_SHORTCUT_KEY = "mod+shift+h" export const COLOR_HIGHLIGHT_SHORTCUT_KEY = "mod+shift+h"
export const HIGHLIGHT_COLORS = [ export const HIGHLIGHT_COLORS = [
@ -136,6 +136,7 @@ export function pickHighlightColorsByValue(values: string[]) {
const colorMap = new Map( const colorMap = new Map(
HIGHLIGHT_COLORS.map((color) => [color.value, color]) HIGHLIGHT_COLORS.map((color) => [color.value, color])
) )
return values return values
.map((value) => colorMap.get(value)) .map((value) => colorMap.get(value))
.filter((color): color is (typeof HIGHLIGHT_COLORS)[number] => !!color) .filter((color): color is (typeof HIGHLIGHT_COLORS)[number] => !!color)
@ -148,11 +149,14 @@ export function getHighlightColorValue(
color: string, color: string,
useColorValue: boolean = false useColorValue: boolean = false
): string { ): string {
if (!useColorValue) return color if (!useColorValue) {
return color
}
const colorItem = HIGHLIGHT_COLORS.find( const colorItem = HIGHLIGHT_COLORS.find(
(c) => c.value === color || c.colorValue === color (c) => c.value === color || c.colorValue === color
) )
return colorItem?.colorValue || color return colorItem?.colorValue || color
} }
@ -163,18 +167,23 @@ export function canColorHighlight(
editor: Editor | null, editor: Editor | null,
mode: HighlightMode = "mark" mode: HighlightMode = "mark"
): boolean { ): boolean {
if (!editor || !editor.isEditable) return false if (!editor || !editor.isEditable) {
return false
}
if (mode === "mark") { if (mode === "mark") {
if ( if (
!isMarkInSchema("highlight", editor) || !isMarkInSchema("highlight", editor) ||
isNodeTypeSelected(editor, ["image"]) isNodeTypeSelected(editor, ["image"])
) ) {
return false return false
}
return editor.can().setMark("highlight") return editor.can().setMark("highlight")
} else { } else {
if (!isExtensionAvailable(editor, ["nodeBackground"])) return false if (!isExtensionAvailable(editor, ["nodeBackground"])) {
return false
}
try { try {
return editor.can().toggleNodeBackgroundColor("test") return editor.can().toggleNodeBackgroundColor("test")
@ -192,26 +201,33 @@ export function isColorHighlightActive(
highlightColor?: string, highlightColor?: string,
mode: HighlightMode = "mark" mode: HighlightMode = "mark"
): boolean { ): boolean {
if (!editor || !editor.isEditable) return false if (!editor || !editor.isEditable) {
return false
}
if (mode === "mark") { if (mode === "mark") {
return highlightColor return highlightColor
? editor.isActive("highlight", { color: highlightColor }) ? editor.isActive("highlight", { color: highlightColor })
: editor.isActive("highlight") : editor.isActive("highlight")
} else { } else {
if (!highlightColor) return false if (!highlightColor) {
return false
}
try { try {
const { state } = editor const { state } = editor
const { selection } = state const { selection } = state
const $pos = selection.$anchor const $pos = selection.$anchor
for (let depth = $pos.depth; depth >= 0; depth--) { for (let depth = $pos.depth; depth >= 0; depth--) {
const node = $pos.node(depth) const node = $pos.node(depth)
if (node && node.attrs?.backgroundColor === highlightColor) { if (node && node.attrs?.backgroundColor === highlightColor) {
return true return true
} }
} }
return false return false
} catch { } catch {
return false return false
@ -226,8 +242,13 @@ export function removeHighlight(
editor: Editor | null, editor: Editor | null,
mode: HighlightMode = "mark" mode: HighlightMode = "mark"
): boolean { ): boolean {
if (!editor || !editor.isEditable) return false if (!editor || !editor.isEditable) {
if (!canColorHighlight(editor, mode)) return false return false
}
if (!canColorHighlight(editor, mode)) {
return false
}
if (mode === "mark") { if (mode === "mark") {
return editor.chain().focus().unsetMark("highlight").run() return editor.chain().focus().unsetMark("highlight").run()
@ -246,19 +267,27 @@ export function shouldShowButton(props: {
}): boolean { }): boolean {
const { editor, hideWhenUnavailable, mode } = props const { editor, hideWhenUnavailable, mode } = props
if (!editor) return false if (!editor) {
return false
}
if (!hideWhenUnavailable) { if (!hideWhenUnavailable) {
return true return true
} }
if (!editor.isEditable) return false if (!editor.isEditable) {
return false
}
// hideWhenUnavailable=true: check schema/extension availability // hideWhenUnavailable=true: check schema/extension availability
if (mode === "mark") { if (mode === "mark") {
if (!isMarkInSchema("highlight", editor)) return false if (!isMarkInSchema("highlight", editor)) {
return false
}
} else { } else {
if (!isExtensionAvailable(editor, ["nodeBackground"])) return false if (!isExtensionAvailable(editor, ["nodeBackground"])) {
return false
}
} }
if (!editor.isActive("code")) { if (!editor.isActive("code")) {
@ -289,7 +318,9 @@ export function useColorHighlight(config: UseColorHighlightConfig) {
const isActive = isColorHighlightActive(editor, actualColor, mode) const isActive = isColorHighlightActive(editor, actualColor, mode)
useEffect(() => { useEffect(() => {
if (!editor) return if (!editor) {
return
}
const handleSelectionUpdate = () => { const handleSelectionUpdate = () => {
setIsVisible(shouldShowButton({ editor, hideWhenUnavailable, mode })) setIsVisible(shouldShowButton({ editor, hideWhenUnavailable, mode }))
@ -305,12 +336,14 @@ export function useColorHighlight(config: UseColorHighlightConfig) {
}, [editor, hideWhenUnavailable, mode]) }, [editor, hideWhenUnavailable, mode])
const handleColorHighlight = useCallback(() => { const handleColorHighlight = useCallback(() => {
if (!editor || !canColorHighlightState || !actualColor || !label) if (!editor || !canColorHighlightState || !actualColor || !label) {
return false return false
}
if (mode === "mark") { if (mode === "mark") {
if (editor.state.storedMarks) { if (editor.state.storedMarks) {
const highlightMarkType = editor.schema.marks.highlight const highlightMarkType = editor.schema.marks.highlight
if (highlightMarkType) { if (highlightMarkType) {
editor.view.dispatch( editor.view.dispatch(
editor.state.tr.removeStoredMark(highlightMarkType) editor.state.tr.removeStoredMark(highlightMarkType)
@ -324,9 +357,11 @@ export function useColorHighlight(config: UseColorHighlightConfig) {
.focus() .focus()
.toggleHighlight({ color: actualColor }) .toggleHighlight({ color: actualColor })
.run() .run()
if (success) { if (success) {
onApplied?.({ color: actualColor, label, mode }) onApplied?.({ color: actualColor, label, mode })
} }
return success return success
}, 0) }, 0)
@ -341,15 +376,18 @@ export function useColorHighlight(config: UseColorHighlightConfig) {
if (success) { if (success) {
onApplied?.({ color: actualColor, label, mode }) onApplied?.({ color: actualColor, label, mode })
} }
return success return success
} }
}, [canColorHighlightState, actualColor, editor, label, onApplied, mode]) }, [canColorHighlightState, actualColor, editor, label, onApplied, mode])
const handleRemoveHighlight = useCallback(() => { const handleRemoveHighlight = useCallback(() => {
const success = removeHighlight(editor, mode) const success = removeHighlight(editor, mode)
if (success) { if (success) {
onApplied?.({ color: "", label: "Remove highlight", mode }) onApplied?.({ color: "", label: "Remove highlight", mode })
} }
return success return success
}, [editor, onApplied, mode]) }, [editor, onApplied, mode])

View File

@ -1,31 +1,13 @@
import type {Editor} from "@tiptap/react";
import { forwardRef, useMemo, useRef, useState } from "react" import { forwardRef, useMemo, useRef, useState } from "react"
import { type Editor } from "@tiptap/react"
// --- Hooks --- // --- Hooks ---
import { useMenuNavigation } from "@/hooks/use-menu-navigation"
import { useIsBreakpoint } from "@/hooks/use-is-breakpoint"
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
// --- Icons --- // --- Icons ---
import { BanIcon } from "@/components/tiptap-icons/ban-icon" import { BanIcon } from "@/components/tiptap-icons/ban-icon"
import { HighlighterIcon } from "@/components/tiptap-icons/highlighter-icon" import { HighlighterIcon } from "@/components/tiptap-icons/highlighter-icon"
// --- UI Primitives --- // --- UI Primitives ---
import type { ButtonProps } from "@/components/tiptap-ui-primitive/button"
import { Button } from "@/components/tiptap-ui-primitive/button"
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "@/components/tiptap-ui-primitive/popover"
import { Separator } from "@/components/tiptap-ui-primitive/separator"
import {
Card,
CardBody,
CardItemGroup,
} from "@/components/tiptap-ui-primitive/card"
// --- Tiptap UI ---
import type { import type {
HighlightColor, HighlightColor,
UseColorHighlightConfig, UseColorHighlightConfig,
@ -35,7 +17,25 @@ import {
pickHighlightColorsByValue, pickHighlightColorsByValue,
useColorHighlight, useColorHighlight,
} from "@/components/tiptap-ui/color-highlight-button" } from "@/components/tiptap-ui/color-highlight-button"
import type { ButtonProps } from "@/components/tiptap-ui-primitive/button"
import { Button } from "@/components/tiptap-ui-primitive/button"
import { ButtonGroup } from "@/components/tiptap-ui-primitive/button-group" import { ButtonGroup } from "@/components/tiptap-ui-primitive/button-group"
import {
Card,
CardBody,
CardItemGroup,
} from "@/components/tiptap-ui-primitive/card"
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "@/components/tiptap-ui-primitive/popover"
import { Separator } from "@/components/tiptap-ui-primitive/separator"
// --- Tiptap UI ---
import { useIsBreakpoint } from "@/hooks/use-is-breakpoint"
import { useMenuNavigation } from "@/hooks/use-menu-navigation"
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
export interface ColorHighlightPopoverContentProps { export interface ColorHighlightPopoverContentProps {
/** /**
@ -120,12 +120,22 @@ export function ColorHighlightPopoverContent({
items: menuItems, items: menuItems,
orientation: "both", orientation: "both",
onSelect: (item) => { onSelect: (item) => {
if (!containerRef.current) return false if (!containerRef.current) {
return false
}
const highlightedElement = containerRef.current.querySelector( const highlightedElement = containerRef.current.querySelector(
'[data-highlighted="true"]' '[data-highlighted="true"]'
) as HTMLElement ) as HTMLElement
if (highlightedElement) highlightedElement.click()
if (item.value === "none") handleRemoveHighlight() if (highlightedElement) {
highlightedElement.click()
}
if (item.value === "none") {
handleRemoveHighlight()
}
return true return true
}, },
autoSelectFirstItem: false, autoSelectFirstItem: false,
@ -200,7 +210,9 @@ export function ColorHighlightPopover({
onApplied, onApplied,
}) })
if (!isVisible) return null if (!isVisible) {
return null
}
return ( return (
<Popover open={isOpen} onOpenChange={setIsOpen}> <Popover open={isOpen} onOpenChange={setIsOpen}>

View File

@ -1,7 +1,6 @@
import { forwardRef, useCallback } from "react" import { forwardRef, useCallback } from "react"
// --- Lib --- // --- Lib ---
import { parseShortcutKeys } from "@/lib/tiptap-utils"
// --- Tiptap UI --- // --- Tiptap UI ---
import type { import type {
@ -14,10 +13,11 @@ import {
} from "@/components/tiptap-ui/heading-button" } from "@/components/tiptap-ui/heading-button"
// --- UI Primitives --- // --- UI Primitives ---
import { Badge } from "@/components/tiptap-ui-primitive/badge"
import type { ButtonProps } from "@/components/tiptap-ui-primitive/button" import type { ButtonProps } from "@/components/tiptap-ui-primitive/button"
import { Button } from "@/components/tiptap-ui-primitive/button" import { Button } from "@/components/tiptap-ui-primitive/button"
import { Badge } from "@/components/tiptap-ui-primitive/badge"
import { useTiptapEditor } from "@/hooks/use-tiptap-editor" import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
import { parseShortcutKeys } from "@/lib/tiptap-utils"
export interface HeadingButtonProps export interface HeadingButtonProps
extends Omit<ButtonProps, "type">, UseHeadingConfig { extends Omit<ButtonProps, "type">, UseHeadingConfig {
@ -81,7 +81,11 @@ export const HeadingButton = forwardRef<HTMLButtonElement, HeadingButtonProps>(
const handleClick = useCallback( const handleClick = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => { (event: React.MouseEvent<HTMLButtonElement>) => {
onClick?.(event) onClick?.(event)
if (event.defaultPrevented) return
if (event.defaultPrevented) {
return
}
handleToggle() handleToggle()
}, },
[handleToggle, onClick] [handleToggle, onClick]

View File

@ -1,10 +1,16 @@
"use client" "use client"
import { useCallback, useEffect, useState } from "react"
import { type Editor } from "@tiptap/react"
import { NodeSelection, TextSelection } from "@tiptap/pm/state" import { NodeSelection, TextSelection } from "@tiptap/pm/state"
import type {Editor} from "@tiptap/react";
import { useCallback, useEffect, useState } from "react"
// --- Hooks --- // --- Hooks ---
import { HeadingFiveIcon } from "@/components/tiptap-icons/heading-five-icon"
import { HeadingFourIcon } from "@/components/tiptap-icons/heading-four-icon"
import { HeadingOneIcon } from "@/components/tiptap-icons/heading-one-icon"
import { HeadingSixIcon } from "@/components/tiptap-icons/heading-six-icon"
import { HeadingThreeIcon } from "@/components/tiptap-icons/heading-three-icon"
import { HeadingTwoIcon } from "@/components/tiptap-icons/heading-two-icon"
import { useTiptapEditor } from "@/hooks/use-tiptap-editor" import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
// --- Lib --- // --- Lib ---
@ -18,12 +24,6 @@ import {
} from "@/lib/tiptap-utils" } from "@/lib/tiptap-utils"
// --- Icons --- // --- Icons ---
import { HeadingOneIcon } from "@/components/tiptap-icons/heading-one-icon"
import { HeadingTwoIcon } from "@/components/tiptap-icons/heading-two-icon"
import { HeadingThreeIcon } from "@/components/tiptap-icons/heading-three-icon"
import { HeadingFourIcon } from "@/components/tiptap-icons/heading-four-icon"
import { HeadingFiveIcon } from "@/components/tiptap-icons/heading-five-icon"
import { HeadingSixIcon } from "@/components/tiptap-icons/heading-six-icon"
export type Level = 1 | 2 | 3 | 4 | 5 | 6 export type Level = 1 | 2 | 3 | 4 | 5 | 6
@ -76,12 +76,16 @@ export function canToggle(
level?: Level, level?: Level,
turnInto: boolean = true turnInto: boolean = true
): boolean { ): boolean {
if (!editor || !editor.isEditable) return false if (!editor || !editor.isEditable) {
return false
}
if ( if (
!isNodeInSchema("heading", editor) || !isNodeInSchema("heading", editor) ||
isNodeTypeSelected(editor, ["image"]) isNodeTypeSelected(editor, ["image"])
) ) {
return false return false
}
if (!turnInto) { if (!turnInto) {
return level return level
@ -100,8 +104,9 @@ export function canToggle(
"blockquote", "blockquote",
"codeBlock", "codeBlock",
]) ])
) ) {
return false return false
}
// Either we can set heading directly on the selection, // Either we can set heading directly on the selection,
// or we can clear formatting/nodes to arrive at a heading. // or we can clear formatting/nodes to arrive at a heading.
@ -117,7 +122,9 @@ export function isHeadingActive(
editor: Editor | null, editor: Editor | null,
level?: Level | Level[] level?: Level | Level[]
): boolean { ): boolean {
if (!editor || !editor.isEditable) return false if (!editor || !editor.isEditable) {
return false
}
if (Array.isArray(level)) { if (Array.isArray(level)) {
return level.some((l) => editor.isActive("heading", { level: l })) return level.some((l) => editor.isActive("heading", { level: l }))
@ -135,12 +142,16 @@ export function toggleHeading(
editor: Editor | null, editor: Editor | null,
level: Level | Level[] level: Level | Level[]
): boolean { ): boolean {
if (!editor || !editor.isEditable) return false if (!editor || !editor.isEditable) {
return false
}
const levels = Array.isArray(level) ? level : [level] const levels = Array.isArray(level) ? level : [level]
const toggleLevel = levels.find((l) => canToggle(editor, l)) const toggleLevel = levels.find((l) => canToggle(editor, l))
if (!toggleLevel) return false if (!toggleLevel) {
return false
}
try { try {
const view = editor.view const view = editor.view
@ -172,7 +183,10 @@ export function toggleHeading(
editor, editor,
node: state.selection.$anchor.node(1), node: state.selection.$anchor.node(1),
})?.pos })?.pos
if (!isValidPosition(pos)) return false
if (!isValidPosition(pos)) {
return false
}
tr = tr.setSelection(NodeSelection.create(state.doc, pos)) tr = tr.setSelection(NodeSelection.create(state.doc, pos))
view.dispatch(tr) view.dispatch(tr)
@ -231,20 +245,27 @@ export function shouldShowButton(props: {
}): boolean { }): boolean {
const { editor, level, hideWhenUnavailable } = props const { editor, level, hideWhenUnavailable } = props
if (!editor) return false if (!editor) {
return false
}
if (!hideWhenUnavailable) { if (!hideWhenUnavailable) {
return true return true
} }
if (!editor.isEditable) return false if (!editor.isEditable) {
return false
}
if (!isNodeInSchema("heading", editor)) return false if (!isNodeInSchema("heading", editor)) {
return false
}
if (!editor.isActive("code")) { if (!editor.isActive("code")) {
if (Array.isArray(level)) { if (Array.isArray(level)) {
return level.some((l) => canToggle(editor, l)) return level.some((l) => canToggle(editor, l))
} }
return canToggle(editor, level) return canToggle(editor, level)
} }
@ -311,7 +332,9 @@ export function useHeading(config: UseHeadingConfig) {
const isActive = isHeadingActive(editor, level) const isActive = isHeadingActive(editor, level)
useEffect(() => { useEffect(() => {
if (!editor) return if (!editor) {
return
}
const handleSelectionUpdate = () => { const handleSelectionUpdate = () => {
setIsVisible(shouldShowButton({ editor, level, hideWhenUnavailable })) setIsVisible(shouldShowButton({ editor, level, hideWhenUnavailable }))
@ -327,12 +350,16 @@ export function useHeading(config: UseHeadingConfig) {
}, [editor, level, hideWhenUnavailable]) }, [editor, level, hideWhenUnavailable])
const handleToggle = useCallback(() => { const handleToggle = useCallback(() => {
if (!editor) return false if (!editor) {
return false
}
const success = toggleHeading(editor, level) const success = toggleHeading(editor, level)
if (success) { if (success) {
onToggled?.() onToggled?.()
} }
return success return success
}, [editor, level, onToggled]) }, [editor, level, onToggled])

View File

@ -4,7 +4,6 @@ import { forwardRef, useCallback, useState } from "react"
import { ChevronDownIcon } from "@/components/tiptap-icons/chevron-down-icon" import { ChevronDownIcon } from "@/components/tiptap-icons/chevron-down-icon"
// --- Hooks --- // --- Hooks ---
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
// --- Tiptap UI --- // --- Tiptap UI ---
import { HeadingButton } from "@/components/tiptap-ui/heading-button" import { HeadingButton } from "@/components/tiptap-ui/heading-button"
@ -21,6 +20,7 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuGroup, DropdownMenuGroup,
} from "@/components/tiptap-ui-primitive/dropdown-menu" } from "@/components/tiptap-ui-primitive/dropdown-menu"
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
export interface HeadingDropdownMenuProps export interface HeadingDropdownMenuProps
extends Omit<ButtonProps, "type">, UseHeadingDropdownMenuConfig { extends Omit<ButtonProps, "type">, UseHeadingDropdownMenuConfig {
@ -65,7 +65,10 @@ export const HeadingDropdownMenu = forwardRef<
const handleOpenChange = useCallback( const handleOpenChange = useCallback(
(open: boolean) => { (open: boolean) => {
if (!editor || !canToggle) return if (!editor || !canToggle) {
return
}
setIsOpen(open) setIsOpen(open)
onOpenChange?.(open) onOpenChange?.(open)
}, },

View File

@ -1,10 +1,9 @@
"use client" "use client"
import { useEffect, useState } from "react"
import type { Editor } from "@tiptap/react" import type { Editor } from "@tiptap/react"
import { useEffect, useState } from "react"
// --- Hooks --- // --- Hooks ---
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
// --- Icons --- // --- Icons ---
import { HeadingIcon } from "@/components/tiptap-icons/heading-icon" import { HeadingIcon } from "@/components/tiptap-icons/heading-icon"
@ -12,11 +11,13 @@ import { HeadingIcon } from "@/components/tiptap-icons/heading-icon"
// --- Tiptap UI --- // --- Tiptap UI ---
import { import {
headingIcons, headingIcons,
type Level,
isHeadingActive, isHeadingActive,
canToggle, canToggle,
shouldShowButton, shouldShowButton
} from "@/components/tiptap-ui/heading-button" } from "@/components/tiptap-ui/heading-button"
import type {Level} from "@/components/tiptap-ui/heading-button";
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
/** /**
* Configuration for the heading dropdown menu functionality * Configuration for the heading dropdown menu functionality
@ -45,7 +46,10 @@ export function getActiveHeadingLevel(
editor: Editor | null, editor: Editor | null,
levels: Level[] = [1, 2, 3, 4, 5, 6] levels: Level[] = [1, 2, 3, 4, 5, 6]
): Level | undefined { ): Level | undefined {
if (!editor || !editor.isEditable) return undefined if (!editor || !editor.isEditable) {
return undefined
}
return levels.find((level) => isHeadingActive(editor, level)) return levels.find((level) => isHeadingActive(editor, level))
} }
@ -103,7 +107,9 @@ export function useHeadingDropdownMenu(config?: UseHeadingDropdownMenuConfig) {
const canToggleState = canToggle(editor) const canToggleState = canToggle(editor)
useEffect(() => { useEffect(() => {
if (!editor) return if (!editor) {
return
}
const handleSelectionUpdate = () => { const handleSelectionUpdate = () => {
setIsVisible( setIsVisible(

View File

@ -1,10 +1,8 @@
import { forwardRef, useCallback } from "react" import { forwardRef, useCallback } from "react"
// --- Lib --- // --- Lib ---
import { parseShortcutKeys } from "@/lib/tiptap-utils"
// --- Hooks --- // --- Hooks ---
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
// --- Tiptap UI --- // --- Tiptap UI ---
import type { UseImageUploadConfig } from "@/components/tiptap-ui/image-upload-button" import type { UseImageUploadConfig } from "@/components/tiptap-ui/image-upload-button"
@ -14,9 +12,11 @@ import {
} from "@/components/tiptap-ui/image-upload-button" } from "@/components/tiptap-ui/image-upload-button"
// --- UI Primitives --- // --- UI Primitives ---
import { Badge } from "@/components/tiptap-ui-primitive/badge"
import type { ButtonProps } from "@/components/tiptap-ui-primitive/button" import type { ButtonProps } from "@/components/tiptap-ui-primitive/button"
import { Button } from "@/components/tiptap-ui-primitive/button" import { Button } from "@/components/tiptap-ui-primitive/button"
import { Badge } from "@/components/tiptap-ui-primitive/badge" import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
import { parseShortcutKeys } from "@/lib/tiptap-utils"
type IconProps = React.SVGProps<SVGSVGElement> type IconProps = React.SVGProps<SVGSVGElement>
type IconComponent = ({ className, ...props }: IconProps) => React.ReactElement type IconComponent = ({ className, ...props }: IconProps) => React.ReactElement
@ -87,7 +87,11 @@ export const ImageUploadButton = forwardRef<
const handleClick = useCallback( const handleClick = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => { (event: React.MouseEvent<HTMLButtonElement>) => {
onClick?.(event) onClick?.(event)
if (event.defaultPrevented) return
if (event.defaultPrevented) {
return
}
handleImage() handleImage()
}, },
[handleImage, onClick] [handleImage, onClick]

View File

@ -1,18 +1,18 @@
"use client" "use client"
import type {Editor} from "@tiptap/react";
import { useCallback, useEffect, useState } from "react" import { useCallback, useEffect, useState } from "react"
import { useHotkeys } from "react-hotkeys-hook" import { useHotkeys } from "react-hotkeys-hook"
import { type Editor } from "@tiptap/react"
// --- Hooks --- // --- Hooks ---
import { useTiptapEditor } from "@/hooks/use-tiptap-editor" import { ImagePlusIcon } from "@/components/tiptap-icons/image-plus-icon"
import { useIsBreakpoint } from "@/hooks/use-is-breakpoint" import { useIsBreakpoint } from "@/hooks/use-is-breakpoint"
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
// --- Lib --- // --- Lib ---
import { isExtensionAvailable } from "@/lib/tiptap-utils" import { isExtensionAvailable } from "@/lib/tiptap-utils"
// --- Icons --- // --- Icons ---
import { ImagePlusIcon } from "@/components/tiptap-icons/image-plus-icon"
export const IMAGE_UPLOAD_SHORTCUT_KEY = "mod+shift+i" export const IMAGE_UPLOAD_SHORTCUT_KEY = "mod+shift+i"
@ -39,8 +39,13 @@ export interface UseImageUploadConfig {
* Checks if image can be inserted in the current editor state * Checks if image can be inserted in the current editor state
*/ */
export function canInsertImage(editor: Editor | null): boolean { export function canInsertImage(editor: Editor | null): boolean {
if (!editor || !editor.isEditable) return false if (!editor || !editor.isEditable) {
if (!isExtensionAvailable(editor, "imageUpload")) return false return false
}
if (!isExtensionAvailable(editor, "imageUpload")) {
return false
}
return editor.can().insertContent({ type: "imageUpload" }) return editor.can().insertContent({ type: "imageUpload" })
} }
@ -49,7 +54,10 @@ export function canInsertImage(editor: Editor | null): boolean {
* Checks if image is currently active * Checks if image is currently active
*/ */
export function isImageActive(editor: Editor | null): boolean { export function isImageActive(editor: Editor | null): boolean {
if (!editor || !editor.isEditable) return false if (!editor || !editor.isEditable) {
return false
}
return editor.isActive("imageUpload") return editor.isActive("imageUpload")
} }
@ -57,8 +65,13 @@ export function isImageActive(editor: Editor | null): boolean {
* Inserts an image in the editor * Inserts an image in the editor
*/ */
export function insertImage(editor: Editor | null): boolean { export function insertImage(editor: Editor | null): boolean {
if (!editor || !editor.isEditable) return false if (!editor || !editor.isEditable) {
if (!canInsertImage(editor)) return false return false
}
if (!canInsertImage(editor)) {
return false
}
try { try {
return editor return editor
@ -82,13 +95,17 @@ export function shouldShowButton(props: {
}): boolean { }): boolean {
const { editor, hideWhenUnavailable } = props const { editor, hideWhenUnavailable } = props
if (!editor || !editor.isEditable) return false if (!editor || !editor.isEditable) {
return false
}
if (!hideWhenUnavailable) { if (!hideWhenUnavailable) {
return true return true
} }
if (!isExtensionAvailable(editor, "imageUpload")) return false if (!isExtensionAvailable(editor, "imageUpload")) {
return false
}
if (!editor.isActive("code")) { if (!editor.isActive("code")) {
return canInsertImage(editor) return canInsertImage(editor)
@ -147,7 +164,9 @@ export function useImageUpload(config?: UseImageUploadConfig) {
const isActive = isImageActive(editor) const isActive = isImageActive(editor)
useEffect(() => { useEffect(() => {
if (!editor) return if (!editor) {
return
}
const handleSelectionUpdate = () => { const handleSelectionUpdate = () => {
setIsVisible(shouldShowButton({ editor, hideWhenUnavailable })) setIsVisible(shouldShowButton({ editor, hideWhenUnavailable }))
@ -163,12 +182,16 @@ export function useImageUpload(config?: UseImageUploadConfig) {
}, [editor, hideWhenUnavailable]) }, [editor, hideWhenUnavailable])
const handleImage = useCallback(() => { const handleImage = useCallback(() => {
if (!editor) return false if (!editor) {
return false
}
const success = insertImage(editor) const success = insertImage(editor)
if (success) { if (success) {
onInserted?.() onInserted?.()
} }
return success return success
}, [editor, onInserted]) }, [editor, onInserted])

View File

@ -1,11 +1,9 @@
"use client" "use client"
import { forwardRef, useCallback, useEffect, useState } from "react"
import type { Editor } from "@tiptap/react" import type { Editor } from "@tiptap/react"
import { forwardRef, useCallback, useEffect, useState } from "react"
// --- Hooks --- // --- Hooks ---
import { useIsBreakpoint } from "@/hooks/use-is-breakpoint"
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
// --- Icons --- // --- Icons ---
import { CornerDownLeftIcon } from "@/components/tiptap-icons/corner-down-left-icon" import { CornerDownLeftIcon } from "@/components/tiptap-icons/corner-down-left-icon"
@ -20,19 +18,21 @@ import { useLinkPopover } from "@/components/tiptap-ui/link-popover"
// --- UI Primitives --- // --- UI Primitives ---
import type { ButtonProps } from "@/components/tiptap-ui-primitive/button" import type { ButtonProps } from "@/components/tiptap-ui-primitive/button"
import { Button } from "@/components/tiptap-ui-primitive/button" import { Button } from "@/components/tiptap-ui-primitive/button"
import { import { ButtonGroup } from "@/components/tiptap-ui-primitive/button-group"
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/tiptap-ui-primitive/popover"
import { Separator } from "@/components/tiptap-ui-primitive/separator"
import { import {
Card, Card,
CardBody, CardBody,
CardItemGroup, CardItemGroup,
} from "@/components/tiptap-ui-primitive/card" } from "@/components/tiptap-ui-primitive/card"
import { Input } from "@/components/tiptap-ui-primitive/input" import { Input } from "@/components/tiptap-ui-primitive/input"
import { ButtonGroup } from "@/components/tiptap-ui-primitive/button-group" import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/tiptap-ui-primitive/popover"
import { Separator } from "@/components/tiptap-ui-primitive/separator"
import { useIsBreakpoint } from "@/hooks/use-is-breakpoint"
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
import "./link-popover.scss" import "./link-popover.scss"
@ -259,7 +259,11 @@ export const LinkPopover = forwardRef<HTMLButtonElement, LinkPopoverProps>(
const handleClick = useCallback( const handleClick = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => { (event: React.MouseEvent<HTMLButtonElement>) => {
onClick?.(event) onClick?.(event)
if (event.defaultPrevented) return
if (event.defaultPrevented) {
return
}
setIsOpen(!isOpen) setIsOpen(!isOpen)
}, },
[onClick, isOpen] [onClick, isOpen]

View File

@ -1,11 +1,11 @@
import { useCallback, useEffect, useState } from "react"
import type { Editor } from "@tiptap/react" import type { Editor } from "@tiptap/react"
import { useCallback, useEffect, useState } from "react"
// --- Hooks --- // --- Hooks ---
import { LinkIcon } from "@/components/tiptap-icons/link-icon"
import { useTiptapEditor } from "@/hooks/use-tiptap-editor" import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
// --- Icons --- // --- Icons ---
import { LinkIcon } from "@/components/tiptap-icons/link-icon"
// --- Lib --- // --- Lib ---
import { import {
@ -51,11 +51,16 @@ export interface LinkHandlerProps {
* Checks if a link can be set in the current editor state * Checks if a link can be set in the current editor state
*/ */
export function canSetLink(editor: Editor | null): boolean { export function canSetLink(editor: Editor | null): boolean {
if (!editor || !editor.isEditable) return false if (!editor || !editor.isEditable) {
return false
}
// The third argument 'true' checks whether the current selection is inside an image caption, and prevents setting a link there // The third argument 'true' checks whether the current selection is inside an image caption, and prevents setting a link there
// If the selection is inside an image caption, we can't set a link // If the selection is inside an image caption, we can't set a link
if (isNodeTypeSelected(editor, ["image"], true)) return false if (isNodeTypeSelected(editor, ["image"], true)) {
return false
}
try { try {
return editor.can().setMark("link") return editor.can().setMark("link")
} catch { } catch {
@ -67,7 +72,10 @@ export function canSetLink(editor: Editor | null): boolean {
* Checks if a link is currently active in the editor * Checks if a link is currently active in the editor
*/ */
export function isLinkActive(editor: Editor | null): boolean { export function isLinkActive(editor: Editor | null): boolean {
if (!editor || !editor.isEditable) return false if (!editor || !editor.isEditable) {
return false
}
return editor.isActive("link") return editor.isActive("link")
} }
@ -80,7 +88,9 @@ export function shouldShowLinkButton(props: {
}): boolean { }): boolean {
const { editor, hideWhenUnavailable } = props const { editor, hideWhenUnavailable } = props
if (!editor || !editor.isEditable) return false if (!editor || !editor.isEditable) {
return false
}
const linkInSchema = isMarkInSchema("link", editor) const linkInSchema = isMarkInSchema("link", editor)
@ -110,7 +120,9 @@ export function useLinkHandler(props: LinkHandlerProps) {
const [url, setUrl] = useState<string | null>(null) const [url, setUrl] = useState<string | null>(null)
useEffect(() => { useEffect(() => {
if (!editor) return if (!editor) {
return
}
// Get URL immediately on mount // Get URL immediately on mount
const { href } = editor.getAttributes("link") const { href } = editor.getAttributes("link")
@ -121,7 +133,9 @@ export function useLinkHandler(props: LinkHandlerProps) {
}, [editor, url]) }, [editor, url])
useEffect(() => { useEffect(() => {
if (!editor) return if (!editor) {
return
}
const updateLinkState = () => { const updateLinkState = () => {
const { href } = editor.getAttributes("link") const { href } = editor.getAttributes("link")
@ -129,13 +143,16 @@ export function useLinkHandler(props: LinkHandlerProps) {
} }
editor.on("selectionUpdate", updateLinkState) editor.on("selectionUpdate", updateLinkState)
return () => { return () => {
editor.off("selectionUpdate", updateLinkState) editor.off("selectionUpdate", updateLinkState)
} }
}, [editor]) }, [editor])
const setLink = useCallback(() => { const setLink = useCallback(() => {
if (!url || !editor) return if (!url || !editor) {
return
}
const { selection } = editor.state const { selection } = editor.state
const isEmpty = selection.empty const isEmpty = selection.empty
@ -156,7 +173,10 @@ export function useLinkHandler(props: LinkHandlerProps) {
}, [editor, onSetLink, url]) }, [editor, onSetLink, url])
const removeLink = useCallback(() => { const removeLink = useCallback(() => {
if (!editor) return if (!editor) {
return
}
editor editor
.chain() .chain()
.focus() .focus()
@ -169,9 +189,12 @@ export function useLinkHandler(props: LinkHandlerProps) {
const openLink = useCallback( const openLink = useCallback(
(target: string = "_blank", features: string = "noopener,noreferrer") => { (target: string = "_blank", features: string = "noopener,noreferrer") => {
if (!url) return if (!url) {
return
}
const safeUrl = sanitizeUrl(url, window.location.href) const safeUrl = sanitizeUrl(url, window.location.href)
if (safeUrl !== "#") { if (safeUrl !== "#") {
window.open(safeUrl, target, features) window.open(safeUrl, target, features)
} }
@ -203,7 +226,9 @@ export function useLinkState(props: {
const [isVisible, setIsVisible] = useState(true) const [isVisible, setIsVisible] = useState(true)
useEffect(() => { useEffect(() => {
if (!editor) return if (!editor) {
return
}
const handleSelectionUpdate = () => { const handleSelectionUpdate = () => {
setIsVisible( setIsVisible(

View File

@ -1,19 +1,19 @@
import { forwardRef, useCallback } from "react" import { forwardRef, useCallback } from "react"
// --- Lib --- // --- Lib ---
import { parseShortcutKeys } from "@/lib/tiptap-utils"
// --- Hooks --- // --- Hooks ---
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
// --- UI Primitives --- // --- UI Primitives ---
import type { ButtonProps } from "@/components/tiptap-ui-primitive/button"
import { Button } from "@/components/tiptap-ui-primitive/button"
import { Badge } from "@/components/tiptap-ui-primitive/badge"
// --- Tiptap UI ---
import type { ListType, UseListConfig } from "@/components/tiptap-ui/list-button" import type { ListType, UseListConfig } from "@/components/tiptap-ui/list-button"
import { LIST_SHORTCUT_KEYS, useList } from "@/components/tiptap-ui/list-button" import { LIST_SHORTCUT_KEYS, useList } from "@/components/tiptap-ui/list-button"
import { Badge } from "@/components/tiptap-ui-primitive/badge"
import type { ButtonProps } from "@/components/tiptap-ui-primitive/button"
import { Button } from "@/components/tiptap-ui-primitive/button"
// --- Tiptap UI ---
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
import { parseShortcutKeys } from "@/lib/tiptap-utils"
export interface ListButtonProps export interface ListButtonProps
extends Omit<ButtonProps, "type">, UseListConfig { extends Omit<ButtonProps, "type">, UseListConfig {
@ -77,7 +77,11 @@ export const ListButton = forwardRef<HTMLButtonElement, ListButtonProps>(
const handleClick = useCallback( const handleClick = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => { (event: React.MouseEvent<HTMLButtonElement>) => {
onClick?.(event) onClick?.(event)
if (event.defaultPrevented) return
if (event.defaultPrevented) {
return
}
handleToggle() handleToggle()
}, },
[handleToggle, onClick] [handleToggle, onClick]

View File

@ -1,16 +1,16 @@
"use client" "use client"
import { useCallback, useEffect, useState } from "react"
import { type Editor } from "@tiptap/react"
import { NodeSelection, TextSelection } from "@tiptap/pm/state" import { NodeSelection, TextSelection } from "@tiptap/pm/state"
import type {Editor} from "@tiptap/react";
import { useCallback, useEffect, useState } from "react"
// --- Hooks --- // --- Hooks ---
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
// --- Icons --- // --- Icons ---
import { ListIcon } from "@/components/tiptap-icons/list-icon" import { ListIcon } from "@/components/tiptap-icons/list-icon"
import { ListOrderedIcon } from "@/components/tiptap-icons/list-ordered-icon" import { ListOrderedIcon } from "@/components/tiptap-icons/list-ordered-icon"
import { ListTodoIcon } from "@/components/tiptap-icons/list-todo-icon" import { ListTodoIcon } from "@/components/tiptap-icons/list-todo-icon"
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
// --- Lib --- // --- Lib ---
import { import {
@ -73,9 +73,13 @@ export function canToggleList(
type: ListType, type: ListType,
turnInto: boolean = true turnInto: boolean = true
): boolean { ): boolean {
if (!editor || !editor.isEditable) return false if (!editor || !editor.isEditable) {
if (!isNodeInSchema(type, editor) || isNodeTypeSelected(editor, ["image"])) return false
return false }
if (!isNodeInSchema(type, editor) || isNodeTypeSelected(editor, ["image"])) {
return false
}
if (!turnInto) { if (!turnInto) {
switch (type) { switch (type) {
@ -101,8 +105,9 @@ export function canToggleList(
"blockquote", "blockquote",
"codeBlock", "codeBlock",
]) ])
) ) {
return false return false
}
// Either we can set list directly on the selection, // Either we can set list directly on the selection,
// or we can clear formatting/nodes to arrive at a list. // or we can clear formatting/nodes to arrive at a list.
@ -125,7 +130,9 @@ export function canToggleList(
* Checks if list is currently active * Checks if list is currently active
*/ */
export function isListActive(editor: Editor | null, type: ListType): boolean { export function isListActive(editor: Editor | null, type: ListType): boolean {
if (!editor || !editor.isEditable) return false if (!editor || !editor.isEditable) {
return false
}
switch (type) { switch (type) {
case "bulletList": case "bulletList":
@ -143,8 +150,13 @@ export function isListActive(editor: Editor | null, type: ListType): boolean {
* Toggles list in the editor * Toggles list in the editor
*/ */
export function toggleList(editor: Editor | null, type: ListType): boolean { export function toggleList(editor: Editor | null, type: ListType): boolean {
if (!editor || !editor.isEditable) return false if (!editor || !editor.isEditable) {
if (!canToggleList(editor, type)) return false return false
}
if (!canToggleList(editor, type)) {
return false
}
try { try {
const view = editor.view const view = editor.view
@ -176,7 +188,10 @@ export function toggleList(editor: Editor | null, type: ListType): boolean {
editor, editor,
node: state.selection.$anchor.node(1), node: state.selection.$anchor.node(1),
})?.pos })?.pos
if (!isValidPosition(pos)) return false
if (!isValidPosition(pos)) {
return false
}
tr = tr.setSelection(NodeSelection.create(state.doc, pos)) tr = tr.setSelection(NodeSelection.create(state.doc, pos))
view.dispatch(tr) view.dispatch(tr)
@ -225,7 +240,10 @@ export function toggleList(editor: Editor | null, type: ListType): boolean {
} }
const toggle = toggleMap[type] const toggle = toggleMap[type]
if (!toggle) return false
if (!toggle) {
return false
}
toggle().run() toggle().run()
} }
@ -248,15 +266,21 @@ export function shouldShowButton(props: {
}): boolean { }): boolean {
const { editor, type, hideWhenUnavailable } = props const { editor, type, hideWhenUnavailable } = props
if (!editor) return false if (!editor) {
return false
}
if (!hideWhenUnavailable) { if (!hideWhenUnavailable) {
return true return true
} }
if (!editor.isEditable) return false if (!editor.isEditable) {
return false
}
if (!isNodeInSchema(type, editor)) return false if (!isNodeInSchema(type, editor)) {
return false
}
if (!editor.isActive("code")) { if (!editor.isActive("code")) {
return canToggleList(editor, type) return canToggleList(editor, type)
@ -316,7 +340,9 @@ export function useList(config: UseListConfig) {
const isActive = isListActive(editor, type) const isActive = isListActive(editor, type)
useEffect(() => { useEffect(() => {
if (!editor) return if (!editor) {
return
}
const handleSelectionUpdate = () => { const handleSelectionUpdate = () => {
setIsVisible(shouldShowButton({ editor, type, hideWhenUnavailable })) setIsVisible(shouldShowButton({ editor, type, hideWhenUnavailable }))
@ -332,12 +358,16 @@ export function useList(config: UseListConfig) {
}, [editor, type, hideWhenUnavailable]) }, [editor, type, hideWhenUnavailable])
const handleToggle = useCallback(() => { const handleToggle = useCallback(() => {
if (!editor) return false if (!editor) {
return false
}
const success = toggleList(editor, type) const success = toggleList(editor, type)
if (success) { if (success) {
onToggled?.() onToggled?.()
} }
return success return success
}, [editor, type, onToggled]) }, [editor, type, onToggled])

View File

@ -1,14 +1,14 @@
import type {Editor} from "@tiptap/react";
import { useCallback, useState } from "react" import { useCallback, useState } from "react"
import { type Editor } from "@tiptap/react"
// --- Hooks --- // --- Hooks ---
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
// --- Icons --- // --- Icons ---
import { ChevronDownIcon } from "@/components/tiptap-icons/chevron-down-icon" import { ChevronDownIcon } from "@/components/tiptap-icons/chevron-down-icon"
// --- Tiptap UI --- // --- Tiptap UI ---
import { ListButton, type ListType } from "@/components/tiptap-ui/list-button" import { ListButton } from "@/components/tiptap-ui/list-button"
import type {ListType} from "@/components/tiptap-ui/list-button";
import { useListDropdownMenu } from "@/components/tiptap-ui/list-dropdown-menu/use-list-dropdown-menu" import { useListDropdownMenu } from "@/components/tiptap-ui/list-dropdown-menu/use-list-dropdown-menu"
@ -22,6 +22,7 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuGroup, DropdownMenuGroup,
} from "@/components/tiptap-ui-primitive/dropdown-menu" } from "@/components/tiptap-ui-primitive/dropdown-menu"
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
export interface ListDropdownMenuProps extends Omit<ButtonProps, "type"> { export interface ListDropdownMenuProps extends Omit<ButtonProps, "type"> {
/** /**

View File

@ -1,10 +1,9 @@
"use client" "use client"
import { useEffect, useMemo, useState } from "react"
import type { Editor } from "@tiptap/react" import type { Editor } from "@tiptap/react"
import { useEffect, useMemo, useState } from "react"
// --- Hooks --- // --- Hooks ---
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
// --- Icons --- // --- Icons ---
import { ListIcon } from "@/components/tiptap-icons/list-icon" import { ListIcon } from "@/components/tiptap-icons/list-icon"
@ -12,15 +11,17 @@ import { ListOrderedIcon } from "@/components/tiptap-icons/list-ordered-icon"
import { ListTodoIcon } from "@/components/tiptap-icons/list-todo-icon" import { ListTodoIcon } from "@/components/tiptap-icons/list-todo-icon"
// --- Lib --- // --- Lib ---
import { isNodeInSchema } from "@/lib/tiptap-utils"
// --- Tiptap UI --- // --- Tiptap UI ---
import { import {
canToggleList, canToggleList,
isListActive, isListActive,
listIcons, listIcons
type ListType,
} from "@/components/tiptap-ui/list-button" } from "@/components/tiptap-ui/list-button"
import type {ListType} from "@/components/tiptap-ui/list-button";
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
import { isNodeInSchema } from "@/lib/tiptap-utils"
/** /**
* Configuration for the list dropdown menu functionality * Configuration for the list dropdown menu functionality
@ -70,7 +71,10 @@ export function canToggleAnyList(
editor: Editor | null, editor: Editor | null,
listTypes: ListType[] listTypes: ListType[]
): boolean { ): boolean {
if (!editor || !editor.isEditable) return false if (!editor || !editor.isEditable) {
return false
}
return listTypes.some((type) => canToggleList(editor, type)) return listTypes.some((type) => canToggleList(editor, type))
} }
@ -78,7 +82,10 @@ export function isAnyListActive(
editor: Editor | null, editor: Editor | null,
listTypes: ListType[] listTypes: ListType[]
): boolean { ): boolean {
if (!editor || !editor.isEditable) return false if (!editor || !editor.isEditable) {
return false
}
return listTypes.some((type) => isListActive(editor, type)) return listTypes.some((type) => isListActive(editor, type))
} }
@ -99,13 +106,17 @@ export function shouldShowListDropdown(params: {
}): boolean { }): boolean {
const { editor, hideWhenUnavailable, listInSchema, canToggleAny } = params const { editor, hideWhenUnavailable, listInSchema, canToggleAny } = params
if (!editor) return false if (!editor) {
return false
}
if (!hideWhenUnavailable) { if (!hideWhenUnavailable) {
return true return true
} }
if (!listInSchema) return false if (!listInSchema) {
return false
}
if (!editor.isActive("code")) { if (!editor.isActive("code")) {
return canToggleAny return canToggleAny
@ -121,7 +132,10 @@ export function getActiveListType(
editor: Editor | null, editor: Editor | null,
availableTypes: ListType[] availableTypes: ListType[]
): ListType | undefined { ): ListType | undefined {
if (!editor || !editor.isEditable) return undefined if (!editor || !editor.isEditable) {
return undefined
}
return availableTypes.find((type) => isListActive(editor, type)) return availableTypes.find((type) => isListActive(editor, type))
} }
@ -184,7 +198,9 @@ export function useListDropdownMenu(config?: UseListDropdownMenuConfig) {
const activeList = filteredLists.find((option) => option.type === activeType) const activeList = filteredLists.find((option) => option.type === activeType)
useEffect(() => { useEffect(() => {
if (!editor) return if (!editor) {
return
}
const handleSelectionUpdate = () => { const handleSelectionUpdate = () => {
setIsVisible( setIsVisible(

View File

@ -3,19 +3,19 @@
import { forwardRef, useCallback } from "react" import { forwardRef, useCallback } from "react"
// --- Lib --- // --- Lib ---
import { parseShortcutKeys } from "@/lib/tiptap-utils"
// --- Hooks --- // --- Hooks ---
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
// --- Tiptap UI --- // --- Tiptap UI ---
import type { Mark, UseMarkConfig } from "@/components/tiptap-ui/mark-button" import type { Mark, UseMarkConfig } from "@/components/tiptap-ui/mark-button"
import { MARK_SHORTCUT_KEYS, useMark } from "@/components/tiptap-ui/mark-button" import { MARK_SHORTCUT_KEYS, useMark } from "@/components/tiptap-ui/mark-button"
// --- UI Primitives --- // --- UI Primitives ---
import { Badge } from "@/components/tiptap-ui-primitive/badge"
import type { ButtonProps } from "@/components/tiptap-ui-primitive/button" import type { ButtonProps } from "@/components/tiptap-ui-primitive/button"
import { Button } from "@/components/tiptap-ui-primitive/button" import { Button } from "@/components/tiptap-ui-primitive/button"
import { Badge } from "@/components/tiptap-ui-primitive/badge" import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
import { parseShortcutKeys } from "@/lib/tiptap-utils"
export interface MarkButtonProps export interface MarkButtonProps
extends Omit<ButtonProps, "type">, UseMarkConfig { extends Omit<ButtonProps, "type">, UseMarkConfig {
@ -79,7 +79,11 @@ export const MarkButton = forwardRef<HTMLButtonElement, MarkButtonProps>(
const handleClick = useCallback( const handleClick = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => { (event: React.MouseEvent<HTMLButtonElement>) => {
onClick?.(event) onClick?.(event)
if (event.defaultPrevented) return
if (event.defaultPrevented) {
return
}
handleMark() handleMark()
}, },
[handleMark, onClick] [handleMark, onClick]

View File

@ -1,11 +1,9 @@
import { useCallback, useEffect, useState } from "react"
import type { Editor } from "@tiptap/react" import type { Editor } from "@tiptap/react"
import { useCallback, useEffect, useState } from "react"
// --- Hooks --- // --- Hooks ---
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
// --- Lib --- // --- Lib ---
import { isMarkInSchema, isNodeTypeSelected } from "@/lib/tiptap-utils"
// --- Icons --- // --- Icons ---
import { BoldIcon } from "@/components/tiptap-icons/bold-icon" import { BoldIcon } from "@/components/tiptap-icons/bold-icon"
@ -15,6 +13,8 @@ import { StrikeIcon } from "@/components/tiptap-icons/strike-icon"
import { SubscriptIcon } from "@/components/tiptap-icons/subscript-icon" import { SubscriptIcon } from "@/components/tiptap-icons/subscript-icon"
import { SuperscriptIcon } from "@/components/tiptap-icons/superscript-icon" import { SuperscriptIcon } from "@/components/tiptap-icons/superscript-icon"
import { UnderlineIcon } from "@/components/tiptap-icons/underline-icon" import { UnderlineIcon } from "@/components/tiptap-icons/underline-icon"
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
import { isMarkInSchema, isNodeTypeSelected } from "@/lib/tiptap-utils"
export type Mark = export type Mark =
| "bold" | "bold"
@ -72,9 +72,13 @@ export const MARK_SHORTCUT_KEYS: Record<Mark, string> = {
* Checks if a mark can be toggled in the current editor state * Checks if a mark can be toggled in the current editor state
*/ */
export function canToggleMark(editor: Editor | null, type: Mark): boolean { export function canToggleMark(editor: Editor | null, type: Mark): boolean {
if (!editor || !editor.isEditable) return false if (!editor || !editor.isEditable) {
if (!isMarkInSchema(type, editor) || isNodeTypeSelected(editor, ["image"])) return false
return false }
if (!isMarkInSchema(type, editor) || isNodeTypeSelected(editor, ["image"])) {
return false
}
return editor.can().toggleMark(type) return editor.can().toggleMark(type)
} }
@ -83,7 +87,10 @@ export function canToggleMark(editor: Editor | null, type: Mark): boolean {
* Checks if a mark is currently active * Checks if a mark is currently active
*/ */
export function isMarkActive(editor: Editor | null, type: Mark): boolean { export function isMarkActive(editor: Editor | null, type: Mark): boolean {
if (!editor || !editor.isEditable) return false if (!editor || !editor.isEditable) {
return false
}
return editor.isActive(type) return editor.isActive(type)
} }
@ -91,8 +98,13 @@ export function isMarkActive(editor: Editor | null, type: Mark): boolean {
* Toggles a mark in the editor * Toggles a mark in the editor
*/ */
export function toggleMark(editor: Editor | null, type: Mark): boolean { export function toggleMark(editor: Editor | null, type: Mark): boolean {
if (!editor || !editor.isEditable) return false if (!editor || !editor.isEditable) {
if (!canToggleMark(editor, type)) return false return false
}
if (!canToggleMark(editor, type)) {
return false
}
return editor.chain().focus().toggleMark(type).run() return editor.chain().focus().toggleMark(type).run()
} }
@ -107,15 +119,21 @@ export function shouldShowButton(props: {
}): boolean { }): boolean {
const { editor, type, hideWhenUnavailable } = props const { editor, type, hideWhenUnavailable } = props
if (!editor) return false if (!editor) {
return false
}
if (!hideWhenUnavailable) { if (!hideWhenUnavailable) {
return true return true
} }
if (!editor.isEditable) return false if (!editor.isEditable) {
return false
}
if (!isMarkInSchema(type, editor)) return false if (!isMarkInSchema(type, editor)) {
return false
}
if (!editor.isActive("code")) { if (!editor.isActive("code")) {
return canToggleMark(editor, type) return canToggleMark(editor, type)
@ -182,7 +200,9 @@ export function useMark(config: UseMarkConfig) {
const isActive = isMarkActive(editor, type) const isActive = isMarkActive(editor, type)
useEffect(() => { useEffect(() => {
if (!editor) return if (!editor) {
return
}
const handleSelectionUpdate = () => { const handleSelectionUpdate = () => {
setIsVisible(shouldShowButton({ editor, type, hideWhenUnavailable })) setIsVisible(shouldShowButton({ editor, type, hideWhenUnavailable }))
@ -198,12 +218,16 @@ export function useMark(config: UseMarkConfig) {
}, [editor, type, hideWhenUnavailable]) }, [editor, type, hideWhenUnavailable])
const handleMark = useCallback(() => { const handleMark = useCallback(() => {
if (!editor) return false if (!editor) {
return false
}
const success = toggleMark(editor, type) const success = toggleMark(editor, type)
if (success) { if (success) {
onToggled?.() onToggled?.()
} }
return success return success
}, [editor, type, onToggled]) }, [editor, type, onToggled])

View File

@ -3,10 +3,8 @@
import { forwardRef, useCallback } from "react" import { forwardRef, useCallback } from "react"
// --- Lib --- // --- Lib ---
import { parseShortcutKeys } from "@/lib/tiptap-utils"
// --- Hooks --- // --- Hooks ---
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
// --- Tiptap UI --- // --- Tiptap UI ---
import type { import type {
@ -19,9 +17,11 @@ import {
} from "@/components/tiptap-ui/text-align-button" } from "@/components/tiptap-ui/text-align-button"
// --- UI Primitives --- // --- UI Primitives ---
import { Badge } from "@/components/tiptap-ui-primitive/badge"
import type { ButtonProps } from "@/components/tiptap-ui-primitive/button" import type { ButtonProps } from "@/components/tiptap-ui-primitive/button"
import { Button } from "@/components/tiptap-ui-primitive/button" import { Button } from "@/components/tiptap-ui-primitive/button"
import { Badge } from "@/components/tiptap-ui-primitive/badge" import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
import { parseShortcutKeys } from "@/lib/tiptap-utils"
type IconProps = React.SVGProps<SVGSVGElement> type IconProps = React.SVGProps<SVGSVGElement>
type IconComponent = ({ className, ...props }: IconProps) => React.ReactElement type IconComponent = ({ className, ...props }: IconProps) => React.ReactElement
@ -96,7 +96,11 @@ export const TextAlignButton = forwardRef<
const handleClick = useCallback( const handleClick = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => { (event: React.MouseEvent<HTMLButtonElement>) => {
onClick?.(event) onClick?.(event)
if (event.defaultPrevented) return
if (event.defaultPrevented) {
return
}
handleTextAlign() handleTextAlign()
}, },
[handleTextAlign, onClick] [handleTextAlign, onClick]

View File

@ -1,21 +1,21 @@
import { useCallback, useEffect, useState } from "react"
import type { ChainedCommands } from "@tiptap/react" import type { ChainedCommands } from "@tiptap/react"
import { type Editor } from "@tiptap/react" import type {Editor} from "@tiptap/react";
import { useCallback, useEffect, useState } from "react"
// --- Hooks --- // --- Hooks ---
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
// --- Lib --- // --- Lib ---
import {
isExtensionAvailable,
isNodeTypeSelected,
} from "@/lib/tiptap-utils"
// --- Icons --- // --- Icons ---
import { AlignCenterIcon } from "@/components/tiptap-icons/align-center-icon" import { AlignCenterIcon } from "@/components/tiptap-icons/align-center-icon"
import { AlignJustifyIcon } from "@/components/tiptap-icons/align-justify-icon" import { AlignJustifyIcon } from "@/components/tiptap-icons/align-justify-icon"
import { AlignLeftIcon } from "@/components/tiptap-icons/align-left-icon" import { AlignLeftIcon } from "@/components/tiptap-icons/align-left-icon"
import { AlignRightIcon } from "@/components/tiptap-icons/align-right-icon" import { AlignRightIcon } from "@/components/tiptap-icons/align-right-icon"
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
import {
isExtensionAvailable,
isNodeTypeSelected,
} from "@/lib/tiptap-utils"
export type TextAlign = "left" | "center" | "right" | "justify" export type TextAlign = "left" | "center" | "right" | "justify"
@ -70,12 +70,16 @@ export function canSetTextAlign(
editor: Editor | null, editor: Editor | null,
align: TextAlign align: TextAlign
): boolean { ): boolean {
if (!editor || !editor.isEditable) return false if (!editor || !editor.isEditable) {
return false
}
if ( if (
!isExtensionAvailable(editor, "textAlign") || !isExtensionAvailable(editor, "textAlign") ||
isNodeTypeSelected(editor, ["image", "horizontalRule"]) isNodeTypeSelected(editor, ["image", "horizontalRule"])
) ) {
return false return false
}
return editor.can().setTextAlign(align) return editor.can().setTextAlign(align)
} }
@ -95,7 +99,10 @@ export function isTextAlignActive(
editor: Editor | null, editor: Editor | null,
align: TextAlign align: TextAlign
): boolean { ): boolean {
if (!editor || !editor.isEditable) return false if (!editor || !editor.isEditable) {
return false
}
return editor.isActive({ textAlign: align }) return editor.isActive({ textAlign: align })
} }
@ -103,10 +110,16 @@ export function isTextAlignActive(
* Sets text alignment in the editor * Sets text alignment in the editor
*/ */
export function setTextAlign(editor: Editor | null, align: TextAlign): boolean { export function setTextAlign(editor: Editor | null, align: TextAlign): boolean {
if (!editor || !editor.isEditable) return false if (!editor || !editor.isEditable) {
if (!canSetTextAlign(editor, align)) return false return false
}
if (!canSetTextAlign(editor, align)) {
return false
}
const chain = editor.chain().focus() const chain = editor.chain().focus()
if (hasSetTextAlign(chain)) { if (hasSetTextAlign(chain)) {
return chain.setTextAlign(align).run() return chain.setTextAlign(align).run()
} }
@ -124,15 +137,21 @@ export function shouldShowButton(props: {
}): boolean { }): boolean {
const { editor, hideWhenUnavailable, align } = props const { editor, hideWhenUnavailable, align } = props
if (!editor) return false if (!editor) {
return false
}
if (!hideWhenUnavailable) { if (!hideWhenUnavailable) {
return true return true
} }
if (!editor.isEditable) return false if (!editor.isEditable) {
return false
}
if (!isExtensionAvailable(editor, "textAlign")) return false if (!isExtensionAvailable(editor, "textAlign")) {
return false
}
if (!editor.isActive("code")) { if (!editor.isActive("code")) {
return canSetTextAlign(editor, align) return canSetTextAlign(editor, align)
@ -192,7 +211,9 @@ export function useTextAlign(config: UseTextAlignConfig) {
const isActive = isTextAlignActive(editor, align) const isActive = isTextAlignActive(editor, align)
useEffect(() => { useEffect(() => {
if (!editor) return if (!editor) {
return
}
const handleSelectionUpdate = () => { const handleSelectionUpdate = () => {
setIsVisible(shouldShowButton({ editor, align, hideWhenUnavailable })) setIsVisible(shouldShowButton({ editor, align, hideWhenUnavailable }))
@ -208,12 +229,16 @@ export function useTextAlign(config: UseTextAlignConfig) {
}, [editor, hideWhenUnavailable, align]) }, [editor, hideWhenUnavailable, align])
const handleTextAlign = useCallback(() => { const handleTextAlign = useCallback(() => {
if (!editor) return false if (!editor) {
return false
}
const success = setTextAlign(editor, align) const success = setTextAlign(editor, align)
if (success) { if (success) {
onAligned?.() onAligned?.()
} }
return success return success
}, [editor, align, onAligned]) }, [editor, align, onAligned])

View File

@ -3,10 +3,8 @@
import { forwardRef, useCallback } from "react" import { forwardRef, useCallback } from "react"
// --- Lib --- // --- Lib ---
import { parseShortcutKeys } from "@/lib/tiptap-utils"
// --- Hooks --- // --- Hooks ---
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
// --- Tiptap UI --- // --- Tiptap UI ---
import type { import type {
@ -19,9 +17,11 @@ import {
} from "@/components/tiptap-ui/undo-redo-button" } from "@/components/tiptap-ui/undo-redo-button"
// --- UI Primitives --- // --- UI Primitives ---
import { Badge } from "@/components/tiptap-ui-primitive/badge"
import type { ButtonProps } from "@/components/tiptap-ui-primitive/button" import type { ButtonProps } from "@/components/tiptap-ui-primitive/button"
import { Button } from "@/components/tiptap-ui-primitive/button" import { Button } from "@/components/tiptap-ui-primitive/button"
import { Badge } from "@/components/tiptap-ui-primitive/badge" import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
import { parseShortcutKeys } from "@/lib/tiptap-utils"
export interface UndoRedoButtonProps export interface UndoRedoButtonProps
extends Omit<ButtonProps, "type">, UseUndoRedoConfig { extends Omit<ButtonProps, "type">, UseUndoRedoConfig {
@ -81,7 +81,11 @@ export const UndoRedoButton = forwardRef<
const handleClick = useCallback( const handleClick = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => { (event: React.MouseEvent<HTMLButtonElement>) => {
onClick?.(event) onClick?.(event)
if (event.defaultPrevented) return
if (event.defaultPrevented) {
return
}
handleAction() handleAction()
}, },
[handleAction, onClick] [handleAction, onClick]

View File

@ -1,15 +1,15 @@
import type {Editor} from "@tiptap/react";
import { useCallback, useEffect, useState } from "react" import { useCallback, useEffect, useState } from "react"
import { type Editor } from "@tiptap/react"
// --- Hooks --- // --- Hooks ---
import { Redo2Icon } from "@/components/tiptap-icons/redo2-icon"
import { Undo2Icon } from "@/components/tiptap-icons/undo2-icon"
import { useTiptapEditor } from "@/hooks/use-tiptap-editor" import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
// --- Lib --- // --- Lib ---
import { isNodeTypeSelected } from "@/lib/tiptap-utils" import { isNodeTypeSelected } from "@/lib/tiptap-utils"
// --- Icons --- // --- Icons ---
import { Redo2Icon } from "@/components/tiptap-icons/redo2-icon"
import { Undo2Icon } from "@/components/tiptap-icons/undo2-icon"
export type UndoRedoAction = "undo" | "redo" export type UndoRedoAction = "undo" | "redo"
@ -58,8 +58,13 @@ export function canExecuteUndoRedoAction(
editor: Editor | null, editor: Editor | null,
action: UndoRedoAction action: UndoRedoAction
): boolean { ): boolean {
if (!editor || !editor.isEditable) return false if (!editor || !editor.isEditable) {
if (isNodeTypeSelected(editor, ["image"])) return false return false
}
if (isNodeTypeSelected(editor, ["image"])) {
return false
}
return action === "undo" ? editor.can().undo() : editor.can().redo() return action === "undo" ? editor.can().undo() : editor.can().redo()
} }
@ -71,10 +76,16 @@ export function executeUndoRedoAction(
editor: Editor | null, editor: Editor | null,
action: UndoRedoAction action: UndoRedoAction
): boolean { ): boolean {
if (!editor || !editor.isEditable) return false if (!editor || !editor.isEditable) {
if (!canExecuteUndoRedoAction(editor, action)) return false return false
}
if (!canExecuteUndoRedoAction(editor, action)) {
return false
}
const chain = editor.chain().focus() const chain = editor.chain().focus()
return action === "undo" ? chain.undo().run() : chain.redo().run() return action === "undo" ? chain.undo().run() : chain.redo().run()
} }
@ -88,13 +99,17 @@ export function shouldShowButton(props: {
}): boolean { }): boolean {
const { editor, hideWhenUnavailable, action } = props const { editor, hideWhenUnavailable, action } = props
if (!editor) return false if (!editor) {
return false
}
if (!hideWhenUnavailable) { if (!hideWhenUnavailable) {
return true return true
} }
if (!editor.isEditable) return false if (!editor.isEditable) {
return false
}
if (!editor.isActive("code")) { if (!editor.isActive("code")) {
return canExecuteUndoRedoAction(editor, action) return canExecuteUndoRedoAction(editor, action)
@ -152,7 +167,9 @@ export function useUndoRedo(config: UseUndoRedoConfig) {
const canExecute = canExecuteUndoRedoAction(editor, action) const canExecute = canExecuteUndoRedoAction(editor, action)
useEffect(() => { useEffect(() => {
if (!editor) return if (!editor) {
return
}
const handleUpdate = () => { const handleUpdate = () => {
setIsVisible(shouldShowButton({ editor, hideWhenUnavailable, action })) setIsVisible(shouldShowButton({ editor, hideWhenUnavailable, action }))
@ -168,12 +185,16 @@ export function useUndoRedo(config: UseUndoRedoConfig) {
}, [editor, hideWhenUnavailable, action]) }, [editor, hideWhenUnavailable, action])
const handleAction = useCallback(() => { const handleAction = useCallback(() => {
if (!editor) return false if (!editor) {
return false
}
const success = executeUndoRedoAction(editor, action) const success = executeUndoRedoAction(editor, action)
if (success) { if (success) {
onExecuted?.() onExecuted?.()
} }
return success return success
}, [editor, action, onExecuted]) }, [editor, action, onExecuted])

View File

@ -44,9 +44,13 @@ export function useCurrentUrl(): UseCurrentUrlReturn {
const currentPath = currentUrl ?? currentUrlPath; const currentPath = currentUrl ?? currentUrlPath;
const clean = (p: string) => { const clean = (p: string) => {
if (!p) return '/'; if (!p) {
return '/';
}
// Remove query and hash, then trailing slash // Remove query and hash, then trailing slash
let path = p.split('?')[0].split('#')[0]; let path = p.split('?')[0].split('#')[0];
if (path.startsWith('http')) { if (path.startsWith('http')) {
try { try {
path = new URL(path).pathname; path = new URL(path).pathname;
@ -54,6 +58,7 @@ export function useCurrentUrl(): UseCurrentUrlReturn {
// Ignore // Ignore
} }
} }
return path.replace(/\/+$/, '') || '/'; return path.replace(/\/+$/, '') || '/';
}; };
@ -64,6 +69,7 @@ export function useCurrentUrl(): UseCurrentUrlReturn {
if (normCheck === '/') { if (normCheck === '/') {
return normCurrent === '/'; return normCurrent === '/';
} }
return normCurrent.startsWith(normCheck); return normCurrent.startsWith(normCheck);
} }

View File

@ -1,7 +1,7 @@
import type { Editor } from "@tiptap/react" import type { Editor } from "@tiptap/react"
import { useWindowSize } from "@/hooks/use-window-size"
import { useBodyRect } from "@/hooks/use-element-rect"
import { useEffect } from "react" import { useEffect } from "react"
import { useBodyRect } from "@/hooks/use-element-rect"
import { useWindowSize } from "@/hooks/use-window-size"
export interface CursorVisibilityOptions { export interface CursorVisibilityOptions {
/** /**
@ -35,10 +35,15 @@ export function useCursorVisibility({
useEffect(() => { useEffect(() => {
const ensureCursorVisibility = () => { const ensureCursorVisibility = () => {
if (!editor) return if (!editor) {
return
}
const { state, view } = editor const { state, view } = editor
if (!view.hasFocus()) return
if (!view.hasFocus()) {
return
}
// Get current cursor position coordinates // Get current cursor position coordinates
const { from } = state.selection const { from } = state.selection

View File

@ -59,7 +59,9 @@ export function useElementRect({
const [rect, setRect] = useState<RectState>(initialRect) const [rect, setRect] = useState<RectState>(initialRect)
const getTargetElement = useCallback((): Element | null => { const getTargetElement = useCallback((): Element | null => {
if (!enabled || !isClientSide()) return null if (!enabled || !isClientSide()) {
return null
}
if (!element) { if (!element) {
return document.body return document.body
@ -78,11 +80,15 @@ export function useElementRect({
const updateRect = useThrottledCallback( const updateRect = useThrottledCallback(
() => { () => {
if (!enabled || !isClientSide()) return if (!enabled || !isClientSide()) {
return
}
const targetElement = getTargetElement() const targetElement = getTargetElement()
if (!targetElement) { if (!targetElement) {
setRect(initialRect) setRect(initialRect)
return return
} }
@ -106,11 +112,15 @@ export function useElementRect({
useEffect(() => { useEffect(() => {
if (!enabled || !isClientSide()) { if (!enabled || !isClientSide()) {
setRect(initialRect) setRect(initialRect)
return return
} }
const targetElement = getTargetElement() const targetElement = getTargetElement()
if (!targetElement) return
if (!targetElement) {
return
}
updateRect() updateRect()

View File

@ -28,6 +28,7 @@ export function useIsBreakpoint(
// Add listener // Add listener
mql.addEventListener("change", onChange) mql.addEventListener("change", onChange)
return () => mql.removeEventListener("change", onChange) return () => mql.removeEventListener("change", onChange)
}, [mode, breakpoint]) }, [mode, breakpoint])

View File

@ -65,83 +65,117 @@ export function useMenuNavigation<T>({
useEffect(() => { useEffect(() => {
const handleKeyboardNavigation = (event: KeyboardEvent) => { const handleKeyboardNavigation = (event: KeyboardEvent) => {
if (!items.length) return false if (!items.length) {
return false
}
const moveNext = () => const moveNext = () =>
setSelectedIndex((currentIndex) => { setSelectedIndex((currentIndex) => {
if (currentIndex === -1) return 0 if (currentIndex === -1) {
return 0
}
return (currentIndex + 1) % items.length return (currentIndex + 1) % items.length
}) })
const movePrev = () => const movePrev = () =>
setSelectedIndex((currentIndex) => { setSelectedIndex((currentIndex) => {
if (currentIndex === -1) return items.length - 1 if (currentIndex === -1) {
return items.length - 1
}
return (currentIndex - 1 + items.length) % items.length return (currentIndex - 1 + items.length) % items.length
}) })
switch (event.key) { switch (event.key) {
case "ArrowUp": { case "ArrowUp": {
if (orientation === "horizontal") return false if (orientation === "horizontal") {
return false
}
event.preventDefault() event.preventDefault()
movePrev() movePrev()
return true return true
} }
case "ArrowDown": { case "ArrowDown": {
if (orientation === "horizontal") return false if (orientation === "horizontal") {
return false
}
event.preventDefault() event.preventDefault()
moveNext() moveNext()
return true return true
} }
case "ArrowLeft": { case "ArrowLeft": {
if (orientation === "vertical") return false if (orientation === "vertical") {
return false
}
event.preventDefault() event.preventDefault()
movePrev() movePrev()
return true return true
} }
case "ArrowRight": { case "ArrowRight": {
if (orientation === "vertical") return false if (orientation === "vertical") {
return false
}
event.preventDefault() event.preventDefault()
moveNext() moveNext()
return true return true
} }
case "Tab": { case "Tab": {
event.preventDefault() event.preventDefault()
if (event.shiftKey) { if (event.shiftKey) {
movePrev() movePrev()
} else { } else {
moveNext() moveNext()
} }
return true return true
} }
case "Home": { case "Home": {
event.preventDefault() event.preventDefault()
setSelectedIndex(0) setSelectedIndex(0)
return true return true
} }
case "End": { case "End": {
event.preventDefault() event.preventDefault()
setSelectedIndex(items.length - 1) setSelectedIndex(items.length - 1)
return true return true
} }
case "Enter": { case "Enter": {
if (event.isComposing) return false if (event.isComposing) {
return false
}
event.preventDefault() event.preventDefault()
if (selectedIndex !== -1 && items[selectedIndex]) { if (selectedIndex !== -1 && items[selectedIndex]) {
onSelect?.(items[selectedIndex]) onSelect?.(items[selectedIndex])
} }
return true return true
} }
case "Escape": { case "Escape": {
event.preventDefault() event.preventDefault()
onClose?.() onClose?.()
return true return true
} }

View File

@ -12,6 +12,7 @@ export function useIsMobile() {
} }
mql.addEventListener("change", onChange) mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange) return () => mql.removeEventListener("change", onChange)
}, []) }, [])

View File

@ -3,7 +3,7 @@ import { toast } from 'sonner';
// Type definitions for Web Bluetooth and Web USB // Type definitions for Web Bluetooth and Web USB
// This is to satisfy TypeScript if the global types are missing // This is to satisfy TypeScript if the global types are missing
/* eslint-disable @typescript-eslint/no-explicit-any */
export type PrinterType = 'bluetooth' | 'usb' | null; export type PrinterType = 'bluetooth' | 'usb' | null;
export type PaperSize = '58' | '80'; export type PaperSize = '58' | '80';
@ -32,8 +32,10 @@ export function usePrinter() {
const connectBluetooth = useCallback(async () => { const connectBluetooth = useCallback(async () => {
try { try {
const nav = navigator as any; const nav = navigator as any;
if (!nav.bluetooth) { if (!nav.bluetooth) {
toast.error('Bluetooth tidak didukung di browser ini'); toast.error('Bluetooth tidak didukung di browser ini');
return; return;
} }
@ -70,6 +72,7 @@ export function usePrinter() {
} catch (error: any) { } catch (error: any) {
console.error('Bluetooth connection error:', error); console.error('Bluetooth connection error:', error);
if (error.name !== 'NotFoundError') { if (error.name !== 'NotFoundError') {
toast.error(error.message || 'Gagal menghubungkan printer bluetooth'); toast.error(error.message || 'Gagal menghubungkan printer bluetooth');
} }
@ -79,8 +82,10 @@ export function usePrinter() {
const connectUsb = useCallback(async () => { const connectUsb = useCallback(async () => {
try { try {
const nav = navigator as any; const nav = navigator as any;
if (!nav.usb) { if (!nav.usb) {
toast.error('USB tidak didukung di browser ini'); toast.error('USB tidak didukung di browser ini');
return; return;
} }
@ -89,6 +94,7 @@ export function usePrinter() {
}); });
await device.open(); await device.open();
if (device.configuration === null) { if (device.configuration === null) {
await device.selectConfiguration(1); await device.selectConfiguration(1);
} }
@ -108,6 +114,7 @@ export function usePrinter() {
} catch (error: any) { } catch (error: any) {
console.error('USB connection error:', error); console.error('USB connection error:', error);
if (error.name !== 'NotFoundError') { if (error.name !== 'NotFoundError') {
toast.error(error.message || 'Gagal menghubungkan printer USB'); toast.error(error.message || 'Gagal menghubungkan printer USB');
} }
@ -121,6 +128,7 @@ export function usePrinter() {
const sendData = useCallback(async (data: Uint8Array) => { const sendData = useCallback(async (data: Uint8Array) => {
if (!state.isConnected || !state.device) { if (!state.isConnected || !state.device) {
toast.error('Printer belum terhubung'); toast.error('Printer belum terhubung');
return; return;
} }
@ -134,20 +142,26 @@ export function usePrinter() {
const server = await device.gatt?.connect(); const server = await device.gatt?.connect();
const services = await server.getPrimaryServices(); const services = await server.getPrimaryServices();
const writeChar = await findWriteCharacteristic(services); const writeChar = await findWriteCharacteristic(services);
if (writeChar) { if (writeChar) {
setCharacteristic(writeChar); setCharacteristic(writeChar);
toast.success('Printer terhubung kembali', { id: 'printer-reconnect' }); toast.success('Printer terhubung kembali', { id: 'printer-reconnect' });
} else { } else {
toast.error('Gagal menghubungkan kembali printer', { id: 'printer-reconnect' }); toast.error('Gagal menghubungkan kembali printer', { id: 'printer-reconnect' });
return; return;
} }
} }
if (!characteristic) throw new Error('Characteristic tidak ditemukan'); if (!characteristic) {
throw new Error('Characteristic tidak ditemukan');
}
const chunkSize = 20; const chunkSize = 20;
for (let i = 0; i < data.length; i += chunkSize) { for (let i = 0; i < data.length; i += chunkSize) {
const chunk = data.slice(i, i + chunkSize); const chunk = data.slice(i, i + chunkSize);
// Use writeValueWithoutResponse for better stability if supported // Use writeValueWithoutResponse for better stability if supported
if (characteristic.writeValueWithoutResponse) { if (characteristic.writeValueWithoutResponse) {
await characteristic.writeValueWithoutResponse(chunk); await characteristic.writeValueWithoutResponse(chunk);
@ -158,7 +172,10 @@ export function usePrinter() {
} else if (state.type === 'usb') { } else if (state.type === 'usb') {
const device = state.device as any; const device = state.device as any;
const endpoint = device.configuration.interfaces[0].alternates[0].endpoints.find((e: any) => e.direction === 'out' && e.type === 'bulk'); const endpoint = device.configuration.interfaces[0].alternates[0].endpoints.find((e: any) => e.direction === 'out' && e.type === 'bulk');
if (!endpoint) throw new Error('Tidak dapat menemukan USB endpoint');
if (!endpoint) {
throw new Error('Tidak dapat menemukan USB endpoint');
}
await device.transferOut(endpoint.endpointNumber, data); await device.transferOut(endpoint.endpointNumber, data);
} }
@ -195,11 +212,13 @@ export function usePrinter() {
async function findWriteCharacteristic(services: any[]) { async function findWriteCharacteristic(services: any[]) {
for (const service of services) { for (const service of services) {
const characteristics = await service.getCharacteristics(); const characteristics = await service.getCharacteristics();
for (const char of characteristics) { for (const char of characteristics) {
if (char.properties.write || char.properties.writeWithoutResponse) { if (char.properties.write || char.properties.writeWithoutResponse) {
return char; return char;
} }
} }
} }
return null; return null;
} }

View File

@ -47,7 +47,9 @@ export function useScrolling(
const supportsScrollEnd = element === window && "onscrollend" in window const supportsScrollEnd = element === window && "onscrollend" in window
const handleScroll: EventListener = () => { const handleScroll: EventListener = () => {
if (!isScrolling) setIsScrolling(true) if (!isScrolling) {
setIsScrolling(true)
}
if (!supportsScrollEnd) { if (!supportsScrollEnd) {
clearTimeout(timeout) clearTimeout(timeout)
@ -58,15 +60,18 @@ export function useScrolling(
const handleScrollEnd: EventListener = () => setIsScrolling(false) const handleScrollEnd: EventListener = () => setIsScrolling(false)
on(eventTarget, "scroll", handleScroll) on(eventTarget, "scroll", handleScroll)
if (supportsScrollEnd) { if (supportsScrollEnd) {
on(eventTarget, "scrollend", handleScrollEnd) on(eventTarget, "scrollend", handleScrollEnd)
} }
return () => { return () => {
off(eventTarget, "scroll", handleScroll) off(eventTarget, "scroll", handleScroll)
if (supportsScrollEnd) { if (supportsScrollEnd) {
off(eventTarget, "scrollend", handleScrollEnd) off(eventTarget, "scrollend", handleScrollEnd)
} }
clearTimeout(timeout) clearTimeout(timeout)
} }
}, [target, debounce, fallbackToDocument, isScrolling]) }, [target, debounce, fallbackToDocument, isScrolling])

View File

@ -1,7 +1,7 @@
import throttle from "lodash.throttle" import throttle from "lodash.throttle"
import { useUnmount } from "@/hooks/use-unmount"
import { useMemo } from "react" import { useMemo } from "react"
import { useUnmount } from "@/hooks/use-unmount"
interface ThrottleSettings { interface ThrottleSettings {
leading?: boolean | undefined leading?: boolean | undefined
@ -21,7 +21,7 @@ const defaultOptions: ThrottleSettings = {
* @param dependencies The dependencies to watch for changes * @param dependencies The dependencies to watch for changes
* @param options The throttle options * @param options The throttle options
*/ */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function useThrottledCallback<T extends (...args: any[]) => any>( export function useThrottledCallback<T extends (...args: any[]) => any>(
fn: T, fn: T,
wait = 250, wait = 250,

View File

@ -5,7 +5,11 @@ import { useEffect, useState } from "react"
function getActivePageEditor(editor: Editor): Editor | null { function getActivePageEditor(editor: Editor): Editor | null {
const storage = editor.storage as unknown as Record<string, unknown> const storage = editor.storage as unknown as Record<string, unknown>
const pages = storage.pages as { activeEditor?: Editor | null } | undefined const pages = storage.pages as { activeEditor?: Editor | null } | undefined
if (!pages || !("activeEditor" in pages)) return null
if (!pages || !("activeEditor" in pages)) {
return null
}
return pages.activeEditor ?? null return pages.activeEditor ?? null
} }
@ -22,6 +26,7 @@ export function useTiptapEditor(providedEditor?: Editor | null): {
useEffect(() => { useEffect(() => {
if (!mainEditor) { if (!mainEditor) {
setStorageEditor(null) setStorageEditor(null)
return return
} }
@ -40,11 +45,14 @@ export function useTiptapEditor(providedEditor?: Editor | null): {
}, [mainEditor]) }, [mainEditor])
useEffect(() => { useEffect(() => {
if (!storageEditor) return if (!storageEditor) {
return
}
const handleDestroy = () => setStorageEditor(null) const handleDestroy = () => setStorageEditor(null)
storageEditor.on("destroy", handleDestroy) storageEditor.on("destroy", handleDestroy)
return () => { return () => {
storageEditor.off("destroy", handleDestroy) storageEditor.off("destroy", handleDestroy)
} }

View File

@ -5,7 +5,7 @@ import { useRef, useEffect } from "react"
* *
* @param callback Function to be called on component unmount * @param callback Function to be called on component unmount
*/ */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const useUnmount = (callback: (...args: Array<any>) => any) => { export const useUnmount = (callback: (...args: Array<any>) => any) => {
const ref = useRef(callback) const ref = useRef(callback)
ref.current = callback ref.current = callback

View File

@ -48,10 +48,15 @@ export function useWindowSize(): WindowSizeState {
}) })
const handleViewportChange = useThrottledCallback(() => { const handleViewportChange = useThrottledCallback(() => {
if (typeof window === "undefined") return if (typeof window === "undefined") {
return
}
const vp = window.visualViewport const vp = window.visualViewport
if (!vp) return
if (!vp) {
return
}
const { const {
width = 0, width = 0,
@ -78,7 +83,10 @@ export function useWindowSize(): WindowSizeState {
useEffect(() => { useEffect(() => {
const visualViewport = window.visualViewport const visualViewport = window.visualViewport
if (!visualViewport) return
if (!visualViewport) {
return
}
visualViewport.addEventListener("resize", handleViewportChange) visualViewport.addEventListener("resize", handleViewportChange)

View File

@ -12,6 +12,7 @@ export class EscPosEncoder {
*/ */
initialize(): this { initialize(): this {
this.buffer.push(0x1b, 0x40); this.buffer.push(0x1b, 0x40);
return this; return this;
} }
@ -21,6 +22,7 @@ export class EscPosEncoder {
text(value: string): this { text(value: string): this {
const bytes = this.encoder.encode(value); const bytes = this.encoder.encode(value);
this.buffer.push(...Array.from(bytes)); this.buffer.push(...Array.from(bytes));
return this; return this;
} }
@ -29,6 +31,7 @@ export class EscPosEncoder {
*/ */
line(value: string = ''): this { line(value: string = ''): this {
this.text(value + '\n'); this.text(value + '\n');
return this; return this;
} }
@ -38,6 +41,7 @@ export class EscPosEncoder {
*/ */
align(value: 0 | 1 | 2): this { align(value: 0 | 1 | 2): this {
this.buffer.push(0x1b, 0x61, value); this.buffer.push(0x1b, 0x61, value);
return this; return this;
} }
@ -46,6 +50,7 @@ export class EscPosEncoder {
*/ */
bold(value: boolean): this { bold(value: boolean): this {
this.buffer.push(0x1b, 0x45, value ? 1 : 0); this.buffer.push(0x1b, 0x45, value ? 1 : 0);
return this; return this;
} }
@ -55,10 +60,21 @@ export class EscPosEncoder {
*/ */
size(value: 0 | 1 | 2 | 3): this { size(value: 0 | 1 | 2 | 3): this {
let size = 0; let size = 0;
if (value === 1) size = 0x01;
if (value === 2) size = 0x10; if (value === 1) {
if (value === 3) size = 0x11; size = 0x01;
}
if (value === 2) {
size = 0x10;
}
if (value === 3) {
size = 0x11;
}
this.buffer.push(0x1d, 0x21, size); this.buffer.push(0x1d, 0x21, size);
return this; return this;
} }
@ -67,6 +83,7 @@ export class EscPosEncoder {
*/ */
cut(): this { cut(): this {
this.buffer.push(0x1d, 0x56, 0x00); this.buffer.push(0x1d, 0x56, 0x00);
return this; return this;
} }
@ -75,6 +92,7 @@ export class EscPosEncoder {
*/ */
feed(lines: number = 1): this { feed(lines: number = 1): this {
this.buffer.push(0x1b, 0x64, lines); this.buffer.push(0x1b, 0x64, lines);
return this; return this;
} }

View File

@ -7,11 +7,15 @@ export const formatTime = (date: Date) => {
}; };
export const formatDate = (date: Date | string | undefined) => { export const formatDate = (date: Date | string | undefined) => {
if (!date) return '-'; if (!date) {
return '-';
}
const parsedDate = date instanceof Date ? date : new Date(date); const parsedDate = date instanceof Date ? date : new Date(date);
if (isNaN(parsedDate.getTime())) return '-'; if (isNaN(parsedDate.getTime())) {
return '-';
}
return parsedDate.toLocaleDateString('id-ID', { return parsedDate.toLocaleDateString('id-ID', {
weekday: 'long', weekday: 'long',

View File

@ -8,10 +8,11 @@ import {
} from "@tiptap/pm/state" } from "@tiptap/pm/state"
import { cellAround, CellSelection } from "@tiptap/pm/tables" import { cellAround, CellSelection } from "@tiptap/pm/tables"
import { import {
findParentNodeClosestToPos, findParentNodeClosestToPos
type Editor,
type NodeWithPos,
} from "@tiptap/react" } from "@tiptap/react"
import type {Editor, NodeWithPos} from "@tiptap/react";
export const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB export const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB
@ -74,6 +75,7 @@ export const formatShortcutKey = (
) => { ) => {
if (isMac) { if (isMac) {
const lowerKey = key.toLowerCase() const lowerKey = key.toLowerCase()
return MAC_SYMBOLS[lowerKey] || (capitalize ? key.toUpperCase() : key) return MAC_SYMBOLS[lowerKey] || (capitalize ? key.toUpperCase() : key)
} }
@ -94,7 +96,9 @@ export const parseShortcutKeys = (props: {
}) => { }) => {
const { shortcutKeys, delimiter = "+", capitalize = true } = props const { shortcutKeys, delimiter = "+", capitalize = true } = props
if (!shortcutKeys) return [] if (!shortcutKeys) {
return []
}
return shortcutKeys return shortcutKeys
.split(delimiter) .split(delimiter)
@ -112,7 +116,10 @@ export const isMarkInSchema = (
markName: string, markName: string,
editor: Editor | null editor: Editor | null
): boolean => { ): boolean => {
if (!editor?.schema) return false if (!editor?.schema) {
return false
}
return editor.schema.spec.marks.get(markName) !== undefined return editor.schema.spec.marks.get(markName) !== undefined
} }
@ -126,7 +133,10 @@ export const isNodeInSchema = (
nodeName: string, nodeName: string,
editor: Editor | null editor: Editor | null
): boolean => { ): boolean => {
if (!editor?.schema) return false if (!editor?.schema) {
return false
}
return editor.schema.spec.nodes.get(nodeName) !== undefined return editor.schema.spec.nodes.get(nodeName) !== undefined
} }
@ -140,14 +150,18 @@ export function focusNextNode(editor: Editor) {
const { doc, selection } = state const { doc, selection } = state
const nextSel = Selection.findFrom(selection.$to, 1, true) const nextSel = Selection.findFrom(selection.$to, 1, true)
if (nextSel) { if (nextSel) {
view.dispatch(state.tr.setSelection(nextSel).scrollIntoView()) view.dispatch(state.tr.setSelection(nextSel).scrollIntoView())
return true return true
} }
const paragraphType = state.schema.nodes.paragraph const paragraphType = state.schema.nodes.paragraph
if (!paragraphType) { if (!paragraphType) {
console.warn("No paragraph node type found in schema.") console.warn("No paragraph node type found in schema.")
return false return false
} }
@ -159,6 +173,7 @@ export function focusNextNode(editor: Editor) {
const $inside = tr.doc.resolve(end + 1) const $inside = tr.doc.resolve(end + 1)
tr = tr.setSelection(TextSelection.near($inside)).scrollIntoView() tr = tr.setSelection(TextSelection.near($inside)).scrollIntoView()
view.dispatch(tr) view.dispatch(tr)
return true return true
} }
@ -181,7 +196,9 @@ export function isExtensionAvailable(
editor: Editor | null, editor: Editor | null,
extensionNames: string | string[] extensionNames: string | string[]
): boolean { ): boolean {
if (!editor) return false if (!editor) {
return false
}
const names = Array.isArray(extensionNames) const names = Array.isArray(extensionNames)
? extensionNames ? extensionNames
@ -209,13 +226,17 @@ export function isExtensionAvailable(
export function findNodeAtPosition(editor: Editor, position: number) { export function findNodeAtPosition(editor: Editor, position: number) {
try { try {
const node = editor.state.doc.nodeAt(position) const node = editor.state.doc.nodeAt(position)
if (!node) { if (!node) {
console.warn(`No node found at position ${position}`) console.warn(`No node found at position ${position}`)
return null return null
} }
return node return node
} catch (error) { } catch (error) {
console.error(`Error getting node at position ${position}:`, error) console.error(`Error getting node at position ${position}:`, error)
return null return null
} }
} }
@ -235,7 +256,9 @@ export function findNodePosition(props: {
}): { pos: number; node: PMNode } | null { }): { pos: number; node: PMNode } | null {
const { editor, node, nodePos } = props const { editor, node, nodePos } = props
if (!editor || !editor.state?.doc) return null if (!editor || !editor.state?.doc) {
return null
}
// Zero is valid position // Zero is valid position
const hasValidNode = node !== undefined && node !== null const hasValidNode = node !== undefined && node !== null
@ -256,8 +279,10 @@ export function findNodePosition(props: {
if (currentNode === node) { if (currentNode === node) {
foundPos = pos foundPos = pos
foundNode = currentNode foundNode = currentNode
return false return false
} }
return true return true
}) })
@ -269,6 +294,7 @@ export function findNodePosition(props: {
// If we have a valid position, use findNodeAtPosition // If we have a valid position, use findNodeAtPosition
if (hasValidPos) { if (hasValidPos) {
const nodeAtPos = findNodeAtPosition(editor, nodePos!) const nodeAtPos = findNodeAtPosition(editor, nodePos!)
if (nodeAtPos) { if (nodeAtPos) {
return { pos: nodePos!, node: nodeAtPos } return { pos: nodePos!, node: nodeAtPos }
} }
@ -289,22 +315,30 @@ export function isNodeTypeSelected(
nodeTypeNames: string[] = [], nodeTypeNames: string[] = [],
checkAncestorNodes: boolean = false checkAncestorNodes: boolean = false
): boolean { ): boolean {
if (!editor || !editor.state.selection) return false if (!editor || !editor.state.selection) {
return false
}
const { selection } = editor.state const { selection } = editor.state
if (selection.empty) return false
if (selection.empty) {
return false
}
// Direct node selection check // Direct node selection check
if (selection instanceof NodeSelection) { if (selection instanceof NodeSelection) {
const selectedNode = selection.node const selectedNode = selection.node
return selectedNode ? nodeTypeNames.includes(selectedNode.type.name) : false return selectedNode ? nodeTypeNames.includes(selectedNode.type.name) : false
} }
// Depth-based ancestor node check // Depth-based ancestor node check
if (checkAncestorNodes) { if (checkAncestorNodes) {
const { $from } = selection const { $from } = selection
for (let depth = $from.depth; depth > 0; depth--) { for (let depth = $from.depth; depth > 0; depth--) {
const ancestorNode = $from.node(depth) const ancestorNode = $from.node(depth)
if (nodeTypeNames.includes(ancestorNode.type.name)) { if (nodeTypeNames.includes(ancestorNode.type.name)) {
return true return true
} }
@ -325,7 +359,9 @@ export function selectionWithinConvertibleTypes(
editor: Editor, editor: Editor,
types: string[] = [] types: string[] = []
): boolean { ): boolean {
if (!editor || types.length === 0) return false if (!editor || types.length === 0) {
return false
}
const { state } = editor const { state } = editor
const { selection } = state const { selection } = state
@ -333,6 +369,7 @@ export function selectionWithinConvertibleTypes(
if (selection instanceof NodeSelection) { if (selection instanceof NodeSelection) {
const nodeType = selection.node?.type?.name const nodeType = selection.node?.type?.name
return !!nodeType && allowed.has(nodeType) return !!nodeType && allowed.has(nodeType)
} }
@ -341,10 +378,13 @@ export function selectionWithinConvertibleTypes(
state.doc.nodesBetween(selection.from, selection.to, (node) => { state.doc.nodesBetween(selection.from, selection.to, (node) => {
if (node.isTextblock && !allowed.has(node.type.name)) { if (node.isTextblock && !allowed.has(node.type.name)) {
valid = false valid = false
return false // stop early return false // stop early
} }
return valid return valid
}) })
return valid return valid
} }
@ -380,6 +420,7 @@ export const handleImageUpload = async (
if (abortSignal?.aborted) { if (abortSignal?.aborted) {
throw new Error("Upload cancelled") throw new Error("Upload cancelled")
} }
await new Promise((resolve) => setTimeout(resolve, 500)) await new Promise((resolve) => setTimeout(resolve, 500))
onProgress?.({ progress }) onProgress?.({ progress })
} }
@ -464,6 +505,7 @@ export function sanitizeUrl(
} catch { } catch {
// If URL creation fails, it's considered invalid // If URL creation fails, it's considered invalid
} }
return "#" return "#"
} }
@ -483,14 +525,19 @@ export function updateNodesAttr<A extends string = string, V = unknown>(
attrName: A, attrName: A,
next: V | ((prev: V | undefined) => V | undefined) next: V | ((prev: V | undefined) => V | undefined)
): boolean { ): boolean {
if (!targets.length) return false if (!targets.length) {
return false
}
let changed = false let changed = false
for (const { pos } of targets) { for (const { pos } of targets) {
// Always re-read from the transaction's current doc // Always re-read from the transaction's current doc
const currentNode = tr.doc.nodeAt(pos) const currentNode = tr.doc.nodeAt(pos)
if (!currentNode) continue
if (!currentNode) {
continue
}
const prevValue = (currentNode.attrs as Record<string, unknown>)[ const prevValue = (currentNode.attrs as Record<string, unknown>)[
attrName attrName
@ -500,9 +547,12 @@ export function updateNodesAttr<A extends string = string, V = unknown>(
? (next as (p: V | undefined) => V | undefined)(prevValue) ? (next as (p: V | undefined) => V | undefined)(prevValue)
: next : next
if (prevValue === resolvedNext) continue if (prevValue === resolvedNext) {
continue
}
const nextAttrs: Record<string, unknown> = { ...currentNode.attrs } const nextAttrs: Record<string, unknown> = { ...currentNode.attrs }
if (resolvedNext === undefined) { if (resolvedNext === undefined) {
// Remove the key entirely instead of setting null // Remove the key entirely instead of setting null
delete nextAttrs[attrName] delete nextAttrs[attrName]
@ -525,7 +575,9 @@ export function updateNodesAttr<A extends string = string, V = unknown>(
export function selectCurrentBlockContent(editor: Editor) { export function selectCurrentBlockContent(editor: Editor) {
const { selection, doc } = editor.state const { selection, doc } = editor.state
if (!selection.empty) return if (!selection.empty) {
return
}
const $pos = selection.$from const $pos = selection.$from
let blockNode = null let blockNode = null
@ -577,14 +629,17 @@ export function getSelectedNodesOfType(
results.push({ node, pos }) results.push({ node, pos })
} }
}) })
return results return results
} }
if (selection instanceof NodeSelection) { if (selection instanceof NodeSelection) {
const { node, from: pos } = selection const { node, from: pos } = selection
if (node && allowed.has(node.type.name)) { if (node && allowed.has(node.type.name)) {
results.push({ node, pos }) results.push({ node, pos })
} }
return results return results
} }
@ -593,8 +648,10 @@ export function getSelectedNodesOfType(
if (cell) { if (cell) {
const cellNode = selection.$anchor.doc.nodeAt(cell.pos) const cellNode = selection.$anchor.doc.nodeAt(cell.pos)
if (cellNode && allowed.has(cellNode.type.name)) { if (cellNode && allowed.has(cellNode.type.name)) {
results.push({ node: cellNode, pos: cell.pos }) results.push({ node: cellNode, pos: cell.pos })
return results return results
} }
} }
@ -626,7 +683,9 @@ export function getSelectedBlockNodes(editor: Editor): PMNode[] {
const seen = new Set<number>() const seen = new Set<number>()
doc.nodesBetween(from, to, (node, pos) => { doc.nodesBetween(from, to, (node, pos) => {
if (!node.isBlock) return if (!node.isBlock) {
return
}
if (!seen.has(pos)) { if (!seen.has(pos)) {
seen.add(pos) seen.add(pos)

View File

@ -8,8 +8,17 @@ export function cn(...inputs: ClassValue[]) {
} }
export function toUrl(url: InertiaLinkProps['href']): string { export function toUrl(url: InertiaLinkProps['href']): string {
if (!url) return ''; if (!url) {
if (typeof url === 'string') return url; return '';
if (typeof url === 'object' && url !== null && 'url' in url) return url.url as string; }
if (typeof url === 'string') {
return url;
}
if (typeof url === 'object' && url !== null && 'url' in url) {
return url.url as string;
}
return String(url); return String(url);
} }

View File

@ -1,8 +1,8 @@
import { useState } from 'react';
import { Expense } from '@/types';
import { router } from '@inertiajs/react'; import { router } from '@inertiajs/react';
import expenseRoutes from '@/routes/expense'; import { useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import expenseRoutes from '@/routes/expense';
import type { Expense } from '@/types';
export function useExpenseIndex() { export function useExpenseIndex() {
const [isFormOpen, setIsFormOpen] = useState(false); const [isFormOpen, setIsFormOpen] = useState(false);

View File

@ -1,15 +1,15 @@
import { Head } from '@inertiajs/react'; import { Head } from '@inertiajs/react';
import type { Expense } from '@/types'; import { Trash2, Plus } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Trash2, X, Plus } from 'lucide-react';
import { DataTable } from '@/components/data-table'; import { DataTable } from '@/components/data-table';
import { DeleteConfirmation } from '@/components/modal/delete-confirmation';
import { ImagePreviewDialog } from '@/components/modal/image-preview';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import type { Expense } from '@/types';
import { useExpenseIndex } from './hooks/use-expense-index'; import { useExpenseIndex } from './hooks/use-expense-index';
import { getColumns } from './partials/columns'; import { getColumns } from './partials/columns';
import { ExpenseFormModal } from './partials/expense-form-modal'; import { ExpenseFormModal } from './partials/expense-form-modal';
import { DeleteConfirmation } from '@/components/modal/delete-confirmation';
import { ImagePreviewDialog } from '@/components/modal/image-preview';
export default function ExpenseIndex({ expenses }: { expenses: Expense[] }) { export default function ExpenseIndex({ expenses }: { expenses: Expense[] }) {
const { const {

View File

@ -1,9 +1,9 @@
import { ColumnDef } from '@tanstack/react-table'; import type { ColumnDef } from '@tanstack/react-table';
import { Expense } from '@/types';
import { DataTableColumnHeader } from '@/components/data-table-column-header';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Button } from '@/components/ui/button';
import { Pencil, Trash2, ImagePlus } from 'lucide-react'; import { Pencil, Trash2, ImagePlus } from 'lucide-react';
import { DataTableColumnHeader } from '@/components/data-table-column-header';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import type { Expense } from '@/types';
interface ColumnProps { interface ColumnProps {
onEdit: (expense: Expense) => void; onEdit: (expense: Expense) => void;
@ -25,6 +25,7 @@ export const getColumns = ({ onEdit, onDelete, onPreviewImage }: ColumnProps): C
header: "Bukti", header: "Bukti",
cell: ({ row }) => { cell: ({ row }) => {
const url = row.original.proof_url; const url = row.original.proof_url;
return url ? ( return url ? (
<button onClick={() => onPreviewImage(url)} className="block w-fit"> <button onClick={() => onPreviewImage(url)} className="block w-fit">
<img src={url} alt="Proof" className="h-10 w-10 object-cover rounded-md border hover:opacity-80 transition-opacity" /> <img src={url} alt="Proof" className="h-10 w-10 object-cover rounded-md border hover:opacity-80 transition-opacity" />
@ -77,6 +78,7 @@ export const getColumns = ({ onEdit, onDelete, onPreviewImage }: ColumnProps): C
header: "Aksi", header: "Aksi",
cell: ({ row }) => { cell: ({ row }) => {
const expense = row.original; const expense = row.original;
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Tooltip> <Tooltip>

View File

@ -1,3 +1,8 @@
import { useForm, router } from '@inertiajs/react';
import { ImagePlus, X, Save, Loader } from 'lucide-react';
import { useEffect, useState, useRef } from 'react';
import { NumericFormat } from 'react-number-format';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
Dialog, Dialog,
@ -9,13 +14,8 @@ import {
import { Field, FieldError, FieldGroup } from "@/components/ui/field" import { Field, FieldError, FieldGroup } from "@/components/ui/field"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Expense } from '@/types';
import { useForm, router } from '@inertiajs/react';
import { useEffect, useState, useRef } from 'react';
import expenseRoutes from '@/routes/expense'; import expenseRoutes from '@/routes/expense';
import { toast } from 'sonner'; import type { Expense } from '@/types';
import { NumericFormat } from 'react-number-format';
import { ImagePlus, X, Save, Loader } from 'lucide-react';
interface ExpenseFormModalProps { interface ExpenseFormModalProps {
isOpen: boolean; isOpen: boolean;
@ -63,6 +63,7 @@ export function ExpenseFormModal({ isOpen, onClose, expense }: ExpenseFormModalP
const onImageChange = (e: React.ChangeEvent<HTMLInputElement>) => { const onImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (file) { if (file) {
setData('image', file); setData('image', file);
const reader = new FileReader(); const reader = new FileReader();
@ -76,6 +77,7 @@ export function ExpenseFormModal({ isOpen, onClose, expense }: ExpenseFormModalP
const removeImage = () => { const removeImage = () => {
setData('image', null); setData('image', null);
setImagePreview(null); setImagePreview(null);
if (fileInputRef.current) { if (fileInputRef.current) {
fileInputRef.current.value = ''; fileInputRef.current.value = '';
} }
@ -83,6 +85,7 @@ export function ExpenseFormModal({ isOpen, onClose, expense }: ExpenseFormModalP
const onSubmit = (e: React.FormEvent) => { const onSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (isEditing && expense) { if (isEditing && expense) {
router.post(expenseRoutes.update(expense.id).url, { router.post(expenseRoutes.update(expense.id).url, {
...data, ...data,

View File

@ -1,8 +1,8 @@
import { useState } from 'react';
import { Payroll } from '@/types';
import { router } from '@inertiajs/react'; import { router } from '@inertiajs/react';
import payrollRoutes from '@/routes/payroll'; import { useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import payrollRoutes from '@/routes/payroll';
import type { Payroll } from '@/types';
export function usePayrollIndex() { export function usePayrollIndex() {
const [isFormOpen, setIsFormOpen] = useState(false); const [isFormOpen, setIsFormOpen] = useState(false);

View File

@ -1,26 +1,17 @@
import { Head } from '@inertiajs/react'; import { Head } from '@inertiajs/react';
import type { Payroll } from '@/types'; import { Trash2, Sparkles } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Trash2, Sparkles, Plus, X } from 'lucide-react';
import { DataTable } from '@/components/data-table'; import { DataTable } from '@/components/data-table';
import {
AlertDialog,
AlertDialogAction, import { DeleteConfirmation } from '@/components/modal/delete-confirmation';
AlertDialogCancel, import { Badge } from '@/components/ui/badge';
AlertDialogContent, import { Button } from '@/components/ui/button';
AlertDialogDescription, import { Card, CardContent } from '@/components/ui/card';
AlertDialogFooter, import type { Payroll } from '@/types';
AlertDialogHeader,
AlertDialogMedia,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { usePayrollIndex } from './hooks/use-payroll-index'; import { usePayrollIndex } from './hooks/use-payroll-index';
import { Badge } from '@/components/ui/badge';
import { getColumns } from './partials/columns'; import { getColumns } from './partials/columns';
import { PayrollFormModal } from './partials/payroll-form-modal'; import { PayrollFormModal } from './partials/payroll-form-modal';
import { DeleteConfirmation } from '@/components/modal/delete-confirmation';
export default function PayrollIndex({ export default function PayrollIndex({
payrolls, payrolls,

View File

@ -1,11 +1,11 @@
import { ColumnDef } from '@tanstack/react-table'; import type { ColumnDef } from '@tanstack/react-table';
import { Payroll } from '@/types'; import { PencilRuler, Trash2 } from 'lucide-react';
import { DataTableColumnHeader } from '@/components/data-table-column-header'; import { DataTableColumnHeader } from '@/components/data-table-column-header';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { Badge } from '@/components/ui/badge'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { PencilRuler, Trash2 } from 'lucide-react'; import type { Payroll } from '@/types';
interface ColumnProps { interface ColumnProps {
onEdit: (payroll: Payroll) => void; onEdit: (payroll: Payroll) => void;
@ -37,6 +37,7 @@ export const getColumns = ({ onEdit, onDelete, onTogglePaid }: ColumnProps): Col
), ),
cell: ({ row }) => { cell: ({ row }) => {
const payroll = row.original; const payroll = row.original;
return ( return (
<div className="flex flex-col gap-1 py-1 min-w-[200px]"> <div className="flex flex-col gap-1 py-1 min-w-[200px]">
<div className="flex justify-between text-[10px] text-muted-foreground uppercase tracking-tighter"> <div className="flex justify-between text-[10px] text-muted-foreground uppercase tracking-tighter">
@ -71,6 +72,7 @@ export const getColumns = ({ onEdit, onDelete, onTogglePaid }: ColumnProps): Col
header: "Status", header: "Status",
cell: ({ row }) => { cell: ({ row }) => {
const payroll = row.original; const payroll = row.original;
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Switch <Switch
@ -93,6 +95,7 @@ export const getColumns = ({ onEdit, onDelete, onTogglePaid }: ColumnProps): Col
header: "Aksi", header: "Aksi",
cell: ({ row }) => { cell: ({ row }) => {
const payroll = row.original; const payroll = row.original;
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Tooltip> <Tooltip>

View File

@ -1,3 +1,9 @@
import { useForm } from '@inertiajs/react';
import { Loader, Plus, Trash2 } from 'lucide-react';
import { X, Save } from 'lucide-react';
import { useEffect } from 'react';
import { NumericFormat } from 'react-number-format';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
Dialog, Dialog,
@ -6,21 +12,14 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog" } from "@/components/ui/dialog"
import { Field, FieldGroup } from "@/components/ui/field" import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from '@/components/ui/empty';
import { FieldGroup } from "@/components/ui/field"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Switch } from '@/components/ui/switch';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Payroll } from '@/types';
import { useForm } from '@inertiajs/react';
import { useEffect } from 'react';
import payrollRoutes from '@/routes/payroll';
import { toast } from 'sonner';
import { Loader, Plus, Trash2 } from 'lucide-react';
import { NumericFormat } from 'react-number-format';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from '@/components/ui/empty'; import { SelectTrigger } from '@/components/ui/select';
import { X, Save } from 'lucide-react'; import payrollRoutes from '@/routes/payroll';
import type { Payroll } from '@/types';
interface PayrollFormModalProps { interface PayrollFormModalProps {
isOpen: boolean; isOpen: boolean;
@ -76,6 +75,7 @@ export function PayrollFormModal({ isOpen, onClose, payroll }: PayrollFormModalP
const onSubmit = (e: React.FormEvent) => { const onSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (payroll) { if (payroll) {
patch(payrollRoutes.update(payroll.id).url, { patch(payrollRoutes.update(payroll.id).url, {
onSuccess: (response: any) => { onSuccess: (response: any) => {

View File

@ -1,19 +1,22 @@
import { Head, Link, useForm, router } from '@inertiajs/react'; import { Head, Link, router, useForm } from '@inertiajs/react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { ArrowLeft, Check, ImagePlus, Loader, Minus, Plus, Save, Search, ShoppingCart, Tag, Trash2, X } from 'lucide-react';
import React, { useMemo, useState } from 'react';
import { NumericFormat } from 'react-number-format';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from '@/components/ui/empty';
import { Field, FieldError } from "@/components/ui/field"; import { Field, FieldError } from "@/components/ui/field";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import * as orderRoutes from '@/routes/order';
import React, { useMemo, useState } from 'react';
import { toast } from 'sonner';
import { Product, ProductPrice } from '@/types';
import { OrderItem } from '@/types/order';
import { Plus, Trash2, CalendarIcon, Minus, ShoppingCart, Search, ImagePlus, X, Tag, ArrowLeft, Check, Loader, Save } from 'lucide-react';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { format } from 'date-fns';
import { cn } from '@/lib/utils';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { import {
Select, Select,
@ -22,21 +25,15 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from '@/components/ui/empty';
import { import {
Sheet, Sheet,
SheetContent, SheetContent,
SheetTrigger, SheetTrigger
SheetClose,
} from "@/components/ui/sheet"; } from "@/components/ui/sheet";
import { NumericFormat } from 'react-number-format'; import { cn } from '@/lib/utils';
import * as orderRoutes from '@/routes/order';
import type { Product } from '@/types';
import type { OrderItem } from '@/types/order';
type CartItem = OrderItem & { id: number }; type CartItem = OrderItem & { id: number };
type EnumOption = { value: string, label: string }; type EnumOption = { value: string, label: string };
@ -60,6 +57,7 @@ export default function OrderCreate({ products, cartItems, orderStatus, orderCha
const categories = useMemo(() => { const categories = useMemo(() => {
const map = new Map<number, string>(); const map = new Map<number, string>();
products.forEach(p => p.categories?.forEach(c => map.set(c.id, c.name))); products.forEach(p => p.categories?.forEach(c => map.set(c.id, c.name)));
return Array.from(map.entries()).map(([id, name]) => ({ id, name })); return Array.from(map.entries()).map(([id, name]) => ({ id, name }));
}, [products]); }, [products]);
@ -77,6 +75,7 @@ export default function OrderCreate({ products, cartItems, orderStatus, orderCha
const filteredProducts = products.filter(p => { const filteredProducts = products.filter(p => {
const matchesSearch = p.name.toLowerCase().includes(search.toLowerCase()); const matchesSearch = p.name.toLowerCase().includes(search.toLowerCase());
const matchesCategory = selectedCategory === 'all' || p.categories?.some(c => String(c.id) === selectedCategory); const matchesCategory = selectedCategory === 'all' || p.categories?.some(c => String(c.id) === selectedCategory);
return matchesSearch && matchesCategory; return matchesSearch && matchesCategory;
}); });
@ -130,7 +129,10 @@ export default function OrderCreate({ products, cartItems, orderStatus, orderCha
}; };
const updateCartQuantity = (itemId: number, qty: number) => { const updateCartQuantity = (itemId: number, qty: number) => {
if (qty < 1) return; if (qty < 1) {
return;
}
router.patch(orderRoutes.updateCartItem(itemId).url, { router.patch(orderRoutes.updateCartItem(itemId).url, {
qty qty
}, { }, {
@ -146,16 +148,20 @@ export default function OrderCreate({ products, cartItems, orderStatus, orderCha
const confirmQty = () => { const confirmQty = () => {
const val = parseInt(qtyInputValue); const val = parseInt(qtyInputValue);
if (!isNaN(val) && val >= 1 && qtyDialogIndex !== null) { if (!isNaN(val) && val >= 1 && qtyDialogIndex !== null) {
updateCartQuantity(qtyDialogIndex, val); updateCartQuantity(qtyDialogIndex, val);
} }
setQtyDialogIndex(null); setQtyDialogIndex(null);
}; };
const onSubmit = (e: React.FormEvent) => { const onSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (cartItems.length === 0) { if (cartItems.length === 0) {
toast.error('Pilih minimal satu produk'); toast.error('Pilih minimal satu produk');
return; return;
} }
@ -187,6 +193,7 @@ export default function OrderCreate({ products, cartItems, orderStatus, orderCha
const PriceTypeLabel = ({ type }: { type: string }) => { const PriceTypeLabel = ({ type }: { type: string }) => {
const option = priceTypes.find(opt => opt.value === type); const option = priceTypes.find(opt => opt.value === type);
return option ? option.label : type; return option ? option.label : type;
}; };
@ -526,7 +533,10 @@ export default function OrderCreate({ products, cartItems, orderStatus, orderCha
cartItem ? "text-foreground hover:text-primary cursor-pointer" : "text-muted-foreground/40 cursor-default" cartItem ? "text-foreground hover:text-primary cursor-pointer" : "text-muted-foreground/40 cursor-default"
)} )}
onClick={(e) => { onClick={(e) => {
if (!cartItem) return; if (!cartItem) {
return;
}
openQtyDialog(cartItem, e); openQtyDialog(cartItem, e);
}} }}
> >
@ -600,7 +610,11 @@ export default function OrderCreate({ products, cartItems, orderStatus, orderCha
</div> </div>
{/* Quantity Input Dialog */} {/* Quantity Input Dialog */}
<Dialog open={qtyDialogIndex !== null} onOpenChange={(open) => { if (!open) setQtyDialogIndex(null); }}> <Dialog open={qtyDialogIndex !== null} onOpenChange={(open) => {
if (!open) {
setQtyDialogIndex(null);
}
}}>
<DialogContent className="max-w-xs"> <DialogContent className="max-w-xs">
<DialogHeader> <DialogHeader>
<DialogTitle>Ubah Jumlah</DialogTitle> <DialogTitle>Ubah Jumlah</DialogTitle>
@ -614,7 +628,11 @@ export default function OrderCreate({ products, cartItems, orderStatus, orderCha
min="1" min="1"
value={qtyInputValue} value={qtyInputValue}
onChange={e => setQtyInputValue(e.target.value)} onChange={e => setQtyInputValue(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') confirmQty(); }} onKeyDown={e => {
if (e.key === 'Enter') {
confirmQty();
}
}}
className="text-lg font-bold" className="text-lg font-bold"
autoFocus autoFocus
/> />

View File

@ -1,19 +1,21 @@
import { Head, Link, useForm, router } from '@inertiajs/react'; import { Head, Link, useForm } from '@inertiajs/react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { ArrowLeft, Check, ImagePlus, Loader, Minus, Plus, Save, Search, ShoppingCart, Tag, Trash2, X } from 'lucide-react';
import React, { useMemo, useState } from 'react';
import { NumericFormat } from 'react-number-format';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Field, FieldError } from "@/components/ui/field"; import { Field, FieldError } from "@/components/ui/field";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import * as orderRoutes from '@/routes/order';
import React, { useMemo, useState } from 'react';
import { toast } from 'sonner';
import { Product, ProductPrice } from '@/types';
import { Order, OrderItem } from '@/types/order';
import { Plus, Trash2, CalendarIcon, Minus, ShoppingCart, Search, ImagePlus, X, Tag, ArrowLeft, Check, Save, Loader } from 'lucide-react';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { format } from 'date-fns';
import { cn } from '@/lib/utils';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { import {
Select, Select,
@ -22,21 +24,15 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from '@/components/ui/empty';
import { import {
Sheet, Sheet,
SheetContent, SheetContent,
SheetTrigger, SheetTrigger
SheetClose,
} from "@/components/ui/sheet"; } from "@/components/ui/sheet";
import { NumericFormat } from 'react-number-format'; import { cn } from '@/lib/utils';
import * as orderRoutes from '@/routes/order';
import type { Product } from '@/types';
import type { Order } from '@/types/order';
type EnumOption = { value: string, label: string }; type EnumOption = { value: string, label: string };
@ -57,6 +53,7 @@ export default function OrderEdit({ order, products, orderStatus, orderChannels,
const categories = useMemo(() => { const categories = useMemo(() => {
const map = new Map<number, string>(); const map = new Map<number, string>();
products.forEach(p => p.categories?.forEach(c => map.set(c.id, c.name))); products.forEach(p => p.categories?.forEach(c => map.set(c.id, c.name)));
return Array.from(map.entries()).map(([id, name]) => ({ id, name })); return Array.from(map.entries()).map(([id, name]) => ({ id, name }));
}, [products]); }, [products]);
@ -81,6 +78,7 @@ export default function OrderEdit({ order, products, orderStatus, orderChannels,
const filteredProducts = products.filter(p => { const filteredProducts = products.filter(p => {
const matchesSearch = p.name.toLowerCase().includes(search.toLowerCase()); const matchesSearch = p.name.toLowerCase().includes(search.toLowerCase());
const matchesCategory = selectedCategory === 'all' || p.categories?.some(c => String(c.id) === selectedCategory); const matchesCategory = selectedCategory === 'all' || p.categories?.some(c => String(c.id) === selectedCategory);
return matchesSearch && matchesCategory; return matchesSearch && matchesCategory;
}); });
@ -118,22 +116,30 @@ export default function OrderEdit({ order, products, orderStatus, orderChannels,
const decreaseQuantity = (productId: number, priceType: string, e: React.MouseEvent) => { const decreaseQuantity = (productId: number, priceType: string, e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
const index = findItemIndex(productId, priceType); const index = findItemIndex(productId, priceType);
if (index === -1) return;
if (index === -1) {
return;
}
const newItems = [...data.items]; const newItems = [...data.items];
if (newItems[index].qty <= 1) { if (newItems[index].qty <= 1) {
newItems.splice(index, 1); newItems.splice(index, 1);
} else { } else {
newItems[index].qty -= 1; newItems[index].qty -= 1;
newItems[index].total = newItems[index].qty * newItems[index].price; newItems[index].total = newItems[index].qty * newItems[index].price;
} }
setData('items', newItems); setData('items', newItems);
}; };
const increaseQuantity = (productId: number, priceType: string, e: React.MouseEvent) => { const increaseQuantity = (productId: number, priceType: string, e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
const product = products.find(p => p.id === productId); const product = products.find(p => p.id === productId);
if (product) addToCart(product, priceType);
if (product) {
addToCart(product, priceType);
}
}; };
const removeFromCart = (productId: number, priceType: string) => { const removeFromCart = (productId: number, priceType: string) => {
@ -142,8 +148,12 @@ export default function OrderEdit({ order, products, orderStatus, orderChannels,
}; };
const updateCartQuantity = (productId: number, priceType: string, qty: number) => { const updateCartQuantity = (productId: number, priceType: string, qty: number) => {
if (qty < 1) return; if (qty < 1) {
return;
}
const index = findItemIndex(productId, priceType); const index = findItemIndex(productId, priceType);
if (index > -1) { if (index > -1) {
const newItems = [...data.items]; const newItems = [...data.items];
newItems[index].qty = qty; newItems[index].qty = qty;
@ -160,16 +170,20 @@ export default function OrderEdit({ order, products, orderStatus, orderChannels,
const confirmQty = () => { const confirmQty = () => {
const val = parseInt(qtyInputValue); const val = parseInt(qtyInputValue);
if (!isNaN(val) && val >= 1 && qtyDialogIndex) { if (!isNaN(val) && val >= 1 && qtyDialogIndex) {
updateCartQuantity(qtyDialogIndex.product_id, qtyDialogIndex.price_type, val); updateCartQuantity(qtyDialogIndex.product_id, qtyDialogIndex.price_type, val);
} }
setQtyDialogIndex(null); setQtyDialogIndex(null);
}; };
const onSubmit = (e: React.FormEvent) => { const onSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (data.items.length === 0) { if (data.items.length === 0) {
toast.error('Pilih minimal satu produk'); toast.error('Pilih minimal satu produk');
return; return;
} }
@ -190,6 +204,7 @@ export default function OrderEdit({ order, products, orderStatus, orderChannels,
const PriceTypeLabel = ({ type }: { type: string }) => { const PriceTypeLabel = ({ type }: { type: string }) => {
const option = priceTypes.find(opt => opt.value === type); const option = priceTypes.find(opt => opt.value === type);
return option ? option.label : type; return option ? option.label : type;
}; };
@ -522,7 +537,10 @@ export default function OrderEdit({ order, products, orderStatus, orderChannels,
cartItem ? "text-foreground hover:text-primary cursor-pointer" : "text-muted-foreground/40 cursor-default" cartItem ? "text-foreground hover:text-primary cursor-pointer" : "text-muted-foreground/40 cursor-default"
)} )}
onClick={(e) => { onClick={(e) => {
if (!cartItem) return; if (!cartItem) {
return;
}
openQtyDialog(cartItem, e); openQtyDialog(cartItem, e);
}} }}
> >
@ -586,7 +604,11 @@ export default function OrderEdit({ order, products, orderStatus, orderChannels,
</div> </div>
{/* Quantity Input Dialog */} {/* Quantity Input Dialog */}
<Dialog open={qtyDialogIndex !== null} onOpenChange={(open) => { if (!open) setQtyDialogIndex(null); }}> <Dialog open={qtyDialogIndex !== null} onOpenChange={(open) => {
if (!open) {
setQtyDialogIndex(null);
}
}}>
<DialogContent className="max-w-xs"> <DialogContent className="max-w-xs">
<DialogHeader> <DialogHeader>
<DialogTitle>Ubah Jumlah</DialogTitle> <DialogTitle>Ubah Jumlah</DialogTitle>
@ -600,7 +622,11 @@ export default function OrderEdit({ order, products, orderStatus, orderChannels,
min="1" min="1"
value={qtyInputValue} value={qtyInputValue}
onChange={e => setQtyInputValue(e.target.value)} onChange={e => setQtyInputValue(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') confirmQty(); }} onKeyDown={e => {
if (e.key === 'Enter') {
confirmQty();
}
}}
className="text-lg font-bold" className="text-lg font-bold"
autoFocus autoFocus
/> />

View File

@ -1,8 +1,8 @@
import { useState } from 'react';
import { Order } from '@/types/order';
import { router } from '@inertiajs/react'; import { router } from '@inertiajs/react';
import * as orderRoutes from '@/routes/order'; import { useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import * as orderRoutes from '@/routes/order';
import type { Order } from '@/types/order';
export function useOrderIndex() { export function useOrderIndex() {
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);

View File

@ -1,44 +1,33 @@
import { Head, Link } from '@inertiajs/react'; import { Head, Link } from '@inertiajs/react';
import { format } from 'date-fns';
import { id } from 'date-fns/locale';
import { Bluetooth, Plus, Printer, Trash2, Unplug, Usb } from 'lucide-react';
import { useCallback } from 'react'; import { useCallback } from 'react';
import type { Order } from '@/types/order'; import { toast } from 'sonner';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Trash2, ShoppingBag, Printer, Bluetooth, Usb, Unplug, Plus, X, ArrowLeft } from 'lucide-react';
import { DataTable } from '@/components/data-table'; import { DataTable } from '@/components/data-table';
import { DeleteConfirmation } from '@/components/modal/delete-confirmation';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenuRadioGroup, DropdownMenuRadioGroup,
DropdownMenuRadioItem, DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuSub, DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent, DropdownMenuSubContent,
} from "@/components/ui/dropdown-menu" DropdownMenuSubTrigger,
import { DropdownMenuTrigger,
AlertDialog, } from "@/components/ui/dropdown-menu";
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import * as orderRoutes from '@/routes/order';
import { useOrderIndex } from './hooks/use-order-index';
import { getColumns } from './partials/columns';
import { usePrinter } from '@/hooks/use-printer'; import { usePrinter } from '@/hooks/use-printer';
import { EscPosEncoder } from '@/lib/esc-pos-encoder'; import { EscPosEncoder } from '@/lib/esc-pos-encoder';
import { format } from 'date-fns'; import * as orderRoutes from '@/routes/order';
import { id } from 'date-fns/locale'; import type { Order } from '@/types/order';
import { toast } from 'sonner'; import { useOrderIndex } from './hooks/use-order-index';
import { DeleteConfirmation } from '@/components/modal/delete-confirmation'; import { getColumns } from './partials/columns';
export default function OrderIndex({ orders }: { orders: Order[] }) { export default function OrderIndex({ orders }: { orders: Order[] }) {
const { const {
@ -70,6 +59,7 @@ export default function OrderIndex({ orders }: { orders: Order[] }) {
const handlePrint = useCallback(async (order: Order) => { const handlePrint = useCallback(async (order: Order) => {
if (!isConnected) { if (!isConnected) {
toast.error('Hubungkan printer terlebih dahulu'); toast.error('Hubungkan printer terlebih dahulu');
return; return;
} }
@ -93,7 +83,10 @@ export default function OrderIndex({ orders }: { orders: Order[] }) {
.line(line); .line(line);
order.items?.forEach((item, index) => { order.items?.forEach((item, index) => {
if (index > 0) result.line(); if (index > 0) {
result.line();
}
result.line(item.product?.name || 'Produk'); result.line(item.product?.name || 'Produk');
const qtyPrice = `${item.qty} x ${item.price_formatted}`; const qtyPrice = `${item.qty} x ${item.price_formatted}`;
const subtotal = item.total_formatted; const subtotal = item.total_formatted;
@ -104,6 +97,7 @@ export default function OrderIndex({ orders }: { orders: Order[] }) {
result.line(line); result.line(line);
const discountAmount = Number(order.discount || 0); const discountAmount = Number(order.discount || 0);
if (discountAmount > 0) { if (discountAmount > 0) {
const discountLabel = 'Diskon:'; const discountLabel = 'Diskon:';
const discountVal = `- Rp ${discountAmount.toLocaleString('id-ID')}`; const discountVal = `- Rp ${discountAmount.toLocaleString('id-ID')}`;

View File

@ -1,14 +1,14 @@
import { ColumnDef } from '@tanstack/react-table';
import { Order } from '@/types/order';
import { DataTableColumnHeader } from '@/components/data-table-column-header';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Button } from '@/components/ui/button';
import { Pencil, Trash2, ShoppingBag, Printer } from 'lucide-react';
import { Link } from '@inertiajs/react'; import { Link } from '@inertiajs/react';
import * as orderRoutes from '@/routes/order'; import type { ColumnDef } from '@tanstack/react-table';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { id } from 'date-fns/locale'; import { id } from 'date-fns/locale';
import { Pencil, Trash2, Printer } from 'lucide-react';
import { DataTableColumnHeader } from '@/components/data-table-column-header';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import * as orderRoutes from '@/routes/order';
import type { Order } from '@/types/order';
interface ColumnProps { interface ColumnProps {
onDelete: (order: Order) => void; onDelete: (order: Order) => void;
@ -100,6 +100,7 @@ export const getColumns = ({ onDelete, onPrint }: ColumnProps): ColumnDef<Order>
header: "Aksi", header: "Aksi",
cell: ({ row }) => { cell: ({ row }) => {
const order = row.original; const order = row.original;
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Tooltip> <Tooltip>

View File

@ -1,19 +1,26 @@
import { Head, Link, useForm, router } from '@inertiajs/react'; import { Head, Link, useForm, router } from '@inertiajs/react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { format } from 'date-fns';
import { Plus, Trash2, CalendarIcon, Minus, ShoppingCart, Search, ImagePlus, X, Loader, Save, ArrowLeft } from 'lucide-react';
import React, { useMemo, useState } from 'react';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Calendar } from '@/components/ui/calendar';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from '@/components/ui/empty';
import { Field, FieldError } from "@/components/ui/field"; import { Field, FieldError } from "@/components/ui/field";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import purchaseRoutes from '@/routes/purchase';
import React, { useMemo, useState } from 'react';
import { toast } from 'sonner';
import { Product, ProductPrice } from '@/types';
import { Plus, Trash2, CalendarIcon, Minus, ShoppingCart, Search, ImagePlus, X, Loader, Save, ArrowLeft } from 'lucide-react';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Calendar } from '@/components/ui/calendar'; import type { Product, ProductPrice } from '@/types';
import { format } from 'date-fns';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { import {
Select, Select,
@ -22,20 +29,13 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from '@/components/ui/empty';
import { import {
Sheet, Sheet,
SheetContent, SheetContent,
SheetTrigger, SheetTrigger,
SheetClose, SheetClose,
} from "@/components/ui/sheet"; } from "@/components/ui/sheet";
import purchaseRoutes from '@/routes/purchase';
type CartItem = { product_id: number; quantity: number; unit_price: number; product: Product }; type CartItem = { product_id: number; quantity: number; unit_price: number; product: Product };
@ -57,6 +57,7 @@ export default function PurchaseCreate({ products, cartItems }: { products: Prod
const categories = useMemo(() => { const categories = useMemo(() => {
const map = new Map<number, string>(); const map = new Map<number, string>();
products.forEach(p => p.categories?.forEach(c => map.set(c.id, c.name))); products.forEach(p => p.categories?.forEach(c => map.set(c.id, c.name)));
return Array.from(map.entries()).map(([id, name]) => ({ id, name })); return Array.from(map.entries()).map(([id, name]) => ({ id, name }));
}, [products]); }, [products]);
@ -69,6 +70,7 @@ export default function PurchaseCreate({ products, cartItems }: { products: Prod
const filteredProducts = products.filter(p => { const filteredProducts = products.filter(p => {
const matchesSearch = p.name.toLowerCase().includes(search.toLowerCase()); const matchesSearch = p.name.toLowerCase().includes(search.toLowerCase());
const matchesCategory = selectedCategory === 'all' || p.categories?.some(c => String(c.id) === selectedCategory); const matchesCategory = selectedCategory === 'all' || p.categories?.some(c => String(c.id) === selectedCategory);
return matchesSearch && matchesCategory; return matchesSearch && matchesCategory;
}); });
@ -89,7 +91,10 @@ export default function PurchaseCreate({ products, cartItems }: { products: Prod
const decreaseQuantity = (product: Product, e: React.MouseEvent) => { const decreaseQuantity = (product: Product, e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
const item = getCartItem(product.id); const item = getCartItem(product.id);
if (!item) return;
if (!item) {
return;
}
router.post(purchaseRoutes.addToCart().url, { router.post(purchaseRoutes.addToCart().url, {
product_id: product.id, product_id: product.id,
@ -112,7 +117,10 @@ export default function PurchaseCreate({ products, cartItems }: { products: Prod
}; };
const updateCartQuantity = (itemId: number, quantity: number) => { const updateCartQuantity = (itemId: number, quantity: number) => {
if (quantity < 1) return; if (quantity < 1) {
return;
}
router.patch(purchaseRoutes.updateCartItem(itemId).url, { router.patch(purchaseRoutes.updateCartItem(itemId).url, {
quantity quantity
}, { }, {
@ -128,16 +136,20 @@ export default function PurchaseCreate({ products, cartItems }: { products: Prod
const confirmQty = () => { const confirmQty = () => {
const val = parseInt(qtyInputValue); const val = parseInt(qtyInputValue);
if (!isNaN(val) && val >= 1 && qtyDialogIndex !== null) { if (!isNaN(val) && val >= 1 && qtyDialogIndex !== null) {
updateCartQuantity(qtyDialogIndex, val); updateCartQuantity(qtyDialogIndex, val);
} }
setQtyDialogIndex(null); setQtyDialogIndex(null);
}; };
const onSubmit = (e: React.FormEvent) => { const onSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (cartItems.length === 0) { if (cartItems.length === 0) {
toast.error('Pilih minimal satu produk'); toast.error('Pilih minimal satu produk');
return; return;
} }
@ -267,6 +279,7 @@ export default function PurchaseCreate({ products, cartItems }: { products: Prod
} else { } else {
setData('purchase_date', ''); setData('purchase_date', '');
} }
setIsCalendarOpen(false); setIsCalendarOpen(false);
}} }}
/> />
@ -429,7 +442,10 @@ export default function PurchaseCreate({ products, cartItems }: { products: Prod
cartItem ? "text-foreground hover:text-primary cursor-pointer" : "text-muted-foreground/40 cursor-default" cartItem ? "text-foreground hover:text-primary cursor-pointer" : "text-muted-foreground/40 cursor-default"
)} )}
onClick={(e) => { onClick={(e) => {
if (!cartItem) return; if (!cartItem) {
return;
}
openQtyDialog(cartItem, e); openQtyDialog(cartItem, e);
}} }}
> >
@ -569,6 +585,7 @@ export default function PurchaseCreate({ products, cartItems }: { products: Prod
} else { } else {
setData('purchase_date', ''); setData('purchase_date', '');
} }
setIsCalendarOpen(false); setIsCalendarOpen(false);
}} }}
/> />
@ -786,7 +803,11 @@ export default function PurchaseCreate({ products, cartItems }: { products: Prod
</div> </div>
{/* Quantity Input Dialog */} {/* Quantity Input Dialog */}
<Dialog open={qtyDialogIndex !== null} onOpenChange={(open) => { if (!open) setQtyDialogIndex(null); }}> <Dialog open={qtyDialogIndex !== null} onOpenChange={(open) => {
if (!open) {
setQtyDialogIndex(null);
}
}}>
<DialogContent className="max-w-xs"> <DialogContent className="max-w-xs">
<DialogHeader> <DialogHeader>
<DialogTitle>Ubah Jumlah</DialogTitle> <DialogTitle>Ubah Jumlah</DialogTitle>
@ -800,7 +821,11 @@ export default function PurchaseCreate({ products, cartItems }: { products: Prod
min="1" min="1"
value={qtyInputValue} value={qtyInputValue}
onChange={e => setQtyInputValue(e.target.value)} onChange={e => setQtyInputValue(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') confirmQty(); }} onKeyDown={e => {
if (e.key === 'Enter') {
confirmQty();
}
}}
className="text-lg font-bold" className="text-lg font-bold"
autoFocus autoFocus
/> />

View File

@ -1,19 +1,26 @@
import { Head, Link, useForm, router } from '@inertiajs/react'; import { Head, Link, useForm } from '@inertiajs/react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { format } from 'date-fns';
import { Plus, Trash2, CalendarIcon, Minus, ShoppingCart, Search, ImagePlus, X, Loader, Save, ArrowLeft } from 'lucide-react';
import React, { useMemo, useState } from 'react';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Calendar } from '@/components/ui/calendar';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from '@/components/ui/empty';
import { Field, FieldError } from "@/components/ui/field"; import { Field, FieldError } from "@/components/ui/field";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import purchaseRoutes from '@/routes/purchase';
import React, { useMemo, useState } from 'react';
import { toast } from 'sonner';
import { Product, ProductPrice, Purchase } from '@/types';
import { Plus, Trash2, CalendarIcon, Minus, ShoppingCart, Search, ImagePlus, X, Loader, Save, ArrowLeft } from 'lucide-react';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Calendar } from '@/components/ui/calendar'; import type { Product, ProductPrice, Purchase } from '@/types';
import { format } from 'date-fns';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { import {
Select, Select,
@ -22,22 +29,13 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from '@/components/ui/empty';
import { import {
Sheet, Sheet,
SheetContent, SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger, SheetTrigger,
SheetClose, SheetClose,
} from "@/components/ui/sheet"; } from "@/components/ui/sheet";
import purchaseRoutes from '@/routes/purchase';
type CartItem = { product_id: number; quantity: number; unit_price: number; product: Product }; type CartItem = { product_id: number; quantity: number; unit_price: number; product: Product };
@ -59,6 +57,7 @@ export default function PurchaseEdit({ purchase, products }: { purchase: Purchas
const categories = useMemo(() => { const categories = useMemo(() => {
const map = new Map<number, string>(); const map = new Map<number, string>();
products.forEach(p => p.categories?.forEach(c => map.set(c.id, c.name))); products.forEach(p => p.categories?.forEach(c => map.set(c.id, c.name)));
return Array.from(map.entries()).map(([id, name]) => ({ id, name })); return Array.from(map.entries()).map(([id, name]) => ({ id, name }));
}, [products]); }, [products]);
@ -76,6 +75,7 @@ export default function PurchaseEdit({ purchase, products }: { purchase: Purchas
const filteredProducts = products.filter(p => { const filteredProducts = products.filter(p => {
const matchesSearch = p.name.toLowerCase().includes(search.toLowerCase()); const matchesSearch = p.name.toLowerCase().includes(search.toLowerCase());
const matchesCategory = selectedCategory === 'all' || p.categories?.some(c => String(c.id) === selectedCategory); const matchesCategory = selectedCategory === 'all' || p.categories?.some(c => String(c.id) === selectedCategory);
return matchesSearch && matchesCategory; return matchesSearch && matchesCategory;
}); });
@ -101,14 +101,19 @@ export default function PurchaseEdit({ purchase, products }: { purchase: Purchas
const decreaseQuantity = (product: Product, e: React.MouseEvent) => { const decreaseQuantity = (product: Product, e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
const existingIndex = data.items.findIndex(i => i.product_id === product.id); const existingIndex = data.items.findIndex(i => i.product_id === product.id);
if (existingIndex === -1) return;
if (existingIndex === -1) {
return;
}
const newItems = [...data.items]; const newItems = [...data.items];
if (newItems[existingIndex].quantity <= 1) { if (newItems[existingIndex].quantity <= 1) {
newItems.splice(existingIndex, 1); newItems.splice(existingIndex, 1);
} else { } else {
newItems[existingIndex].quantity -= 1; newItems[existingIndex].quantity -= 1;
} }
setData('items', newItems); setData('items', newItems);
}; };
@ -123,9 +128,13 @@ export default function PurchaseEdit({ purchase, products }: { purchase: Purchas
}; };
const updateCartQuantity = (productId: number, quantity: number) => { const updateCartQuantity = (productId: number, quantity: number) => {
if (quantity < 1) return; if (quantity < 1) {
return;
}
const newItems = [...data.items]; const newItems = [...data.items];
const index = newItems.findIndex(i => i.product_id === productId); const index = newItems.findIndex(i => i.product_id === productId);
if (index > -1) { if (index > -1) {
newItems[index].quantity = quantity; newItems[index].quantity = quantity;
setData('items', newItems); setData('items', newItems);
@ -141,16 +150,20 @@ export default function PurchaseEdit({ purchase, products }: { purchase: Purchas
const confirmQty = () => { const confirmQty = () => {
const val = parseInt(qtyInputValue); const val = parseInt(qtyInputValue);
if (!isNaN(val) && val >= 1 && qtyDialogIndex !== null) { if (!isNaN(val) && val >= 1 && qtyDialogIndex !== null) {
updateCartQuantity(qtyDialogIndex, val); updateCartQuantity(qtyDialogIndex, val);
} }
setQtyDialogIndex(null); setQtyDialogIndex(null);
}; };
const onSubmit = (e: React.FormEvent) => { const onSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (data.items.length === 0) { if (data.items.length === 0) {
toast.error('Pilih minimal satu produk'); toast.error('Pilih minimal satu produk');
return; return;
} }
@ -271,6 +284,7 @@ export default function PurchaseEdit({ purchase, products }: { purchase: Purchas
} else { } else {
setData('purchase_date', ''); setData('purchase_date', '');
} }
setIsCalendarOpen(false); setIsCalendarOpen(false);
}} }}
/> />
@ -433,7 +447,10 @@ export default function PurchaseEdit({ purchase, products }: { purchase: Purchas
cartItem ? "text-foreground hover:text-primary cursor-pointer" : "text-muted-foreground/40 cursor-default" cartItem ? "text-foreground hover:text-primary cursor-pointer" : "text-muted-foreground/40 cursor-default"
)} )}
onClick={(e) => { onClick={(e) => {
if (!cartItem) return; if (!cartItem) {
return;
}
openQtyDialog(cartItem, e); openQtyDialog(cartItem, e);
}} }}
> >
@ -573,6 +590,7 @@ export default function PurchaseEdit({ purchase, products }: { purchase: Purchas
} else { } else {
setData('purchase_date', ''); setData('purchase_date', '');
} }
setIsCalendarOpen(false); setIsCalendarOpen(false);
}} }}
/> />
@ -790,7 +808,11 @@ export default function PurchaseEdit({ purchase, products }: { purchase: Purchas
</div> </div>
</div> </div>
{/* Quantity Input Dialog */} {/* Quantity Input Dialog */}
<Dialog open={qtyDialogIndex !== null} onOpenChange={(open) => { if (!open) setQtyDialogIndex(null); }}> <Dialog open={qtyDialogIndex !== null} onOpenChange={(open) => {
if (!open) {
setQtyDialogIndex(null);
}
}}>
<DialogContent className="max-w-xs"> <DialogContent className="max-w-xs">
<DialogHeader> <DialogHeader>
<DialogTitle>Ubah Jumlah</DialogTitle> <DialogTitle>Ubah Jumlah</DialogTitle>
@ -804,7 +826,11 @@ export default function PurchaseEdit({ purchase, products }: { purchase: Purchas
min="1" min="1"
value={qtyInputValue} value={qtyInputValue}
onChange={e => setQtyInputValue(e.target.value)} onChange={e => setQtyInputValue(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') confirmQty(); }} onKeyDown={e => {
if (e.key === 'Enter') {
confirmQty();
}
}}
className="text-lg font-bold" className="text-lg font-bold"
autoFocus autoFocus
/> />

View File

@ -1,8 +1,8 @@
import { useState } from 'react';
import { Purchase } from '@/types';
import { router } from '@inertiajs/react'; import { router } from '@inertiajs/react';
import purchaseRoutes from '@/routes/purchase'; import { useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import purchaseRoutes from '@/routes/purchase';
import type { Purchase } from '@/types';
export function usePurchaseIndex() { export function usePurchaseIndex() {
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);

View File

@ -1,28 +1,18 @@
import { Head, Link } from '@inertiajs/react'; import { Head, Link } from '@inertiajs/react';
import type { Purchase } from '@/types';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Plus, Trash2 } from 'lucide-react'; import { Plus, Trash2 } from 'lucide-react';
import { format } from 'date-fns';
import { id } from 'date-fns/locale';
import { DataTable } from '@/components/data-table'; import { DataTable } from '@/components/data-table';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { DeleteConfirmation } from '@/components/modal/delete-confirmation';
import {
AlertDialogMedia,
} from "@/components/ui/alert-dialog"
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { formatDate } from '@/lib/formatters';
import purchaseRoutes from '@/routes/purchase'; import purchaseRoutes from '@/routes/purchase';
import type { Purchase } from '@/types';
import { usePurchaseIndex } from './hooks/use-purchase-index'; import { usePurchaseIndex } from './hooks/use-purchase-index';
import { getColumns } from './partials/columns'; import { getColumns } from './partials/columns';
import { DeleteConfirmation } from '@/components/modal/delete-confirmation';
import { formatDate } from '@/lib/formatters';
export default function PurchaseIndex({ purchases }: { purchases: Purchase[] }) { export default function PurchaseIndex({ purchases }: { purchases: Purchase[] }) {
const { const {

View File

@ -1,13 +1,13 @@
import { ColumnDef } from '@tanstack/react-table';
import { Purchase } from '@/types';
import { DataTableColumnHeader } from '@/components/data-table-column-header';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Button } from '@/components/ui/button';
import { Pencil, Trash2 } from 'lucide-react';
import { Link } from '@inertiajs/react'; import { Link } from '@inertiajs/react';
import purchaseRoutes from '@/routes/purchase'; import type { ColumnDef } from '@tanstack/react-table';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { id } from 'date-fns/locale'; import { id } from 'date-fns/locale';
import { Pencil, Trash2 } from 'lucide-react';
import { DataTableColumnHeader } from '@/components/data-table-column-header';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import purchaseRoutes from '@/routes/purchase';
import type { Purchase } from '@/types';
interface ColumnProps { interface ColumnProps {
onDelete: (purchase: Purchase) => void; onDelete: (purchase: Purchase) => void;
@ -21,6 +21,7 @@ export const getColumns = ({ onDelete }: ColumnProps): ColumnDef<Purchase>[] =>
), ),
cell: ({ row }) => { cell: ({ row }) => {
const date = row.original.purchase_date; const date = row.original.purchase_date;
return format(new Date(date), 'dd MMMM yyyy', { locale: id }); return format(new Date(date), 'dd MMMM yyyy', { locale: id });
}, },
meta: { title: "Tanggal" }, meta: { title: "Tanggal" },
@ -80,6 +81,7 @@ export const getColumns = ({ onDelete }: ColumnProps): ColumnDef<Purchase>[] =>
header: "Aksi", header: "Aksi",
cell: ({ row }) => { cell: ({ row }) => {
const purchase = row.original; const purchase = row.original;
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Tooltip> <Tooltip>

View File

@ -1,8 +1,8 @@
import { useState } from 'react';
import { Category } from '@/types';
import { router } from '@inertiajs/react'; import { router } from '@inertiajs/react';
import categoryRoutes from '@/routes/category'; import { useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import categoryRoutes from '@/routes/category';
import type { Category } from '@/types';
export function useCategoryIndex() { export function useCategoryIndex() {
const [isFormOpen, setIsFormOpen] = useState(false); const [isFormOpen, setIsFormOpen] = useState(false);

View File

@ -1,14 +1,14 @@
import { Head } from '@inertiajs/react'; import { Head } from '@inertiajs/react';
import type { Category } from '@/types'; import { Trash2, Plus } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Trash2, Plus, X } from 'lucide-react';
import { DataTable } from '@/components/data-table'; import { DataTable } from '@/components/data-table';
import { DeleteConfirmation } from '@/components/modal/delete-confirmation';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import type { Category } from '@/types';
import { useCategoryIndex } from './hooks/use-category-index'; import { useCategoryIndex } from './hooks/use-category-index';
import { getColumns } from './partials/columns';
import { CategoryFormModal } from './partials/category-form-modal'; import { CategoryFormModal } from './partials/category-form-modal';
import { DeleteConfirmation } from '@/components/modal/delete-confirmation'; import { getColumns } from './partials/columns';
export default function CategoryIndex({ categories }: { categories: Category[] }) { export default function CategoryIndex({ categories }: { categories: Category[] }) {
const { const {

View File

@ -1,3 +1,7 @@
import { useForm } from '@inertiajs/react';
import { X, Save, Loader } from 'lucide-react';
import { useEffect } from 'react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
Dialog, Dialog,
@ -9,12 +13,8 @@ import {
import { Field, FieldError, FieldGroup } from "@/components/ui/field" import { Field, FieldError, FieldGroup } from "@/components/ui/field"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Category } from '@/types';
import { useForm } from '@inertiajs/react';
import { useEffect } from 'react';
import categoryRoutes from '@/routes/category'; import categoryRoutes from '@/routes/category';
import { toast } from 'sonner'; import type { Category } from '@/types';
import { X, Save, Loader } from 'lucide-react';
interface CategoryFormModalProps { interface CategoryFormModalProps {
isOpen: boolean; isOpen: boolean;
@ -49,6 +49,7 @@ export function CategoryFormModal({ isOpen, onClose, category }: CategoryFormMod
const onSubmit = (e: React.FormEvent) => { const onSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (isEditing && category) { if (isEditing && category) {
patch(categoryRoutes.update(category.id).url, { patch(categoryRoutes.update(category.id).url, {
onSuccess: (response: any) => { onSuccess: (response: any) => {

View File

@ -1,10 +1,10 @@
import { ColumnDef } from '@tanstack/react-table'; import type { ColumnDef } from '@tanstack/react-table';
import { Category } from '@/types'; import { Pencil, Trash2 } from 'lucide-react';
import { DataTableColumnHeader } from '@/components/data-table-column-header'; import { DataTableColumnHeader } from '@/components/data-table-column-header';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Button } from '@/components/ui/button'; import type { Category } from '@/types';
import { Pencil, Trash2 } from 'lucide-react';
interface ColumnProps { interface ColumnProps {
onEdit: (category: Category) => void; onEdit: (category: Category) => void;
@ -26,6 +26,7 @@ export const getColumns = ({ onEdit, onDelete, onToggleStatus }: ColumnProps): C
meta: { title: "Status" }, meta: { title: "Status" },
cell: ({ row }) => { cell: ({ row }) => {
const category = row.original; const category = row.original;
return ( return (
<Switch <Switch
checked={category.is_active} checked={category.is_active}
@ -39,6 +40,7 @@ export const getColumns = ({ onEdit, onDelete, onToggleStatus }: ColumnProps): C
header: "Aksi", header: "Aksi",
cell: ({ row }) => { cell: ({ row }) => {
const category = row.original; const category = row.original;
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Tooltip> <Tooltip>

View File

@ -1,15 +1,12 @@
import { Head, Link } from '@inertiajs/react'; import { Head, Link } from '@inertiajs/react';
import { useForm } from '@inertiajs/react'; import { useForm } from '@inertiajs/react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { ImagePlus, X, Upload, ArrowLeft, Save, Loader } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Field, FieldError } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import productRoutes from '@/routes/product';
import React, { useRef, useState } from 'react'; import React, { useRef, useState } from 'react';
import { NumericFormat } from 'react-number-format';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { SimpleEditor } from '@/components/tiptap-templates/simple/simple-editor' import { SimpleEditor } from '@/components/tiptap-templates/simple/simple-editor'
import { Category } from '@/types/category'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { import {
Combobox, Combobox,
ComboboxContent, ComboboxContent,
@ -22,8 +19,11 @@ import {
ComboboxValue, ComboboxValue,
useComboboxAnchor, useComboboxAnchor,
} from "@/components/ui/combobox" } from "@/components/ui/combobox"
import { NumericFormat } from 'react-number-format'; import { Field, FieldError } from "@/components/ui/field"
import { ImagePlus, X, Upload, ArrowLeft, Save, Loader } from 'lucide-react'; import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import productRoutes from '@/routes/product';
import type { Category } from '@/types/category';
export default function ProductCreate({ categories }: { categories: Category[] }) { export default function ProductCreate({ categories }: { categories: Category[] }) {
const { data, setData, post, processing, errors } = useForm<{ const { data, setData, post, processing, errors } = useForm<{
@ -63,7 +63,11 @@ export default function ProductCreate({ categories }: { categories: Category[] }
const handleThumbnailChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleThumbnailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return;
if (!file) {
return;
}
setData('thumbnail', file); setData('thumbnail', file);
const reader = new FileReader(); const reader = new FileReader();
reader.onloadend = () => setThumbnailPreview(reader.result as string); reader.onloadend = () => setThumbnailPreview(reader.result as string);
@ -73,12 +77,18 @@ export default function ProductCreate({ categories }: { categories: Category[] }
const removeThumbnail = () => { const removeThumbnail = () => {
setData('thumbnail', null); setData('thumbnail', null);
setThumbnailPreview(null); setThumbnailPreview(null);
if (thumbnailInputRef.current) thumbnailInputRef.current.value = '';
if (thumbnailInputRef.current) {
thumbnailInputRef.current.value = '';
}
}; };
const handleImagesChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleImagesChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files ?? []); const files = Array.from(e.target.files ?? []);
if (!files.length) return;
if (!files.length) {
return;
}
const newPreviews = files.map(file => ({ const newPreviews = files.map(file => ({
file, file,
@ -87,7 +97,10 @@ export default function ProductCreate({ categories }: { categories: Category[] }
setImagePreviews(prev => [...prev, ...newPreviews]); setImagePreviews(prev => [...prev, ...newPreviews]);
setData('images', [...data.images, ...files]); setData('images', [...data.images, ...files]);
if (imagesInputRef.current) imagesInputRef.current.value = '';
if (imagesInputRef.current) {
imagesInputRef.current.value = '';
}
}; };
const removeImage = (index: number) => { const removeImage = (index: number) => {

View File

@ -1,15 +1,11 @@
import { Head, useForm, Link } from '@inertiajs/react'; import { Head, useForm, Link } from '@inertiajs/react';
import type { Product, ProductPrice } from '@/types'; import { ImagePlus, X, Upload, ArrowLeft, Save, Loader } from 'lucide-react';
import { Category } from '@/types/category';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Field, FieldError } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { toast } from 'sonner';
import productRoutes from '@/routes/product';
import React, { useRef, useState } from 'react'; import React, { useRef, useState } from 'react';
import { NumericFormat } from 'react-number-format';
import { toast } from 'sonner';
import { SimpleEditor } from '@/components/tiptap-templates/simple/simple-editor'; import { SimpleEditor } from '@/components/tiptap-templates/simple/simple-editor';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { import {
Combobox, Combobox,
ComboboxContent, ComboboxContent,
@ -22,8 +18,12 @@ import {
ComboboxValue, ComboboxValue,
useComboboxAnchor, useComboboxAnchor,
} from "@/components/ui/combobox" } from "@/components/ui/combobox"
import { NumericFormat } from 'react-number-format'; import { Field, FieldError } from "@/components/ui/field"
import { ImagePlus, X, Upload, ArrowLeft, Save, Loader } from 'lucide-react'; import { Category } from '@/types/category';
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import productRoutes from '@/routes/product';
import type { Product, ProductPrice } from '@/types';
interface ExistingImage { interface ExistingImage {
kind: 'existing'; kind: 'existing';
@ -42,6 +42,7 @@ type GalleryItem = ExistingImage | NewImage;
export default function ProductEdit({ product, categories }: { product: Product, categories: Category[] }) { export default function ProductEdit({ product, categories }: { product: Product, categories: Category[] }) {
const getPrice = (type: string) => { const getPrice = (type: string) => {
const priceObj = product.prices?.find((p: ProductPrice) => p.price_type === type); const priceObj = product.prices?.find((p: ProductPrice) => p.price_type === type);
return priceObj ? priceObj.price.toString() : ''; return priceObj ? priceObj.price.toString() : '';
}; };
@ -98,7 +99,11 @@ export default function ProductEdit({ product, categories }: { product: Product,
const handleThumbnailChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleThumbnailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return;
if (!file) {
return;
}
setData('thumbnail', file); setData('thumbnail', file);
setThumbnailCleared(false); setThumbnailCleared(false);
const reader = new FileReader(); const reader = new FileReader();
@ -110,12 +115,18 @@ export default function ProductEdit({ product, categories }: { product: Product,
setData('thumbnail', null); setData('thumbnail', null);
setThumbnailPreview(null); setThumbnailPreview(null);
setThumbnailCleared(true); setThumbnailCleared(true);
if (thumbnailInputRef.current) thumbnailInputRef.current.value = '';
if (thumbnailInputRef.current) {
thumbnailInputRef.current.value = '';
}
}; };
const handleImagesChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleImagesChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files ?? []); const files = Array.from(e.target.files ?? []);
if (!files.length) return;
if (!files.length) {
return;
}
const newItems: NewImage[] = files.map(file => ({ const newItems: NewImage[] = files.map(file => ({
kind: 'new', kind: 'new',
@ -125,7 +136,10 @@ export default function ProductEdit({ product, categories }: { product: Product,
setGallery(prev => [...prev, ...newItems]); setGallery(prev => [...prev, ...newItems]);
setData('images', [...data.images, ...files]); setData('images', [...data.images, ...files]);
if (imagesInputRef.current) imagesInputRef.current.value = '';
if (imagesInputRef.current) {
imagesInputRef.current.value = '';
}
}; };
const removeGalleryItem = (index: number) => { const removeGalleryItem = (index: number) => {

View File

@ -1,8 +1,8 @@
import { useState } from 'react';
import { Product } from '@/types';
import { router } from '@inertiajs/react'; import { router } from '@inertiajs/react';
import productRoutes from '@/routes/product'; import { useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import productRoutes from '@/routes/product';
import type { Product } from '@/types';
export function useProductIndex() { export function useProductIndex() {
const [selectedImage, setSelectedImage] = useState<string | null>(null); const [selectedImage, setSelectedImage] = useState<string | null>(null);

View File

@ -1,15 +1,15 @@
import { Head, Link } from '@inertiajs/react'; import { Head, Link } from '@inertiajs/react';
import type { Product, Category } from '@/types';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Trash2, Plus } from 'lucide-react'; import { Trash2, Plus } from 'lucide-react';
import { DataTable } from '@/components/data-table'; import { DataTable } from '@/components/data-table';
import { DeleteConfirmation } from '@/components/modal/delete-confirmation';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import productRoutes from '@/routes/product'; import productRoutes from '@/routes/product';
import type { Product, Category } from '@/types';
import { ImagePreviewDialog } from '../../../../components/modal/image-preview';
import { useProductIndex } from './hooks/use-product-index'; import { useProductIndex } from './hooks/use-product-index';
import { getColumns } from './partials/columns'; import { getColumns } from './partials/columns';
import { ImagePreviewDialog } from '../../../../components/modal/image-preview';
import { DeleteConfirmation } from '@/components/modal/delete-confirmation';
export default function ProductIndex({ products, categories }: { products: Product[], categories: Category[] }) { export default function ProductIndex({ products, categories }: { products: Product[], categories: Category[] }) {
const { const {

View File

@ -1,13 +1,13 @@
import { ColumnDef } from '@tanstack/react-table'; import { Link } from '@inertiajs/react';
import { Product, Category } from '@/types'; import type { ColumnDef } from '@tanstack/react-table';
import { Pencil, Trash2, ImagePlus } from 'lucide-react';
import { DataTableColumnHeader } from '@/components/data-table-column-header'; import { DataTableColumnHeader } from '@/components/data-table-column-header';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Button } from '@/components/ui/button';
import { Pencil, Trash2, ImagePlus } from 'lucide-react';
import { Link } from '@inertiajs/react';
import productRoutes from '@/routes/product'; import productRoutes from '@/routes/product';
import type { Product, Category } from '@/types';
interface ColumnProps { interface ColumnProps {
onEdit?: (product: Product) => void; onEdit?: (product: Product) => void;
@ -23,6 +23,7 @@ export const getColumns = ({ onDelete, onToggleStatus, onPreviewImage }: ColumnP
meta: { title: "Thumbnail" }, meta: { title: "Thumbnail" },
cell: ({ row }) => { cell: ({ row }) => {
const url = row.original.thumbnail_url; const url = row.original.thumbnail_url;
return url ? ( return url ? (
<button onClick={() => onPreviewImage(url)} className="block w-fit"> <button onClick={() => onPreviewImage(url)} className="block w-fit">
<img src={url} alt="Thumbnail" className="h-10 w-10 object-cover rounded-md border hover:opacity-80 transition-opacity" /> <img src={url} alt="Thumbnail" className="h-10 w-10 object-cover rounded-md border hover:opacity-80 transition-opacity" />
@ -49,11 +50,16 @@ export const getColumns = ({ onDelete, onToggleStatus, onPreviewImage }: ColumnP
meta: { title: "Kategori" }, meta: { title: "Kategori" },
filterFn: (row, id, value) => { filterFn: (row, id, value) => {
const categories = row.getValue(id) as Category[]; const categories = row.getValue(id) as Category[];
if (!categories) return false;
if (!categories) {
return false;
}
return categories.some(cat => String(cat.id) === String(value)); return categories.some(cat => String(cat.id) === String(value));
}, },
cell: ({ row }) => { cell: ({ row }) => {
const product = row.original; const product = row.original;
return ( return (
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{product.categories?.map((category) => ( {product.categories?.map((category) => (
@ -118,6 +124,7 @@ export const getColumns = ({ onDelete, onToggleStatus, onPreviewImage }: ColumnP
meta: { title: "Status" }, meta: { title: "Status" },
cell: ({ row }) => { cell: ({ row }) => {
const product = row.original; const product = row.original;
return ( return (
<Switch <Switch
checked={product.is_active} checked={product.is_active}
@ -131,6 +138,7 @@ export const getColumns = ({ onDelete, onToggleStatus, onPreviewImage }: ColumnP
header: "Aksi", header: "Aksi",
cell: ({ row }) => { cell: ({ row }) => {
const product = row.original; const product = row.original;
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Tooltip> <Tooltip>

View File

@ -1,22 +1,22 @@
import { Head, Link } from '@inertiajs/react'; import { Head, Link } from '@inertiajs/react';
import { useForm } from '@inertiajs/react'; import { useForm } from '@inertiajs/react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { ArrowLeft, Loader, Save } from 'lucide-react';
import React from 'react';
import { NumericFormat } from 'react-number-format';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Calendar } from "@/components/ui/calendar"
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Field, FieldError } from "@/components/ui/field" import { Field, FieldError } from "@/components/ui/field"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import userRoutes from '@/routes/user';
import React from 'react';
import { toast } from 'sonner';
import { Textarea } from '@/components/ui/textarea';
import { Calendar } from "@/components/ui/calendar"
import { import {
Popover, Popover,
PopoverContent, PopoverContent,
PopoverTrigger, PopoverTrigger,
} from "@/components/ui/popover" } from "@/components/ui/popover"
import { NumericFormat } from 'react-number-format'; import { Textarea } from '@/components/ui/textarea';
import { ArrowLeft, Loader, Save } from 'lucide-react'; import userRoutes from '@/routes/user';
export default function UserCreate() { export default function UserCreate() {
const [isCalendarOpen, setIsCalendarOpen] = React.useState(false); const [isCalendarOpen, setIsCalendarOpen] = React.useState(false);
@ -170,6 +170,7 @@ export default function UserCreate() {
} else { } else {
setData('birth_date', ''); setData('birth_date', '');
} }
setIsCalendarOpen(false); setIsCalendarOpen(false);
}} }}
/> />

View File

@ -1,22 +1,22 @@
import { Head, Link } from '@inertiajs/react'; import { Head, Link } from '@inertiajs/react';
import { useForm } from '@inertiajs/react'; import { useForm } from '@inertiajs/react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { ArrowLeft, Loader, Save } from 'lucide-react';
import React from 'react';
import { NumericFormat } from 'react-number-format';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Calendar } from "@/components/ui/calendar"
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Field, FieldError } from "@/components/ui/field" import { Field, FieldError } from "@/components/ui/field"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import userRoutes from '@/routes/user';
import React from 'react';
import { toast } from 'sonner';
import { Textarea } from '@/components/ui/textarea';
import { Calendar } from "@/components/ui/calendar"
import { import {
Popover, Popover,
PopoverContent, PopoverContent,
PopoverTrigger, PopoverTrigger,
} from "@/components/ui/popover" } from "@/components/ui/popover"
import { NumericFormat } from 'react-number-format'; import { Textarea } from '@/components/ui/textarea';
import { ArrowLeft, Loader, Save } from 'lucide-react'; import userRoutes from '@/routes/user';
export default function UserEdit({ user }: { user: any }) { export default function UserEdit({ user }: { user: any }) {
const [isCalendarOpen, setIsCalendarOpen] = React.useState(false); const [isCalendarOpen, setIsCalendarOpen] = React.useState(false);
@ -171,6 +171,7 @@ export default function UserEdit({ user }: { user: any }) {
} else { } else {
setData('birth_date', ''); setData('birth_date', '');
} }
setIsCalendarOpen(false); setIsCalendarOpen(false);
}} }}
/> />

View File

@ -1,8 +1,8 @@
import { useState } from 'react';
import { User } from '@/types';
import { router } from '@inertiajs/react'; import { router } from '@inertiajs/react';
import userRoutes from '@/routes/user'; import { useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import userRoutes from '@/routes/user';
import type { User } from '@/types';
export function useUserIndex() { export function useUserIndex() {
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);

View File

@ -1,9 +1,7 @@
import { Head, Link } from '@inertiajs/react'; import { Head, Link } from '@inertiajs/react';
import type { User } from '@/types';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Trash2, KeyRound, Plus, X } from 'lucide-react'; import { Trash2, KeyRound, Plus, X } from 'lucide-react';
import { DataTable } from '@/components/data-table'; import { DataTable } from '@/components/data-table';
import { DeleteConfirmation } from '@/components/modal/delete-confirmation';
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@ -15,11 +13,13 @@ import {
AlertDialogMedia, AlertDialogMedia,
AlertDialogTitle, AlertDialogTitle,
} from "@/components/ui/alert-dialog" } from "@/components/ui/alert-dialog"
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import userRoutes from '@/routes/user'; import userRoutes from '@/routes/user';
import type { User } from '@/types';
import { useUserIndex } from './hooks/use-user-index'; import { useUserIndex } from './hooks/use-user-index';
import { getColumns } from './partials/columns'; import { getColumns } from './partials/columns';
import { DeleteConfirmation } from '@/components/modal/delete-confirmation';
export default function UserIndex({ users, defaultPassword }: { users: User[], defaultPassword: string }) { export default function UserIndex({ users, defaultPassword }: { users: User[], defaultPassword: string }) {
const { const {

View File

@ -1,12 +1,12 @@
import { ColumnDef } from '@tanstack/react-table';
import { User } from '@/types';
import { DataTableColumnHeader } from '@/components/data-table-column-header';
import { UserInfo } from '@/components/user-info';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Button } from '@/components/ui/button';
import { KeyRound, Pencil, Trash2 } from 'lucide-react';
import { Link } from '@inertiajs/react'; import { Link } from '@inertiajs/react';
import type { ColumnDef } from '@tanstack/react-table';
import { KeyRound, Pencil, Trash2 } from 'lucide-react';
import { DataTableColumnHeader } from '@/components/data-table-column-header';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { UserInfo } from '@/components/user-info';
import userRoutes from '@/routes/user'; import userRoutes from '@/routes/user';
import type { User } from '@/types';
interface ColumnProps { interface ColumnProps {
onResetPassword: (user: User) => void; onResetPassword: (user: User) => void;
@ -22,6 +22,7 @@ export const getColumns = ({ onResetPassword, onDelete }: ColumnProps): ColumnDe
meta: { title: "Nama dan Alamat Surel" }, meta: { title: "Nama dan Alamat Surel" },
cell: ({ row }) => { cell: ({ row }) => {
const user = row.original; const user = row.original;
return ( return (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<UserInfo user={user} showEmail={true} /> <UserInfo user={user} showEmail={true} />
@ -49,6 +50,7 @@ export const getColumns = ({ onResetPassword, onDelete }: ColumnProps): ColumnDe
header: "Aksi", header: "Aksi",
cell: ({ row }) => { cell: ({ row }) => {
const user = row.original; const user = row.original;
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Tooltip> <Tooltip>

View File

@ -1,8 +1,8 @@
import { Head } from '@inertiajs/react'; import { Head } from '@inertiajs/react';
import { Card, CardContent } from '@/components/ui/card';
import { DataTable } from '@/components/data-table'; import { DataTable } from '@/components/data-table';
import { Card, CardContent } from '@/components/ui/card';
import type { Activity } from '@/types';
import { getColumns } from './partials/columns'; import { getColumns } from './partials/columns';
import { Activity } from '@/types';
interface Props { interface Props {
activities: Activity[]; activities: Activity[];

View File

@ -1,6 +1,6 @@
import { ColumnDef } from '@tanstack/react-table'; import type { ColumnDef } from '@tanstack/react-table';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Activity } from '@/types'; import type { Activity } from '@/types';
export function getColumns(): ColumnDef<Activity>[] { export function getColumns(): ColumnDef<Activity>[] {
return [ return [
@ -66,7 +66,9 @@ export function getColumns(): ColumnDef<Activity>[] {
const attributes = row.original.properties?.attributes; const attributes = row.original.properties?.attributes;
const old = row.original.properties?.old; const old = row.original.properties?.old;
if (!attributes) return <span className="text-muted-foreground text-xs italic">-</span>; if (!attributes) {
return <span className="text-muted-foreground text-xs italic">-</span>;
}
return ( return (
<div className="flex flex-col gap-1 py-1 max-w-[400px]"> <div className="flex flex-col gap-1 py-1 max-w-[400px]">

View File

@ -1,10 +1,7 @@
import { Head, router } from '@inertiajs/react'; import { Head, router } from '@inertiajs/react';
import { Card, CardContent } from '@/components/ui/card';
import { DataTable } from '@/components/data-table';
import { useState } from 'react'; import { useState } from 'react';
import { getColumns } from './partials/columns'; import { DataTable } from '@/components/data-table';
import { SystemLog } from '@/types'; import { Card, CardContent } from '@/components/ui/card';
import system from '@/routes/system';
import { import {
Select, Select,
SelectContent, SelectContent,
@ -12,6 +9,9 @@ import {
SelectTrigger, SelectTrigger,
SelectValue SelectValue
} from "@/components/ui/select"; } from "@/components/ui/select";
import system from '@/routes/system';
import type { SystemLog } from '@/types';
import { getColumns } from './partials/columns';
import { LogDetailModal } from './partials/log-detail-modal'; import { LogDetailModal } from './partials/log-detail-modal';
interface Props { interface Props {

View File

@ -1,11 +1,11 @@
import { ColumnDef } from '@tanstack/react-table'; import type { ColumnDef } from '@tanstack/react-table';
import { Eye } from 'lucide-react';
import { DataTableColumnHeader } from '@/components/data-table-column-header'; import { DataTableColumnHeader } from '@/components/data-table-column-header';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Eye } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { SystemLog } from '@/types';
import { getLevelColor } from '@/lib/log-helpers'; import { getLevelColor } from '@/lib/log-helpers';
import type { SystemLog } from '@/types';
interface ColumnProps { interface ColumnProps {
onView: (log: SystemLog) => void; onView: (log: SystemLog) => void;
@ -27,6 +27,7 @@ export const getColumns = ({ onView }: ColumnProps): ColumnDef<SystemLog>[] => [
), ),
cell: ({ row }) => { cell: ({ row }) => {
const level = row.original.level; const level = row.original.level;
return ( return (
<Badge variant={getLevelColor(level) as any}> <Badge variant={getLevelColor(level) as any}>
{level} {level}
@ -42,6 +43,7 @@ export const getColumns = ({ onView }: ColumnProps): ColumnDef<SystemLog>[] => [
), ),
cell: ({ row }) => { cell: ({ row }) => {
const message = row.original.message; const message = row.original.message;
return ( return (
<div className="max-w-[400px] lg:max-w-[600px] truncate font-sans text-xs" title={message}> <div className="max-w-[400px] lg:max-w-[600px] truncate font-sans text-xs" title={message}>
{message} {message}
@ -55,6 +57,7 @@ export const getColumns = ({ onView }: ColumnProps): ColumnDef<SystemLog>[] => [
header: "Aksi", header: "Aksi",
cell: ({ row }) => { cell: ({ row }) => {
const log = row.original; const log = row.original;
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Tooltip> <Tooltip>

Some files were not shown because too many files have changed in this diff Show More