refactor: reorganize imports across multiple components for improved clarity and consistency
This commit is contained in:
parent
24da9d5333
commit
221f796696
@ -1,5 +1,5 @@
|
||||
import { NavUser } from '@/components/nav-user';
|
||||
import { Breadcrumbs } from '@/components/breadcrumbs';
|
||||
import { NavUser } from '@/components/nav-user';
|
||||
import { SidebarTrigger } from '@/components/ui/sidebar';
|
||||
import type { BreadcrumbItem as BreadcrumbItemType } from '@/types';
|
||||
|
||||
|
||||
@ -13,14 +13,14 @@ import {
|
||||
import { dashboard } from '@/routes';
|
||||
import category from '@/routes/category';
|
||||
|
||||
import type { NavItem } from '@/types';
|
||||
import product from '@/routes/product';
|
||||
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 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[] = [
|
||||
{
|
||||
|
||||
@ -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 { Card, CardHeader, CardTitle, CardDescription, CardAction, CardFooter } from '@/components/ui/card';
|
||||
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 {
|
||||
title: string;
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { formatTime, formatDate } from '@/lib/formatters';
|
||||
import { Sun, Moon, Cloud, Trees, Stars, Bird } from 'lucide-react';
|
||||
import { formatTime, formatDate } from '@/lib/formatters';
|
||||
|
||||
const DynamicScene = ({ hour }: { hour: number }) => {
|
||||
if (hour >= 5 && hour < 11) {
|
||||
@ -11,6 +11,7 @@ const DynamicScene = ({ hour }: { hour: number }) => {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (hour >= 11 && hour < 15) {
|
||||
return (
|
||||
<div className="relative h-24 w-32 overflow-hidden">
|
||||
@ -20,6 +21,7 @@ const DynamicScene = ({ hour }: { hour: number }) => {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (hour >= 15 && hour < 19) {
|
||||
return (
|
||||
<div className="relative h-24 w-32 overflow-hidden">
|
||||
@ -29,6 +31,7 @@ const DynamicScene = ({ hour }: { hour: number }) => {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<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" />
|
||||
|
||||
@ -13,9 +13,10 @@ import {
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
ChartTooltipContent
|
||||
|
||||
} from "@/components/ui/chart"
|
||||
import type {ChartConfig} from "@/components/ui/chart";
|
||||
|
||||
export const CustomBarChart = React.memo(function CustomBarChart({
|
||||
title,
|
||||
@ -44,6 +45,7 @@ export const CustomBarChart = React.memo(function CustomBarChart({
|
||||
label: String(item.name),
|
||||
color: `hsl(${hue}, 60%, 65%)`,
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
fill: `var(--color-${key})`,
|
||||
@ -51,11 +53,15 @@ export const CustomBarChart = React.memo(function CustomBarChart({
|
||||
total: Number(item.total)
|
||||
}
|
||||
})
|
||||
|
||||
return { config: cfg, chartData: formattedData }
|
||||
}, [data, colorOffset])
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
|
||||
@ -13,9 +13,10 @@ import {
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
ChartTooltipContent
|
||||
|
||||
} from "@/components/ui/chart"
|
||||
import type {ChartConfig} from "@/components/ui/chart";
|
||||
|
||||
export const CustomPieChart = React.memo(function CustomPieChart({
|
||||
title,
|
||||
@ -44,12 +45,14 @@ export const CustomPieChart = React.memo(function CustomPieChart({
|
||||
label: String(item.name),
|
||||
color: `hsl(${hue}, 70%, 50%)`,
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
fill: `var(--color-${key})`,
|
||||
name: String(item.name),
|
||||
}
|
||||
})
|
||||
|
||||
return { config: cfg, chartData: formattedData }
|
||||
}, [data, colorOffset])
|
||||
|
||||
|
||||
@ -14,9 +14,10 @@ import {
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
ChartTooltipContent
|
||||
|
||||
} from "@/components/ui/chart"
|
||||
import type {ChartConfig} from "@/components/ui/chart";
|
||||
|
||||
export const description = "Menampilkan aktivitas toko berdasarkan pesanan yang masuk."
|
||||
|
||||
|
||||
@ -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 { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface DataTableColumnHeaderProps<TData, TValue>
|
||||
extends React.HTMLAttributes<HTMLDivElement> {
|
||||
|
||||
@ -1,26 +1,20 @@
|
||||
import {
|
||||
import type {
|
||||
ColumnDef,
|
||||
SortingState,
|
||||
ColumnFiltersState,
|
||||
VisibilityState} from "@tanstack/react-table";
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
getPaginationRowModel,
|
||||
SortingState,
|
||||
getSortedRowModel,
|
||||
ColumnFiltersState,
|
||||
getFilteredRowModel,
|
||||
VisibilityState,
|
||||
getFilteredRowModel
|
||||
} from "@tanstack/react-table";
|
||||
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Settings2, ChevronDown, ListFilter, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, X } from "lucide-react";
|
||||
import React from "react";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Button } from "./ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
@ -33,16 +27,11 @@ import {
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} 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 {
|
||||
Empty,
|
||||
EmptyContent,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from "@/components/ui/empty"
|
||||
import {
|
||||
@ -52,7 +41,17 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
|
||||
interface DataTableFilterOption {
|
||||
columnId: string;
|
||||
@ -245,7 +244,10 @@ export function DataTable<TData, TValue>({
|
||||
<DropdownMenuSeparator />
|
||||
{filters.map((filter) => {
|
||||
const column = table.getColumn(filter.columnId);
|
||||
if (!column) return null;
|
||||
|
||||
if (!column) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuSub key={filter.columnId}>
|
||||
@ -265,9 +267,13 @@ export function DataTable<TData, TValue>({
|
||||
key={option.value}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
onClick={() => {
|
||||
if (option.value === "true") column.setFilterValue(true);
|
||||
else if (option.value === "false") column.setFilterValue(false);
|
||||
else column.setFilterValue(option.value);
|
||||
if (option.value === "true") {
|
||||
column.setFilterValue(true);
|
||||
} else if (option.value === "false") {
|
||||
column.setFilterValue(false);
|
||||
} else {
|
||||
column.setFilterValue(option.value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useMemo } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface InputErrorProps extends HTMLAttributes<HTMLParagraphElement> {
|
||||
message?: string;
|
||||
@ -14,8 +14,13 @@ export default function InputError({
|
||||
...props
|
||||
}: InputErrorProps) {
|
||||
const formattedError = useMemo(() => {
|
||||
if (!message) return null;
|
||||
if (!label) return message;
|
||||
if (!message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!label) {
|
||||
return message;
|
||||
}
|
||||
|
||||
// Preserve all-caps labels (like NIK), otherwise capitalize first letter and lowercase the rest
|
||||
const isAllOptionsCaps = label === label.toUpperCase() && label.length > 1;
|
||||
@ -33,6 +38,7 @@ export default function InputError({
|
||||
|
||||
// Find the first occurrence of a verb
|
||||
let verbIndex = -1;
|
||||
|
||||
for (let i = 0; i < words.length; i++) {
|
||||
if (verbs.includes(words[i].toLowerCase())) {
|
||||
verbIndex = i;
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { Trash2, X } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@ -10,7 +11,6 @@ import {
|
||||
AlertDialogMedia,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import React from 'react';
|
||||
|
||||
interface DeleteConfirmationProps {
|
||||
isOpen: boolean;
|
||||
|
||||
@ -2,9 +2,9 @@ interface ImagePreviewDialogProps {
|
||||
imageUrl: string | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
import { Dialog, DialogContent, DialogTitle, DialogDescription } from "@/components/ui/dialog"
|
||||
import { VisuallyHidden } from "@radix-ui/react-visually-hidden"
|
||||
import { X } from 'lucide-react';
|
||||
import { Dialog, DialogContent, DialogTitle, DialogDescription } from "@/components/ui/dialog"
|
||||
|
||||
export function ImagePreviewDialog({ imageUrl, onClose }: ImagePreviewDialogProps) {
|
||||
return (
|
||||
|
||||
@ -34,10 +34,13 @@ function getToggleColor(
|
||||
targets: NodeWithPos[],
|
||||
inputColor: string
|
||||
): string | null {
|
||||
if (targets.length === 0) return null
|
||||
if (targets.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
for (const target of targets) {
|
||||
const currentColor = target.node.attrs?.backgroundColor ?? null
|
||||
|
||||
if (currentColor !== inputColor) {
|
||||
return inputColor
|
||||
}
|
||||
@ -75,15 +78,22 @@ export const NodeBackground = Extension.create<NodeBackgroundOptions>({
|
||||
|
||||
parseHTML: (element: HTMLElement) => {
|
||||
const styleColor = element.style?.backgroundColor
|
||||
if (styleColor) return styleColor
|
||||
|
||||
if (styleColor) {
|
||||
return styleColor
|
||||
}
|
||||
|
||||
const dataColor = element.getAttribute("data-background-color")
|
||||
|
||||
return dataColor || null
|
||||
},
|
||||
|
||||
renderHTML: (attributes) => {
|
||||
const color = attributes.backgroundColor as string | null
|
||||
if (!color) return {}
|
||||
|
||||
if (!color) {
|
||||
return {}
|
||||
}
|
||||
|
||||
if (this.options.useStyle) {
|
||||
return {
|
||||
@ -118,7 +128,9 @@ export const NodeBackground = Extension.create<NodeBackgroundOptions>({
|
||||
this.options.types
|
||||
)
|
||||
|
||||
if (targets.length === 0) return false
|
||||
if (targets.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
const targetColor = getTargetColor(targets, inputColor)
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { mergeAttributes } from "@tiptap/react"
|
||||
import TiptapHorizontalRule from "@tiptap/extension-horizontal-rule"
|
||||
import { mergeAttributes } from "@tiptap/react"
|
||||
|
||||
export const HorizontalRule = TiptapHorizontalRule.extend({
|
||||
renderHTML() {
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import type { NodeType } from "@tiptap/pm/model"
|
||||
import { mergeAttributes, Node } from "@tiptap/react"
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react"
|
||||
import { ImageUploadNode as ImageUploadNodeComponent } from "@/components/tiptap-node/image-upload-node/image-upload-node"
|
||||
import type { NodeType } from "@tiptap/pm/model"
|
||||
|
||||
export type UploadFunction = (
|
||||
file: File,
|
||||
@ -47,7 +47,7 @@ export interface ImageUploadNodeOptions {
|
||||
* @default {}
|
||||
* @example { class: 'foo' }
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
||||
HTMLAttributes: Record<string, any>
|
||||
}
|
||||
|
||||
@ -144,15 +144,19 @@ export const ImageUploadNode = Node.create<ImageUploadNodeOptions>({
|
||||
editor.isActive("imageUpload")
|
||||
) {
|
||||
const nodeEl = editor.view.nodeDOM(selection.$from.pos)
|
||||
|
||||
if (nodeEl && nodeEl instanceof HTMLElement) {
|
||||
// Since NodeViewWrapper is wrapped with a div, we need to click the first child
|
||||
const firstChild = nodeEl.firstChild
|
||||
|
||||
if (firstChild && firstChild instanceof HTMLElement) {
|
||||
firstChild.click()
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
}
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
"use client"
|
||||
|
||||
import { useRef, useState } from "react"
|
||||
import type { NodeViewProps } 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 { Button } from "@/components/tiptap-ui-primitive/button"
|
||||
import "@/components/tiptap-node/image-upload-node/image-upload-node.scss"
|
||||
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)`
|
||||
)
|
||||
options.onError?.(error)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@ -124,7 +125,9 @@ function useFileUpload(options: UploadOptions) {
|
||||
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) {
|
||||
setFileItems((prev) =>
|
||||
@ -135,6 +138,7 @@ function useFileUpload(options: UploadOptions) {
|
||||
)
|
||||
)
|
||||
options.onSuccess?.(url)
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
@ -152,6 +156,7 @@ function useFileUpload(options: UploadOptions) {
|
||||
error instanceof Error ? error : new Error("Upload failed")
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
@ -159,6 +164,7 @@ function useFileUpload(options: UploadOptions) {
|
||||
const uploadFiles = async (files: File[]): Promise<string[]> => {
|
||||
if (!files || files.length === 0) {
|
||||
options.onError?.(new Error("No files to upload"))
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
@ -168,6 +174,7 @@ function useFileUpload(options: UploadOptions) {
|
||||
`Maximum ${options.limit} file${options.limit === 1 ? "" : "s"} allowed`
|
||||
)
|
||||
)
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
@ -182,12 +189,15 @@ function useFileUpload(options: UploadOptions) {
|
||||
const removeFileItem = (fileId: string) => {
|
||||
setFileItems((prev) => {
|
||||
const fileToRemove = prev.find((item) => item.id === fileId)
|
||||
|
||||
if (fileToRemove?.abortController) {
|
||||
fileToRemove.abortController.abort()
|
||||
}
|
||||
|
||||
if (fileToRemove?.url) {
|
||||
URL.revokeObjectURL(fileToRemove.url)
|
||||
}
|
||||
|
||||
return prev.filter((item) => item.id !== fileId)
|
||||
})
|
||||
}
|
||||
@ -197,6 +207,7 @@ function useFileUpload(options: UploadOptions) {
|
||||
if (item.abortController) {
|
||||
item.abortController.abort()
|
||||
}
|
||||
|
||||
if (item.url) {
|
||||
URL.revokeObjectURL(item.url)
|
||||
}
|
||||
@ -300,6 +311,7 @@ const ImageUploadDragArea: React.FC<ImageUploadDragAreaProps> = ({
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
|
||||
setIsDragActive(false)
|
||||
setIsDragOver(false)
|
||||
@ -319,6 +331,7 @@ const ImageUploadDragArea: React.FC<ImageUploadDragAreaProps> = ({
|
||||
setIsDragOver(false)
|
||||
|
||||
const files = Array.from(e.dataTransfer.files)
|
||||
|
||||
if (files.length > 0) {
|
||||
onFile(files)
|
||||
}
|
||||
@ -356,10 +369,14 @@ const ImageUploadPreview: React.FC<ImageUploadPreviewProps> = ({
|
||||
onRemove,
|
||||
}) => {
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes === 0) return "0 Bytes"
|
||||
if (bytes === 0) {
|
||||
return "0 Bytes"
|
||||
}
|
||||
|
||||
const k = 1024
|
||||
const sizes = ["Bytes", "KB", "MB", "GB"]
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
|
||||
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 filename =
|
||||
files[index]?.name.replace(/\.[^/.]+$/, "") || "unknown"
|
||||
|
||||
return {
|
||||
type: extension.options.type,
|
||||
attrs: {
|
||||
@ -485,10 +503,13 @@ export const ImageUploadNode: React.FC<NodeViewProps> = (props) => {
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files
|
||||
|
||||
if (!files || files.length === 0) {
|
||||
extension.options.onError?.(new Error("No file selected"))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
handleUpload(Array.from(files))
|
||||
}
|
||||
|
||||
|
||||
@ -1,18 +1,18 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { EditorContent, EditorContext, useEditor } from "@tiptap/react"
|
||||
|
||||
// --- Tiptap Core Extensions ---
|
||||
import { StarterKit } from "@tiptap/starter-kit"
|
||||
import { Highlight } from "@tiptap/extension-highlight"
|
||||
import { Image } from "@tiptap/extension-image"
|
||||
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 { 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 { EditorContent, EditorContext, useEditor } from "@tiptap/react"
|
||||
import { StarterKit } from "@tiptap/starter-kit"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
|
||||
// --- UI Primitives ---
|
||||
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"
|
||||
|
||||
// --- Tiptap UI ---
|
||||
import { HeadingDropdownMenu } from "@/components/tiptap-ui/heading-dropdown-menu"
|
||||
import { ImageUploadButton } from "@/components/tiptap-ui/image-upload-button"
|
||||
import { ListDropdownMenu } from "@/components/tiptap-ui/list-dropdown-menu"
|
||||
|
||||
// --- 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"
|
||||
import { BlockquoteButton } from "@/components/tiptap-ui/blockquote-button"
|
||||
import { CodeBlockButton } from "@/components/tiptap-ui/code-block-button"
|
||||
import {
|
||||
@ -45,20 +47,18 @@ import {
|
||||
ColorHighlightPopoverContent,
|
||||
ColorHighlightPopoverButton,
|
||||
} 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 {
|
||||
LinkPopover,
|
||||
LinkContent,
|
||||
LinkButton,
|
||||
} from "@/components/tiptap-ui/link-popover"
|
||||
import { ListDropdownMenu } from "@/components/tiptap-ui/list-dropdown-menu"
|
||||
import { MarkButton } from "@/components/tiptap-ui/mark-button"
|
||||
import { TextAlignButton } from "@/components/tiptap-ui/text-align-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 ---
|
||||
import { useIsBreakpoint } from "@/hooks/use-is-breakpoint"
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
// --- 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() {
|
||||
const [isDarkMode, setIsDarkMode] = useState<boolean>(false)
|
||||
@ -12,6 +12,7 @@ export function ThemeToggle() {
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
const handleChange = () => setIsDarkMode(mediaQuery.matches)
|
||||
mediaQuery.addEventListener("change", handleChange)
|
||||
|
||||
return () => mediaQuery.removeEventListener("change", handleChange)
|
||||
}, [])
|
||||
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/tiptap-utils"
|
||||
import { cva } from "class-variance-authority"
|
||||
import type {VariantProps} from "class-variance-authority";
|
||||
import { Separator } from "@/components/tiptap-ui-primitive/separator"
|
||||
import { cn } from "@/lib/tiptap-utils"
|
||||
import "./button-group.scss"
|
||||
|
||||
const buttonGroupVariants = cva("tiptap-button-group", {
|
||||
|
||||
@ -27,7 +27,9 @@ export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElemen
|
||||
export const ShortcutDisplay: React.FC<{ shortcuts: string[] }> = ({
|
||||
shortcuts,
|
||||
}) => {
|
||||
if (shortcuts.length === 0) return null
|
||||
if (shortcuts.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { cn } from "@/lib/tiptap-utils"
|
||||
import { CheckIcon } from "@/components/tiptap-icons/check-icon"
|
||||
import { cn } from "@/lib/tiptap-utils"
|
||||
|
||||
import "@/components/tiptap-ui-primitive/dropdown-menu/dropdown-menu.scss"
|
||||
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { forwardRef, useCallback, useEffect, useRef, useState } from "react"
|
||||
import { Separator } from "@/components/tiptap-ui-primitive/separator"
|
||||
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 { useMenuNavigation } from "@/hooks/use-menu-navigation"
|
||||
import { cn } from "@/lib/tiptap-utils"
|
||||
|
||||
type BaseProps = React.HTMLAttributes<HTMLDivElement>
|
||||
|
||||
@ -17,7 +17,10 @@ const useToolbarNavigation = (
|
||||
const [items, setItems] = useState<HTMLElement[]>([])
|
||||
|
||||
const collectItems = useCallback(() => {
|
||||
if (!toolbarRef.current) return []
|
||||
if (!toolbarRef.current) {
|
||||
return []
|
||||
}
|
||||
|
||||
return Array.from(
|
||||
toolbarRef.current.querySelectorAll<HTMLElement>(
|
||||
'button:not([disabled]), [role="button"]:not([disabled]), [tabindex="0"]:not([disabled])'
|
||||
@ -27,7 +30,10 @@ const useToolbarNavigation = (
|
||||
|
||||
useEffect(() => {
|
||||
const toolbar = toolbarRef.current
|
||||
if (!toolbar) return
|
||||
|
||||
if (!toolbar) {
|
||||
return
|
||||
}
|
||||
|
||||
const updateItems = () => setItems(collectItems())
|
||||
|
||||
@ -48,17 +54,25 @@ const useToolbarNavigation = (
|
||||
|
||||
useEffect(() => {
|
||||
const toolbar = toolbarRef.current
|
||||
if (!toolbar) return
|
||||
|
||||
if (!toolbar) {
|
||||
return
|
||||
}
|
||||
|
||||
const handleFocus = (e: FocusEvent) => {
|
||||
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 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)
|
||||
|
||||
@ -1,15 +1,5 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
cloneElement,
|
||||
createContext,
|
||||
forwardRef,
|
||||
isValidElement,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
version,
|
||||
} from "react"
|
||||
import {
|
||||
useFloating,
|
||||
autoUpdate,
|
||||
@ -23,11 +13,22 @@ import {
|
||||
useInteractions,
|
||||
useMergeRefs,
|
||||
FloatingPortal,
|
||||
type Placement,
|
||||
type UseFloatingReturn,
|
||||
type ReferenceType,
|
||||
FloatingDelayGroup,
|
||||
|
||||
|
||||
|
||||
FloatingDelayGroup
|
||||
} 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"
|
||||
|
||||
interface TooltipProviderProps {
|
||||
@ -165,9 +166,9 @@ export const TooltipTrigger = forwardRef<HTMLElement, TooltipTriggerProps>(
|
||||
const context = useTooltipContext()
|
||||
const childrenRef = isValidElement(children)
|
||||
? parseInt(version, 10) >= 19
|
||||
? // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
?
|
||||
(children as { props: { ref?: React.Ref<any> } }).props.ref
|
||||
: // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
:
|
||||
(children as any).ref
|
||||
: undefined
|
||||
const ref = useMergeRefs([context.refs.setReference, propRef, childrenRef])
|
||||
@ -208,7 +209,9 @@ export const TooltipContent = forwardRef<HTMLDivElement, TooltipContentProps>(
|
||||
const context = useTooltipContext()
|
||||
const ref = useMergeRefs([context.refs.setFloating, propRef])
|
||||
|
||||
if (!context.open) return null
|
||||
if (!context.open) {
|
||||
return null
|
||||
}
|
||||
|
||||
const content = (
|
||||
<div
|
||||
|
||||
@ -8,15 +8,15 @@ import {
|
||||
} from "@/components/tiptap-ui/blockquote-button"
|
||||
|
||||
// --- 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"
|
||||
|
||||
// --- Lib ---
|
||||
import { parseShortcutKeys } from "@/lib/tiptap-utils"
|
||||
|
||||
// --- 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
|
||||
extends Omit<ButtonProps, "type">, UseBlockquoteConfig {
|
||||
@ -79,7 +79,11 @@ export const BlockquoteButton = forwardRef<
|
||||
const handleClick = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
onClick?.(event)
|
||||
if (event.defaultPrevented) return
|
||||
|
||||
if (event.defaultPrevented) {
|
||||
return
|
||||
}
|
||||
|
||||
handleToggle()
|
||||
},
|
||||
[handleToggle, onClick]
|
||||
|
||||
@ -1,14 +1,14 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import type { Editor } from "@tiptap/react"
|
||||
import { NodeSelection, TextSelection } from "@tiptap/pm/state"
|
||||
import type { Editor } from "@tiptap/react"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
|
||||
// --- Hooks ---
|
||||
import { BlockquoteIcon } from "@/components/tiptap-icons/blockquote-icon"
|
||||
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
||||
|
||||
// --- Icons ---
|
||||
import { BlockquoteIcon } from "@/components/tiptap-icons/blockquote-icon"
|
||||
|
||||
// --- UI Utils ---
|
||||
import {
|
||||
@ -48,12 +48,16 @@ export function canToggleBlockquote(
|
||||
editor: Editor | null,
|
||||
turnInto: boolean = true
|
||||
): boolean {
|
||||
if (!editor || !editor.isEditable) return false
|
||||
if (!editor || !editor.isEditable) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (
|
||||
!isNodeInSchema("blockquote", editor) ||
|
||||
isNodeTypeSelected(editor, ["image"])
|
||||
)
|
||||
return false
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!turnInto) {
|
||||
return editor.can().toggleWrap("blockquote")
|
||||
@ -70,8 +74,9 @@ export function canToggleBlockquote(
|
||||
"blockquote",
|
||||
"codeBlock",
|
||||
])
|
||||
)
|
||||
return false
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Either we can wrap in blockquote directly on the selection,
|
||||
// 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
|
||||
*/
|
||||
export function toggleBlockquote(editor: Editor | null): boolean {
|
||||
if (!editor || !editor.isEditable) return false
|
||||
if (!canToggleBlockquote(editor)) return false
|
||||
if (!editor || !editor.isEditable) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!canToggleBlockquote(editor)) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const view = editor.view
|
||||
@ -115,7 +125,10 @@ export function toggleBlockquote(editor: Editor | null): boolean {
|
||||
editor,
|
||||
node: state.selection.$anchor.node(1),
|
||||
})?.pos
|
||||
if (!isValidPosition(pos)) return false
|
||||
|
||||
if (!isValidPosition(pos)) {
|
||||
return false
|
||||
}
|
||||
|
||||
tr = tr.setSelection(NodeSelection.create(state.doc, pos))
|
||||
view.dispatch(tr)
|
||||
@ -170,15 +183,21 @@ export function shouldShowButton(props: {
|
||||
}): boolean {
|
||||
const { editor, hideWhenUnavailable } = props
|
||||
|
||||
if (!editor) return false
|
||||
if (!editor) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!hideWhenUnavailable) {
|
||||
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")) {
|
||||
return canToggleBlockquote(editor)
|
||||
@ -236,7 +255,9 @@ export function useBlockquote(config?: UseBlockquoteConfig) {
|
||||
const isActive = editor?.isActive("blockquote") || false
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
if (!editor) {
|
||||
return
|
||||
}
|
||||
|
||||
const handleSelectionUpdate = () => {
|
||||
setIsVisible(shouldShowButton({ editor, hideWhenUnavailable }))
|
||||
@ -252,12 +273,16 @@ export function useBlockquote(config?: UseBlockquoteConfig) {
|
||||
}, [editor, hideWhenUnavailable])
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
if (!editor) return false
|
||||
if (!editor) {
|
||||
return false
|
||||
}
|
||||
|
||||
const success = toggleBlockquote(editor)
|
||||
|
||||
if (success) {
|
||||
onToggled?.()
|
||||
}
|
||||
|
||||
return success
|
||||
}, [editor, onToggled])
|
||||
|
||||
|
||||
@ -1,10 +1,8 @@
|
||||
import { forwardRef, useCallback } from "react"
|
||||
|
||||
// --- Hooks ---
|
||||
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
||||
|
||||
// --- Lib ---
|
||||
import { parseShortcutKeys } from "@/lib/tiptap-utils"
|
||||
|
||||
// --- Tiptap UI ---
|
||||
import type { UseCodeBlockConfig } from "@/components/tiptap-ui/code-block-button"
|
||||
@ -14,9 +12,11 @@ import {
|
||||
} from "@/components/tiptap-ui/code-block-button"
|
||||
|
||||
// --- UI Primitives ---
|
||||
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 { Badge } from "@/components/tiptap-ui-primitive/badge"
|
||||
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
||||
import { parseShortcutKeys } from "@/lib/tiptap-utils"
|
||||
|
||||
export interface CodeBlockButtonProps
|
||||
extends Omit<ButtonProps, "type">, UseCodeBlockConfig {
|
||||
@ -79,7 +79,11 @@ export const CodeBlockButton = forwardRef<
|
||||
const handleClick = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
onClick?.(event)
|
||||
if (event.defaultPrevented) return
|
||||
|
||||
if (event.defaultPrevented) {
|
||||
return
|
||||
}
|
||||
|
||||
handleToggle()
|
||||
},
|
||||
[handleToggle, onClick]
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { type Editor } from "@tiptap/react"
|
||||
import { NodeSelection, TextSelection } from "@tiptap/pm/state"
|
||||
import type {Editor} from "@tiptap/react";
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
|
||||
// --- Hooks ---
|
||||
import { CodeBlockIcon } from "@/components/tiptap-icons/code-block-icon"
|
||||
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
||||
|
||||
// --- Lib ---
|
||||
@ -18,7 +19,6 @@ import {
|
||||
} from "@/lib/tiptap-utils"
|
||||
|
||||
// --- Icons ---
|
||||
import { CodeBlockIcon } from "@/components/tiptap-icons/code-block-icon"
|
||||
|
||||
export const CODE_BLOCK_SHORTCUT_KEY = "mod+alt+c"
|
||||
|
||||
@ -48,12 +48,16 @@ export function canToggle(
|
||||
editor: Editor | null,
|
||||
turnInto: boolean = true
|
||||
): boolean {
|
||||
if (!editor || !editor.isEditable) return false
|
||||
if (!editor || !editor.isEditable) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (
|
||||
!isNodeInSchema("codeBlock", editor) ||
|
||||
isNodeTypeSelected(editor, ["image"])
|
||||
)
|
||||
return false
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!turnInto) {
|
||||
return editor.can().toggleNode("codeBlock", "paragraph")
|
||||
@ -70,8 +74,9 @@ export function canToggle(
|
||||
"blockquote",
|
||||
"codeBlock",
|
||||
])
|
||||
)
|
||||
return false
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Either we can toggle code block directly on the selection,
|
||||
// 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
|
||||
*/
|
||||
export function toggleCodeBlock(editor: Editor | null): boolean {
|
||||
if (!editor || !editor.isEditable) return false
|
||||
if (!canToggle(editor)) return false
|
||||
if (!editor || !editor.isEditable) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!canToggle(editor)) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const view = editor.view
|
||||
@ -118,7 +128,10 @@ export function toggleCodeBlock(editor: Editor | null): boolean {
|
||||
editor,
|
||||
node: state.selection.$anchor.node(1),
|
||||
})?.pos
|
||||
if (!isValidPosition(pos)) return false
|
||||
|
||||
if (!isValidPosition(pos)) {
|
||||
return false
|
||||
}
|
||||
|
||||
tr = tr.setSelection(NodeSelection.create(state.doc, pos))
|
||||
view.dispatch(tr)
|
||||
@ -173,15 +186,21 @@ export function shouldShowButton(props: {
|
||||
}): boolean {
|
||||
const { editor, hideWhenUnavailable } = props
|
||||
|
||||
if (!editor) return false
|
||||
if (!editor) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!hideWhenUnavailable) {
|
||||
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")) {
|
||||
return canToggle(editor)
|
||||
@ -246,7 +265,9 @@ export function useCodeBlock(config?: UseCodeBlockConfig) {
|
||||
const isActive = editor?.isActive("codeBlock") || false
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
if (!editor) {
|
||||
return
|
||||
}
|
||||
|
||||
const handleSelectionUpdate = () => {
|
||||
setIsVisible(shouldShowButton({ editor, hideWhenUnavailable }))
|
||||
@ -262,12 +283,16 @@ export function useCodeBlock(config?: UseCodeBlockConfig) {
|
||||
}, [editor, hideWhenUnavailable])
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
if (!editor) return false
|
||||
if (!editor) {
|
||||
return false
|
||||
}
|
||||
|
||||
const success = toggleCodeBlock(editor)
|
||||
|
||||
if (success) {
|
||||
onToggled?.()
|
||||
}
|
||||
|
||||
return success
|
||||
}, [editor, onToggled])
|
||||
|
||||
|
||||
@ -1,10 +1,8 @@
|
||||
import { forwardRef, useCallback, useMemo } from "react"
|
||||
|
||||
// --- Lib ---
|
||||
import { parseShortcutKeys } from "@/lib/tiptap-utils"
|
||||
|
||||
// --- Hooks ---
|
||||
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
||||
|
||||
// --- Tiptap UI ---
|
||||
import type { UseColorHighlightConfig } from "@/components/tiptap-ui/color-highlight-button"
|
||||
@ -14,9 +12,11 @@ import {
|
||||
} from "@/components/tiptap-ui/color-highlight-button"
|
||||
|
||||
// --- UI Primitives ---
|
||||
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 { Badge } from "@/components/tiptap-ui-primitive/badge"
|
||||
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
||||
import { parseShortcutKeys } from "@/lib/tiptap-utils"
|
||||
|
||||
// --- Styles ---
|
||||
import "@/components/tiptap-ui/color-highlight-button/color-highlight-button.scss"
|
||||
@ -112,7 +112,11 @@ export const ColorHighlightButton = forwardRef<
|
||||
const handleClick = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
onClick?.(event)
|
||||
if (event.defaultPrevented) return
|
||||
|
||||
if (event.defaultPrevented) {
|
||||
return
|
||||
}
|
||||
|
||||
handleColorHighlight()
|
||||
},
|
||||
[handleColorHighlight, onClick]
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
"use client"
|
||||
|
||||
import type {Editor} from "@tiptap/react";
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { type Editor } from "@tiptap/react"
|
||||
import { useHotkeys } from "react-hotkeys-hook"
|
||||
|
||||
// --- Hooks ---
|
||||
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
||||
import { HighlighterIcon } from "@/components/tiptap-icons/highlighter-icon"
|
||||
import { useIsBreakpoint } from "@/hooks/use-is-breakpoint"
|
||||
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
||||
|
||||
// --- Lib ---
|
||||
import {
|
||||
@ -16,7 +17,6 @@ import {
|
||||
} from "@/lib/tiptap-utils"
|
||||
|
||||
// --- Icons ---
|
||||
import { HighlighterIcon } from "@/components/tiptap-icons/highlighter-icon"
|
||||
|
||||
export const COLOR_HIGHLIGHT_SHORTCUT_KEY = "mod+shift+h"
|
||||
export const HIGHLIGHT_COLORS = [
|
||||
@ -136,6 +136,7 @@ export function pickHighlightColorsByValue(values: string[]) {
|
||||
const colorMap = new Map(
|
||||
HIGHLIGHT_COLORS.map((color) => [color.value, color])
|
||||
)
|
||||
|
||||
return values
|
||||
.map((value) => colorMap.get(value))
|
||||
.filter((color): color is (typeof HIGHLIGHT_COLORS)[number] => !!color)
|
||||
@ -148,11 +149,14 @@ export function getHighlightColorValue(
|
||||
color: string,
|
||||
useColorValue: boolean = false
|
||||
): string {
|
||||
if (!useColorValue) return color
|
||||
if (!useColorValue) {
|
||||
return color
|
||||
}
|
||||
|
||||
const colorItem = HIGHLIGHT_COLORS.find(
|
||||
(c) => c.value === color || c.colorValue === color
|
||||
)
|
||||
|
||||
return colorItem?.colorValue || color
|
||||
}
|
||||
|
||||
@ -163,18 +167,23 @@ export function canColorHighlight(
|
||||
editor: Editor | null,
|
||||
mode: HighlightMode = "mark"
|
||||
): boolean {
|
||||
if (!editor || !editor.isEditable) return false
|
||||
if (!editor || !editor.isEditable) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (mode === "mark") {
|
||||
if (
|
||||
!isMarkInSchema("highlight", editor) ||
|
||||
isNodeTypeSelected(editor, ["image"])
|
||||
)
|
||||
return false
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
return editor.can().setMark("highlight")
|
||||
} else {
|
||||
if (!isExtensionAvailable(editor, ["nodeBackground"])) return false
|
||||
if (!isExtensionAvailable(editor, ["nodeBackground"])) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
return editor.can().toggleNodeBackgroundColor("test")
|
||||
@ -192,26 +201,33 @@ export function isColorHighlightActive(
|
||||
highlightColor?: string,
|
||||
mode: HighlightMode = "mark"
|
||||
): boolean {
|
||||
if (!editor || !editor.isEditable) return false
|
||||
if (!editor || !editor.isEditable) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (mode === "mark") {
|
||||
return highlightColor
|
||||
? editor.isActive("highlight", { color: highlightColor })
|
||||
: editor.isActive("highlight")
|
||||
} else {
|
||||
if (!highlightColor) return false
|
||||
if (!highlightColor) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const { state } = editor
|
||||
const { selection } = state
|
||||
|
||||
const $pos = selection.$anchor
|
||||
|
||||
for (let depth = $pos.depth; depth >= 0; depth--) {
|
||||
const node = $pos.node(depth)
|
||||
|
||||
if (node && node.attrs?.backgroundColor === highlightColor) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
} catch {
|
||||
return false
|
||||
@ -226,8 +242,13 @@ export function removeHighlight(
|
||||
editor: Editor | null,
|
||||
mode: HighlightMode = "mark"
|
||||
): boolean {
|
||||
if (!editor || !editor.isEditable) return false
|
||||
if (!canColorHighlight(editor, mode)) return false
|
||||
if (!editor || !editor.isEditable) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!canColorHighlight(editor, mode)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (mode === "mark") {
|
||||
return editor.chain().focus().unsetMark("highlight").run()
|
||||
@ -246,19 +267,27 @@ export function shouldShowButton(props: {
|
||||
}): boolean {
|
||||
const { editor, hideWhenUnavailable, mode } = props
|
||||
|
||||
if (!editor) return false
|
||||
if (!editor) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!hideWhenUnavailable) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!editor.isEditable) return false
|
||||
if (!editor.isEditable) {
|
||||
return false
|
||||
}
|
||||
|
||||
// hideWhenUnavailable=true: check schema/extension availability
|
||||
if (mode === "mark") {
|
||||
if (!isMarkInSchema("highlight", editor)) return false
|
||||
if (!isMarkInSchema("highlight", editor)) {
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
if (!isExtensionAvailable(editor, ["nodeBackground"])) return false
|
||||
if (!isExtensionAvailable(editor, ["nodeBackground"])) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (!editor.isActive("code")) {
|
||||
@ -289,7 +318,9 @@ export function useColorHighlight(config: UseColorHighlightConfig) {
|
||||
const isActive = isColorHighlightActive(editor, actualColor, mode)
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
if (!editor) {
|
||||
return
|
||||
}
|
||||
|
||||
const handleSelectionUpdate = () => {
|
||||
setIsVisible(shouldShowButton({ editor, hideWhenUnavailable, mode }))
|
||||
@ -305,12 +336,14 @@ export function useColorHighlight(config: UseColorHighlightConfig) {
|
||||
}, [editor, hideWhenUnavailable, mode])
|
||||
|
||||
const handleColorHighlight = useCallback(() => {
|
||||
if (!editor || !canColorHighlightState || !actualColor || !label)
|
||||
return false
|
||||
if (!editor || !canColorHighlightState || !actualColor || !label) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (mode === "mark") {
|
||||
if (editor.state.storedMarks) {
|
||||
const highlightMarkType = editor.schema.marks.highlight
|
||||
|
||||
if (highlightMarkType) {
|
||||
editor.view.dispatch(
|
||||
editor.state.tr.removeStoredMark(highlightMarkType)
|
||||
@ -324,9 +357,11 @@ export function useColorHighlight(config: UseColorHighlightConfig) {
|
||||
.focus()
|
||||
.toggleHighlight({ color: actualColor })
|
||||
.run()
|
||||
|
||||
if (success) {
|
||||
onApplied?.({ color: actualColor, label, mode })
|
||||
}
|
||||
|
||||
return success
|
||||
}, 0)
|
||||
|
||||
@ -341,15 +376,18 @@ export function useColorHighlight(config: UseColorHighlightConfig) {
|
||||
if (success) {
|
||||
onApplied?.({ color: actualColor, label, mode })
|
||||
}
|
||||
|
||||
return success
|
||||
}
|
||||
}, [canColorHighlightState, actualColor, editor, label, onApplied, mode])
|
||||
|
||||
const handleRemoveHighlight = useCallback(() => {
|
||||
const success = removeHighlight(editor, mode)
|
||||
|
||||
if (success) {
|
||||
onApplied?.({ color: "", label: "Remove highlight", mode })
|
||||
}
|
||||
|
||||
return success
|
||||
}, [editor, onApplied, mode])
|
||||
|
||||
|
||||
@ -1,31 +1,13 @@
|
||||
import type {Editor} from "@tiptap/react";
|
||||
import { forwardRef, useMemo, useRef, useState } from "react"
|
||||
import { type Editor } from "@tiptap/react"
|
||||
|
||||
// --- Hooks ---
|
||||
import { useMenuNavigation } from "@/hooks/use-menu-navigation"
|
||||
import { useIsBreakpoint } from "@/hooks/use-is-breakpoint"
|
||||
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
||||
|
||||
// --- Icons ---
|
||||
import { BanIcon } from "@/components/tiptap-icons/ban-icon"
|
||||
import { HighlighterIcon } from "@/components/tiptap-icons/highlighter-icon"
|
||||
|
||||
// --- 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 {
|
||||
HighlightColor,
|
||||
UseColorHighlightConfig,
|
||||
@ -35,7 +17,25 @@ import {
|
||||
pickHighlightColorsByValue,
|
||||
useColorHighlight,
|
||||
} 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 {
|
||||
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 {
|
||||
/**
|
||||
@ -120,12 +120,22 @@ export function ColorHighlightPopoverContent({
|
||||
items: menuItems,
|
||||
orientation: "both",
|
||||
onSelect: (item) => {
|
||||
if (!containerRef.current) return false
|
||||
if (!containerRef.current) {
|
||||
return false
|
||||
}
|
||||
|
||||
const highlightedElement = containerRef.current.querySelector(
|
||||
'[data-highlighted="true"]'
|
||||
) as HTMLElement
|
||||
if (highlightedElement) highlightedElement.click()
|
||||
if (item.value === "none") handleRemoveHighlight()
|
||||
|
||||
if (highlightedElement) {
|
||||
highlightedElement.click()
|
||||
}
|
||||
|
||||
if (item.value === "none") {
|
||||
handleRemoveHighlight()
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
autoSelectFirstItem: false,
|
||||
@ -200,7 +210,9 @@ export function ColorHighlightPopover({
|
||||
onApplied,
|
||||
})
|
||||
|
||||
if (!isVisible) return null
|
||||
if (!isVisible) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import { forwardRef, useCallback } from "react"
|
||||
|
||||
// --- Lib ---
|
||||
import { parseShortcutKeys } from "@/lib/tiptap-utils"
|
||||
|
||||
// --- Tiptap UI ---
|
||||
import type {
|
||||
@ -14,10 +13,11 @@ import {
|
||||
} from "@/components/tiptap-ui/heading-button"
|
||||
|
||||
// --- UI Primitives ---
|
||||
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 { Badge } from "@/components/tiptap-ui-primitive/badge"
|
||||
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
||||
import { parseShortcutKeys } from "@/lib/tiptap-utils"
|
||||
|
||||
export interface HeadingButtonProps
|
||||
extends Omit<ButtonProps, "type">, UseHeadingConfig {
|
||||
@ -81,7 +81,11 @@ export const HeadingButton = forwardRef<HTMLButtonElement, HeadingButtonProps>(
|
||||
const handleClick = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
onClick?.(event)
|
||||
if (event.defaultPrevented) return
|
||||
|
||||
if (event.defaultPrevented) {
|
||||
return
|
||||
}
|
||||
|
||||
handleToggle()
|
||||
},
|
||||
[handleToggle, onClick]
|
||||
|
||||
@ -1,10 +1,16 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { type Editor } from "@tiptap/react"
|
||||
import { NodeSelection, TextSelection } from "@tiptap/pm/state"
|
||||
import type {Editor} from "@tiptap/react";
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
|
||||
// --- 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"
|
||||
|
||||
// --- Lib ---
|
||||
@ -18,12 +24,6 @@ import {
|
||||
} from "@/lib/tiptap-utils"
|
||||
|
||||
// --- 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
|
||||
|
||||
@ -76,12 +76,16 @@ export function canToggle(
|
||||
level?: Level,
|
||||
turnInto: boolean = true
|
||||
): boolean {
|
||||
if (!editor || !editor.isEditable) return false
|
||||
if (!editor || !editor.isEditable) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (
|
||||
!isNodeInSchema("heading", editor) ||
|
||||
isNodeTypeSelected(editor, ["image"])
|
||||
)
|
||||
return false
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!turnInto) {
|
||||
return level
|
||||
@ -100,8 +104,9 @@ export function canToggle(
|
||||
"blockquote",
|
||||
"codeBlock",
|
||||
])
|
||||
)
|
||||
return false
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Either we can set heading directly on the selection,
|
||||
// or we can clear formatting/nodes to arrive at a heading.
|
||||
@ -117,7 +122,9 @@ export function isHeadingActive(
|
||||
editor: Editor | null,
|
||||
level?: Level | Level[]
|
||||
): boolean {
|
||||
if (!editor || !editor.isEditable) return false
|
||||
if (!editor || !editor.isEditable) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (Array.isArray(level)) {
|
||||
return level.some((l) => editor.isActive("heading", { level: l }))
|
||||
@ -135,12 +142,16 @@ export function toggleHeading(
|
||||
editor: Editor | null,
|
||||
level: Level | Level[]
|
||||
): boolean {
|
||||
if (!editor || !editor.isEditable) return false
|
||||
if (!editor || !editor.isEditable) {
|
||||
return false
|
||||
}
|
||||
|
||||
const levels = Array.isArray(level) ? level : [level]
|
||||
const toggleLevel = levels.find((l) => canToggle(editor, l))
|
||||
|
||||
if (!toggleLevel) return false
|
||||
if (!toggleLevel) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const view = editor.view
|
||||
@ -172,7 +183,10 @@ export function toggleHeading(
|
||||
editor,
|
||||
node: state.selection.$anchor.node(1),
|
||||
})?.pos
|
||||
if (!isValidPosition(pos)) return false
|
||||
|
||||
if (!isValidPosition(pos)) {
|
||||
return false
|
||||
}
|
||||
|
||||
tr = tr.setSelection(NodeSelection.create(state.doc, pos))
|
||||
view.dispatch(tr)
|
||||
@ -231,20 +245,27 @@ export function shouldShowButton(props: {
|
||||
}): boolean {
|
||||
const { editor, level, hideWhenUnavailable } = props
|
||||
|
||||
if (!editor) return false
|
||||
if (!editor) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!hideWhenUnavailable) {
|
||||
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 (Array.isArray(level)) {
|
||||
return level.some((l) => canToggle(editor, l))
|
||||
}
|
||||
|
||||
return canToggle(editor, level)
|
||||
}
|
||||
|
||||
@ -311,7 +332,9 @@ export function useHeading(config: UseHeadingConfig) {
|
||||
const isActive = isHeadingActive(editor, level)
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
if (!editor) {
|
||||
return
|
||||
}
|
||||
|
||||
const handleSelectionUpdate = () => {
|
||||
setIsVisible(shouldShowButton({ editor, level, hideWhenUnavailable }))
|
||||
@ -327,12 +350,16 @@ export function useHeading(config: UseHeadingConfig) {
|
||||
}, [editor, level, hideWhenUnavailable])
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
if (!editor) return false
|
||||
if (!editor) {
|
||||
return false
|
||||
}
|
||||
|
||||
const success = toggleHeading(editor, level)
|
||||
|
||||
if (success) {
|
||||
onToggled?.()
|
||||
}
|
||||
|
||||
return success
|
||||
}, [editor, level, onToggled])
|
||||
|
||||
|
||||
@ -4,7 +4,6 @@ import { forwardRef, useCallback, useState } from "react"
|
||||
import { ChevronDownIcon } from "@/components/tiptap-icons/chevron-down-icon"
|
||||
|
||||
// --- Hooks ---
|
||||
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
||||
|
||||
// --- Tiptap UI ---
|
||||
import { HeadingButton } from "@/components/tiptap-ui/heading-button"
|
||||
@ -21,6 +20,7 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuGroup,
|
||||
} from "@/components/tiptap-ui-primitive/dropdown-menu"
|
||||
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
||||
|
||||
export interface HeadingDropdownMenuProps
|
||||
extends Omit<ButtonProps, "type">, UseHeadingDropdownMenuConfig {
|
||||
@ -65,7 +65,10 @@ export const HeadingDropdownMenu = forwardRef<
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
if (!editor || !canToggle) return
|
||||
if (!editor || !canToggle) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsOpen(open)
|
||||
onOpenChange?.(open)
|
||||
},
|
||||
|
||||
@ -1,10 +1,9 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import type { Editor } from "@tiptap/react"
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
// --- Hooks ---
|
||||
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
||||
|
||||
// --- Icons ---
|
||||
import { HeadingIcon } from "@/components/tiptap-icons/heading-icon"
|
||||
@ -12,11 +11,13 @@ import { HeadingIcon } from "@/components/tiptap-icons/heading-icon"
|
||||
// --- Tiptap UI ---
|
||||
import {
|
||||
headingIcons,
|
||||
type Level,
|
||||
|
||||
isHeadingActive,
|
||||
canToggle,
|
||||
shouldShowButton,
|
||||
shouldShowButton
|
||||
} 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
|
||||
@ -45,7 +46,10 @@ export function getActiveHeadingLevel(
|
||||
editor: Editor | null,
|
||||
levels: Level[] = [1, 2, 3, 4, 5, 6]
|
||||
): Level | undefined {
|
||||
if (!editor || !editor.isEditable) return undefined
|
||||
if (!editor || !editor.isEditable) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return levels.find((level) => isHeadingActive(editor, level))
|
||||
}
|
||||
|
||||
@ -103,7 +107,9 @@ export function useHeadingDropdownMenu(config?: UseHeadingDropdownMenuConfig) {
|
||||
const canToggleState = canToggle(editor)
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
if (!editor) {
|
||||
return
|
||||
}
|
||||
|
||||
const handleSelectionUpdate = () => {
|
||||
setIsVisible(
|
||||
|
||||
@ -1,10 +1,8 @@
|
||||
import { forwardRef, useCallback } from "react"
|
||||
|
||||
// --- Lib ---
|
||||
import { parseShortcutKeys } from "@/lib/tiptap-utils"
|
||||
|
||||
// --- Hooks ---
|
||||
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
||||
|
||||
// --- Tiptap UI ---
|
||||
import type { UseImageUploadConfig } from "@/components/tiptap-ui/image-upload-button"
|
||||
@ -14,9 +12,11 @@ import {
|
||||
} from "@/components/tiptap-ui/image-upload-button"
|
||||
|
||||
// --- UI Primitives ---
|
||||
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 { 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 IconComponent = ({ className, ...props }: IconProps) => React.ReactElement
|
||||
@ -87,7 +87,11 @@ export const ImageUploadButton = forwardRef<
|
||||
const handleClick = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
onClick?.(event)
|
||||
if (event.defaultPrevented) return
|
||||
|
||||
if (event.defaultPrevented) {
|
||||
return
|
||||
}
|
||||
|
||||
handleImage()
|
||||
},
|
||||
[handleImage, onClick]
|
||||
|
||||
@ -1,18 +1,18 @@
|
||||
"use client"
|
||||
|
||||
import type {Editor} from "@tiptap/react";
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { useHotkeys } from "react-hotkeys-hook"
|
||||
import { type Editor } from "@tiptap/react"
|
||||
|
||||
// --- 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 { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
||||
|
||||
// --- Lib ---
|
||||
import { isExtensionAvailable } from "@/lib/tiptap-utils"
|
||||
|
||||
// --- Icons ---
|
||||
import { ImagePlusIcon } from "@/components/tiptap-icons/image-plus-icon"
|
||||
|
||||
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
|
||||
*/
|
||||
export function canInsertImage(editor: Editor | null): boolean {
|
||||
if (!editor || !editor.isEditable) return false
|
||||
if (!isExtensionAvailable(editor, "imageUpload")) return false
|
||||
if (!editor || !editor.isEditable) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!isExtensionAvailable(editor, "imageUpload")) {
|
||||
return false
|
||||
}
|
||||
|
||||
return editor.can().insertContent({ type: "imageUpload" })
|
||||
}
|
||||
@ -49,7 +54,10 @@ export function canInsertImage(editor: Editor | null): boolean {
|
||||
* Checks if image is currently active
|
||||
*/
|
||||
export function isImageActive(editor: Editor | null): boolean {
|
||||
if (!editor || !editor.isEditable) return false
|
||||
if (!editor || !editor.isEditable) {
|
||||
return false
|
||||
}
|
||||
|
||||
return editor.isActive("imageUpload")
|
||||
}
|
||||
|
||||
@ -57,8 +65,13 @@ export function isImageActive(editor: Editor | null): boolean {
|
||||
* Inserts an image in the editor
|
||||
*/
|
||||
export function insertImage(editor: Editor | null): boolean {
|
||||
if (!editor || !editor.isEditable) return false
|
||||
if (!canInsertImage(editor)) return false
|
||||
if (!editor || !editor.isEditable) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!canInsertImage(editor)) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
return editor
|
||||
@ -82,13 +95,17 @@ export function shouldShowButton(props: {
|
||||
}): boolean {
|
||||
const { editor, hideWhenUnavailable } = props
|
||||
|
||||
if (!editor || !editor.isEditable) return false
|
||||
if (!editor || !editor.isEditable) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!hideWhenUnavailable) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!isExtensionAvailable(editor, "imageUpload")) return false
|
||||
if (!isExtensionAvailable(editor, "imageUpload")) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!editor.isActive("code")) {
|
||||
return canInsertImage(editor)
|
||||
@ -147,7 +164,9 @@ export function useImageUpload(config?: UseImageUploadConfig) {
|
||||
const isActive = isImageActive(editor)
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
if (!editor) {
|
||||
return
|
||||
}
|
||||
|
||||
const handleSelectionUpdate = () => {
|
||||
setIsVisible(shouldShowButton({ editor, hideWhenUnavailable }))
|
||||
@ -163,12 +182,16 @@ export function useImageUpload(config?: UseImageUploadConfig) {
|
||||
}, [editor, hideWhenUnavailable])
|
||||
|
||||
const handleImage = useCallback(() => {
|
||||
if (!editor) return false
|
||||
if (!editor) {
|
||||
return false
|
||||
}
|
||||
|
||||
const success = insertImage(editor)
|
||||
|
||||
if (success) {
|
||||
onInserted?.()
|
||||
}
|
||||
|
||||
return success
|
||||
}, [editor, onInserted])
|
||||
|
||||
|
||||
@ -1,11 +1,9 @@
|
||||
"use client"
|
||||
|
||||
import { forwardRef, useCallback, useEffect, useState } from "react"
|
||||
import type { Editor } from "@tiptap/react"
|
||||
import { forwardRef, useCallback, useEffect, useState } from "react"
|
||||
|
||||
// --- Hooks ---
|
||||
import { useIsBreakpoint } from "@/hooks/use-is-breakpoint"
|
||||
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
||||
|
||||
// --- Icons ---
|
||||
import { CornerDownLeftIcon } from "@/components/tiptap-icons/corner-down-left-icon"
|
||||
@ -20,19 +18,21 @@ import { useLinkPopover } from "@/components/tiptap-ui/link-popover"
|
||||
// --- UI Primitives ---
|
||||
import type { ButtonProps } from "@/components/tiptap-ui-primitive/button"
|
||||
import { Button } from "@/components/tiptap-ui-primitive/button"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/tiptap-ui-primitive/popover"
|
||||
import { Separator } from "@/components/tiptap-ui-primitive/separator"
|
||||
import { ButtonGroup } from "@/components/tiptap-ui-primitive/button-group"
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
CardItemGroup,
|
||||
} from "@/components/tiptap-ui-primitive/card"
|
||||
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"
|
||||
|
||||
@ -259,7 +259,11 @@ export const LinkPopover = forwardRef<HTMLButtonElement, LinkPopoverProps>(
|
||||
const handleClick = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
onClick?.(event)
|
||||
if (event.defaultPrevented) return
|
||||
|
||||
if (event.defaultPrevented) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsOpen(!isOpen)
|
||||
},
|
||||
[onClick, isOpen]
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import type { Editor } from "@tiptap/react"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
|
||||
// --- Hooks ---
|
||||
import { LinkIcon } from "@/components/tiptap-icons/link-icon"
|
||||
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
||||
|
||||
// --- Icons ---
|
||||
import { LinkIcon } from "@/components/tiptap-icons/link-icon"
|
||||
|
||||
// --- Lib ---
|
||||
import {
|
||||
@ -51,11 +51,16 @@ export interface LinkHandlerProps {
|
||||
* Checks if a link can be set in the current editor state
|
||||
*/
|
||||
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
|
||||
// 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 {
|
||||
return editor.can().setMark("link")
|
||||
} catch {
|
||||
@ -67,7 +72,10 @@ export function canSetLink(editor: Editor | null): boolean {
|
||||
* Checks if a link is currently active in the editor
|
||||
*/
|
||||
export function isLinkActive(editor: Editor | null): boolean {
|
||||
if (!editor || !editor.isEditable) return false
|
||||
if (!editor || !editor.isEditable) {
|
||||
return false
|
||||
}
|
||||
|
||||
return editor.isActive("link")
|
||||
}
|
||||
|
||||
@ -80,7 +88,9 @@ export function shouldShowLinkButton(props: {
|
||||
}): boolean {
|
||||
const { editor, hideWhenUnavailable } = props
|
||||
|
||||
if (!editor || !editor.isEditable) return false
|
||||
if (!editor || !editor.isEditable) {
|
||||
return false
|
||||
}
|
||||
|
||||
const linkInSchema = isMarkInSchema("link", editor)
|
||||
|
||||
@ -110,7 +120,9 @@ export function useLinkHandler(props: LinkHandlerProps) {
|
||||
const [url, setUrl] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
if (!editor) {
|
||||
return
|
||||
}
|
||||
|
||||
// Get URL immediately on mount
|
||||
const { href } = editor.getAttributes("link")
|
||||
@ -121,7 +133,9 @@ export function useLinkHandler(props: LinkHandlerProps) {
|
||||
}, [editor, url])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
if (!editor) {
|
||||
return
|
||||
}
|
||||
|
||||
const updateLinkState = () => {
|
||||
const { href } = editor.getAttributes("link")
|
||||
@ -129,13 +143,16 @@ export function useLinkHandler(props: LinkHandlerProps) {
|
||||
}
|
||||
|
||||
editor.on("selectionUpdate", updateLinkState)
|
||||
|
||||
return () => {
|
||||
editor.off("selectionUpdate", updateLinkState)
|
||||
}
|
||||
}, [editor])
|
||||
|
||||
const setLink = useCallback(() => {
|
||||
if (!url || !editor) return
|
||||
if (!url || !editor) {
|
||||
return
|
||||
}
|
||||
|
||||
const { selection } = editor.state
|
||||
const isEmpty = selection.empty
|
||||
@ -156,7 +173,10 @@ export function useLinkHandler(props: LinkHandlerProps) {
|
||||
}, [editor, onSetLink, url])
|
||||
|
||||
const removeLink = useCallback(() => {
|
||||
if (!editor) return
|
||||
if (!editor) {
|
||||
return
|
||||
}
|
||||
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
@ -169,9 +189,12 @@ export function useLinkHandler(props: LinkHandlerProps) {
|
||||
|
||||
const openLink = useCallback(
|
||||
(target: string = "_blank", features: string = "noopener,noreferrer") => {
|
||||
if (!url) return
|
||||
if (!url) {
|
||||
return
|
||||
}
|
||||
|
||||
const safeUrl = sanitizeUrl(url, window.location.href)
|
||||
|
||||
if (safeUrl !== "#") {
|
||||
window.open(safeUrl, target, features)
|
||||
}
|
||||
@ -203,7 +226,9 @@ export function useLinkState(props: {
|
||||
const [isVisible, setIsVisible] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
if (!editor) {
|
||||
return
|
||||
}
|
||||
|
||||
const handleSelectionUpdate = () => {
|
||||
setIsVisible(
|
||||
|
||||
@ -1,19 +1,19 @@
|
||||
import { forwardRef, useCallback } from "react"
|
||||
|
||||
// --- Lib ---
|
||||
import { parseShortcutKeys } from "@/lib/tiptap-utils"
|
||||
|
||||
// --- Hooks ---
|
||||
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
||||
|
||||
// --- 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 { 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
|
||||
extends Omit<ButtonProps, "type">, UseListConfig {
|
||||
@ -77,7 +77,11 @@ export const ListButton = forwardRef<HTMLButtonElement, ListButtonProps>(
|
||||
const handleClick = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
onClick?.(event)
|
||||
if (event.defaultPrevented) return
|
||||
|
||||
if (event.defaultPrevented) {
|
||||
return
|
||||
}
|
||||
|
||||
handleToggle()
|
||||
},
|
||||
[handleToggle, onClick]
|
||||
|
||||
@ -1,16 +1,16 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { type Editor } from "@tiptap/react"
|
||||
import { NodeSelection, TextSelection } from "@tiptap/pm/state"
|
||||
import type {Editor} from "@tiptap/react";
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
|
||||
// --- Hooks ---
|
||||
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
||||
|
||||
// --- Icons ---
|
||||
import { ListIcon } from "@/components/tiptap-icons/list-icon"
|
||||
import { ListOrderedIcon } from "@/components/tiptap-icons/list-ordered-icon"
|
||||
import { ListTodoIcon } from "@/components/tiptap-icons/list-todo-icon"
|
||||
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
||||
|
||||
// --- Lib ---
|
||||
import {
|
||||
@ -73,9 +73,13 @@ export function canToggleList(
|
||||
type: ListType,
|
||||
turnInto: boolean = true
|
||||
): boolean {
|
||||
if (!editor || !editor.isEditable) return false
|
||||
if (!isNodeInSchema(type, editor) || isNodeTypeSelected(editor, ["image"]))
|
||||
return false
|
||||
if (!editor || !editor.isEditable) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!isNodeInSchema(type, editor) || isNodeTypeSelected(editor, ["image"])) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!turnInto) {
|
||||
switch (type) {
|
||||
@ -101,8 +105,9 @@ export function canToggleList(
|
||||
"blockquote",
|
||||
"codeBlock",
|
||||
])
|
||||
)
|
||||
return false
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Either we can set list directly on the selection,
|
||||
// or we can clear formatting/nodes to arrive at a list.
|
||||
@ -125,7 +130,9 @@ export function canToggleList(
|
||||
* Checks if list is currently active
|
||||
*/
|
||||
export function isListActive(editor: Editor | null, type: ListType): boolean {
|
||||
if (!editor || !editor.isEditable) return false
|
||||
if (!editor || !editor.isEditable) {
|
||||
return false
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case "bulletList":
|
||||
@ -143,8 +150,13 @@ export function isListActive(editor: Editor | null, type: ListType): boolean {
|
||||
* Toggles list in the editor
|
||||
*/
|
||||
export function toggleList(editor: Editor | null, type: ListType): boolean {
|
||||
if (!editor || !editor.isEditable) return false
|
||||
if (!canToggleList(editor, type)) return false
|
||||
if (!editor || !editor.isEditable) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!canToggleList(editor, type)) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const view = editor.view
|
||||
@ -176,7 +188,10 @@ export function toggleList(editor: Editor | null, type: ListType): boolean {
|
||||
editor,
|
||||
node: state.selection.$anchor.node(1),
|
||||
})?.pos
|
||||
if (!isValidPosition(pos)) return false
|
||||
|
||||
if (!isValidPosition(pos)) {
|
||||
return false
|
||||
}
|
||||
|
||||
tr = tr.setSelection(NodeSelection.create(state.doc, pos))
|
||||
view.dispatch(tr)
|
||||
@ -225,7 +240,10 @@ export function toggleList(editor: Editor | null, type: ListType): boolean {
|
||||
}
|
||||
|
||||
const toggle = toggleMap[type]
|
||||
if (!toggle) return false
|
||||
|
||||
if (!toggle) {
|
||||
return false
|
||||
}
|
||||
|
||||
toggle().run()
|
||||
}
|
||||
@ -248,15 +266,21 @@ export function shouldShowButton(props: {
|
||||
}): boolean {
|
||||
const { editor, type, hideWhenUnavailable } = props
|
||||
|
||||
if (!editor) return false
|
||||
if (!editor) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!hideWhenUnavailable) {
|
||||
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")) {
|
||||
return canToggleList(editor, type)
|
||||
@ -316,7 +340,9 @@ export function useList(config: UseListConfig) {
|
||||
const isActive = isListActive(editor, type)
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
if (!editor) {
|
||||
return
|
||||
}
|
||||
|
||||
const handleSelectionUpdate = () => {
|
||||
setIsVisible(shouldShowButton({ editor, type, hideWhenUnavailable }))
|
||||
@ -332,12 +358,16 @@ export function useList(config: UseListConfig) {
|
||||
}, [editor, type, hideWhenUnavailable])
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
if (!editor) return false
|
||||
if (!editor) {
|
||||
return false
|
||||
}
|
||||
|
||||
const success = toggleList(editor, type)
|
||||
|
||||
if (success) {
|
||||
onToggled?.()
|
||||
}
|
||||
|
||||
return success
|
||||
}, [editor, type, onToggled])
|
||||
|
||||
|
||||
@ -1,14 +1,14 @@
|
||||
import type {Editor} from "@tiptap/react";
|
||||
import { useCallback, useState } from "react"
|
||||
import { type Editor } from "@tiptap/react"
|
||||
|
||||
// --- Hooks ---
|
||||
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
||||
|
||||
// --- Icons ---
|
||||
import { ChevronDownIcon } from "@/components/tiptap-icons/chevron-down-icon"
|
||||
|
||||
// --- 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"
|
||||
|
||||
@ -22,6 +22,7 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuGroup,
|
||||
} from "@/components/tiptap-ui-primitive/dropdown-menu"
|
||||
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
||||
|
||||
export interface ListDropdownMenuProps extends Omit<ButtonProps, "type"> {
|
||||
/**
|
||||
|
||||
@ -1,10 +1,9 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import type { Editor } from "@tiptap/react"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
|
||||
// --- Hooks ---
|
||||
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
||||
|
||||
// --- Icons ---
|
||||
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"
|
||||
|
||||
// --- Lib ---
|
||||
import { isNodeInSchema } from "@/lib/tiptap-utils"
|
||||
|
||||
// --- Tiptap UI ---
|
||||
import {
|
||||
canToggleList,
|
||||
isListActive,
|
||||
listIcons,
|
||||
type ListType,
|
||||
listIcons
|
||||
|
||||
} 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
|
||||
@ -70,7 +71,10 @@ export function canToggleAnyList(
|
||||
editor: Editor | null,
|
||||
listTypes: ListType[]
|
||||
): boolean {
|
||||
if (!editor || !editor.isEditable) return false
|
||||
if (!editor || !editor.isEditable) {
|
||||
return false
|
||||
}
|
||||
|
||||
return listTypes.some((type) => canToggleList(editor, type))
|
||||
}
|
||||
|
||||
@ -78,7 +82,10 @@ export function isAnyListActive(
|
||||
editor: Editor | null,
|
||||
listTypes: ListType[]
|
||||
): boolean {
|
||||
if (!editor || !editor.isEditable) return false
|
||||
if (!editor || !editor.isEditable) {
|
||||
return false
|
||||
}
|
||||
|
||||
return listTypes.some((type) => isListActive(editor, type))
|
||||
}
|
||||
|
||||
@ -99,13 +106,17 @@ export function shouldShowListDropdown(params: {
|
||||
}): boolean {
|
||||
const { editor, hideWhenUnavailable, listInSchema, canToggleAny } = params
|
||||
|
||||
if (!editor) return false
|
||||
if (!editor) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!hideWhenUnavailable) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!listInSchema) return false
|
||||
if (!listInSchema) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!editor.isActive("code")) {
|
||||
return canToggleAny
|
||||
@ -121,7 +132,10 @@ export function getActiveListType(
|
||||
editor: Editor | null,
|
||||
availableTypes: ListType[]
|
||||
): ListType | undefined {
|
||||
if (!editor || !editor.isEditable) return undefined
|
||||
if (!editor || !editor.isEditable) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return availableTypes.find((type) => isListActive(editor, type))
|
||||
}
|
||||
|
||||
@ -184,7 +198,9 @@ export function useListDropdownMenu(config?: UseListDropdownMenuConfig) {
|
||||
const activeList = filteredLists.find((option) => option.type === activeType)
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
if (!editor) {
|
||||
return
|
||||
}
|
||||
|
||||
const handleSelectionUpdate = () => {
|
||||
setIsVisible(
|
||||
|
||||
@ -3,19 +3,19 @@
|
||||
import { forwardRef, useCallback } from "react"
|
||||
|
||||
// --- Lib ---
|
||||
import { parseShortcutKeys } from "@/lib/tiptap-utils"
|
||||
|
||||
// --- Hooks ---
|
||||
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
||||
|
||||
// --- Tiptap UI ---
|
||||
import type { Mark, UseMarkConfig } from "@/components/tiptap-ui/mark-button"
|
||||
import { MARK_SHORTCUT_KEYS, useMark } from "@/components/tiptap-ui/mark-button"
|
||||
|
||||
// --- UI Primitives ---
|
||||
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 { Badge } from "@/components/tiptap-ui-primitive/badge"
|
||||
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
||||
import { parseShortcutKeys } from "@/lib/tiptap-utils"
|
||||
|
||||
export interface MarkButtonProps
|
||||
extends Omit<ButtonProps, "type">, UseMarkConfig {
|
||||
@ -79,7 +79,11 @@ export const MarkButton = forwardRef<HTMLButtonElement, MarkButtonProps>(
|
||||
const handleClick = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
onClick?.(event)
|
||||
if (event.defaultPrevented) return
|
||||
|
||||
if (event.defaultPrevented) {
|
||||
return
|
||||
}
|
||||
|
||||
handleMark()
|
||||
},
|
||||
[handleMark, onClick]
|
||||
|
||||
@ -1,11 +1,9 @@
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import type { Editor } from "@tiptap/react"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
|
||||
// --- Hooks ---
|
||||
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
||||
|
||||
// --- Lib ---
|
||||
import { isMarkInSchema, isNodeTypeSelected } from "@/lib/tiptap-utils"
|
||||
|
||||
// --- Icons ---
|
||||
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 { SuperscriptIcon } from "@/components/tiptap-icons/superscript-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 =
|
||||
| "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
|
||||
*/
|
||||
export function canToggleMark(editor: Editor | null, type: Mark): boolean {
|
||||
if (!editor || !editor.isEditable) return false
|
||||
if (!isMarkInSchema(type, editor) || isNodeTypeSelected(editor, ["image"]))
|
||||
return false
|
||||
if (!editor || !editor.isEditable) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!isMarkInSchema(type, editor) || isNodeTypeSelected(editor, ["image"])) {
|
||||
return false
|
||||
}
|
||||
|
||||
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
|
||||
*/
|
||||
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)
|
||||
}
|
||||
|
||||
@ -91,8 +98,13 @@ export function isMarkActive(editor: Editor | null, type: Mark): boolean {
|
||||
* Toggles a mark in the editor
|
||||
*/
|
||||
export function toggleMark(editor: Editor | null, type: Mark): boolean {
|
||||
if (!editor || !editor.isEditable) return false
|
||||
if (!canToggleMark(editor, type)) return false
|
||||
if (!editor || !editor.isEditable) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!canToggleMark(editor, type)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return editor.chain().focus().toggleMark(type).run()
|
||||
}
|
||||
@ -107,15 +119,21 @@ export function shouldShowButton(props: {
|
||||
}): boolean {
|
||||
const { editor, type, hideWhenUnavailable } = props
|
||||
|
||||
if (!editor) return false
|
||||
if (!editor) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!hideWhenUnavailable) {
|
||||
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")) {
|
||||
return canToggleMark(editor, type)
|
||||
@ -182,7 +200,9 @@ export function useMark(config: UseMarkConfig) {
|
||||
const isActive = isMarkActive(editor, type)
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
if (!editor) {
|
||||
return
|
||||
}
|
||||
|
||||
const handleSelectionUpdate = () => {
|
||||
setIsVisible(shouldShowButton({ editor, type, hideWhenUnavailable }))
|
||||
@ -198,12 +218,16 @@ export function useMark(config: UseMarkConfig) {
|
||||
}, [editor, type, hideWhenUnavailable])
|
||||
|
||||
const handleMark = useCallback(() => {
|
||||
if (!editor) return false
|
||||
if (!editor) {
|
||||
return false
|
||||
}
|
||||
|
||||
const success = toggleMark(editor, type)
|
||||
|
||||
if (success) {
|
||||
onToggled?.()
|
||||
}
|
||||
|
||||
return success
|
||||
}, [editor, type, onToggled])
|
||||
|
||||
|
||||
@ -3,10 +3,8 @@
|
||||
import { forwardRef, useCallback } from "react"
|
||||
|
||||
// --- Lib ---
|
||||
import { parseShortcutKeys } from "@/lib/tiptap-utils"
|
||||
|
||||
// --- Hooks ---
|
||||
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
||||
|
||||
// --- Tiptap UI ---
|
||||
import type {
|
||||
@ -19,9 +17,11 @@ import {
|
||||
} from "@/components/tiptap-ui/text-align-button"
|
||||
|
||||
// --- UI Primitives ---
|
||||
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 { 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 IconComponent = ({ className, ...props }: IconProps) => React.ReactElement
|
||||
@ -96,7 +96,11 @@ export const TextAlignButton = forwardRef<
|
||||
const handleClick = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
onClick?.(event)
|
||||
if (event.defaultPrevented) return
|
||||
|
||||
if (event.defaultPrevented) {
|
||||
return
|
||||
}
|
||||
|
||||
handleTextAlign()
|
||||
},
|
||||
[handleTextAlign, onClick]
|
||||
|
||||
@ -1,21 +1,21 @@
|
||||
import { useCallback, useEffect, useState } from "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 ---
|
||||
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
||||
|
||||
// --- Lib ---
|
||||
import {
|
||||
isExtensionAvailable,
|
||||
isNodeTypeSelected,
|
||||
} from "@/lib/tiptap-utils"
|
||||
|
||||
// --- Icons ---
|
||||
import { AlignCenterIcon } from "@/components/tiptap-icons/align-center-icon"
|
||||
import { AlignJustifyIcon } from "@/components/tiptap-icons/align-justify-icon"
|
||||
import { AlignLeftIcon } from "@/components/tiptap-icons/align-left-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"
|
||||
|
||||
@ -70,12 +70,16 @@ export function canSetTextAlign(
|
||||
editor: Editor | null,
|
||||
align: TextAlign
|
||||
): boolean {
|
||||
if (!editor || !editor.isEditable) return false
|
||||
if (!editor || !editor.isEditable) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (
|
||||
!isExtensionAvailable(editor, "textAlign") ||
|
||||
isNodeTypeSelected(editor, ["image", "horizontalRule"])
|
||||
)
|
||||
return false
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
return editor.can().setTextAlign(align)
|
||||
}
|
||||
@ -95,7 +99,10 @@ export function isTextAlignActive(
|
||||
editor: Editor | null,
|
||||
align: TextAlign
|
||||
): boolean {
|
||||
if (!editor || !editor.isEditable) return false
|
||||
if (!editor || !editor.isEditable) {
|
||||
return false
|
||||
}
|
||||
|
||||
return editor.isActive({ textAlign: align })
|
||||
}
|
||||
|
||||
@ -103,10 +110,16 @@ export function isTextAlignActive(
|
||||
* Sets text alignment in the editor
|
||||
*/
|
||||
export function setTextAlign(editor: Editor | null, align: TextAlign): boolean {
|
||||
if (!editor || !editor.isEditable) return false
|
||||
if (!canSetTextAlign(editor, align)) return false
|
||||
if (!editor || !editor.isEditable) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!canSetTextAlign(editor, align)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const chain = editor.chain().focus()
|
||||
|
||||
if (hasSetTextAlign(chain)) {
|
||||
return chain.setTextAlign(align).run()
|
||||
}
|
||||
@ -124,15 +137,21 @@ export function shouldShowButton(props: {
|
||||
}): boolean {
|
||||
const { editor, hideWhenUnavailable, align } = props
|
||||
|
||||
if (!editor) return false
|
||||
if (!editor) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!hideWhenUnavailable) {
|
||||
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")) {
|
||||
return canSetTextAlign(editor, align)
|
||||
@ -192,7 +211,9 @@ export function useTextAlign(config: UseTextAlignConfig) {
|
||||
const isActive = isTextAlignActive(editor, align)
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
if (!editor) {
|
||||
return
|
||||
}
|
||||
|
||||
const handleSelectionUpdate = () => {
|
||||
setIsVisible(shouldShowButton({ editor, align, hideWhenUnavailable }))
|
||||
@ -208,12 +229,16 @@ export function useTextAlign(config: UseTextAlignConfig) {
|
||||
}, [editor, hideWhenUnavailable, align])
|
||||
|
||||
const handleTextAlign = useCallback(() => {
|
||||
if (!editor) return false
|
||||
if (!editor) {
|
||||
return false
|
||||
}
|
||||
|
||||
const success = setTextAlign(editor, align)
|
||||
|
||||
if (success) {
|
||||
onAligned?.()
|
||||
}
|
||||
|
||||
return success
|
||||
}, [editor, align, onAligned])
|
||||
|
||||
|
||||
@ -3,10 +3,8 @@
|
||||
import { forwardRef, useCallback } from "react"
|
||||
|
||||
// --- Lib ---
|
||||
import { parseShortcutKeys } from "@/lib/tiptap-utils"
|
||||
|
||||
// --- Hooks ---
|
||||
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
||||
|
||||
// --- Tiptap UI ---
|
||||
import type {
|
||||
@ -19,9 +17,11 @@ import {
|
||||
} from "@/components/tiptap-ui/undo-redo-button"
|
||||
|
||||
// --- UI Primitives ---
|
||||
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 { Badge } from "@/components/tiptap-ui-primitive/badge"
|
||||
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
||||
import { parseShortcutKeys } from "@/lib/tiptap-utils"
|
||||
|
||||
export interface UndoRedoButtonProps
|
||||
extends Omit<ButtonProps, "type">, UseUndoRedoConfig {
|
||||
@ -81,7 +81,11 @@ export const UndoRedoButton = forwardRef<
|
||||
const handleClick = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
onClick?.(event)
|
||||
if (event.defaultPrevented) return
|
||||
|
||||
if (event.defaultPrevented) {
|
||||
return
|
||||
}
|
||||
|
||||
handleAction()
|
||||
},
|
||||
[handleAction, onClick]
|
||||
|
||||
@ -1,15 +1,15 @@
|
||||
import type {Editor} from "@tiptap/react";
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { type Editor } from "@tiptap/react"
|
||||
|
||||
// --- Hooks ---
|
||||
import { Redo2Icon } from "@/components/tiptap-icons/redo2-icon"
|
||||
import { Undo2Icon } from "@/components/tiptap-icons/undo2-icon"
|
||||
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
||||
|
||||
// --- Lib ---
|
||||
import { isNodeTypeSelected } from "@/lib/tiptap-utils"
|
||||
|
||||
// --- Icons ---
|
||||
import { Redo2Icon } from "@/components/tiptap-icons/redo2-icon"
|
||||
import { Undo2Icon } from "@/components/tiptap-icons/undo2-icon"
|
||||
|
||||
export type UndoRedoAction = "undo" | "redo"
|
||||
|
||||
@ -58,8 +58,13 @@ export function canExecuteUndoRedoAction(
|
||||
editor: Editor | null,
|
||||
action: UndoRedoAction
|
||||
): boolean {
|
||||
if (!editor || !editor.isEditable) return false
|
||||
if (isNodeTypeSelected(editor, ["image"])) return false
|
||||
if (!editor || !editor.isEditable) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (isNodeTypeSelected(editor, ["image"])) {
|
||||
return false
|
||||
}
|
||||
|
||||
return action === "undo" ? editor.can().undo() : editor.can().redo()
|
||||
}
|
||||
@ -71,10 +76,16 @@ export function executeUndoRedoAction(
|
||||
editor: Editor | null,
|
||||
action: UndoRedoAction
|
||||
): boolean {
|
||||
if (!editor || !editor.isEditable) return false
|
||||
if (!canExecuteUndoRedoAction(editor, action)) return false
|
||||
if (!editor || !editor.isEditable) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!canExecuteUndoRedoAction(editor, action)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const chain = editor.chain().focus()
|
||||
|
||||
return action === "undo" ? chain.undo().run() : chain.redo().run()
|
||||
}
|
||||
|
||||
@ -88,13 +99,17 @@ export function shouldShowButton(props: {
|
||||
}): boolean {
|
||||
const { editor, hideWhenUnavailable, action } = props
|
||||
|
||||
if (!editor) return false
|
||||
if (!editor) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!hideWhenUnavailable) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!editor.isEditable) return false
|
||||
if (!editor.isEditable) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!editor.isActive("code")) {
|
||||
return canExecuteUndoRedoAction(editor, action)
|
||||
@ -152,7 +167,9 @@ export function useUndoRedo(config: UseUndoRedoConfig) {
|
||||
const canExecute = canExecuteUndoRedoAction(editor, action)
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
if (!editor) {
|
||||
return
|
||||
}
|
||||
|
||||
const handleUpdate = () => {
|
||||
setIsVisible(shouldShowButton({ editor, hideWhenUnavailable, action }))
|
||||
@ -168,12 +185,16 @@ export function useUndoRedo(config: UseUndoRedoConfig) {
|
||||
}, [editor, hideWhenUnavailable, action])
|
||||
|
||||
const handleAction = useCallback(() => {
|
||||
if (!editor) return false
|
||||
if (!editor) {
|
||||
return false
|
||||
}
|
||||
|
||||
const success = executeUndoRedoAction(editor, action)
|
||||
|
||||
if (success) {
|
||||
onExecuted?.()
|
||||
}
|
||||
|
||||
return success
|
||||
}, [editor, action, onExecuted])
|
||||
|
||||
|
||||
@ -44,9 +44,13 @@ export function useCurrentUrl(): UseCurrentUrlReturn {
|
||||
const currentPath = currentUrl ?? currentUrlPath;
|
||||
|
||||
const clean = (p: string) => {
|
||||
if (!p) return '/';
|
||||
if (!p) {
|
||||
return '/';
|
||||
}
|
||||
|
||||
// Remove query and hash, then trailing slash
|
||||
let path = p.split('?')[0].split('#')[0];
|
||||
|
||||
if (path.startsWith('http')) {
|
||||
try {
|
||||
path = new URL(path).pathname;
|
||||
@ -54,6 +58,7 @@ export function useCurrentUrl(): UseCurrentUrlReturn {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
return path.replace(/\/+$/, '') || '/';
|
||||
};
|
||||
|
||||
@ -64,6 +69,7 @@ export function useCurrentUrl(): UseCurrentUrlReturn {
|
||||
if (normCheck === '/') {
|
||||
return normCurrent === '/';
|
||||
}
|
||||
|
||||
return normCurrent.startsWith(normCheck);
|
||||
}
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
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 { useBodyRect } from "@/hooks/use-element-rect"
|
||||
import { useWindowSize } from "@/hooks/use-window-size"
|
||||
|
||||
export interface CursorVisibilityOptions {
|
||||
/**
|
||||
@ -35,10 +35,15 @@ export function useCursorVisibility({
|
||||
|
||||
useEffect(() => {
|
||||
const ensureCursorVisibility = () => {
|
||||
if (!editor) return
|
||||
if (!editor) {
|
||||
return
|
||||
}
|
||||
|
||||
const { state, view } = editor
|
||||
if (!view.hasFocus()) return
|
||||
|
||||
if (!view.hasFocus()) {
|
||||
return
|
||||
}
|
||||
|
||||
// Get current cursor position coordinates
|
||||
const { from } = state.selection
|
||||
|
||||
@ -59,7 +59,9 @@ export function useElementRect({
|
||||
const [rect, setRect] = useState<RectState>(initialRect)
|
||||
|
||||
const getTargetElement = useCallback((): Element | null => {
|
||||
if (!enabled || !isClientSide()) return null
|
||||
if (!enabled || !isClientSide()) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!element) {
|
||||
return document.body
|
||||
@ -78,11 +80,15 @@ export function useElementRect({
|
||||
|
||||
const updateRect = useThrottledCallback(
|
||||
() => {
|
||||
if (!enabled || !isClientSide()) return
|
||||
if (!enabled || !isClientSide()) {
|
||||
return
|
||||
}
|
||||
|
||||
const targetElement = getTargetElement()
|
||||
|
||||
if (!targetElement) {
|
||||
setRect(initialRect)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@ -106,11 +112,15 @@ export function useElementRect({
|
||||
useEffect(() => {
|
||||
if (!enabled || !isClientSide()) {
|
||||
setRect(initialRect)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const targetElement = getTargetElement()
|
||||
if (!targetElement) return
|
||||
|
||||
if (!targetElement) {
|
||||
return
|
||||
}
|
||||
|
||||
updateRect()
|
||||
|
||||
|
||||
@ -28,6 +28,7 @@ export function useIsBreakpoint(
|
||||
|
||||
// Add listener
|
||||
mql.addEventListener("change", onChange)
|
||||
|
||||
return () => mql.removeEventListener("change", onChange)
|
||||
}, [mode, breakpoint])
|
||||
|
||||
|
||||
@ -65,83 +65,117 @@ export function useMenuNavigation<T>({
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyboardNavigation = (event: KeyboardEvent) => {
|
||||
if (!items.length) return false
|
||||
if (!items.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
const moveNext = () =>
|
||||
setSelectedIndex((currentIndex) => {
|
||||
if (currentIndex === -1) return 0
|
||||
if (currentIndex === -1) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return (currentIndex + 1) % items.length
|
||||
})
|
||||
|
||||
const movePrev = () =>
|
||||
setSelectedIndex((currentIndex) => {
|
||||
if (currentIndex === -1) return items.length - 1
|
||||
if (currentIndex === -1) {
|
||||
return items.length - 1
|
||||
}
|
||||
|
||||
return (currentIndex - 1 + items.length) % items.length
|
||||
})
|
||||
|
||||
switch (event.key) {
|
||||
case "ArrowUp": {
|
||||
if (orientation === "horizontal") return false
|
||||
if (orientation === "horizontal") {
|
||||
return false
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
movePrev()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
case "ArrowDown": {
|
||||
if (orientation === "horizontal") return false
|
||||
if (orientation === "horizontal") {
|
||||
return false
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
moveNext()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
case "ArrowLeft": {
|
||||
if (orientation === "vertical") return false
|
||||
if (orientation === "vertical") {
|
||||
return false
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
movePrev()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
case "ArrowRight": {
|
||||
if (orientation === "vertical") return false
|
||||
if (orientation === "vertical") {
|
||||
return false
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
moveNext()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
case "Tab": {
|
||||
event.preventDefault()
|
||||
|
||||
if (event.shiftKey) {
|
||||
movePrev()
|
||||
} else {
|
||||
moveNext()
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
case "Home": {
|
||||
event.preventDefault()
|
||||
setSelectedIndex(0)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
case "End": {
|
||||
event.preventDefault()
|
||||
setSelectedIndex(items.length - 1)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
case "Enter": {
|
||||
if (event.isComposing) return false
|
||||
if (event.isComposing) {
|
||||
return false
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
if (selectedIndex !== -1 && items[selectedIndex]) {
|
||||
onSelect?.(items[selectedIndex])
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
case "Escape": {
|
||||
event.preventDefault()
|
||||
onClose?.()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@ -12,6 +12,7 @@ export function useIsMobile() {
|
||||
}
|
||||
mql.addEventListener("change", onChange)
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
|
||||
return () => mql.removeEventListener("change", onChange)
|
||||
}, [])
|
||||
|
||||
|
||||
@ -3,7 +3,7 @@ import { toast } from 'sonner';
|
||||
|
||||
// Type definitions for Web Bluetooth and Web USB
|
||||
// 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 PaperSize = '58' | '80';
|
||||
@ -32,8 +32,10 @@ export function usePrinter() {
|
||||
const connectBluetooth = useCallback(async () => {
|
||||
try {
|
||||
const nav = navigator as any;
|
||||
|
||||
if (!nav.bluetooth) {
|
||||
toast.error('Bluetooth tidak didukung di browser ini');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@ -70,6 +72,7 @@ export function usePrinter() {
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Bluetooth connection error:', error);
|
||||
|
||||
if (error.name !== 'NotFoundError') {
|
||||
toast.error(error.message || 'Gagal menghubungkan printer bluetooth');
|
||||
}
|
||||
@ -79,8 +82,10 @@ export function usePrinter() {
|
||||
const connectUsb = useCallback(async () => {
|
||||
try {
|
||||
const nav = navigator as any;
|
||||
|
||||
if (!nav.usb) {
|
||||
toast.error('USB tidak didukung di browser ini');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@ -89,6 +94,7 @@ export function usePrinter() {
|
||||
});
|
||||
|
||||
await device.open();
|
||||
|
||||
if (device.configuration === null) {
|
||||
await device.selectConfiguration(1);
|
||||
}
|
||||
@ -108,6 +114,7 @@ export function usePrinter() {
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('USB connection error:', error);
|
||||
|
||||
if (error.name !== 'NotFoundError') {
|
||||
toast.error(error.message || 'Gagal menghubungkan printer USB');
|
||||
}
|
||||
@ -121,6 +128,7 @@ export function usePrinter() {
|
||||
const sendData = useCallback(async (data: Uint8Array) => {
|
||||
if (!state.isConnected || !state.device) {
|
||||
toast.error('Printer belum terhubung');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@ -134,20 +142,26 @@ export function usePrinter() {
|
||||
const server = await device.gatt?.connect();
|
||||
const services = await server.getPrimaryServices();
|
||||
const writeChar = await findWriteCharacteristic(services);
|
||||
|
||||
if (writeChar) {
|
||||
setCharacteristic(writeChar);
|
||||
toast.success('Printer terhubung kembali', { id: 'printer-reconnect' });
|
||||
} else {
|
||||
toast.error('Gagal menghubungkan kembali printer', { id: 'printer-reconnect' });
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!characteristic) throw new Error('Characteristic tidak ditemukan');
|
||||
if (!characteristic) {
|
||||
throw new Error('Characteristic tidak ditemukan');
|
||||
}
|
||||
|
||||
const chunkSize = 20;
|
||||
|
||||
for (let i = 0; i < data.length; i += chunkSize) {
|
||||
const chunk = data.slice(i, i + chunkSize);
|
||||
|
||||
// Use writeValueWithoutResponse for better stability if supported
|
||||
if (characteristic.writeValueWithoutResponse) {
|
||||
await characteristic.writeValueWithoutResponse(chunk);
|
||||
@ -158,7 +172,10 @@ export function usePrinter() {
|
||||
} else if (state.type === 'usb') {
|
||||
const device = state.device as any;
|
||||
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);
|
||||
}
|
||||
@ -195,11 +212,13 @@ export function usePrinter() {
|
||||
async function findWriteCharacteristic(services: any[]) {
|
||||
for (const service of services) {
|
||||
const characteristics = await service.getCharacteristics();
|
||||
|
||||
for (const char of characteristics) {
|
||||
if (char.properties.write || char.properties.writeWithoutResponse) {
|
||||
return char;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -47,7 +47,9 @@ export function useScrolling(
|
||||
const supportsScrollEnd = element === window && "onscrollend" in window
|
||||
|
||||
const handleScroll: EventListener = () => {
|
||||
if (!isScrolling) setIsScrolling(true)
|
||||
if (!isScrolling) {
|
||||
setIsScrolling(true)
|
||||
}
|
||||
|
||||
if (!supportsScrollEnd) {
|
||||
clearTimeout(timeout)
|
||||
@ -58,15 +60,18 @@ export function useScrolling(
|
||||
const handleScrollEnd: EventListener = () => setIsScrolling(false)
|
||||
|
||||
on(eventTarget, "scroll", handleScroll)
|
||||
|
||||
if (supportsScrollEnd) {
|
||||
on(eventTarget, "scrollend", handleScrollEnd)
|
||||
}
|
||||
|
||||
return () => {
|
||||
off(eventTarget, "scroll", handleScroll)
|
||||
|
||||
if (supportsScrollEnd) {
|
||||
off(eventTarget, "scrollend", handleScrollEnd)
|
||||
}
|
||||
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}, [target, debounce, fallbackToDocument, isScrolling])
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import throttle from "lodash.throttle"
|
||||
|
||||
import { useUnmount } from "@/hooks/use-unmount"
|
||||
import { useMemo } from "react"
|
||||
import { useUnmount } from "@/hooks/use-unmount"
|
||||
|
||||
interface ThrottleSettings {
|
||||
leading?: boolean | undefined
|
||||
@ -21,7 +21,7 @@ const defaultOptions: ThrottleSettings = {
|
||||
* @param dependencies The dependencies to watch for changes
|
||||
* @param options The throttle options
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
||||
export function useThrottledCallback<T extends (...args: any[]) => any>(
|
||||
fn: T,
|
||||
wait = 250,
|
||||
|
||||
@ -5,7 +5,11 @@ import { useEffect, useState } from "react"
|
||||
function getActivePageEditor(editor: Editor): Editor | null {
|
||||
const storage = editor.storage as unknown as Record<string, unknown>
|
||||
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
|
||||
}
|
||||
|
||||
@ -22,6 +26,7 @@ export function useTiptapEditor(providedEditor?: Editor | null): {
|
||||
useEffect(() => {
|
||||
if (!mainEditor) {
|
||||
setStorageEditor(null)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@ -40,11 +45,14 @@ export function useTiptapEditor(providedEditor?: Editor | null): {
|
||||
}, [mainEditor])
|
||||
|
||||
useEffect(() => {
|
||||
if (!storageEditor) return
|
||||
if (!storageEditor) {
|
||||
return
|
||||
}
|
||||
|
||||
const handleDestroy = () => setStorageEditor(null)
|
||||
|
||||
storageEditor.on("destroy", handleDestroy)
|
||||
|
||||
return () => {
|
||||
storageEditor.off("destroy", handleDestroy)
|
||||
}
|
||||
|
||||
@ -5,7 +5,7 @@ import { useRef, useEffect } from "react"
|
||||
*
|
||||
* @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) => {
|
||||
const ref = useRef(callback)
|
||||
ref.current = callback
|
||||
|
||||
@ -48,10 +48,15 @@ export function useWindowSize(): WindowSizeState {
|
||||
})
|
||||
|
||||
const handleViewportChange = useThrottledCallback(() => {
|
||||
if (typeof window === "undefined") return
|
||||
if (typeof window === "undefined") {
|
||||
return
|
||||
}
|
||||
|
||||
const vp = window.visualViewport
|
||||
if (!vp) return
|
||||
|
||||
if (!vp) {
|
||||
return
|
||||
}
|
||||
|
||||
const {
|
||||
width = 0,
|
||||
@ -78,7 +83,10 @@ export function useWindowSize(): WindowSizeState {
|
||||
|
||||
useEffect(() => {
|
||||
const visualViewport = window.visualViewport
|
||||
if (!visualViewport) return
|
||||
|
||||
if (!visualViewport) {
|
||||
return
|
||||
}
|
||||
|
||||
visualViewport.addEventListener("resize", handleViewportChange)
|
||||
|
||||
|
||||
@ -12,6 +12,7 @@ export class EscPosEncoder {
|
||||
*/
|
||||
initialize(): this {
|
||||
this.buffer.push(0x1b, 0x40);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
@ -21,6 +22,7 @@ export class EscPosEncoder {
|
||||
text(value: string): this {
|
||||
const bytes = this.encoder.encode(value);
|
||||
this.buffer.push(...Array.from(bytes));
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
@ -29,6 +31,7 @@ export class EscPosEncoder {
|
||||
*/
|
||||
line(value: string = ''): this {
|
||||
this.text(value + '\n');
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
@ -38,6 +41,7 @@ export class EscPosEncoder {
|
||||
*/
|
||||
align(value: 0 | 1 | 2): this {
|
||||
this.buffer.push(0x1b, 0x61, value);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
@ -46,6 +50,7 @@ export class EscPosEncoder {
|
||||
*/
|
||||
bold(value: boolean): this {
|
||||
this.buffer.push(0x1b, 0x45, value ? 1 : 0);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
@ -55,10 +60,21 @@ export class EscPosEncoder {
|
||||
*/
|
||||
size(value: 0 | 1 | 2 | 3): this {
|
||||
let size = 0;
|
||||
if (value === 1) size = 0x01;
|
||||
if (value === 2) size = 0x10;
|
||||
if (value === 3) size = 0x11;
|
||||
|
||||
if (value === 1) {
|
||||
size = 0x01;
|
||||
}
|
||||
|
||||
if (value === 2) {
|
||||
size = 0x10;
|
||||
}
|
||||
|
||||
if (value === 3) {
|
||||
size = 0x11;
|
||||
}
|
||||
|
||||
this.buffer.push(0x1d, 0x21, size);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
@ -67,6 +83,7 @@ export class EscPosEncoder {
|
||||
*/
|
||||
cut(): this {
|
||||
this.buffer.push(0x1d, 0x56, 0x00);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
@ -75,6 +92,7 @@ export class EscPosEncoder {
|
||||
*/
|
||||
feed(lines: number = 1): this {
|
||||
this.buffer.push(0x1b, 0x64, lines);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
@ -7,11 +7,15 @@ export const formatTime = (date: Date) => {
|
||||
};
|
||||
|
||||
export const formatDate = (date: Date | string | undefined) => {
|
||||
if (!date) return '-';
|
||||
if (!date) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
const parsedDate = date instanceof Date ? date : new Date(date);
|
||||
|
||||
if (isNaN(parsedDate.getTime())) return '-';
|
||||
if (isNaN(parsedDate.getTime())) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
return parsedDate.toLocaleDateString('id-ID', {
|
||||
weekday: 'long',
|
||||
|
||||
@ -8,10 +8,11 @@ import {
|
||||
} from "@tiptap/pm/state"
|
||||
import { cellAround, CellSelection } from "@tiptap/pm/tables"
|
||||
import {
|
||||
findParentNodeClosestToPos,
|
||||
type Editor,
|
||||
type NodeWithPos,
|
||||
findParentNodeClosestToPos
|
||||
|
||||
|
||||
} from "@tiptap/react"
|
||||
import type {Editor, NodeWithPos} from "@tiptap/react";
|
||||
|
||||
export const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB
|
||||
|
||||
@ -74,6 +75,7 @@ export const formatShortcutKey = (
|
||||
) => {
|
||||
if (isMac) {
|
||||
const lowerKey = key.toLowerCase()
|
||||
|
||||
return MAC_SYMBOLS[lowerKey] || (capitalize ? key.toUpperCase() : key)
|
||||
}
|
||||
|
||||
@ -94,7 +96,9 @@ export const parseShortcutKeys = (props: {
|
||||
}) => {
|
||||
const { shortcutKeys, delimiter = "+", capitalize = true } = props
|
||||
|
||||
if (!shortcutKeys) return []
|
||||
if (!shortcutKeys) {
|
||||
return []
|
||||
}
|
||||
|
||||
return shortcutKeys
|
||||
.split(delimiter)
|
||||
@ -112,7 +116,10 @@ export const isMarkInSchema = (
|
||||
markName: string,
|
||||
editor: Editor | null
|
||||
): boolean => {
|
||||
if (!editor?.schema) return false
|
||||
if (!editor?.schema) {
|
||||
return false
|
||||
}
|
||||
|
||||
return editor.schema.spec.marks.get(markName) !== undefined
|
||||
}
|
||||
|
||||
@ -126,7 +133,10 @@ export const isNodeInSchema = (
|
||||
nodeName: string,
|
||||
editor: Editor | null
|
||||
): boolean => {
|
||||
if (!editor?.schema) return false
|
||||
if (!editor?.schema) {
|
||||
return false
|
||||
}
|
||||
|
||||
return editor.schema.spec.nodes.get(nodeName) !== undefined
|
||||
}
|
||||
|
||||
@ -140,14 +150,18 @@ export function focusNextNode(editor: Editor) {
|
||||
const { doc, selection } = state
|
||||
|
||||
const nextSel = Selection.findFrom(selection.$to, 1, true)
|
||||
|
||||
if (nextSel) {
|
||||
view.dispatch(state.tr.setSelection(nextSel).scrollIntoView())
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const paragraphType = state.schema.nodes.paragraph
|
||||
|
||||
if (!paragraphType) {
|
||||
console.warn("No paragraph node type found in schema.")
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@ -159,6 +173,7 @@ export function focusNextNode(editor: Editor) {
|
||||
const $inside = tr.doc.resolve(end + 1)
|
||||
tr = tr.setSelection(TextSelection.near($inside)).scrollIntoView()
|
||||
view.dispatch(tr)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@ -181,7 +196,9 @@ export function isExtensionAvailable(
|
||||
editor: Editor | null,
|
||||
extensionNames: string | string[]
|
||||
): boolean {
|
||||
if (!editor) return false
|
||||
if (!editor) {
|
||||
return false
|
||||
}
|
||||
|
||||
const names = Array.isArray(extensionNames)
|
||||
? extensionNames
|
||||
@ -209,13 +226,17 @@ export function isExtensionAvailable(
|
||||
export function findNodeAtPosition(editor: Editor, position: number) {
|
||||
try {
|
||||
const node = editor.state.doc.nodeAt(position)
|
||||
|
||||
if (!node) {
|
||||
console.warn(`No node found at position ${position}`)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
return node
|
||||
} catch (error) {
|
||||
console.error(`Error getting node at position ${position}:`, error)
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
@ -235,7 +256,9 @@ export function findNodePosition(props: {
|
||||
}): { pos: number; node: PMNode } | null {
|
||||
const { editor, node, nodePos } = props
|
||||
|
||||
if (!editor || !editor.state?.doc) return null
|
||||
if (!editor || !editor.state?.doc) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Zero is valid position
|
||||
const hasValidNode = node !== undefined && node !== null
|
||||
@ -256,8 +279,10 @@ export function findNodePosition(props: {
|
||||
if (currentNode === node) {
|
||||
foundPos = pos
|
||||
foundNode = currentNode
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
@ -269,6 +294,7 @@ export function findNodePosition(props: {
|
||||
// If we have a valid position, use findNodeAtPosition
|
||||
if (hasValidPos) {
|
||||
const nodeAtPos = findNodeAtPosition(editor, nodePos!)
|
||||
|
||||
if (nodeAtPos) {
|
||||
return { pos: nodePos!, node: nodeAtPos }
|
||||
}
|
||||
@ -289,22 +315,30 @@ export function isNodeTypeSelected(
|
||||
nodeTypeNames: string[] = [],
|
||||
checkAncestorNodes: boolean = false
|
||||
): boolean {
|
||||
if (!editor || !editor.state.selection) return false
|
||||
if (!editor || !editor.state.selection) {
|
||||
return false
|
||||
}
|
||||
|
||||
const { selection } = editor.state
|
||||
if (selection.empty) return false
|
||||
|
||||
if (selection.empty) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Direct node selection check
|
||||
if (selection instanceof NodeSelection) {
|
||||
const selectedNode = selection.node
|
||||
|
||||
return selectedNode ? nodeTypeNames.includes(selectedNode.type.name) : false
|
||||
}
|
||||
|
||||
// Depth-based ancestor node check
|
||||
if (checkAncestorNodes) {
|
||||
const { $from } = selection
|
||||
|
||||
for (let depth = $from.depth; depth > 0; depth--) {
|
||||
const ancestorNode = $from.node(depth)
|
||||
|
||||
if (nodeTypeNames.includes(ancestorNode.type.name)) {
|
||||
return true
|
||||
}
|
||||
@ -325,7 +359,9 @@ export function selectionWithinConvertibleTypes(
|
||||
editor: Editor,
|
||||
types: string[] = []
|
||||
): boolean {
|
||||
if (!editor || types.length === 0) return false
|
||||
if (!editor || types.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
const { state } = editor
|
||||
const { selection } = state
|
||||
@ -333,6 +369,7 @@ export function selectionWithinConvertibleTypes(
|
||||
|
||||
if (selection instanceof NodeSelection) {
|
||||
const nodeType = selection.node?.type?.name
|
||||
|
||||
return !!nodeType && allowed.has(nodeType)
|
||||
}
|
||||
|
||||
@ -341,10 +378,13 @@ export function selectionWithinConvertibleTypes(
|
||||
state.doc.nodesBetween(selection.from, selection.to, (node) => {
|
||||
if (node.isTextblock && !allowed.has(node.type.name)) {
|
||||
valid = false
|
||||
|
||||
return false // stop early
|
||||
}
|
||||
|
||||
return valid
|
||||
})
|
||||
|
||||
return valid
|
||||
}
|
||||
|
||||
@ -380,6 +420,7 @@ export const handleImageUpload = async (
|
||||
if (abortSignal?.aborted) {
|
||||
throw new Error("Upload cancelled")
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
onProgress?.({ progress })
|
||||
}
|
||||
@ -464,6 +505,7 @@ export function sanitizeUrl(
|
||||
} catch {
|
||||
// If URL creation fails, it's considered invalid
|
||||
}
|
||||
|
||||
return "#"
|
||||
}
|
||||
|
||||
@ -483,14 +525,19 @@ export function updateNodesAttr<A extends string = string, V = unknown>(
|
||||
attrName: A,
|
||||
next: V | ((prev: V | undefined) => V | undefined)
|
||||
): boolean {
|
||||
if (!targets.length) return false
|
||||
if (!targets.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
let changed = false
|
||||
|
||||
for (const { pos } of targets) {
|
||||
// Always re-read from the transaction's current doc
|
||||
const currentNode = tr.doc.nodeAt(pos)
|
||||
if (!currentNode) continue
|
||||
|
||||
if (!currentNode) {
|
||||
continue
|
||||
}
|
||||
|
||||
const prevValue = (currentNode.attrs as Record<string, unknown>)[
|
||||
attrName
|
||||
@ -500,9 +547,12 @@ export function updateNodesAttr<A extends string = string, V = unknown>(
|
||||
? (next as (p: V | undefined) => V | undefined)(prevValue)
|
||||
: next
|
||||
|
||||
if (prevValue === resolvedNext) continue
|
||||
if (prevValue === resolvedNext) {
|
||||
continue
|
||||
}
|
||||
|
||||
const nextAttrs: Record<string, unknown> = { ...currentNode.attrs }
|
||||
|
||||
if (resolvedNext === undefined) {
|
||||
// Remove the key entirely instead of setting null
|
||||
delete nextAttrs[attrName]
|
||||
@ -525,7 +575,9 @@ export function updateNodesAttr<A extends string = string, V = unknown>(
|
||||
export function selectCurrentBlockContent(editor: Editor) {
|
||||
const { selection, doc } = editor.state
|
||||
|
||||
if (!selection.empty) return
|
||||
if (!selection.empty) {
|
||||
return
|
||||
}
|
||||
|
||||
const $pos = selection.$from
|
||||
let blockNode = null
|
||||
@ -577,14 +629,17 @@ export function getSelectedNodesOfType(
|
||||
results.push({ node, pos })
|
||||
}
|
||||
})
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
if (selection instanceof NodeSelection) {
|
||||
const { node, from: pos } = selection
|
||||
|
||||
if (node && allowed.has(node.type.name)) {
|
||||
results.push({ node, pos })
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
@ -593,8 +648,10 @@ export function getSelectedNodesOfType(
|
||||
|
||||
if (cell) {
|
||||
const cellNode = selection.$anchor.doc.nodeAt(cell.pos)
|
||||
|
||||
if (cellNode && allowed.has(cellNode.type.name)) {
|
||||
results.push({ node: cellNode, pos: cell.pos })
|
||||
|
||||
return results
|
||||
}
|
||||
}
|
||||
@ -626,7 +683,9 @@ export function getSelectedBlockNodes(editor: Editor): PMNode[] {
|
||||
const seen = new Set<number>()
|
||||
|
||||
doc.nodesBetween(from, to, (node, pos) => {
|
||||
if (!node.isBlock) return
|
||||
if (!node.isBlock) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!seen.has(pos)) {
|
||||
seen.add(pos)
|
||||
|
||||
@ -8,8 +8,17 @@ export function cn(...inputs: ClassValue[]) {
|
||||
}
|
||||
|
||||
export function toUrl(url: InertiaLinkProps['href']): string {
|
||||
if (!url) return '';
|
||||
if (typeof url === 'string') return url;
|
||||
if (typeof url === 'object' && url !== null && 'url' in url) return url.url as string;
|
||||
if (!url) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (typeof url === 'string') {
|
||||
return url;
|
||||
}
|
||||
|
||||
if (typeof url === 'object' && url !== null && 'url' in url) {
|
||||
return url.url as string;
|
||||
}
|
||||
|
||||
return String(url);
|
||||
}
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { Expense } from '@/types';
|
||||
import { router } from '@inertiajs/react';
|
||||
import expenseRoutes from '@/routes/expense';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import expenseRoutes from '@/routes/expense';
|
||||
import type { Expense } from '@/types';
|
||||
|
||||
export function useExpenseIndex() {
|
||||
const [isFormOpen, setIsFormOpen] = useState(false);
|
||||
|
||||
@ -1,15 +1,15 @@
|
||||
import { Head } from '@inertiajs/react';
|
||||
import type { Expense } from '@/types';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Trash2, X, Plus } from 'lucide-react';
|
||||
import { Trash2, Plus } from 'lucide-react';
|
||||
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 { getColumns } from './partials/columns';
|
||||
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[] }) {
|
||||
const {
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { 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 type { ColumnDef } from '@tanstack/react-table';
|
||||
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 {
|
||||
onEdit: (expense: Expense) => void;
|
||||
@ -25,6 +25,7 @@ export const getColumns = ({ onEdit, onDelete, onPreviewImage }: ColumnProps): C
|
||||
header: "Bukti",
|
||||
cell: ({ row }) => {
|
||||
const url = row.original.proof_url;
|
||||
|
||||
return url ? (
|
||||
<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" />
|
||||
@ -77,6 +78,7 @@ export const getColumns = ({ onEdit, onDelete, onPreviewImage }: ColumnProps): C
|
||||
header: "Aksi",
|
||||
cell: ({ row }) => {
|
||||
const expense = row.original;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip>
|
||||
|
||||
@ -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 {
|
||||
Dialog,
|
||||
@ -9,13 +14,8 @@ import {
|
||||
import { Field, FieldError, FieldGroup } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
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 { toast } from 'sonner';
|
||||
import { NumericFormat } from 'react-number-format';
|
||||
import { ImagePlus, X, Save, Loader } from 'lucide-react';
|
||||
import type { Expense } from '@/types';
|
||||
|
||||
interface ExpenseFormModalProps {
|
||||
isOpen: boolean;
|
||||
@ -63,6 +63,7 @@ export function ExpenseFormModal({ isOpen, onClose, expense }: ExpenseFormModalP
|
||||
|
||||
const onImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
|
||||
if (file) {
|
||||
setData('image', file);
|
||||
const reader = new FileReader();
|
||||
@ -76,6 +77,7 @@ export function ExpenseFormModal({ isOpen, onClose, expense }: ExpenseFormModalP
|
||||
const removeImage = () => {
|
||||
setData('image', null);
|
||||
setImagePreview(null);
|
||||
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
@ -83,6 +85,7 @@ export function ExpenseFormModal({ isOpen, onClose, expense }: ExpenseFormModalP
|
||||
|
||||
const onSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (isEditing && expense) {
|
||||
router.post(expenseRoutes.update(expense.id).url, {
|
||||
...data,
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { Payroll } from '@/types';
|
||||
import { router } from '@inertiajs/react';
|
||||
import payrollRoutes from '@/routes/payroll';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import payrollRoutes from '@/routes/payroll';
|
||||
import type { Payroll } from '@/types';
|
||||
|
||||
export function usePayrollIndex() {
|
||||
const [isFormOpen, setIsFormOpen] = useState(false);
|
||||
|
||||
@ -1,26 +1,17 @@
|
||||
import { Head } from '@inertiajs/react';
|
||||
import type { Payroll } from '@/types';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Trash2, Sparkles, Plus, X } from 'lucide-react';
|
||||
import { Trash2, Sparkles } from 'lucide-react';
|
||||
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 { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import type { Payroll } from '@/types';
|
||||
|
||||
import { usePayrollIndex } from './hooks/use-payroll-index';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { getColumns } from './partials/columns';
|
||||
import { PayrollFormModal } from './partials/payroll-form-modal';
|
||||
import { DeleteConfirmation } from '@/components/modal/delete-confirmation';
|
||||
|
||||
export default function PayrollIndex({
|
||||
payrolls,
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { Payroll } from '@/types';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { PencilRuler, Trash2 } from 'lucide-react';
|
||||
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 { Switch } from '@/components/ui/switch';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { PencilRuler, Trash2 } from 'lucide-react';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import type { Payroll } from '@/types';
|
||||
|
||||
interface ColumnProps {
|
||||
onEdit: (payroll: Payroll) => void;
|
||||
@ -37,6 +37,7 @@ export const getColumns = ({ onEdit, onDelete, onTogglePaid }: ColumnProps): Col
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const payroll = row.original;
|
||||
|
||||
return (
|
||||
<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">
|
||||
@ -71,6 +72,7 @@ export const getColumns = ({ onEdit, onDelete, onTogglePaid }: ColumnProps): Col
|
||||
header: "Status",
|
||||
cell: ({ row }) => {
|
||||
const payroll = row.original;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
@ -93,6 +95,7 @@ export const getColumns = ({ onEdit, onDelete, onTogglePaid }: ColumnProps): Col
|
||||
header: "Aksi",
|
||||
cell: ({ row }) => {
|
||||
const payroll = row.original;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip>
|
||||
|
||||
@ -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 {
|
||||
Dialog,
|
||||
@ -6,21 +12,14 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} 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 { 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 { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from '@/components/ui/empty';
|
||||
import { X, Save } from 'lucide-react';
|
||||
import { SelectTrigger } from '@/components/ui/select';
|
||||
import payrollRoutes from '@/routes/payroll';
|
||||
import type { Payroll } from '@/types';
|
||||
|
||||
interface PayrollFormModalProps {
|
||||
isOpen: boolean;
|
||||
@ -76,6 +75,7 @@ export function PayrollFormModal({ isOpen, onClose, payroll }: PayrollFormModalP
|
||||
|
||||
const onSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (payroll) {
|
||||
patch(payrollRoutes.update(payroll.id).url, {
|
||||
onSuccess: (response: any) => {
|
||||
|
||||
@ -1,19 +1,22 @@
|
||||
import { Head, Link, useForm, router } from '@inertiajs/react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Head, Link, router, useForm } from '@inertiajs/react';
|
||||
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 { 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 { Input } from "@/components/ui/input";
|
||||
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 {
|
||||
Select,
|
||||
@ -22,21 +25,15 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from '@/components/ui/empty';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetTrigger
|
||||
} 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 EnumOption = { value: string, label: string };
|
||||
@ -60,6 +57,7 @@ export default function OrderCreate({ products, cartItems, orderStatus, orderCha
|
||||
const categories = useMemo(() => {
|
||||
const map = new Map<number, string>();
|
||||
products.forEach(p => p.categories?.forEach(c => map.set(c.id, c.name)));
|
||||
|
||||
return Array.from(map.entries()).map(([id, name]) => ({ id, name }));
|
||||
}, [products]);
|
||||
|
||||
@ -77,6 +75,7 @@ export default function OrderCreate({ products, cartItems, orderStatus, orderCha
|
||||
const filteredProducts = products.filter(p => {
|
||||
const matchesSearch = p.name.toLowerCase().includes(search.toLowerCase());
|
||||
const matchesCategory = selectedCategory === 'all' || p.categories?.some(c => String(c.id) === selectedCategory);
|
||||
|
||||
return matchesSearch && matchesCategory;
|
||||
});
|
||||
|
||||
@ -130,7 +129,10 @@ export default function OrderCreate({ products, cartItems, orderStatus, orderCha
|
||||
};
|
||||
|
||||
const updateCartQuantity = (itemId: number, qty: number) => {
|
||||
if (qty < 1) return;
|
||||
if (qty < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
router.patch(orderRoutes.updateCartItem(itemId).url, {
|
||||
qty
|
||||
}, {
|
||||
@ -146,16 +148,20 @@ export default function OrderCreate({ products, cartItems, orderStatus, orderCha
|
||||
|
||||
const confirmQty = () => {
|
||||
const val = parseInt(qtyInputValue);
|
||||
|
||||
if (!isNaN(val) && val >= 1 && qtyDialogIndex !== null) {
|
||||
updateCartQuantity(qtyDialogIndex, val);
|
||||
}
|
||||
|
||||
setQtyDialogIndex(null);
|
||||
};
|
||||
|
||||
const onSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (cartItems.length === 0) {
|
||||
toast.error('Pilih minimal satu produk');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@ -187,6 +193,7 @@ export default function OrderCreate({ products, cartItems, orderStatus, orderCha
|
||||
|
||||
const PriceTypeLabel = ({ type }: { type: string }) => {
|
||||
const option = priceTypes.find(opt => opt.value === 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"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (!cartItem) return;
|
||||
if (!cartItem) {
|
||||
return;
|
||||
}
|
||||
|
||||
openQtyDialog(cartItem, e);
|
||||
}}
|
||||
>
|
||||
@ -600,7 +610,11 @@ export default function OrderCreate({ products, cartItems, orderStatus, orderCha
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Ubah Jumlah</DialogTitle>
|
||||
@ -614,7 +628,11 @@ export default function OrderCreate({ products, cartItems, orderStatus, orderCha
|
||||
min="1"
|
||||
value={qtyInputValue}
|
||||
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"
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
@ -1,19 +1,21 @@
|
||||
import { Head, Link, useForm, router } from '@inertiajs/react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Head, Link, useForm } from '@inertiajs/react';
|
||||
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 { 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 { Input } from "@/components/ui/input";
|
||||
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 {
|
||||
Select,
|
||||
@ -22,21 +24,15 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from '@/components/ui/empty';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetTrigger
|
||||
} 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 };
|
||||
|
||||
@ -57,6 +53,7 @@ export default function OrderEdit({ order, products, orderStatus, orderChannels,
|
||||
const categories = useMemo(() => {
|
||||
const map = new Map<number, string>();
|
||||
products.forEach(p => p.categories?.forEach(c => map.set(c.id, c.name)));
|
||||
|
||||
return Array.from(map.entries()).map(([id, name]) => ({ id, name }));
|
||||
}, [products]);
|
||||
|
||||
@ -81,6 +78,7 @@ export default function OrderEdit({ order, products, orderStatus, orderChannels,
|
||||
const filteredProducts = products.filter(p => {
|
||||
const matchesSearch = p.name.toLowerCase().includes(search.toLowerCase());
|
||||
const matchesCategory = selectedCategory === 'all' || p.categories?.some(c => String(c.id) === selectedCategory);
|
||||
|
||||
return matchesSearch && matchesCategory;
|
||||
});
|
||||
|
||||
@ -118,22 +116,30 @@ export default function OrderEdit({ order, products, orderStatus, orderChannels,
|
||||
const decreaseQuantity = (productId: number, priceType: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const index = findItemIndex(productId, priceType);
|
||||
if (index === -1) return;
|
||||
|
||||
if (index === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newItems = [...data.items];
|
||||
|
||||
if (newItems[index].qty <= 1) {
|
||||
newItems.splice(index, 1);
|
||||
} else {
|
||||
newItems[index].qty -= 1;
|
||||
newItems[index].total = newItems[index].qty * newItems[index].price;
|
||||
}
|
||||
|
||||
setData('items', newItems);
|
||||
};
|
||||
|
||||
const increaseQuantity = (productId: number, priceType: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const product = products.find(p => p.id === productId);
|
||||
if (product) addToCart(product, priceType);
|
||||
|
||||
if (product) {
|
||||
addToCart(product, priceType);
|
||||
}
|
||||
};
|
||||
|
||||
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) => {
|
||||
if (qty < 1) return;
|
||||
if (qty < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const index = findItemIndex(productId, priceType);
|
||||
|
||||
if (index > -1) {
|
||||
const newItems = [...data.items];
|
||||
newItems[index].qty = qty;
|
||||
@ -160,16 +170,20 @@ export default function OrderEdit({ order, products, orderStatus, orderChannels,
|
||||
|
||||
const confirmQty = () => {
|
||||
const val = parseInt(qtyInputValue);
|
||||
|
||||
if (!isNaN(val) && val >= 1 && qtyDialogIndex) {
|
||||
updateCartQuantity(qtyDialogIndex.product_id, qtyDialogIndex.price_type, val);
|
||||
}
|
||||
|
||||
setQtyDialogIndex(null);
|
||||
};
|
||||
|
||||
const onSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (data.items.length === 0) {
|
||||
toast.error('Pilih minimal satu produk');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@ -190,6 +204,7 @@ export default function OrderEdit({ order, products, orderStatus, orderChannels,
|
||||
|
||||
const PriceTypeLabel = ({ type }: { type: string }) => {
|
||||
const option = priceTypes.find(opt => opt.value === 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"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (!cartItem) return;
|
||||
if (!cartItem) {
|
||||
return;
|
||||
}
|
||||
|
||||
openQtyDialog(cartItem, e);
|
||||
}}
|
||||
>
|
||||
@ -586,7 +604,11 @@ export default function OrderEdit({ order, products, orderStatus, orderChannels,
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Ubah Jumlah</DialogTitle>
|
||||
@ -600,7 +622,11 @@ export default function OrderEdit({ order, products, orderStatus, orderChannels,
|
||||
min="1"
|
||||
value={qtyInputValue}
|
||||
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"
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { Order } from '@/types/order';
|
||||
import { router } from '@inertiajs/react';
|
||||
import * as orderRoutes from '@/routes/order';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import * as orderRoutes from '@/routes/order';
|
||||
import type { Order } from '@/types/order';
|
||||
|
||||
export function useOrderIndex() {
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
|
||||
@ -1,44 +1,33 @@
|
||||
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 type { Order } from '@/types/order';
|
||||
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 { toast } from 'sonner';
|
||||
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 {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogMedia,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
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 { EscPosEncoder } from '@/lib/esc-pos-encoder';
|
||||
import { format } from 'date-fns';
|
||||
import { id } from 'date-fns/locale';
|
||||
import { toast } from 'sonner';
|
||||
import { DeleteConfirmation } from '@/components/modal/delete-confirmation';
|
||||
import * as orderRoutes from '@/routes/order';
|
||||
import type { Order } from '@/types/order';
|
||||
import { useOrderIndex } from './hooks/use-order-index';
|
||||
import { getColumns } from './partials/columns';
|
||||
|
||||
export default function OrderIndex({ orders }: { orders: Order[] }) {
|
||||
const {
|
||||
@ -70,6 +59,7 @@ export default function OrderIndex({ orders }: { orders: Order[] }) {
|
||||
const handlePrint = useCallback(async (order: Order) => {
|
||||
if (!isConnected) {
|
||||
toast.error('Hubungkan printer terlebih dahulu');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@ -93,7 +83,10 @@ export default function OrderIndex({ orders }: { orders: Order[] }) {
|
||||
.line(line);
|
||||
|
||||
order.items?.forEach((item, index) => {
|
||||
if (index > 0) result.line();
|
||||
if (index > 0) {
|
||||
result.line();
|
||||
}
|
||||
|
||||
result.line(item.product?.name || 'Produk');
|
||||
const qtyPrice = `${item.qty} x ${item.price_formatted}`;
|
||||
const subtotal = item.total_formatted;
|
||||
@ -104,6 +97,7 @@ export default function OrderIndex({ orders }: { orders: Order[] }) {
|
||||
result.line(line);
|
||||
|
||||
const discountAmount = Number(order.discount || 0);
|
||||
|
||||
if (discountAmount > 0) {
|
||||
const discountLabel = 'Diskon:';
|
||||
const discountVal = `- Rp ${discountAmount.toLocaleString('id-ID')}`;
|
||||
|
||||
@ -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 * as orderRoutes from '@/routes/order';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { format } from 'date-fns';
|
||||
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 { 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 {
|
||||
onDelete: (order: Order) => void;
|
||||
@ -100,6 +100,7 @@ export const getColumns = ({ onDelete, onPrint }: ColumnProps): ColumnDef<Order>
|
||||
header: "Aksi",
|
||||
cell: ({ row }) => {
|
||||
const order = row.original;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip>
|
||||
|
||||
@ -1,19 +1,26 @@
|
||||
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 { 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 { Input } from "@/components/ui/input";
|
||||
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 { Calendar } from '@/components/ui/calendar';
|
||||
import { format } from 'date-fns';
|
||||
import type { Product, ProductPrice } from '@/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import {
|
||||
Select,
|
||||
@ -22,20 +29,13 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from '@/components/ui/empty';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
} from "@/components/ui/sheet";
|
||||
import purchaseRoutes from '@/routes/purchase';
|
||||
|
||||
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 map = new Map<number, string>();
|
||||
products.forEach(p => p.categories?.forEach(c => map.set(c.id, c.name)));
|
||||
|
||||
return Array.from(map.entries()).map(([id, name]) => ({ id, name }));
|
||||
}, [products]);
|
||||
|
||||
@ -69,6 +70,7 @@ export default function PurchaseCreate({ products, cartItems }: { products: Prod
|
||||
const filteredProducts = products.filter(p => {
|
||||
const matchesSearch = p.name.toLowerCase().includes(search.toLowerCase());
|
||||
const matchesCategory = selectedCategory === 'all' || p.categories?.some(c => String(c.id) === selectedCategory);
|
||||
|
||||
return matchesSearch && matchesCategory;
|
||||
});
|
||||
|
||||
@ -89,7 +91,10 @@ export default function PurchaseCreate({ products, cartItems }: { products: Prod
|
||||
const decreaseQuantity = (product: Product, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const item = getCartItem(product.id);
|
||||
if (!item) return;
|
||||
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
|
||||
router.post(purchaseRoutes.addToCart().url, {
|
||||
product_id: product.id,
|
||||
@ -112,7 +117,10 @@ export default function PurchaseCreate({ products, cartItems }: { products: Prod
|
||||
};
|
||||
|
||||
const updateCartQuantity = (itemId: number, quantity: number) => {
|
||||
if (quantity < 1) return;
|
||||
if (quantity < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
router.patch(purchaseRoutes.updateCartItem(itemId).url, {
|
||||
quantity
|
||||
}, {
|
||||
@ -128,16 +136,20 @@ export default function PurchaseCreate({ products, cartItems }: { products: Prod
|
||||
|
||||
const confirmQty = () => {
|
||||
const val = parseInt(qtyInputValue);
|
||||
|
||||
if (!isNaN(val) && val >= 1 && qtyDialogIndex !== null) {
|
||||
updateCartQuantity(qtyDialogIndex, val);
|
||||
}
|
||||
|
||||
setQtyDialogIndex(null);
|
||||
};
|
||||
|
||||
const onSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (cartItems.length === 0) {
|
||||
toast.error('Pilih minimal satu produk');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@ -267,6 +279,7 @@ export default function PurchaseCreate({ products, cartItems }: { products: Prod
|
||||
} else {
|
||||
setData('purchase_date', '');
|
||||
}
|
||||
|
||||
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"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (!cartItem) return;
|
||||
if (!cartItem) {
|
||||
return;
|
||||
}
|
||||
|
||||
openQtyDialog(cartItem, e);
|
||||
}}
|
||||
>
|
||||
@ -569,6 +585,7 @@ export default function PurchaseCreate({ products, cartItems }: { products: Prod
|
||||
} else {
|
||||
setData('purchase_date', '');
|
||||
}
|
||||
|
||||
setIsCalendarOpen(false);
|
||||
}}
|
||||
/>
|
||||
@ -786,7 +803,11 @@ export default function PurchaseCreate({ products, cartItems }: { products: Prod
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Ubah Jumlah</DialogTitle>
|
||||
@ -800,7 +821,11 @@ export default function PurchaseCreate({ products, cartItems }: { products: Prod
|
||||
min="1"
|
||||
value={qtyInputValue}
|
||||
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"
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
@ -1,19 +1,26 @@
|
||||
import { Head, Link, useForm, router } from '@inertiajs/react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Head, Link, useForm } from '@inertiajs/react';
|
||||
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 { 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 { Input } from "@/components/ui/input";
|
||||
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 { Calendar } from '@/components/ui/calendar';
|
||||
import { format } from 'date-fns';
|
||||
import type { Product, ProductPrice, Purchase } from '@/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import {
|
||||
Select,
|
||||
@ -22,22 +29,13 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from '@/components/ui/empty';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
} from "@/components/ui/sheet";
|
||||
import purchaseRoutes from '@/routes/purchase';
|
||||
|
||||
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 map = new Map<number, string>();
|
||||
products.forEach(p => p.categories?.forEach(c => map.set(c.id, c.name)));
|
||||
|
||||
return Array.from(map.entries()).map(([id, name]) => ({ id, name }));
|
||||
}, [products]);
|
||||
|
||||
@ -76,6 +75,7 @@ export default function PurchaseEdit({ purchase, products }: { purchase: Purchas
|
||||
const filteredProducts = products.filter(p => {
|
||||
const matchesSearch = p.name.toLowerCase().includes(search.toLowerCase());
|
||||
const matchesCategory = selectedCategory === 'all' || p.categories?.some(c => String(c.id) === selectedCategory);
|
||||
|
||||
return matchesSearch && matchesCategory;
|
||||
});
|
||||
|
||||
@ -101,14 +101,19 @@ export default function PurchaseEdit({ purchase, products }: { purchase: Purchas
|
||||
const decreaseQuantity = (product: Product, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const existingIndex = data.items.findIndex(i => i.product_id === product.id);
|
||||
if (existingIndex === -1) return;
|
||||
|
||||
if (existingIndex === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newItems = [...data.items];
|
||||
|
||||
if (newItems[existingIndex].quantity <= 1) {
|
||||
newItems.splice(existingIndex, 1);
|
||||
} else {
|
||||
newItems[existingIndex].quantity -= 1;
|
||||
}
|
||||
|
||||
setData('items', newItems);
|
||||
};
|
||||
|
||||
@ -123,9 +128,13 @@ export default function PurchaseEdit({ purchase, products }: { purchase: Purchas
|
||||
};
|
||||
|
||||
const updateCartQuantity = (productId: number, quantity: number) => {
|
||||
if (quantity < 1) return;
|
||||
if (quantity < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newItems = [...data.items];
|
||||
const index = newItems.findIndex(i => i.product_id === productId);
|
||||
|
||||
if (index > -1) {
|
||||
newItems[index].quantity = quantity;
|
||||
setData('items', newItems);
|
||||
@ -141,16 +150,20 @@ export default function PurchaseEdit({ purchase, products }: { purchase: Purchas
|
||||
|
||||
const confirmQty = () => {
|
||||
const val = parseInt(qtyInputValue);
|
||||
|
||||
if (!isNaN(val) && val >= 1 && qtyDialogIndex !== null) {
|
||||
updateCartQuantity(qtyDialogIndex, val);
|
||||
}
|
||||
|
||||
setQtyDialogIndex(null);
|
||||
};
|
||||
|
||||
const onSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (data.items.length === 0) {
|
||||
toast.error('Pilih minimal satu produk');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@ -271,6 +284,7 @@ export default function PurchaseEdit({ purchase, products }: { purchase: Purchas
|
||||
} else {
|
||||
setData('purchase_date', '');
|
||||
}
|
||||
|
||||
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"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (!cartItem) return;
|
||||
if (!cartItem) {
|
||||
return;
|
||||
}
|
||||
|
||||
openQtyDialog(cartItem, e);
|
||||
}}
|
||||
>
|
||||
@ -573,6 +590,7 @@ export default function PurchaseEdit({ purchase, products }: { purchase: Purchas
|
||||
} else {
|
||||
setData('purchase_date', '');
|
||||
}
|
||||
|
||||
setIsCalendarOpen(false);
|
||||
}}
|
||||
/>
|
||||
@ -790,7 +808,11 @@ export default function PurchaseEdit({ purchase, products }: { purchase: Purchas
|
||||
</div>
|
||||
</div>
|
||||
{/* 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">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Ubah Jumlah</DialogTitle>
|
||||
@ -804,7 +826,11 @@ export default function PurchaseEdit({ purchase, products }: { purchase: Purchas
|
||||
min="1"
|
||||
value={qtyInputValue}
|
||||
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"
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { Purchase } from '@/types';
|
||||
import { router } from '@inertiajs/react';
|
||||
import purchaseRoutes from '@/routes/purchase';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import purchaseRoutes from '@/routes/purchase';
|
||||
import type { Purchase } from '@/types';
|
||||
|
||||
export function usePurchaseIndex() {
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
|
||||
@ -1,28 +1,18 @@
|
||||
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 { format } from 'date-fns';
|
||||
import { id } from 'date-fns/locale';
|
||||
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 type { Purchase } from '@/types';
|
||||
import { usePurchaseIndex } from './hooks/use-purchase-index';
|
||||
import { getColumns } from './partials/columns';
|
||||
import { DeleteConfirmation } from '@/components/modal/delete-confirmation';
|
||||
import { formatDate } from '@/lib/formatters';
|
||||
|
||||
export default function PurchaseIndex({ purchases }: { purchases: Purchase[] }) {
|
||||
const {
|
||||
|
||||
@ -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 purchaseRoutes from '@/routes/purchase';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { format } from 'date-fns';
|
||||
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 {
|
||||
onDelete: (purchase: Purchase) => void;
|
||||
@ -21,6 +21,7 @@ export const getColumns = ({ onDelete }: ColumnProps): ColumnDef<Purchase>[] =>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const date = row.original.purchase_date;
|
||||
|
||||
return format(new Date(date), 'dd MMMM yyyy', { locale: id });
|
||||
},
|
||||
meta: { title: "Tanggal" },
|
||||
@ -80,6 +81,7 @@ export const getColumns = ({ onDelete }: ColumnProps): ColumnDef<Purchase>[] =>
|
||||
header: "Aksi",
|
||||
cell: ({ row }) => {
|
||||
const purchase = row.original;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip>
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { Category } from '@/types';
|
||||
import { router } from '@inertiajs/react';
|
||||
import categoryRoutes from '@/routes/category';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import categoryRoutes from '@/routes/category';
|
||||
import type { Category } from '@/types';
|
||||
|
||||
export function useCategoryIndex() {
|
||||
const [isFormOpen, setIsFormOpen] = useState(false);
|
||||
|
||||
@ -1,14 +1,14 @@
|
||||
import { Head } from '@inertiajs/react';
|
||||
import type { Category } from '@/types';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Trash2, Plus, X } from 'lucide-react';
|
||||
import { Trash2, Plus } from 'lucide-react';
|
||||
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 { getColumns } from './partials/columns';
|
||||
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[] }) {
|
||||
const {
|
||||
|
||||
@ -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 {
|
||||
Dialog,
|
||||
@ -9,12 +13,8 @@ import {
|
||||
import { Field, FieldError, FieldGroup } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
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 { toast } from 'sonner';
|
||||
import { X, Save, Loader } from 'lucide-react';
|
||||
import type { Category } from '@/types';
|
||||
|
||||
interface CategoryFormModalProps {
|
||||
isOpen: boolean;
|
||||
@ -49,6 +49,7 @@ export function CategoryFormModal({ isOpen, onClose, category }: CategoryFormMod
|
||||
|
||||
const onSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (isEditing && category) {
|
||||
patch(categoryRoutes.update(category.id).url, {
|
||||
onSuccess: (response: any) => {
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { Category } from '@/types';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { Pencil, Trash2 } from 'lucide-react';
|
||||
import { DataTableColumnHeader } from '@/components/data-table-column-header';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Pencil, Trash2 } from 'lucide-react';
|
||||
import type { Category } from '@/types';
|
||||
|
||||
interface ColumnProps {
|
||||
onEdit: (category: Category) => void;
|
||||
@ -26,6 +26,7 @@ export const getColumns = ({ onEdit, onDelete, onToggleStatus }: ColumnProps): C
|
||||
meta: { title: "Status" },
|
||||
cell: ({ row }) => {
|
||||
const category = row.original;
|
||||
|
||||
return (
|
||||
<Switch
|
||||
checked={category.is_active}
|
||||
@ -39,6 +40,7 @@ export const getColumns = ({ onEdit, onDelete, onToggleStatus }: ColumnProps): C
|
||||
header: "Aksi",
|
||||
cell: ({ row }) => {
|
||||
const category = row.original;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip>
|
||||
|
||||
@ -1,15 +1,12 @@
|
||||
import { Head, Link } from '@inertiajs/react';
|
||||
import { useForm } from '@inertiajs/react';
|
||||
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 productRoutes from '@/routes/product';
|
||||
import { ImagePlus, X, Upload, ArrowLeft, Save, Loader } from 'lucide-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 { Category } from '@/types/category';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxContent,
|
||||
@ -22,8 +19,11 @@ import {
|
||||
ComboboxValue,
|
||||
useComboboxAnchor,
|
||||
} from "@/components/ui/combobox"
|
||||
import { NumericFormat } from 'react-number-format';
|
||||
import { ImagePlus, X, Upload, ArrowLeft, Save, Loader } from 'lucide-react';
|
||||
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 type { Category } from '@/types/category';
|
||||
|
||||
export default function ProductCreate({ categories }: { categories: Category[] }) {
|
||||
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 file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
setData('thumbnail', file);
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => setThumbnailPreview(reader.result as string);
|
||||
@ -73,12 +77,18 @@ export default function ProductCreate({ categories }: { categories: Category[] }
|
||||
const removeThumbnail = () => {
|
||||
setData('thumbnail', null);
|
||||
setThumbnailPreview(null);
|
||||
if (thumbnailInputRef.current) thumbnailInputRef.current.value = '';
|
||||
|
||||
if (thumbnailInputRef.current) {
|
||||
thumbnailInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleImagesChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files ?? []);
|
||||
if (!files.length) return;
|
||||
|
||||
if (!files.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newPreviews = files.map(file => ({
|
||||
file,
|
||||
@ -87,7 +97,10 @@ export default function ProductCreate({ categories }: { categories: Category[] }
|
||||
|
||||
setImagePreviews(prev => [...prev, ...newPreviews]);
|
||||
setData('images', [...data.images, ...files]);
|
||||
if (imagesInputRef.current) imagesInputRef.current.value = '';
|
||||
|
||||
if (imagesInputRef.current) {
|
||||
imagesInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const removeImage = (index: number) => {
|
||||
|
||||
@ -1,15 +1,11 @@
|
||||
import { Head, useForm, Link } from '@inertiajs/react';
|
||||
import type { Product, ProductPrice } from '@/types';
|
||||
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 { ImagePlus, X, Upload, ArrowLeft, Save, Loader } from 'lucide-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 { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxContent,
|
||||
@ -22,8 +18,12 @@ import {
|
||||
ComboboxValue,
|
||||
useComboboxAnchor,
|
||||
} from "@/components/ui/combobox"
|
||||
import { NumericFormat } from 'react-number-format';
|
||||
import { ImagePlus, X, Upload, ArrowLeft, Save, Loader } from 'lucide-react';
|
||||
import { Field, FieldError } from "@/components/ui/field"
|
||||
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 {
|
||||
kind: 'existing';
|
||||
@ -42,6 +42,7 @@ type GalleryItem = ExistingImage | NewImage;
|
||||
export default function ProductEdit({ product, categories }: { product: Product, categories: Category[] }) {
|
||||
const getPrice = (type: string) => {
|
||||
const priceObj = product.prices?.find((p: ProductPrice) => p.price_type === type);
|
||||
|
||||
return priceObj ? priceObj.price.toString() : '';
|
||||
};
|
||||
|
||||
@ -98,7 +99,11 @@ export default function ProductEdit({ product, categories }: { product: Product,
|
||||
|
||||
const handleThumbnailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
setData('thumbnail', file);
|
||||
setThumbnailCleared(false);
|
||||
const reader = new FileReader();
|
||||
@ -110,12 +115,18 @@ export default function ProductEdit({ product, categories }: { product: Product,
|
||||
setData('thumbnail', null);
|
||||
setThumbnailPreview(null);
|
||||
setThumbnailCleared(true);
|
||||
if (thumbnailInputRef.current) thumbnailInputRef.current.value = '';
|
||||
|
||||
if (thumbnailInputRef.current) {
|
||||
thumbnailInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleImagesChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files ?? []);
|
||||
if (!files.length) return;
|
||||
|
||||
if (!files.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newItems: NewImage[] = files.map(file => ({
|
||||
kind: 'new',
|
||||
@ -125,7 +136,10 @@ export default function ProductEdit({ product, categories }: { product: Product,
|
||||
|
||||
setGallery(prev => [...prev, ...newItems]);
|
||||
setData('images', [...data.images, ...files]);
|
||||
if (imagesInputRef.current) imagesInputRef.current.value = '';
|
||||
|
||||
if (imagesInputRef.current) {
|
||||
imagesInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const removeGalleryItem = (index: number) => {
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { Product } from '@/types';
|
||||
import { router } from '@inertiajs/react';
|
||||
import productRoutes from '@/routes/product';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import productRoutes from '@/routes/product';
|
||||
import type { Product } from '@/types';
|
||||
|
||||
export function useProductIndex() {
|
||||
const [selectedImage, setSelectedImage] = useState<string | null>(null);
|
||||
|
||||
@ -1,15 +1,15 @@
|
||||
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 { 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 type { Product, Category } from '@/types';
|
||||
|
||||
import { ImagePreviewDialog } from '../../../../components/modal/image-preview';
|
||||
import { useProductIndex } from './hooks/use-product-index';
|
||||
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[] }) {
|
||||
const {
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { Product, Category } from '@/types';
|
||||
import { Link } from '@inertiajs/react';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { Pencil, Trash2, ImagePlus } from 'lucide-react';
|
||||
import { DataTableColumnHeader } from '@/components/data-table-column-header';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
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 type { Product, Category } from '@/types';
|
||||
|
||||
interface ColumnProps {
|
||||
onEdit?: (product: Product) => void;
|
||||
@ -23,6 +23,7 @@ export const getColumns = ({ onDelete, onToggleStatus, onPreviewImage }: ColumnP
|
||||
meta: { title: "Thumbnail" },
|
||||
cell: ({ row }) => {
|
||||
const url = row.original.thumbnail_url;
|
||||
|
||||
return url ? (
|
||||
<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" />
|
||||
@ -49,11 +50,16 @@ export const getColumns = ({ onDelete, onToggleStatus, onPreviewImage }: ColumnP
|
||||
meta: { title: "Kategori" },
|
||||
filterFn: (row, id, value) => {
|
||||
const categories = row.getValue(id) as Category[];
|
||||
if (!categories) return false;
|
||||
|
||||
if (!categories) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return categories.some(cat => String(cat.id) === String(value));
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const product = row.original;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{product.categories?.map((category) => (
|
||||
@ -118,6 +124,7 @@ export const getColumns = ({ onDelete, onToggleStatus, onPreviewImage }: ColumnP
|
||||
meta: { title: "Status" },
|
||||
cell: ({ row }) => {
|
||||
const product = row.original;
|
||||
|
||||
return (
|
||||
<Switch
|
||||
checked={product.is_active}
|
||||
@ -131,6 +138,7 @@ export const getColumns = ({ onDelete, onToggleStatus, onPreviewImage }: ColumnP
|
||||
header: "Aksi",
|
||||
cell: ({ row }) => {
|
||||
const product = row.original;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip>
|
||||
|
||||
@ -1,22 +1,22 @@
|
||||
import { Head, Link } 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 { Calendar } from "@/components/ui/calendar"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Field, FieldError } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
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 {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover"
|
||||
import { NumericFormat } from 'react-number-format';
|
||||
import { ArrowLeft, Loader, Save } from 'lucide-react';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import userRoutes from '@/routes/user';
|
||||
|
||||
export default function UserCreate() {
|
||||
const [isCalendarOpen, setIsCalendarOpen] = React.useState(false);
|
||||
@ -170,6 +170,7 @@ export default function UserCreate() {
|
||||
} else {
|
||||
setData('birth_date', '');
|
||||
}
|
||||
|
||||
setIsCalendarOpen(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
@ -1,22 +1,22 @@
|
||||
import { Head, Link } 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 { Calendar } from "@/components/ui/calendar"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Field, FieldError } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
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 {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover"
|
||||
import { NumericFormat } from 'react-number-format';
|
||||
import { ArrowLeft, Loader, Save } from 'lucide-react';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import userRoutes from '@/routes/user';
|
||||
|
||||
export default function UserEdit({ user }: { user: any }) {
|
||||
const [isCalendarOpen, setIsCalendarOpen] = React.useState(false);
|
||||
@ -171,6 +171,7 @@ export default function UserEdit({ user }: { user: any }) {
|
||||
} else {
|
||||
setData('birth_date', '');
|
||||
}
|
||||
|
||||
setIsCalendarOpen(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { User } from '@/types';
|
||||
import { router } from '@inertiajs/react';
|
||||
import userRoutes from '@/routes/user';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import userRoutes from '@/routes/user';
|
||||
import type { User } from '@/types';
|
||||
|
||||
export function useUserIndex() {
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
|
||||
@ -1,9 +1,7 @@
|
||||
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 { DataTable } from '@/components/data-table';
|
||||
import { DeleteConfirmation } from '@/components/modal/delete-confirmation';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@ -15,11 +13,13 @@ import {
|
||||
AlertDialogMedia,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import userRoutes from '@/routes/user';
|
||||
import type { User } from '@/types';
|
||||
|
||||
import { useUserIndex } from './hooks/use-user-index';
|
||||
import { getColumns } from './partials/columns';
|
||||
import { DeleteConfirmation } from '@/components/modal/delete-confirmation';
|
||||
|
||||
export default function UserIndex({ users, defaultPassword }: { users: User[], defaultPassword: string }) {
|
||||
const {
|
||||
|
||||
@ -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 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 type { User } from '@/types';
|
||||
|
||||
interface ColumnProps {
|
||||
onResetPassword: (user: User) => void;
|
||||
@ -22,6 +22,7 @@ export const getColumns = ({ onResetPassword, onDelete }: ColumnProps): ColumnDe
|
||||
meta: { title: "Nama dan Alamat Surel" },
|
||||
cell: ({ row }) => {
|
||||
const user = row.original;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<UserInfo user={user} showEmail={true} />
|
||||
@ -49,6 +50,7 @@ export const getColumns = ({ onResetPassword, onDelete }: ColumnProps): ColumnDe
|
||||
header: "Aksi",
|
||||
cell: ({ row }) => {
|
||||
const user = row.original;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip>
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { Head } from '@inertiajs/react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { DataTable } from '@/components/data-table';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import type { Activity } from '@/types';
|
||||
import { getColumns } from './partials/columns';
|
||||
import { Activity } from '@/types';
|
||||
|
||||
interface Props {
|
||||
activities: Activity[];
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Activity } from '@/types';
|
||||
import type { Activity } from '@/types';
|
||||
|
||||
export function getColumns(): ColumnDef<Activity>[] {
|
||||
return [
|
||||
@ -66,7 +66,9 @@ export function getColumns(): ColumnDef<Activity>[] {
|
||||
const attributes = row.original.properties?.attributes;
|
||||
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 (
|
||||
<div className="flex flex-col gap-1 py-1 max-w-[400px]">
|
||||
|
||||
@ -1,10 +1,7 @@
|
||||
import { Head, router } from '@inertiajs/react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { DataTable } from '@/components/data-table';
|
||||
import { useState } from 'react';
|
||||
import { getColumns } from './partials/columns';
|
||||
import { SystemLog } from '@/types';
|
||||
import system from '@/routes/system';
|
||||
import { DataTable } from '@/components/data-table';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@ -12,6 +9,9 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} 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';
|
||||
|
||||
interface Props {
|
||||
|
||||
@ -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 { Badge } from '@/components/ui/badge';
|
||||
import { Eye } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { SystemLog } from '@/types';
|
||||
import { getLevelColor } from '@/lib/log-helpers';
|
||||
import type { SystemLog } from '@/types';
|
||||
|
||||
interface ColumnProps {
|
||||
onView: (log: SystemLog) => void;
|
||||
@ -27,6 +27,7 @@ export const getColumns = ({ onView }: ColumnProps): ColumnDef<SystemLog>[] => [
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const level = row.original.level;
|
||||
|
||||
return (
|
||||
<Badge variant={getLevelColor(level) as any}>
|
||||
{level}
|
||||
@ -42,6 +43,7 @@ export const getColumns = ({ onView }: ColumnProps): ColumnDef<SystemLog>[] => [
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const message = row.original.message;
|
||||
|
||||
return (
|
||||
<div className="max-w-[400px] lg:max-w-[600px] truncate font-sans text-xs" title={message}>
|
||||
{message}
|
||||
@ -55,6 +57,7 @@ export const getColumns = ({ onView }: ColumnProps): ColumnDef<SystemLog>[] => [
|
||||
header: "Aksi",
|
||||
cell: ({ row }) => {
|
||||
const log = row.original;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user