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 { Breadcrumbs } from '@/components/breadcrumbs';
|
||||||
|
import { NavUser } from '@/components/nav-user';
|
||||||
import { SidebarTrigger } from '@/components/ui/sidebar';
|
import { SidebarTrigger } from '@/components/ui/sidebar';
|
||||||
import type { BreadcrumbItem as BreadcrumbItemType } from '@/types';
|
import type { BreadcrumbItem as BreadcrumbItemType } from '@/types';
|
||||||
|
|
||||||
|
|||||||
@ -13,14 +13,14 @@ import {
|
|||||||
import { dashboard } from '@/routes';
|
import { dashboard } from '@/routes';
|
||||||
import category from '@/routes/category';
|
import category from '@/routes/category';
|
||||||
|
|
||||||
import type { NavItem } from '@/types';
|
|
||||||
import product from '@/routes/product';
|
|
||||||
import expense from '@/routes/expense';
|
import expense from '@/routes/expense';
|
||||||
import payroll from '@/routes/payroll';
|
|
||||||
import user from '@/routes/user';
|
|
||||||
import system from '@/routes/system';
|
|
||||||
import purchase from '@/routes/purchase';
|
|
||||||
import order from '@/routes/order';
|
import order from '@/routes/order';
|
||||||
|
import payroll from '@/routes/payroll';
|
||||||
|
import product from '@/routes/product';
|
||||||
|
import purchase from '@/routes/purchase';
|
||||||
|
import system from '@/routes/system';
|
||||||
|
import user from '@/routes/user';
|
||||||
|
import type { NavItem } from '@/types';
|
||||||
|
|
||||||
const mainNavItems: NavItem[] = [
|
const mainNavItems: NavItem[] = [
|
||||||
{
|
{
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { formatCurrency, formatNumber } from '@/lib/formatters';
|
|
||||||
import { StatItem } from '@/types';
|
|
||||||
import { TrendingUp, TrendingDown } from 'lucide-react';
|
import { TrendingUp, TrendingDown } from 'lucide-react';
|
||||||
import { Card, CardHeader, CardTitle, CardDescription, CardAction, CardFooter } from '@/components/ui/card';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Card, CardHeader, CardTitle, CardDescription, CardAction, CardFooter } from '@/components/ui/card';
|
||||||
|
import { formatCurrency, formatNumber } from '@/lib/formatters';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { StatItem } from '@/types';
|
||||||
|
|
||||||
export interface StatCardProps {
|
export interface StatCardProps {
|
||||||
title: string;
|
title: string;
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { formatTime, formatDate } from '@/lib/formatters';
|
|
||||||
import { Sun, Moon, Cloud, Trees, Stars, Bird } from 'lucide-react';
|
import { Sun, Moon, Cloud, Trees, Stars, Bird } from 'lucide-react';
|
||||||
|
import { formatTime, formatDate } from '@/lib/formatters';
|
||||||
|
|
||||||
const DynamicScene = ({ hour }: { hour: number }) => {
|
const DynamicScene = ({ hour }: { hour: number }) => {
|
||||||
if (hour >= 5 && hour < 11) {
|
if (hour >= 5 && hour < 11) {
|
||||||
@ -11,6 +11,7 @@ const DynamicScene = ({ hour }: { hour: number }) => {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hour >= 11 && hour < 15) {
|
if (hour >= 11 && hour < 15) {
|
||||||
return (
|
return (
|
||||||
<div className="relative h-24 w-32 overflow-hidden">
|
<div className="relative h-24 w-32 overflow-hidden">
|
||||||
@ -20,6 +21,7 @@ const DynamicScene = ({ hour }: { hour: number }) => {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hour >= 15 && hour < 19) {
|
if (hour >= 15 && hour < 19) {
|
||||||
return (
|
return (
|
||||||
<div className="relative h-24 w-32 overflow-hidden">
|
<div className="relative h-24 w-32 overflow-hidden">
|
||||||
@ -29,6 +31,7 @@ const DynamicScene = ({ hour }: { hour: number }) => {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-24 w-32 overflow-hidden">
|
<div className="relative h-24 w-32 overflow-hidden">
|
||||||
<Moon className="absolute top-2 left-6 h-14 w-14 rotate-12 text-indigo-200" fill="currentColor" />
|
<Moon className="absolute top-2 left-6 h-14 w-14 rotate-12 text-indigo-200" fill="currentColor" />
|
||||||
|
|||||||
@ -13,9 +13,10 @@ import {
|
|||||||
import {
|
import {
|
||||||
ChartContainer,
|
ChartContainer,
|
||||||
ChartTooltip,
|
ChartTooltip,
|
||||||
ChartTooltipContent,
|
ChartTooltipContent
|
||||||
type ChartConfig,
|
|
||||||
} from "@/components/ui/chart"
|
} from "@/components/ui/chart"
|
||||||
|
import type {ChartConfig} from "@/components/ui/chart";
|
||||||
|
|
||||||
export const CustomBarChart = React.memo(function CustomBarChart({
|
export const CustomBarChart = React.memo(function CustomBarChart({
|
||||||
title,
|
title,
|
||||||
@ -44,6 +45,7 @@ export const CustomBarChart = React.memo(function CustomBarChart({
|
|||||||
label: String(item.name),
|
label: String(item.name),
|
||||||
color: `hsl(${hue}, 60%, 65%)`,
|
color: `hsl(${hue}, 60%, 65%)`,
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...item,
|
...item,
|
||||||
fill: `var(--color-${key})`,
|
fill: `var(--color-${key})`,
|
||||||
@ -51,11 +53,15 @@ export const CustomBarChart = React.memo(function CustomBarChart({
|
|||||||
total: Number(item.total)
|
total: Number(item.total)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return { config: cfg, chartData: formattedData }
|
return { config: cfg, chartData: formattedData }
|
||||||
}, [data, colorOffset])
|
}, [data, colorOffset])
|
||||||
|
|
||||||
const formatValue = (val: any) => {
|
const formatValue = (val: any) => {
|
||||||
if (!isCurrency) return val;
|
if (!isCurrency) {
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
|
||||||
return new Intl.NumberFormat('id-ID', { style: 'currency', currency: 'IDR', maximumFractionDigits: 0 }).format(val);
|
return new Intl.NumberFormat('id-ID', { style: 'currency', currency: 'IDR', maximumFractionDigits: 0 }).format(val);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -13,9 +13,10 @@ import {
|
|||||||
import {
|
import {
|
||||||
ChartContainer,
|
ChartContainer,
|
||||||
ChartTooltip,
|
ChartTooltip,
|
||||||
ChartTooltipContent,
|
ChartTooltipContent
|
||||||
type ChartConfig,
|
|
||||||
} from "@/components/ui/chart"
|
} from "@/components/ui/chart"
|
||||||
|
import type {ChartConfig} from "@/components/ui/chart";
|
||||||
|
|
||||||
export const CustomPieChart = React.memo(function CustomPieChart({
|
export const CustomPieChart = React.memo(function CustomPieChart({
|
||||||
title,
|
title,
|
||||||
@ -44,12 +45,14 @@ export const CustomPieChart = React.memo(function CustomPieChart({
|
|||||||
label: String(item.name),
|
label: String(item.name),
|
||||||
color: `hsl(${hue}, 70%, 50%)`,
|
color: `hsl(${hue}, 70%, 50%)`,
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...item,
|
...item,
|
||||||
fill: `var(--color-${key})`,
|
fill: `var(--color-${key})`,
|
||||||
name: String(item.name),
|
name: String(item.name),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return { config: cfg, chartData: formattedData }
|
return { config: cfg, chartData: formattedData }
|
||||||
}, [data, colorOffset])
|
}, [data, colorOffset])
|
||||||
|
|
||||||
|
|||||||
@ -14,9 +14,10 @@ import {
|
|||||||
ChartLegend,
|
ChartLegend,
|
||||||
ChartLegendContent,
|
ChartLegendContent,
|
||||||
ChartTooltip,
|
ChartTooltip,
|
||||||
ChartTooltipContent,
|
ChartTooltipContent
|
||||||
type ChartConfig,
|
|
||||||
} from "@/components/ui/chart"
|
} from "@/components/ui/chart"
|
||||||
|
import type {ChartConfig} from "@/components/ui/chart";
|
||||||
|
|
||||||
export const description = "Menampilkan aktivitas toko berdasarkan pesanan yang masuk."
|
export const description = "Menampilkan aktivitas toko berdasarkan pesanan yang masuk."
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import { type Column } from "@tanstack/react-table"
|
import type {Column} from "@tanstack/react-table";
|
||||||
import { ArrowDown, ArrowUp, ChevronsUpDown } from "lucide-react"
|
import { ArrowDown, ArrowUp, ChevronsUpDown } from "lucide-react"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
interface DataTableColumnHeaderProps<TData, TValue>
|
interface DataTableColumnHeaderProps<TData, TValue>
|
||||||
extends React.HTMLAttributes<HTMLDivElement> {
|
extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
|||||||
@ -1,26 +1,20 @@
|
|||||||
import {
|
import type {
|
||||||
ColumnDef,
|
ColumnDef,
|
||||||
|
SortingState,
|
||||||
|
ColumnFiltersState,
|
||||||
|
VisibilityState} from "@tanstack/react-table";
|
||||||
|
import {
|
||||||
flexRender,
|
flexRender,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
useReactTable,
|
useReactTable,
|
||||||
getPaginationRowModel,
|
getPaginationRowModel,
|
||||||
SortingState,
|
|
||||||
getSortedRowModel,
|
getSortedRowModel,
|
||||||
ColumnFiltersState,
|
getFilteredRowModel
|
||||||
getFilteredRowModel,
|
|
||||||
VisibilityState,
|
|
||||||
} from "@tanstack/react-table";
|
} from "@tanstack/react-table";
|
||||||
|
|
||||||
import {
|
import { Settings2, ChevronDown, ListFilter, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, X } from "lucide-react";
|
||||||
Table,
|
import React from "react";
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Button } from "./ui/button";
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuCheckboxItem,
|
DropdownMenuCheckboxItem,
|
||||||
@ -33,16 +27,11 @@ import {
|
|||||||
DropdownMenuSubTrigger,
|
DropdownMenuSubTrigger,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import React from "react";
|
|
||||||
import { Input } from "./ui/input";
|
|
||||||
import { Settings2, ChevronDown, ListFilter, Trash2, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, List, X } from "lucide-react";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Empty,
|
Empty,
|
||||||
EmptyContent,
|
|
||||||
EmptyDescription,
|
EmptyDescription,
|
||||||
EmptyHeader,
|
EmptyHeader,
|
||||||
EmptyMedia,
|
|
||||||
EmptyTitle,
|
EmptyTitle,
|
||||||
} from "@/components/ui/empty"
|
} from "@/components/ui/empty"
|
||||||
import {
|
import {
|
||||||
@ -52,7 +41,17 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select"
|
} from "@/components/ui/select"
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
import { Badge } from "./ui/badge";
|
import { Badge } from "./ui/badge";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
import { Input } from "./ui/input";
|
||||||
|
|
||||||
interface DataTableFilterOption {
|
interface DataTableFilterOption {
|
||||||
columnId: string;
|
columnId: string;
|
||||||
@ -245,7 +244,10 @@ export function DataTable<TData, TValue>({
|
|||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
{filters.map((filter) => {
|
{filters.map((filter) => {
|
||||||
const column = table.getColumn(filter.columnId);
|
const column = table.getColumn(filter.columnId);
|
||||||
if (!column) return null;
|
|
||||||
|
if (!column) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenuSub key={filter.columnId}>
|
<DropdownMenuSub key={filter.columnId}>
|
||||||
@ -265,9 +267,13 @@ export function DataTable<TData, TValue>({
|
|||||||
key={option.value}
|
key={option.value}
|
||||||
onSelect={(e) => e.preventDefault()}
|
onSelect={(e) => e.preventDefault()}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (option.value === "true") column.setFilterValue(true);
|
if (option.value === "true") {
|
||||||
else if (option.value === "false") column.setFilterValue(false);
|
column.setFilterValue(true);
|
||||||
else column.setFilterValue(option.value);
|
} else if (option.value === "false") {
|
||||||
|
column.setFilterValue(false);
|
||||||
|
} else {
|
||||||
|
column.setFilterValue(option.value);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import type { HTMLAttributes } from 'react';
|
import type { HTMLAttributes } from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface InputErrorProps extends HTMLAttributes<HTMLParagraphElement> {
|
interface InputErrorProps extends HTMLAttributes<HTMLParagraphElement> {
|
||||||
message?: string;
|
message?: string;
|
||||||
@ -14,8 +14,13 @@ export default function InputError({
|
|||||||
...props
|
...props
|
||||||
}: InputErrorProps) {
|
}: InputErrorProps) {
|
||||||
const formattedError = useMemo(() => {
|
const formattedError = useMemo(() => {
|
||||||
if (!message) return null;
|
if (!message) {
|
||||||
if (!label) return message;
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!label) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
// Preserve all-caps labels (like NIK), otherwise capitalize first letter and lowercase the rest
|
// Preserve all-caps labels (like NIK), otherwise capitalize first letter and lowercase the rest
|
||||||
const isAllOptionsCaps = label === label.toUpperCase() && label.length > 1;
|
const isAllOptionsCaps = label === label.toUpperCase() && label.length > 1;
|
||||||
@ -33,6 +38,7 @@ export default function InputError({
|
|||||||
|
|
||||||
// Find the first occurrence of a verb
|
// Find the first occurrence of a verb
|
||||||
let verbIndex = -1;
|
let verbIndex = -1;
|
||||||
|
|
||||||
for (let i = 0; i < words.length; i++) {
|
for (let i = 0; i < words.length; i++) {
|
||||||
if (verbs.includes(words[i].toLowerCase())) {
|
if (verbs.includes(words[i].toLowerCase())) {
|
||||||
verbIndex = i;
|
verbIndex = i;
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { Trash2, X } from 'lucide-react';
|
import { Trash2, X } from 'lucide-react';
|
||||||
|
import React from 'react';
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@ -10,7 +11,6 @@ import {
|
|||||||
AlertDialogMedia,
|
AlertDialogMedia,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
interface DeleteConfirmationProps {
|
interface DeleteConfirmationProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|||||||
@ -2,9 +2,9 @@ interface ImagePreviewDialogProps {
|
|||||||
imageUrl: string | null;
|
imageUrl: string | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
import { Dialog, DialogContent, DialogTitle, DialogDescription } from "@/components/ui/dialog"
|
|
||||||
import { VisuallyHidden } from "@radix-ui/react-visually-hidden"
|
import { VisuallyHidden } from "@radix-ui/react-visually-hidden"
|
||||||
import { X } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
|
import { Dialog, DialogContent, DialogTitle, DialogDescription } from "@/components/ui/dialog"
|
||||||
|
|
||||||
export function ImagePreviewDialog({ imageUrl, onClose }: ImagePreviewDialogProps) {
|
export function ImagePreviewDialog({ imageUrl, onClose }: ImagePreviewDialogProps) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -34,10 +34,13 @@ function getToggleColor(
|
|||||||
targets: NodeWithPos[],
|
targets: NodeWithPos[],
|
||||||
inputColor: string
|
inputColor: string
|
||||||
): string | null {
|
): string | null {
|
||||||
if (targets.length === 0) return null
|
if (targets.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
for (const target of targets) {
|
for (const target of targets) {
|
||||||
const currentColor = target.node.attrs?.backgroundColor ?? null
|
const currentColor = target.node.attrs?.backgroundColor ?? null
|
||||||
|
|
||||||
if (currentColor !== inputColor) {
|
if (currentColor !== inputColor) {
|
||||||
return inputColor
|
return inputColor
|
||||||
}
|
}
|
||||||
@ -75,15 +78,22 @@ export const NodeBackground = Extension.create<NodeBackgroundOptions>({
|
|||||||
|
|
||||||
parseHTML: (element: HTMLElement) => {
|
parseHTML: (element: HTMLElement) => {
|
||||||
const styleColor = element.style?.backgroundColor
|
const styleColor = element.style?.backgroundColor
|
||||||
if (styleColor) return styleColor
|
|
||||||
|
if (styleColor) {
|
||||||
|
return styleColor
|
||||||
|
}
|
||||||
|
|
||||||
const dataColor = element.getAttribute("data-background-color")
|
const dataColor = element.getAttribute("data-background-color")
|
||||||
|
|
||||||
return dataColor || null
|
return dataColor || null
|
||||||
},
|
},
|
||||||
|
|
||||||
renderHTML: (attributes) => {
|
renderHTML: (attributes) => {
|
||||||
const color = attributes.backgroundColor as string | null
|
const color = attributes.backgroundColor as string | null
|
||||||
if (!color) return {}
|
|
||||||
|
if (!color) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
if (this.options.useStyle) {
|
if (this.options.useStyle) {
|
||||||
return {
|
return {
|
||||||
@ -118,7 +128,9 @@ export const NodeBackground = Extension.create<NodeBackgroundOptions>({
|
|||||||
this.options.types
|
this.options.types
|
||||||
)
|
)
|
||||||
|
|
||||||
if (targets.length === 0) return false
|
if (targets.length === 0) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
const targetColor = getTargetColor(targets, inputColor)
|
const targetColor = getTargetColor(targets, inputColor)
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { mergeAttributes } from "@tiptap/react"
|
|
||||||
import TiptapHorizontalRule from "@tiptap/extension-horizontal-rule"
|
import TiptapHorizontalRule from "@tiptap/extension-horizontal-rule"
|
||||||
|
import { mergeAttributes } from "@tiptap/react"
|
||||||
|
|
||||||
export const HorizontalRule = TiptapHorizontalRule.extend({
|
export const HorizontalRule = TiptapHorizontalRule.extend({
|
||||||
renderHTML() {
|
renderHTML() {
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
|
import type { NodeType } from "@tiptap/pm/model"
|
||||||
import { mergeAttributes, Node } from "@tiptap/react"
|
import { mergeAttributes, Node } from "@tiptap/react"
|
||||||
import { ReactNodeViewRenderer } from "@tiptap/react"
|
import { ReactNodeViewRenderer } from "@tiptap/react"
|
||||||
import { ImageUploadNode as ImageUploadNodeComponent } from "@/components/tiptap-node/image-upload-node/image-upload-node"
|
import { ImageUploadNode as ImageUploadNodeComponent } from "@/components/tiptap-node/image-upload-node/image-upload-node"
|
||||||
import type { NodeType } from "@tiptap/pm/model"
|
|
||||||
|
|
||||||
export type UploadFunction = (
|
export type UploadFunction = (
|
||||||
file: File,
|
file: File,
|
||||||
@ -47,7 +47,7 @@ export interface ImageUploadNodeOptions {
|
|||||||
* @default {}
|
* @default {}
|
||||||
* @example { class: 'foo' }
|
* @example { class: 'foo' }
|
||||||
*/
|
*/
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
HTMLAttributes: Record<string, any>
|
HTMLAttributes: Record<string, any>
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -144,15 +144,19 @@ export const ImageUploadNode = Node.create<ImageUploadNodeOptions>({
|
|||||||
editor.isActive("imageUpload")
|
editor.isActive("imageUpload")
|
||||||
) {
|
) {
|
||||||
const nodeEl = editor.view.nodeDOM(selection.$from.pos)
|
const nodeEl = editor.view.nodeDOM(selection.$from.pos)
|
||||||
|
|
||||||
if (nodeEl && nodeEl instanceof HTMLElement) {
|
if (nodeEl && nodeEl instanceof HTMLElement) {
|
||||||
// Since NodeViewWrapper is wrapped with a div, we need to click the first child
|
// Since NodeViewWrapper is wrapped with a div, we need to click the first child
|
||||||
const firstChild = nodeEl.firstChild
|
const firstChild = nodeEl.firstChild
|
||||||
|
|
||||||
if (firstChild && firstChild instanceof HTMLElement) {
|
if (firstChild && firstChild instanceof HTMLElement) {
|
||||||
firstChild.click()
|
firstChild.click()
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useRef, useState } from "react"
|
|
||||||
import type { NodeViewProps } from "@tiptap/react"
|
import type { NodeViewProps } from "@tiptap/react"
|
||||||
import { NodeViewWrapper } from "@tiptap/react"
|
import { NodeViewWrapper } from "@tiptap/react"
|
||||||
import { Button } from "@/components/tiptap-ui-primitive/button"
|
import { useRef, useState } from "react"
|
||||||
import { CloseIcon } from "@/components/tiptap-icons/close-icon"
|
import { CloseIcon } from "@/components/tiptap-icons/close-icon"
|
||||||
|
import { Button } from "@/components/tiptap-ui-primitive/button"
|
||||||
import "@/components/tiptap-node/image-upload-node/image-upload-node.scss"
|
import "@/components/tiptap-node/image-upload-node/image-upload-node.scss"
|
||||||
import { focusNextNode, isValidPosition } from "@/lib/tiptap-utils"
|
import { focusNextNode, isValidPosition } from "@/lib/tiptap-utils"
|
||||||
|
|
||||||
@ -91,6 +91,7 @@ function useFileUpload(options: UploadOptions) {
|
|||||||
`File size exceeds maximum allowed (${options.maxSize / 1024 / 1024}MB)`
|
`File size exceeds maximum allowed (${options.maxSize / 1024 / 1024}MB)`
|
||||||
)
|
)
|
||||||
options.onError?.(error)
|
options.onError?.(error)
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -124,7 +125,9 @@ function useFileUpload(options: UploadOptions) {
|
|||||||
abortController.signal
|
abortController.signal
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!url) throw new Error("Upload failed: No URL returned")
|
if (!url) {
|
||||||
|
throw new Error("Upload failed: No URL returned")
|
||||||
|
}
|
||||||
|
|
||||||
if (!abortController.signal.aborted) {
|
if (!abortController.signal.aborted) {
|
||||||
setFileItems((prev) =>
|
setFileItems((prev) =>
|
||||||
@ -135,6 +138,7 @@ function useFileUpload(options: UploadOptions) {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
options.onSuccess?.(url)
|
options.onSuccess?.(url)
|
||||||
|
|
||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -152,6 +156,7 @@ function useFileUpload(options: UploadOptions) {
|
|||||||
error instanceof Error ? error : new Error("Upload failed")
|
error instanceof Error ? error : new Error("Upload failed")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -159,6 +164,7 @@ function useFileUpload(options: UploadOptions) {
|
|||||||
const uploadFiles = async (files: File[]): Promise<string[]> => {
|
const uploadFiles = async (files: File[]): Promise<string[]> => {
|
||||||
if (!files || files.length === 0) {
|
if (!files || files.length === 0) {
|
||||||
options.onError?.(new Error("No files to upload"))
|
options.onError?.(new Error("No files to upload"))
|
||||||
|
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -168,6 +174,7 @@ function useFileUpload(options: UploadOptions) {
|
|||||||
`Maximum ${options.limit} file${options.limit === 1 ? "" : "s"} allowed`
|
`Maximum ${options.limit} file${options.limit === 1 ? "" : "s"} allowed`
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -182,12 +189,15 @@ function useFileUpload(options: UploadOptions) {
|
|||||||
const removeFileItem = (fileId: string) => {
|
const removeFileItem = (fileId: string) => {
|
||||||
setFileItems((prev) => {
|
setFileItems((prev) => {
|
||||||
const fileToRemove = prev.find((item) => item.id === fileId)
|
const fileToRemove = prev.find((item) => item.id === fileId)
|
||||||
|
|
||||||
if (fileToRemove?.abortController) {
|
if (fileToRemove?.abortController) {
|
||||||
fileToRemove.abortController.abort()
|
fileToRemove.abortController.abort()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fileToRemove?.url) {
|
if (fileToRemove?.url) {
|
||||||
URL.revokeObjectURL(fileToRemove.url)
|
URL.revokeObjectURL(fileToRemove.url)
|
||||||
}
|
}
|
||||||
|
|
||||||
return prev.filter((item) => item.id !== fileId)
|
return prev.filter((item) => item.id !== fileId)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -197,6 +207,7 @@ function useFileUpload(options: UploadOptions) {
|
|||||||
if (item.abortController) {
|
if (item.abortController) {
|
||||||
item.abortController.abort()
|
item.abortController.abort()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.url) {
|
if (item.url) {
|
||||||
URL.revokeObjectURL(item.url)
|
URL.revokeObjectURL(item.url)
|
||||||
}
|
}
|
||||||
@ -300,6 +311,7 @@ const ImageUploadDragArea: React.FC<ImageUploadDragAreaProps> = ({
|
|||||||
const handleDragLeave = (e: React.DragEvent) => {
|
const handleDragLeave = (e: React.DragEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
|
||||||
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
|
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
|
||||||
setIsDragActive(false)
|
setIsDragActive(false)
|
||||||
setIsDragOver(false)
|
setIsDragOver(false)
|
||||||
@ -319,6 +331,7 @@ const ImageUploadDragArea: React.FC<ImageUploadDragAreaProps> = ({
|
|||||||
setIsDragOver(false)
|
setIsDragOver(false)
|
||||||
|
|
||||||
const files = Array.from(e.dataTransfer.files)
|
const files = Array.from(e.dataTransfer.files)
|
||||||
|
|
||||||
if (files.length > 0) {
|
if (files.length > 0) {
|
||||||
onFile(files)
|
onFile(files)
|
||||||
}
|
}
|
||||||
@ -356,10 +369,14 @@ const ImageUploadPreview: React.FC<ImageUploadPreviewProps> = ({
|
|||||||
onRemove,
|
onRemove,
|
||||||
}) => {
|
}) => {
|
||||||
const formatFileSize = (bytes: number) => {
|
const formatFileSize = (bytes: number) => {
|
||||||
if (bytes === 0) return "0 Bytes"
|
if (bytes === 0) {
|
||||||
|
return "0 Bytes"
|
||||||
|
}
|
||||||
|
|
||||||
const k = 1024
|
const k = 1024
|
||||||
const sizes = ["Bytes", "KB", "MB", "GB"]
|
const sizes = ["Bytes", "KB", "MB", "GB"]
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||||
|
|
||||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`
|
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -460,6 +477,7 @@ export const ImageUploadNode: React.FC<NodeViewProps> = (props) => {
|
|||||||
const imageNodes = urls.map((url, index) => {
|
const imageNodes = urls.map((url, index) => {
|
||||||
const filename =
|
const filename =
|
||||||
files[index]?.name.replace(/\.[^/.]+$/, "") || "unknown"
|
files[index]?.name.replace(/\.[^/.]+$/, "") || "unknown"
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: extension.options.type,
|
type: extension.options.type,
|
||||||
attrs: {
|
attrs: {
|
||||||
@ -485,10 +503,13 @@ export const ImageUploadNode: React.FC<NodeViewProps> = (props) => {
|
|||||||
|
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const files = e.target.files
|
const files = e.target.files
|
||||||
|
|
||||||
if (!files || files.length === 0) {
|
if (!files || files.length === 0) {
|
||||||
extension.options.onError?.(new Error("No file selected"))
|
extension.options.onError?.(new Error("No file selected"))
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
handleUpload(Array.from(files))
|
handleUpload(Array.from(files))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,18 +1,18 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react"
|
|
||||||
import { EditorContent, EditorContext, useEditor } from "@tiptap/react"
|
|
||||||
|
|
||||||
// --- Tiptap Core Extensions ---
|
// --- Tiptap Core Extensions ---
|
||||||
import { StarterKit } from "@tiptap/starter-kit"
|
import { Highlight } from "@tiptap/extension-highlight"
|
||||||
import { Image } from "@tiptap/extension-image"
|
import { Image } from "@tiptap/extension-image"
|
||||||
import { TaskItem, TaskList } from "@tiptap/extension-list"
|
import { TaskItem, TaskList } from "@tiptap/extension-list"
|
||||||
import { TextAlign } from "@tiptap/extension-text-align"
|
|
||||||
import { Typography } from "@tiptap/extension-typography"
|
|
||||||
import { Highlight } from "@tiptap/extension-highlight"
|
|
||||||
import { Subscript } from "@tiptap/extension-subscript"
|
import { Subscript } from "@tiptap/extension-subscript"
|
||||||
import { Superscript } from "@tiptap/extension-superscript"
|
import { Superscript } from "@tiptap/extension-superscript"
|
||||||
|
import { TextAlign } from "@tiptap/extension-text-align"
|
||||||
|
import { Typography } from "@tiptap/extension-typography"
|
||||||
import { Placeholder, Selection } from "@tiptap/extensions"
|
import { Placeholder, Selection } from "@tiptap/extensions"
|
||||||
|
import { EditorContent, EditorContext, useEditor } from "@tiptap/react"
|
||||||
|
import { StarterKit } from "@tiptap/starter-kit"
|
||||||
|
import { useEffect, useRef, useState } from "react"
|
||||||
|
|
||||||
// --- UI Primitives ---
|
// --- UI Primitives ---
|
||||||
import { Button } from "@/components/tiptap-ui-primitive/button"
|
import { Button } from "@/components/tiptap-ui-primitive/button"
|
||||||
@ -35,9 +35,11 @@ import "@/components/tiptap-node/heading-node/heading-node.scss"
|
|||||||
import "@/components/tiptap-node/paragraph-node/paragraph-node.scss"
|
import "@/components/tiptap-node/paragraph-node/paragraph-node.scss"
|
||||||
|
|
||||||
// --- Tiptap UI ---
|
// --- Tiptap UI ---
|
||||||
import { HeadingDropdownMenu } from "@/components/tiptap-ui/heading-dropdown-menu"
|
|
||||||
import { ImageUploadButton } from "@/components/tiptap-ui/image-upload-button"
|
// --- Icons ---
|
||||||
import { ListDropdownMenu } from "@/components/tiptap-ui/list-dropdown-menu"
|
import { ArrowLeftIcon } from "@/components/tiptap-icons/arrow-left-icon"
|
||||||
|
import { HighlighterIcon } from "@/components/tiptap-icons/highlighter-icon"
|
||||||
|
import { LinkIcon } from "@/components/tiptap-icons/link-icon"
|
||||||
import { BlockquoteButton } from "@/components/tiptap-ui/blockquote-button"
|
import { BlockquoteButton } from "@/components/tiptap-ui/blockquote-button"
|
||||||
import { CodeBlockButton } from "@/components/tiptap-ui/code-block-button"
|
import { CodeBlockButton } from "@/components/tiptap-ui/code-block-button"
|
||||||
import {
|
import {
|
||||||
@ -45,20 +47,18 @@ import {
|
|||||||
ColorHighlightPopoverContent,
|
ColorHighlightPopoverContent,
|
||||||
ColorHighlightPopoverButton,
|
ColorHighlightPopoverButton,
|
||||||
} from "@/components/tiptap-ui/color-highlight-popover"
|
} from "@/components/tiptap-ui/color-highlight-popover"
|
||||||
|
import { HeadingDropdownMenu } from "@/components/tiptap-ui/heading-dropdown-menu"
|
||||||
|
import { ImageUploadButton } from "@/components/tiptap-ui/image-upload-button"
|
||||||
import {
|
import {
|
||||||
LinkPopover,
|
LinkPopover,
|
||||||
LinkContent,
|
LinkContent,
|
||||||
LinkButton,
|
LinkButton,
|
||||||
} from "@/components/tiptap-ui/link-popover"
|
} from "@/components/tiptap-ui/link-popover"
|
||||||
|
import { ListDropdownMenu } from "@/components/tiptap-ui/list-dropdown-menu"
|
||||||
import { MarkButton } from "@/components/tiptap-ui/mark-button"
|
import { MarkButton } from "@/components/tiptap-ui/mark-button"
|
||||||
import { TextAlignButton } from "@/components/tiptap-ui/text-align-button"
|
import { TextAlignButton } from "@/components/tiptap-ui/text-align-button"
|
||||||
import { UndoRedoButton } from "@/components/tiptap-ui/undo-redo-button"
|
import { UndoRedoButton } from "@/components/tiptap-ui/undo-redo-button"
|
||||||
|
|
||||||
// --- Icons ---
|
|
||||||
import { ArrowLeftIcon } from "@/components/tiptap-icons/arrow-left-icon"
|
|
||||||
import { HighlighterIcon } from "@/components/tiptap-icons/highlighter-icon"
|
|
||||||
import { LinkIcon } from "@/components/tiptap-icons/link-icon"
|
|
||||||
|
|
||||||
// --- Hooks ---
|
// --- Hooks ---
|
||||||
import { useIsBreakpoint } from "@/hooks/use-is-breakpoint"
|
import { useIsBreakpoint } from "@/hooks/use-is-breakpoint"
|
||||||
|
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { MoonStarIcon } from "@/components/tiptap-icons/moon-star-icon"
|
||||||
|
import { SunIcon } from "@/components/tiptap-icons/sun-icon"
|
||||||
import { Button } from "@/components/tiptap-ui-primitive/button"
|
import { Button } from "@/components/tiptap-ui-primitive/button"
|
||||||
|
|
||||||
// --- Icons ---
|
// --- Icons ---
|
||||||
import { MoonStarIcon } from "@/components/tiptap-icons/moon-star-icon"
|
|
||||||
import { SunIcon } from "@/components/tiptap-icons/sun-icon"
|
|
||||||
import { useEffect, useState } from "react"
|
|
||||||
|
|
||||||
export function ThemeToggle() {
|
export function ThemeToggle() {
|
||||||
const [isDarkMode, setIsDarkMode] = useState<boolean>(false)
|
const [isDarkMode, setIsDarkMode] = useState<boolean>(false)
|
||||||
@ -12,6 +12,7 @@ export function ThemeToggle() {
|
|||||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
|
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
|
||||||
const handleChange = () => setIsDarkMode(mediaQuery.matches)
|
const handleChange = () => setIsDarkMode(mediaQuery.matches)
|
||||||
mediaQuery.addEventListener("change", handleChange)
|
mediaQuery.addEventListener("change", handleChange)
|
||||||
|
|
||||||
return () => mediaQuery.removeEventListener("change", handleChange)
|
return () => mediaQuery.removeEventListener("change", handleChange)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
import { mergeProps } from "@base-ui/react/merge-props"
|
import { mergeProps } from "@base-ui/react/merge-props"
|
||||||
import { useRender } from "@base-ui/react/use-render"
|
import { useRender } from "@base-ui/react/use-render"
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva } from "class-variance-authority"
|
||||||
import { cn } from "@/lib/tiptap-utils"
|
import type {VariantProps} from "class-variance-authority";
|
||||||
import { Separator } from "@/components/tiptap-ui-primitive/separator"
|
import { Separator } from "@/components/tiptap-ui-primitive/separator"
|
||||||
|
import { cn } from "@/lib/tiptap-utils"
|
||||||
import "./button-group.scss"
|
import "./button-group.scss"
|
||||||
|
|
||||||
const buttonGroupVariants = cva("tiptap-button-group", {
|
const buttonGroupVariants = cva("tiptap-button-group", {
|
||||||
|
|||||||
@ -27,7 +27,9 @@ export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElemen
|
|||||||
export const ShortcutDisplay: React.FC<{ shortcuts: string[] }> = ({
|
export const ShortcutDisplay: React.FC<{ shortcuts: string[] }> = ({
|
||||||
shortcuts,
|
shortcuts,
|
||||||
}) => {
|
}) => {
|
||||||
if (shortcuts.length === 0) return null
|
if (shortcuts.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||||
import { cn } from "@/lib/tiptap-utils"
|
|
||||||
import { CheckIcon } from "@/components/tiptap-icons/check-icon"
|
import { CheckIcon } from "@/components/tiptap-icons/check-icon"
|
||||||
|
import { cn } from "@/lib/tiptap-utils"
|
||||||
|
|
||||||
import "@/components/tiptap-ui-primitive/dropdown-menu/dropdown-menu.scss"
|
import "@/components/tiptap-ui-primitive/dropdown-menu/dropdown-menu.scss"
|
||||||
|
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
import { forwardRef, useCallback, useEffect, useRef, useState } from "react"
|
import { forwardRef, useCallback, useEffect, useRef, useState } from "react"
|
||||||
import { Separator } from "@/components/tiptap-ui-primitive/separator"
|
import { Separator } from "@/components/tiptap-ui-primitive/separator"
|
||||||
import "@/components/tiptap-ui-primitive/toolbar/toolbar.scss"
|
import "@/components/tiptap-ui-primitive/toolbar/toolbar.scss"
|
||||||
import { cn } from "@/lib/tiptap-utils"
|
|
||||||
import { useMenuNavigation } from "@/hooks/use-menu-navigation"
|
|
||||||
import { useComposedRef } from "@/hooks/use-composed-ref"
|
import { useComposedRef } from "@/hooks/use-composed-ref"
|
||||||
|
import { useMenuNavigation } from "@/hooks/use-menu-navigation"
|
||||||
|
import { cn } from "@/lib/tiptap-utils"
|
||||||
|
|
||||||
type BaseProps = React.HTMLAttributes<HTMLDivElement>
|
type BaseProps = React.HTMLAttributes<HTMLDivElement>
|
||||||
|
|
||||||
@ -17,7 +17,10 @@ const useToolbarNavigation = (
|
|||||||
const [items, setItems] = useState<HTMLElement[]>([])
|
const [items, setItems] = useState<HTMLElement[]>([])
|
||||||
|
|
||||||
const collectItems = useCallback(() => {
|
const collectItems = useCallback(() => {
|
||||||
if (!toolbarRef.current) return []
|
if (!toolbarRef.current) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
return Array.from(
|
return Array.from(
|
||||||
toolbarRef.current.querySelectorAll<HTMLElement>(
|
toolbarRef.current.querySelectorAll<HTMLElement>(
|
||||||
'button:not([disabled]), [role="button"]:not([disabled]), [tabindex="0"]:not([disabled])'
|
'button:not([disabled]), [role="button"]:not([disabled]), [tabindex="0"]:not([disabled])'
|
||||||
@ -27,7 +30,10 @@ const useToolbarNavigation = (
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const toolbar = toolbarRef.current
|
const toolbar = toolbarRef.current
|
||||||
if (!toolbar) return
|
|
||||||
|
if (!toolbar) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const updateItems = () => setItems(collectItems())
|
const updateItems = () => setItems(collectItems())
|
||||||
|
|
||||||
@ -48,17 +54,25 @@ const useToolbarNavigation = (
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const toolbar = toolbarRef.current
|
const toolbar = toolbarRef.current
|
||||||
if (!toolbar) return
|
|
||||||
|
if (!toolbar) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const handleFocus = (e: FocusEvent) => {
|
const handleFocus = (e: FocusEvent) => {
|
||||||
const target = e.target as HTMLElement
|
const target = e.target as HTMLElement
|
||||||
if (toolbar.contains(target))
|
|
||||||
target.setAttribute("data-focus-visible", "true")
|
if (toolbar.contains(target)) {
|
||||||
|
target.setAttribute("data-focus-visible", "true")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleBlur = (e: FocusEvent) => {
|
const handleBlur = (e: FocusEvent) => {
|
||||||
const target = e.target as HTMLElement
|
const target = e.target as HTMLElement
|
||||||
if (toolbar.contains(target)) target.removeAttribute("data-focus-visible")
|
|
||||||
|
if (toolbar.contains(target)) {
|
||||||
|
target.removeAttribute("data-focus-visible")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
toolbar.addEventListener("focus", handleFocus, true)
|
toolbar.addEventListener("focus", handleFocus, true)
|
||||||
|
|||||||
@ -1,15 +1,5 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import {
|
|
||||||
cloneElement,
|
|
||||||
createContext,
|
|
||||||
forwardRef,
|
|
||||||
isValidElement,
|
|
||||||
useContext,
|
|
||||||
useMemo,
|
|
||||||
useState,
|
|
||||||
version,
|
|
||||||
} from "react"
|
|
||||||
import {
|
import {
|
||||||
useFloating,
|
useFloating,
|
||||||
autoUpdate,
|
autoUpdate,
|
||||||
@ -23,11 +13,22 @@ import {
|
|||||||
useInteractions,
|
useInteractions,
|
||||||
useMergeRefs,
|
useMergeRefs,
|
||||||
FloatingPortal,
|
FloatingPortal,
|
||||||
type Placement,
|
|
||||||
type UseFloatingReturn,
|
|
||||||
type ReferenceType,
|
|
||||||
FloatingDelayGroup,
|
FloatingDelayGroup
|
||||||
} from "@floating-ui/react"
|
} from "@floating-ui/react"
|
||||||
|
import type {Placement, UseFloatingReturn, ReferenceType} from "@floating-ui/react";
|
||||||
|
import {
|
||||||
|
cloneElement,
|
||||||
|
createContext,
|
||||||
|
forwardRef,
|
||||||
|
isValidElement,
|
||||||
|
useContext,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
version,
|
||||||
|
} from "react"
|
||||||
import "@/components/tiptap-ui-primitive/tooltip/tooltip.scss"
|
import "@/components/tiptap-ui-primitive/tooltip/tooltip.scss"
|
||||||
|
|
||||||
interface TooltipProviderProps {
|
interface TooltipProviderProps {
|
||||||
@ -165,9 +166,9 @@ export const TooltipTrigger = forwardRef<HTMLElement, TooltipTriggerProps>(
|
|||||||
const context = useTooltipContext()
|
const context = useTooltipContext()
|
||||||
const childrenRef = isValidElement(children)
|
const childrenRef = isValidElement(children)
|
||||||
? parseInt(version, 10) >= 19
|
? parseInt(version, 10) >= 19
|
||||||
? // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
?
|
||||||
(children as { props: { ref?: React.Ref<any> } }).props.ref
|
(children as { props: { ref?: React.Ref<any> } }).props.ref
|
||||||
: // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
:
|
||||||
(children as any).ref
|
(children as any).ref
|
||||||
: undefined
|
: undefined
|
||||||
const ref = useMergeRefs([context.refs.setReference, propRef, childrenRef])
|
const ref = useMergeRefs([context.refs.setReference, propRef, childrenRef])
|
||||||
@ -208,7 +209,9 @@ export const TooltipContent = forwardRef<HTMLDivElement, TooltipContentProps>(
|
|||||||
const context = useTooltipContext()
|
const context = useTooltipContext()
|
||||||
const ref = useMergeRefs([context.refs.setFloating, propRef])
|
const ref = useMergeRefs([context.refs.setFloating, propRef])
|
||||||
|
|
||||||
if (!context.open) return null
|
if (!context.open) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@ -8,15 +8,15 @@ import {
|
|||||||
} from "@/components/tiptap-ui/blockquote-button"
|
} from "@/components/tiptap-ui/blockquote-button"
|
||||||
|
|
||||||
// --- Hooks ---
|
// --- Hooks ---
|
||||||
|
import { Badge } from "@/components/tiptap-ui-primitive/badge"
|
||||||
|
import type { ButtonProps } from "@/components/tiptap-ui-primitive/button"
|
||||||
|
import { Button } from "@/components/tiptap-ui-primitive/button"
|
||||||
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
||||||
|
|
||||||
// --- Lib ---
|
// --- Lib ---
|
||||||
import { parseShortcutKeys } from "@/lib/tiptap-utils"
|
import { parseShortcutKeys } from "@/lib/tiptap-utils"
|
||||||
|
|
||||||
// --- UI Primitives ---
|
// --- UI Primitives ---
|
||||||
import type { ButtonProps } from "@/components/tiptap-ui-primitive/button"
|
|
||||||
import { Button } from "@/components/tiptap-ui-primitive/button"
|
|
||||||
import { Badge } from "@/components/tiptap-ui-primitive/badge"
|
|
||||||
|
|
||||||
export interface BlockquoteButtonProps
|
export interface BlockquoteButtonProps
|
||||||
extends Omit<ButtonProps, "type">, UseBlockquoteConfig {
|
extends Omit<ButtonProps, "type">, UseBlockquoteConfig {
|
||||||
@ -79,7 +79,11 @@ export const BlockquoteButton = forwardRef<
|
|||||||
const handleClick = useCallback(
|
const handleClick = useCallback(
|
||||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
onClick?.(event)
|
onClick?.(event)
|
||||||
if (event.defaultPrevented) return
|
|
||||||
|
if (event.defaultPrevented) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
handleToggle()
|
handleToggle()
|
||||||
},
|
},
|
||||||
[handleToggle, onClick]
|
[handleToggle, onClick]
|
||||||
|
|||||||
@ -1,14 +1,14 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react"
|
|
||||||
import type { Editor } from "@tiptap/react"
|
|
||||||
import { NodeSelection, TextSelection } from "@tiptap/pm/state"
|
import { NodeSelection, TextSelection } from "@tiptap/pm/state"
|
||||||
|
import type { Editor } from "@tiptap/react"
|
||||||
|
import { useCallback, useEffect, useState } from "react"
|
||||||
|
|
||||||
// --- Hooks ---
|
// --- Hooks ---
|
||||||
|
import { BlockquoteIcon } from "@/components/tiptap-icons/blockquote-icon"
|
||||||
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
||||||
|
|
||||||
// --- Icons ---
|
// --- Icons ---
|
||||||
import { BlockquoteIcon } from "@/components/tiptap-icons/blockquote-icon"
|
|
||||||
|
|
||||||
// --- UI Utils ---
|
// --- UI Utils ---
|
||||||
import {
|
import {
|
||||||
@ -48,12 +48,16 @@ export function canToggleBlockquote(
|
|||||||
editor: Editor | null,
|
editor: Editor | null,
|
||||||
turnInto: boolean = true
|
turnInto: boolean = true
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!editor || !editor.isEditable) return false
|
if (!editor || !editor.isEditable) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!isNodeInSchema("blockquote", editor) ||
|
!isNodeInSchema("blockquote", editor) ||
|
||||||
isNodeTypeSelected(editor, ["image"])
|
isNodeTypeSelected(editor, ["image"])
|
||||||
)
|
) {
|
||||||
return false
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
if (!turnInto) {
|
if (!turnInto) {
|
||||||
return editor.can().toggleWrap("blockquote")
|
return editor.can().toggleWrap("blockquote")
|
||||||
@ -70,8 +74,9 @@ export function canToggleBlockquote(
|
|||||||
"blockquote",
|
"blockquote",
|
||||||
"codeBlock",
|
"codeBlock",
|
||||||
])
|
])
|
||||||
)
|
) {
|
||||||
return false
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// Either we can wrap in blockquote directly on the selection,
|
// Either we can wrap in blockquote directly on the selection,
|
||||||
// or we can clear formatting/nodes to arrive at a blockquote.
|
// or we can clear formatting/nodes to arrive at a blockquote.
|
||||||
@ -82,8 +87,13 @@ export function canToggleBlockquote(
|
|||||||
* Toggles blockquote formatting for a specific node or the current selection
|
* Toggles blockquote formatting for a specific node or the current selection
|
||||||
*/
|
*/
|
||||||
export function toggleBlockquote(editor: Editor | null): boolean {
|
export function toggleBlockquote(editor: Editor | null): boolean {
|
||||||
if (!editor || !editor.isEditable) return false
|
if (!editor || !editor.isEditable) {
|
||||||
if (!canToggleBlockquote(editor)) return false
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canToggleBlockquote(editor)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const view = editor.view
|
const view = editor.view
|
||||||
@ -115,7 +125,10 @@ export function toggleBlockquote(editor: Editor | null): boolean {
|
|||||||
editor,
|
editor,
|
||||||
node: state.selection.$anchor.node(1),
|
node: state.selection.$anchor.node(1),
|
||||||
})?.pos
|
})?.pos
|
||||||
if (!isValidPosition(pos)) return false
|
|
||||||
|
if (!isValidPosition(pos)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
tr = tr.setSelection(NodeSelection.create(state.doc, pos))
|
tr = tr.setSelection(NodeSelection.create(state.doc, pos))
|
||||||
view.dispatch(tr)
|
view.dispatch(tr)
|
||||||
@ -170,15 +183,21 @@ export function shouldShowButton(props: {
|
|||||||
}): boolean {
|
}): boolean {
|
||||||
const { editor, hideWhenUnavailable } = props
|
const { editor, hideWhenUnavailable } = props
|
||||||
|
|
||||||
if (!editor) return false
|
if (!editor) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
if (!hideWhenUnavailable) {
|
if (!hideWhenUnavailable) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!editor.isEditable) return false
|
if (!editor.isEditable) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
if (!isNodeInSchema("blockquote", editor)) return false
|
if (!isNodeInSchema("blockquote", editor)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
if (!editor.isActive("code")) {
|
if (!editor.isActive("code")) {
|
||||||
return canToggleBlockquote(editor)
|
return canToggleBlockquote(editor)
|
||||||
@ -236,7 +255,9 @@ export function useBlockquote(config?: UseBlockquoteConfig) {
|
|||||||
const isActive = editor?.isActive("blockquote") || false
|
const isActive = editor?.isActive("blockquote") || false
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editor) return
|
if (!editor) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const handleSelectionUpdate = () => {
|
const handleSelectionUpdate = () => {
|
||||||
setIsVisible(shouldShowButton({ editor, hideWhenUnavailable }))
|
setIsVisible(shouldShowButton({ editor, hideWhenUnavailable }))
|
||||||
@ -252,12 +273,16 @@ export function useBlockquote(config?: UseBlockquoteConfig) {
|
|||||||
}, [editor, hideWhenUnavailable])
|
}, [editor, hideWhenUnavailable])
|
||||||
|
|
||||||
const handleToggle = useCallback(() => {
|
const handleToggle = useCallback(() => {
|
||||||
if (!editor) return false
|
if (!editor) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
const success = toggleBlockquote(editor)
|
const success = toggleBlockquote(editor)
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
onToggled?.()
|
onToggled?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
return success
|
return success
|
||||||
}, [editor, onToggled])
|
}, [editor, onToggled])
|
||||||
|
|
||||||
|
|||||||
@ -1,10 +1,8 @@
|
|||||||
import { forwardRef, useCallback } from "react"
|
import { forwardRef, useCallback } from "react"
|
||||||
|
|
||||||
// --- Hooks ---
|
// --- Hooks ---
|
||||||
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
|
||||||
|
|
||||||
// --- Lib ---
|
// --- Lib ---
|
||||||
import { parseShortcutKeys } from "@/lib/tiptap-utils"
|
|
||||||
|
|
||||||
// --- Tiptap UI ---
|
// --- Tiptap UI ---
|
||||||
import type { UseCodeBlockConfig } from "@/components/tiptap-ui/code-block-button"
|
import type { UseCodeBlockConfig } from "@/components/tiptap-ui/code-block-button"
|
||||||
@ -14,9 +12,11 @@ import {
|
|||||||
} from "@/components/tiptap-ui/code-block-button"
|
} from "@/components/tiptap-ui/code-block-button"
|
||||||
|
|
||||||
// --- UI Primitives ---
|
// --- UI Primitives ---
|
||||||
|
import { Badge } from "@/components/tiptap-ui-primitive/badge"
|
||||||
import type { ButtonProps } from "@/components/tiptap-ui-primitive/button"
|
import type { ButtonProps } from "@/components/tiptap-ui-primitive/button"
|
||||||
import { Button } from "@/components/tiptap-ui-primitive/button"
|
import { Button } from "@/components/tiptap-ui-primitive/button"
|
||||||
import { Badge } from "@/components/tiptap-ui-primitive/badge"
|
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
||||||
|
import { parseShortcutKeys } from "@/lib/tiptap-utils"
|
||||||
|
|
||||||
export interface CodeBlockButtonProps
|
export interface CodeBlockButtonProps
|
||||||
extends Omit<ButtonProps, "type">, UseCodeBlockConfig {
|
extends Omit<ButtonProps, "type">, UseCodeBlockConfig {
|
||||||
@ -79,7 +79,11 @@ export const CodeBlockButton = forwardRef<
|
|||||||
const handleClick = useCallback(
|
const handleClick = useCallback(
|
||||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
onClick?.(event)
|
onClick?.(event)
|
||||||
if (event.defaultPrevented) return
|
|
||||||
|
if (event.defaultPrevented) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
handleToggle()
|
handleToggle()
|
||||||
},
|
},
|
||||||
[handleToggle, onClick]
|
[handleToggle, onClick]
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react"
|
|
||||||
import { type Editor } from "@tiptap/react"
|
|
||||||
import { NodeSelection, TextSelection } from "@tiptap/pm/state"
|
import { NodeSelection, TextSelection } from "@tiptap/pm/state"
|
||||||
|
import type {Editor} from "@tiptap/react";
|
||||||
|
import { useCallback, useEffect, useState } from "react"
|
||||||
|
|
||||||
// --- Hooks ---
|
// --- Hooks ---
|
||||||
|
import { CodeBlockIcon } from "@/components/tiptap-icons/code-block-icon"
|
||||||
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
||||||
|
|
||||||
// --- Lib ---
|
// --- Lib ---
|
||||||
@ -18,7 +19,6 @@ import {
|
|||||||
} from "@/lib/tiptap-utils"
|
} from "@/lib/tiptap-utils"
|
||||||
|
|
||||||
// --- Icons ---
|
// --- Icons ---
|
||||||
import { CodeBlockIcon } from "@/components/tiptap-icons/code-block-icon"
|
|
||||||
|
|
||||||
export const CODE_BLOCK_SHORTCUT_KEY = "mod+alt+c"
|
export const CODE_BLOCK_SHORTCUT_KEY = "mod+alt+c"
|
||||||
|
|
||||||
@ -48,12 +48,16 @@ export function canToggle(
|
|||||||
editor: Editor | null,
|
editor: Editor | null,
|
||||||
turnInto: boolean = true
|
turnInto: boolean = true
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!editor || !editor.isEditable) return false
|
if (!editor || !editor.isEditable) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!isNodeInSchema("codeBlock", editor) ||
|
!isNodeInSchema("codeBlock", editor) ||
|
||||||
isNodeTypeSelected(editor, ["image"])
|
isNodeTypeSelected(editor, ["image"])
|
||||||
)
|
) {
|
||||||
return false
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
if (!turnInto) {
|
if (!turnInto) {
|
||||||
return editor.can().toggleNode("codeBlock", "paragraph")
|
return editor.can().toggleNode("codeBlock", "paragraph")
|
||||||
@ -70,8 +74,9 @@ export function canToggle(
|
|||||||
"blockquote",
|
"blockquote",
|
||||||
"codeBlock",
|
"codeBlock",
|
||||||
])
|
])
|
||||||
)
|
) {
|
||||||
return false
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// Either we can toggle code block directly on the selection,
|
// Either we can toggle code block directly on the selection,
|
||||||
// or we can clear formatting/nodes to arrive at a code block.
|
// or we can clear formatting/nodes to arrive at a code block.
|
||||||
@ -85,8 +90,13 @@ export function canToggle(
|
|||||||
* Toggles code block in the editor
|
* Toggles code block in the editor
|
||||||
*/
|
*/
|
||||||
export function toggleCodeBlock(editor: Editor | null): boolean {
|
export function toggleCodeBlock(editor: Editor | null): boolean {
|
||||||
if (!editor || !editor.isEditable) return false
|
if (!editor || !editor.isEditable) {
|
||||||
if (!canToggle(editor)) return false
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canToggle(editor)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const view = editor.view
|
const view = editor.view
|
||||||
@ -118,7 +128,10 @@ export function toggleCodeBlock(editor: Editor | null): boolean {
|
|||||||
editor,
|
editor,
|
||||||
node: state.selection.$anchor.node(1),
|
node: state.selection.$anchor.node(1),
|
||||||
})?.pos
|
})?.pos
|
||||||
if (!isValidPosition(pos)) return false
|
|
||||||
|
if (!isValidPosition(pos)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
tr = tr.setSelection(NodeSelection.create(state.doc, pos))
|
tr = tr.setSelection(NodeSelection.create(state.doc, pos))
|
||||||
view.dispatch(tr)
|
view.dispatch(tr)
|
||||||
@ -173,15 +186,21 @@ export function shouldShowButton(props: {
|
|||||||
}): boolean {
|
}): boolean {
|
||||||
const { editor, hideWhenUnavailable } = props
|
const { editor, hideWhenUnavailable } = props
|
||||||
|
|
||||||
if (!editor) return false
|
if (!editor) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
if (!hideWhenUnavailable) {
|
if (!hideWhenUnavailable) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!editor.isEditable) return false
|
if (!editor.isEditable) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
if (!isNodeInSchema("codeBlock", editor)) return false
|
if (!isNodeInSchema("codeBlock", editor)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
if (!editor.isActive("code")) {
|
if (!editor.isActive("code")) {
|
||||||
return canToggle(editor)
|
return canToggle(editor)
|
||||||
@ -246,7 +265,9 @@ export function useCodeBlock(config?: UseCodeBlockConfig) {
|
|||||||
const isActive = editor?.isActive("codeBlock") || false
|
const isActive = editor?.isActive("codeBlock") || false
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editor) return
|
if (!editor) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const handleSelectionUpdate = () => {
|
const handleSelectionUpdate = () => {
|
||||||
setIsVisible(shouldShowButton({ editor, hideWhenUnavailable }))
|
setIsVisible(shouldShowButton({ editor, hideWhenUnavailable }))
|
||||||
@ -262,12 +283,16 @@ export function useCodeBlock(config?: UseCodeBlockConfig) {
|
|||||||
}, [editor, hideWhenUnavailable])
|
}, [editor, hideWhenUnavailable])
|
||||||
|
|
||||||
const handleToggle = useCallback(() => {
|
const handleToggle = useCallback(() => {
|
||||||
if (!editor) return false
|
if (!editor) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
const success = toggleCodeBlock(editor)
|
const success = toggleCodeBlock(editor)
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
onToggled?.()
|
onToggled?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
return success
|
return success
|
||||||
}, [editor, onToggled])
|
}, [editor, onToggled])
|
||||||
|
|
||||||
|
|||||||
@ -1,10 +1,8 @@
|
|||||||
import { forwardRef, useCallback, useMemo } from "react"
|
import { forwardRef, useCallback, useMemo } from "react"
|
||||||
|
|
||||||
// --- Lib ---
|
// --- Lib ---
|
||||||
import { parseShortcutKeys } from "@/lib/tiptap-utils"
|
|
||||||
|
|
||||||
// --- Hooks ---
|
// --- Hooks ---
|
||||||
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
|
||||||
|
|
||||||
// --- Tiptap UI ---
|
// --- Tiptap UI ---
|
||||||
import type { UseColorHighlightConfig } from "@/components/tiptap-ui/color-highlight-button"
|
import type { UseColorHighlightConfig } from "@/components/tiptap-ui/color-highlight-button"
|
||||||
@ -14,9 +12,11 @@ import {
|
|||||||
} from "@/components/tiptap-ui/color-highlight-button"
|
} from "@/components/tiptap-ui/color-highlight-button"
|
||||||
|
|
||||||
// --- UI Primitives ---
|
// --- UI Primitives ---
|
||||||
|
import { Badge } from "@/components/tiptap-ui-primitive/badge"
|
||||||
import type { ButtonProps } from "@/components/tiptap-ui-primitive/button"
|
import type { ButtonProps } from "@/components/tiptap-ui-primitive/button"
|
||||||
import { Button } from "@/components/tiptap-ui-primitive/button"
|
import { Button } from "@/components/tiptap-ui-primitive/button"
|
||||||
import { Badge } from "@/components/tiptap-ui-primitive/badge"
|
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
||||||
|
import { parseShortcutKeys } from "@/lib/tiptap-utils"
|
||||||
|
|
||||||
// --- Styles ---
|
// --- Styles ---
|
||||||
import "@/components/tiptap-ui/color-highlight-button/color-highlight-button.scss"
|
import "@/components/tiptap-ui/color-highlight-button/color-highlight-button.scss"
|
||||||
@ -112,7 +112,11 @@ export const ColorHighlightButton = forwardRef<
|
|||||||
const handleClick = useCallback(
|
const handleClick = useCallback(
|
||||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
onClick?.(event)
|
onClick?.(event)
|
||||||
if (event.defaultPrevented) return
|
|
||||||
|
if (event.defaultPrevented) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
handleColorHighlight()
|
handleColorHighlight()
|
||||||
},
|
},
|
||||||
[handleColorHighlight, onClick]
|
[handleColorHighlight, onClick]
|
||||||
|
|||||||
@ -1,12 +1,13 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import type {Editor} from "@tiptap/react";
|
||||||
import { useCallback, useEffect, useState } from "react"
|
import { useCallback, useEffect, useState } from "react"
|
||||||
import { type Editor } from "@tiptap/react"
|
|
||||||
import { useHotkeys } from "react-hotkeys-hook"
|
import { useHotkeys } from "react-hotkeys-hook"
|
||||||
|
|
||||||
// --- Hooks ---
|
// --- Hooks ---
|
||||||
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
import { HighlighterIcon } from "@/components/tiptap-icons/highlighter-icon"
|
||||||
import { useIsBreakpoint } from "@/hooks/use-is-breakpoint"
|
import { useIsBreakpoint } from "@/hooks/use-is-breakpoint"
|
||||||
|
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
||||||
|
|
||||||
// --- Lib ---
|
// --- Lib ---
|
||||||
import {
|
import {
|
||||||
@ -16,7 +17,6 @@ import {
|
|||||||
} from "@/lib/tiptap-utils"
|
} from "@/lib/tiptap-utils"
|
||||||
|
|
||||||
// --- Icons ---
|
// --- Icons ---
|
||||||
import { HighlighterIcon } from "@/components/tiptap-icons/highlighter-icon"
|
|
||||||
|
|
||||||
export const COLOR_HIGHLIGHT_SHORTCUT_KEY = "mod+shift+h"
|
export const COLOR_HIGHLIGHT_SHORTCUT_KEY = "mod+shift+h"
|
||||||
export const HIGHLIGHT_COLORS = [
|
export const HIGHLIGHT_COLORS = [
|
||||||
@ -136,6 +136,7 @@ export function pickHighlightColorsByValue(values: string[]) {
|
|||||||
const colorMap = new Map(
|
const colorMap = new Map(
|
||||||
HIGHLIGHT_COLORS.map((color) => [color.value, color])
|
HIGHLIGHT_COLORS.map((color) => [color.value, color])
|
||||||
)
|
)
|
||||||
|
|
||||||
return values
|
return values
|
||||||
.map((value) => colorMap.get(value))
|
.map((value) => colorMap.get(value))
|
||||||
.filter((color): color is (typeof HIGHLIGHT_COLORS)[number] => !!color)
|
.filter((color): color is (typeof HIGHLIGHT_COLORS)[number] => !!color)
|
||||||
@ -148,11 +149,14 @@ export function getHighlightColorValue(
|
|||||||
color: string,
|
color: string,
|
||||||
useColorValue: boolean = false
|
useColorValue: boolean = false
|
||||||
): string {
|
): string {
|
||||||
if (!useColorValue) return color
|
if (!useColorValue) {
|
||||||
|
return color
|
||||||
|
}
|
||||||
|
|
||||||
const colorItem = HIGHLIGHT_COLORS.find(
|
const colorItem = HIGHLIGHT_COLORS.find(
|
||||||
(c) => c.value === color || c.colorValue === color
|
(c) => c.value === color || c.colorValue === color
|
||||||
)
|
)
|
||||||
|
|
||||||
return colorItem?.colorValue || color
|
return colorItem?.colorValue || color
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -163,18 +167,23 @@ export function canColorHighlight(
|
|||||||
editor: Editor | null,
|
editor: Editor | null,
|
||||||
mode: HighlightMode = "mark"
|
mode: HighlightMode = "mark"
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!editor || !editor.isEditable) return false
|
if (!editor || !editor.isEditable) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
if (mode === "mark") {
|
if (mode === "mark") {
|
||||||
if (
|
if (
|
||||||
!isMarkInSchema("highlight", editor) ||
|
!isMarkInSchema("highlight", editor) ||
|
||||||
isNodeTypeSelected(editor, ["image"])
|
isNodeTypeSelected(editor, ["image"])
|
||||||
)
|
) {
|
||||||
return false
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
return editor.can().setMark("highlight")
|
return editor.can().setMark("highlight")
|
||||||
} else {
|
} else {
|
||||||
if (!isExtensionAvailable(editor, ["nodeBackground"])) return false
|
if (!isExtensionAvailable(editor, ["nodeBackground"])) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return editor.can().toggleNodeBackgroundColor("test")
|
return editor.can().toggleNodeBackgroundColor("test")
|
||||||
@ -192,26 +201,33 @@ export function isColorHighlightActive(
|
|||||||
highlightColor?: string,
|
highlightColor?: string,
|
||||||
mode: HighlightMode = "mark"
|
mode: HighlightMode = "mark"
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!editor || !editor.isEditable) return false
|
if (!editor || !editor.isEditable) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
if (mode === "mark") {
|
if (mode === "mark") {
|
||||||
return highlightColor
|
return highlightColor
|
||||||
? editor.isActive("highlight", { color: highlightColor })
|
? editor.isActive("highlight", { color: highlightColor })
|
||||||
: editor.isActive("highlight")
|
: editor.isActive("highlight")
|
||||||
} else {
|
} else {
|
||||||
if (!highlightColor) return false
|
if (!highlightColor) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { state } = editor
|
const { state } = editor
|
||||||
const { selection } = state
|
const { selection } = state
|
||||||
|
|
||||||
const $pos = selection.$anchor
|
const $pos = selection.$anchor
|
||||||
|
|
||||||
for (let depth = $pos.depth; depth >= 0; depth--) {
|
for (let depth = $pos.depth; depth >= 0; depth--) {
|
||||||
const node = $pos.node(depth)
|
const node = $pos.node(depth)
|
||||||
|
|
||||||
if (node && node.attrs?.backgroundColor === highlightColor) {
|
if (node && node.attrs?.backgroundColor === highlightColor) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
return false
|
||||||
@ -226,8 +242,13 @@ export function removeHighlight(
|
|||||||
editor: Editor | null,
|
editor: Editor | null,
|
||||||
mode: HighlightMode = "mark"
|
mode: HighlightMode = "mark"
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!editor || !editor.isEditable) return false
|
if (!editor || !editor.isEditable) {
|
||||||
if (!canColorHighlight(editor, mode)) return false
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canColorHighlight(editor, mode)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
if (mode === "mark") {
|
if (mode === "mark") {
|
||||||
return editor.chain().focus().unsetMark("highlight").run()
|
return editor.chain().focus().unsetMark("highlight").run()
|
||||||
@ -246,19 +267,27 @@ export function shouldShowButton(props: {
|
|||||||
}): boolean {
|
}): boolean {
|
||||||
const { editor, hideWhenUnavailable, mode } = props
|
const { editor, hideWhenUnavailable, mode } = props
|
||||||
|
|
||||||
if (!editor) return false
|
if (!editor) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
if (!hideWhenUnavailable) {
|
if (!hideWhenUnavailable) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!editor.isEditable) return false
|
if (!editor.isEditable) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// hideWhenUnavailable=true: check schema/extension availability
|
// hideWhenUnavailable=true: check schema/extension availability
|
||||||
if (mode === "mark") {
|
if (mode === "mark") {
|
||||||
if (!isMarkInSchema("highlight", editor)) return false
|
if (!isMarkInSchema("highlight", editor)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
if (!isExtensionAvailable(editor, ["nodeBackground"])) return false
|
if (!isExtensionAvailable(editor, ["nodeBackground"])) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!editor.isActive("code")) {
|
if (!editor.isActive("code")) {
|
||||||
@ -289,7 +318,9 @@ export function useColorHighlight(config: UseColorHighlightConfig) {
|
|||||||
const isActive = isColorHighlightActive(editor, actualColor, mode)
|
const isActive = isColorHighlightActive(editor, actualColor, mode)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editor) return
|
if (!editor) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const handleSelectionUpdate = () => {
|
const handleSelectionUpdate = () => {
|
||||||
setIsVisible(shouldShowButton({ editor, hideWhenUnavailable, mode }))
|
setIsVisible(shouldShowButton({ editor, hideWhenUnavailable, mode }))
|
||||||
@ -305,12 +336,14 @@ export function useColorHighlight(config: UseColorHighlightConfig) {
|
|||||||
}, [editor, hideWhenUnavailable, mode])
|
}, [editor, hideWhenUnavailable, mode])
|
||||||
|
|
||||||
const handleColorHighlight = useCallback(() => {
|
const handleColorHighlight = useCallback(() => {
|
||||||
if (!editor || !canColorHighlightState || !actualColor || !label)
|
if (!editor || !canColorHighlightState || !actualColor || !label) {
|
||||||
return false
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
if (mode === "mark") {
|
if (mode === "mark") {
|
||||||
if (editor.state.storedMarks) {
|
if (editor.state.storedMarks) {
|
||||||
const highlightMarkType = editor.schema.marks.highlight
|
const highlightMarkType = editor.schema.marks.highlight
|
||||||
|
|
||||||
if (highlightMarkType) {
|
if (highlightMarkType) {
|
||||||
editor.view.dispatch(
|
editor.view.dispatch(
|
||||||
editor.state.tr.removeStoredMark(highlightMarkType)
|
editor.state.tr.removeStoredMark(highlightMarkType)
|
||||||
@ -324,9 +357,11 @@ export function useColorHighlight(config: UseColorHighlightConfig) {
|
|||||||
.focus()
|
.focus()
|
||||||
.toggleHighlight({ color: actualColor })
|
.toggleHighlight({ color: actualColor })
|
||||||
.run()
|
.run()
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
onApplied?.({ color: actualColor, label, mode })
|
onApplied?.({ color: actualColor, label, mode })
|
||||||
}
|
}
|
||||||
|
|
||||||
return success
|
return success
|
||||||
}, 0)
|
}, 0)
|
||||||
|
|
||||||
@ -341,15 +376,18 @@ export function useColorHighlight(config: UseColorHighlightConfig) {
|
|||||||
if (success) {
|
if (success) {
|
||||||
onApplied?.({ color: actualColor, label, mode })
|
onApplied?.({ color: actualColor, label, mode })
|
||||||
}
|
}
|
||||||
|
|
||||||
return success
|
return success
|
||||||
}
|
}
|
||||||
}, [canColorHighlightState, actualColor, editor, label, onApplied, mode])
|
}, [canColorHighlightState, actualColor, editor, label, onApplied, mode])
|
||||||
|
|
||||||
const handleRemoveHighlight = useCallback(() => {
|
const handleRemoveHighlight = useCallback(() => {
|
||||||
const success = removeHighlight(editor, mode)
|
const success = removeHighlight(editor, mode)
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
onApplied?.({ color: "", label: "Remove highlight", mode })
|
onApplied?.({ color: "", label: "Remove highlight", mode })
|
||||||
}
|
}
|
||||||
|
|
||||||
return success
|
return success
|
||||||
}, [editor, onApplied, mode])
|
}, [editor, onApplied, mode])
|
||||||
|
|
||||||
|
|||||||
@ -1,31 +1,13 @@
|
|||||||
|
import type {Editor} from "@tiptap/react";
|
||||||
import { forwardRef, useMemo, useRef, useState } from "react"
|
import { forwardRef, useMemo, useRef, useState } from "react"
|
||||||
import { type Editor } from "@tiptap/react"
|
|
||||||
|
|
||||||
// --- Hooks ---
|
// --- Hooks ---
|
||||||
import { useMenuNavigation } from "@/hooks/use-menu-navigation"
|
|
||||||
import { useIsBreakpoint } from "@/hooks/use-is-breakpoint"
|
|
||||||
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
|
||||||
|
|
||||||
// --- Icons ---
|
// --- Icons ---
|
||||||
import { BanIcon } from "@/components/tiptap-icons/ban-icon"
|
import { BanIcon } from "@/components/tiptap-icons/ban-icon"
|
||||||
import { HighlighterIcon } from "@/components/tiptap-icons/highlighter-icon"
|
import { HighlighterIcon } from "@/components/tiptap-icons/highlighter-icon"
|
||||||
|
|
||||||
// --- UI Primitives ---
|
// --- UI Primitives ---
|
||||||
import type { ButtonProps } from "@/components/tiptap-ui-primitive/button"
|
|
||||||
import { Button } from "@/components/tiptap-ui-primitive/button"
|
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverTrigger,
|
|
||||||
PopoverContent,
|
|
||||||
} from "@/components/tiptap-ui-primitive/popover"
|
|
||||||
import { Separator } from "@/components/tiptap-ui-primitive/separator"
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardBody,
|
|
||||||
CardItemGroup,
|
|
||||||
} from "@/components/tiptap-ui-primitive/card"
|
|
||||||
|
|
||||||
// --- Tiptap UI ---
|
|
||||||
import type {
|
import type {
|
||||||
HighlightColor,
|
HighlightColor,
|
||||||
UseColorHighlightConfig,
|
UseColorHighlightConfig,
|
||||||
@ -35,7 +17,25 @@ import {
|
|||||||
pickHighlightColorsByValue,
|
pickHighlightColorsByValue,
|
||||||
useColorHighlight,
|
useColorHighlight,
|
||||||
} from "@/components/tiptap-ui/color-highlight-button"
|
} from "@/components/tiptap-ui/color-highlight-button"
|
||||||
|
import type { ButtonProps } from "@/components/tiptap-ui-primitive/button"
|
||||||
|
import { Button } from "@/components/tiptap-ui-primitive/button"
|
||||||
import { ButtonGroup } from "@/components/tiptap-ui-primitive/button-group"
|
import { ButtonGroup } from "@/components/tiptap-ui-primitive/button-group"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardBody,
|
||||||
|
CardItemGroup,
|
||||||
|
} from "@/components/tiptap-ui-primitive/card"
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverTrigger,
|
||||||
|
PopoverContent,
|
||||||
|
} from "@/components/tiptap-ui-primitive/popover"
|
||||||
|
import { Separator } from "@/components/tiptap-ui-primitive/separator"
|
||||||
|
|
||||||
|
// --- Tiptap UI ---
|
||||||
|
import { useIsBreakpoint } from "@/hooks/use-is-breakpoint"
|
||||||
|
import { useMenuNavigation } from "@/hooks/use-menu-navigation"
|
||||||
|
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
||||||
|
|
||||||
export interface ColorHighlightPopoverContentProps {
|
export interface ColorHighlightPopoverContentProps {
|
||||||
/**
|
/**
|
||||||
@ -120,12 +120,22 @@ export function ColorHighlightPopoverContent({
|
|||||||
items: menuItems,
|
items: menuItems,
|
||||||
orientation: "both",
|
orientation: "both",
|
||||||
onSelect: (item) => {
|
onSelect: (item) => {
|
||||||
if (!containerRef.current) return false
|
if (!containerRef.current) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
const highlightedElement = containerRef.current.querySelector(
|
const highlightedElement = containerRef.current.querySelector(
|
||||||
'[data-highlighted="true"]'
|
'[data-highlighted="true"]'
|
||||||
) as HTMLElement
|
) as HTMLElement
|
||||||
if (highlightedElement) highlightedElement.click()
|
|
||||||
if (item.value === "none") handleRemoveHighlight()
|
if (highlightedElement) {
|
||||||
|
highlightedElement.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.value === "none") {
|
||||||
|
handleRemoveHighlight()
|
||||||
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
autoSelectFirstItem: false,
|
autoSelectFirstItem: false,
|
||||||
@ -200,7 +210,9 @@ export function ColorHighlightPopover({
|
|||||||
onApplied,
|
onApplied,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!isVisible) return null
|
if (!isVisible) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import { forwardRef, useCallback } from "react"
|
import { forwardRef, useCallback } from "react"
|
||||||
|
|
||||||
// --- Lib ---
|
// --- Lib ---
|
||||||
import { parseShortcutKeys } from "@/lib/tiptap-utils"
|
|
||||||
|
|
||||||
// --- Tiptap UI ---
|
// --- Tiptap UI ---
|
||||||
import type {
|
import type {
|
||||||
@ -14,10 +13,11 @@ import {
|
|||||||
} from "@/components/tiptap-ui/heading-button"
|
} from "@/components/tiptap-ui/heading-button"
|
||||||
|
|
||||||
// --- UI Primitives ---
|
// --- UI Primitives ---
|
||||||
|
import { Badge } from "@/components/tiptap-ui-primitive/badge"
|
||||||
import type { ButtonProps } from "@/components/tiptap-ui-primitive/button"
|
import type { ButtonProps } from "@/components/tiptap-ui-primitive/button"
|
||||||
import { Button } from "@/components/tiptap-ui-primitive/button"
|
import { Button } from "@/components/tiptap-ui-primitive/button"
|
||||||
import { Badge } from "@/components/tiptap-ui-primitive/badge"
|
|
||||||
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
||||||
|
import { parseShortcutKeys } from "@/lib/tiptap-utils"
|
||||||
|
|
||||||
export interface HeadingButtonProps
|
export interface HeadingButtonProps
|
||||||
extends Omit<ButtonProps, "type">, UseHeadingConfig {
|
extends Omit<ButtonProps, "type">, UseHeadingConfig {
|
||||||
@ -81,7 +81,11 @@ export const HeadingButton = forwardRef<HTMLButtonElement, HeadingButtonProps>(
|
|||||||
const handleClick = useCallback(
|
const handleClick = useCallback(
|
||||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
onClick?.(event)
|
onClick?.(event)
|
||||||
if (event.defaultPrevented) return
|
|
||||||
|
if (event.defaultPrevented) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
handleToggle()
|
handleToggle()
|
||||||
},
|
},
|
||||||
[handleToggle, onClick]
|
[handleToggle, onClick]
|
||||||
|
|||||||
@ -1,10 +1,16 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react"
|
|
||||||
import { type Editor } from "@tiptap/react"
|
|
||||||
import { NodeSelection, TextSelection } from "@tiptap/pm/state"
|
import { NodeSelection, TextSelection } from "@tiptap/pm/state"
|
||||||
|
import type {Editor} from "@tiptap/react";
|
||||||
|
import { useCallback, useEffect, useState } from "react"
|
||||||
|
|
||||||
// --- Hooks ---
|
// --- Hooks ---
|
||||||
|
import { HeadingFiveIcon } from "@/components/tiptap-icons/heading-five-icon"
|
||||||
|
import { HeadingFourIcon } from "@/components/tiptap-icons/heading-four-icon"
|
||||||
|
import { HeadingOneIcon } from "@/components/tiptap-icons/heading-one-icon"
|
||||||
|
import { HeadingSixIcon } from "@/components/tiptap-icons/heading-six-icon"
|
||||||
|
import { HeadingThreeIcon } from "@/components/tiptap-icons/heading-three-icon"
|
||||||
|
import { HeadingTwoIcon } from "@/components/tiptap-icons/heading-two-icon"
|
||||||
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
||||||
|
|
||||||
// --- Lib ---
|
// --- Lib ---
|
||||||
@ -18,12 +24,6 @@ import {
|
|||||||
} from "@/lib/tiptap-utils"
|
} from "@/lib/tiptap-utils"
|
||||||
|
|
||||||
// --- Icons ---
|
// --- Icons ---
|
||||||
import { HeadingOneIcon } from "@/components/tiptap-icons/heading-one-icon"
|
|
||||||
import { HeadingTwoIcon } from "@/components/tiptap-icons/heading-two-icon"
|
|
||||||
import { HeadingThreeIcon } from "@/components/tiptap-icons/heading-three-icon"
|
|
||||||
import { HeadingFourIcon } from "@/components/tiptap-icons/heading-four-icon"
|
|
||||||
import { HeadingFiveIcon } from "@/components/tiptap-icons/heading-five-icon"
|
|
||||||
import { HeadingSixIcon } from "@/components/tiptap-icons/heading-six-icon"
|
|
||||||
|
|
||||||
export type Level = 1 | 2 | 3 | 4 | 5 | 6
|
export type Level = 1 | 2 | 3 | 4 | 5 | 6
|
||||||
|
|
||||||
@ -76,12 +76,16 @@ export function canToggle(
|
|||||||
level?: Level,
|
level?: Level,
|
||||||
turnInto: boolean = true
|
turnInto: boolean = true
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!editor || !editor.isEditable) return false
|
if (!editor || !editor.isEditable) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!isNodeInSchema("heading", editor) ||
|
!isNodeInSchema("heading", editor) ||
|
||||||
isNodeTypeSelected(editor, ["image"])
|
isNodeTypeSelected(editor, ["image"])
|
||||||
)
|
) {
|
||||||
return false
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
if (!turnInto) {
|
if (!turnInto) {
|
||||||
return level
|
return level
|
||||||
@ -100,8 +104,9 @@ export function canToggle(
|
|||||||
"blockquote",
|
"blockquote",
|
||||||
"codeBlock",
|
"codeBlock",
|
||||||
])
|
])
|
||||||
)
|
) {
|
||||||
return false
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// Either we can set heading directly on the selection,
|
// Either we can set heading directly on the selection,
|
||||||
// or we can clear formatting/nodes to arrive at a heading.
|
// or we can clear formatting/nodes to arrive at a heading.
|
||||||
@ -117,7 +122,9 @@ export function isHeadingActive(
|
|||||||
editor: Editor | null,
|
editor: Editor | null,
|
||||||
level?: Level | Level[]
|
level?: Level | Level[]
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!editor || !editor.isEditable) return false
|
if (!editor || !editor.isEditable) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
if (Array.isArray(level)) {
|
if (Array.isArray(level)) {
|
||||||
return level.some((l) => editor.isActive("heading", { level: l }))
|
return level.some((l) => editor.isActive("heading", { level: l }))
|
||||||
@ -135,12 +142,16 @@ export function toggleHeading(
|
|||||||
editor: Editor | null,
|
editor: Editor | null,
|
||||||
level: Level | Level[]
|
level: Level | Level[]
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!editor || !editor.isEditable) return false
|
if (!editor || !editor.isEditable) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
const levels = Array.isArray(level) ? level : [level]
|
const levels = Array.isArray(level) ? level : [level]
|
||||||
const toggleLevel = levels.find((l) => canToggle(editor, l))
|
const toggleLevel = levels.find((l) => canToggle(editor, l))
|
||||||
|
|
||||||
if (!toggleLevel) return false
|
if (!toggleLevel) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const view = editor.view
|
const view = editor.view
|
||||||
@ -172,7 +183,10 @@ export function toggleHeading(
|
|||||||
editor,
|
editor,
|
||||||
node: state.selection.$anchor.node(1),
|
node: state.selection.$anchor.node(1),
|
||||||
})?.pos
|
})?.pos
|
||||||
if (!isValidPosition(pos)) return false
|
|
||||||
|
if (!isValidPosition(pos)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
tr = tr.setSelection(NodeSelection.create(state.doc, pos))
|
tr = tr.setSelection(NodeSelection.create(state.doc, pos))
|
||||||
view.dispatch(tr)
|
view.dispatch(tr)
|
||||||
@ -231,20 +245,27 @@ export function shouldShowButton(props: {
|
|||||||
}): boolean {
|
}): boolean {
|
||||||
const { editor, level, hideWhenUnavailable } = props
|
const { editor, level, hideWhenUnavailable } = props
|
||||||
|
|
||||||
if (!editor) return false
|
if (!editor) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
if (!hideWhenUnavailable) {
|
if (!hideWhenUnavailable) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!editor.isEditable) return false
|
if (!editor.isEditable) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
if (!isNodeInSchema("heading", editor)) return false
|
if (!isNodeInSchema("heading", editor)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
if (!editor.isActive("code")) {
|
if (!editor.isActive("code")) {
|
||||||
if (Array.isArray(level)) {
|
if (Array.isArray(level)) {
|
||||||
return level.some((l) => canToggle(editor, l))
|
return level.some((l) => canToggle(editor, l))
|
||||||
}
|
}
|
||||||
|
|
||||||
return canToggle(editor, level)
|
return canToggle(editor, level)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -311,7 +332,9 @@ export function useHeading(config: UseHeadingConfig) {
|
|||||||
const isActive = isHeadingActive(editor, level)
|
const isActive = isHeadingActive(editor, level)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editor) return
|
if (!editor) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const handleSelectionUpdate = () => {
|
const handleSelectionUpdate = () => {
|
||||||
setIsVisible(shouldShowButton({ editor, level, hideWhenUnavailable }))
|
setIsVisible(shouldShowButton({ editor, level, hideWhenUnavailable }))
|
||||||
@ -327,12 +350,16 @@ export function useHeading(config: UseHeadingConfig) {
|
|||||||
}, [editor, level, hideWhenUnavailable])
|
}, [editor, level, hideWhenUnavailable])
|
||||||
|
|
||||||
const handleToggle = useCallback(() => {
|
const handleToggle = useCallback(() => {
|
||||||
if (!editor) return false
|
if (!editor) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
const success = toggleHeading(editor, level)
|
const success = toggleHeading(editor, level)
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
onToggled?.()
|
onToggled?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
return success
|
return success
|
||||||
}, [editor, level, onToggled])
|
}, [editor, level, onToggled])
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import { forwardRef, useCallback, useState } from "react"
|
|||||||
import { ChevronDownIcon } from "@/components/tiptap-icons/chevron-down-icon"
|
import { ChevronDownIcon } from "@/components/tiptap-icons/chevron-down-icon"
|
||||||
|
|
||||||
// --- Hooks ---
|
// --- Hooks ---
|
||||||
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
|
||||||
|
|
||||||
// --- Tiptap UI ---
|
// --- Tiptap UI ---
|
||||||
import { HeadingButton } from "@/components/tiptap-ui/heading-button"
|
import { HeadingButton } from "@/components/tiptap-ui/heading-button"
|
||||||
@ -21,6 +20,7 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuGroup,
|
DropdownMenuGroup,
|
||||||
} from "@/components/tiptap-ui-primitive/dropdown-menu"
|
} from "@/components/tiptap-ui-primitive/dropdown-menu"
|
||||||
|
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
||||||
|
|
||||||
export interface HeadingDropdownMenuProps
|
export interface HeadingDropdownMenuProps
|
||||||
extends Omit<ButtonProps, "type">, UseHeadingDropdownMenuConfig {
|
extends Omit<ButtonProps, "type">, UseHeadingDropdownMenuConfig {
|
||||||
@ -65,7 +65,10 @@ export const HeadingDropdownMenu = forwardRef<
|
|||||||
|
|
||||||
const handleOpenChange = useCallback(
|
const handleOpenChange = useCallback(
|
||||||
(open: boolean) => {
|
(open: boolean) => {
|
||||||
if (!editor || !canToggle) return
|
if (!editor || !canToggle) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setIsOpen(open)
|
setIsOpen(open)
|
||||||
onOpenChange?.(open)
|
onOpenChange?.(open)
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,10 +1,9 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
|
||||||
import type { Editor } from "@tiptap/react"
|
import type { Editor } from "@tiptap/react"
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
|
||||||
// --- Hooks ---
|
// --- Hooks ---
|
||||||
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
|
||||||
|
|
||||||
// --- Icons ---
|
// --- Icons ---
|
||||||
import { HeadingIcon } from "@/components/tiptap-icons/heading-icon"
|
import { HeadingIcon } from "@/components/tiptap-icons/heading-icon"
|
||||||
@ -12,11 +11,13 @@ import { HeadingIcon } from "@/components/tiptap-icons/heading-icon"
|
|||||||
// --- Tiptap UI ---
|
// --- Tiptap UI ---
|
||||||
import {
|
import {
|
||||||
headingIcons,
|
headingIcons,
|
||||||
type Level,
|
|
||||||
isHeadingActive,
|
isHeadingActive,
|
||||||
canToggle,
|
canToggle,
|
||||||
shouldShowButton,
|
shouldShowButton
|
||||||
} from "@/components/tiptap-ui/heading-button"
|
} from "@/components/tiptap-ui/heading-button"
|
||||||
|
import type {Level} from "@/components/tiptap-ui/heading-button";
|
||||||
|
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configuration for the heading dropdown menu functionality
|
* Configuration for the heading dropdown menu functionality
|
||||||
@ -45,7 +46,10 @@ export function getActiveHeadingLevel(
|
|||||||
editor: Editor | null,
|
editor: Editor | null,
|
||||||
levels: Level[] = [1, 2, 3, 4, 5, 6]
|
levels: Level[] = [1, 2, 3, 4, 5, 6]
|
||||||
): Level | undefined {
|
): Level | undefined {
|
||||||
if (!editor || !editor.isEditable) return undefined
|
if (!editor || !editor.isEditable) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
return levels.find((level) => isHeadingActive(editor, level))
|
return levels.find((level) => isHeadingActive(editor, level))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -103,7 +107,9 @@ export function useHeadingDropdownMenu(config?: UseHeadingDropdownMenuConfig) {
|
|||||||
const canToggleState = canToggle(editor)
|
const canToggleState = canToggle(editor)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editor) return
|
if (!editor) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const handleSelectionUpdate = () => {
|
const handleSelectionUpdate = () => {
|
||||||
setIsVisible(
|
setIsVisible(
|
||||||
|
|||||||
@ -1,10 +1,8 @@
|
|||||||
import { forwardRef, useCallback } from "react"
|
import { forwardRef, useCallback } from "react"
|
||||||
|
|
||||||
// --- Lib ---
|
// --- Lib ---
|
||||||
import { parseShortcutKeys } from "@/lib/tiptap-utils"
|
|
||||||
|
|
||||||
// --- Hooks ---
|
// --- Hooks ---
|
||||||
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
|
||||||
|
|
||||||
// --- Tiptap UI ---
|
// --- Tiptap UI ---
|
||||||
import type { UseImageUploadConfig } from "@/components/tiptap-ui/image-upload-button"
|
import type { UseImageUploadConfig } from "@/components/tiptap-ui/image-upload-button"
|
||||||
@ -14,9 +12,11 @@ import {
|
|||||||
} from "@/components/tiptap-ui/image-upload-button"
|
} from "@/components/tiptap-ui/image-upload-button"
|
||||||
|
|
||||||
// --- UI Primitives ---
|
// --- UI Primitives ---
|
||||||
|
import { Badge } from "@/components/tiptap-ui-primitive/badge"
|
||||||
import type { ButtonProps } from "@/components/tiptap-ui-primitive/button"
|
import type { ButtonProps } from "@/components/tiptap-ui-primitive/button"
|
||||||
import { Button } from "@/components/tiptap-ui-primitive/button"
|
import { Button } from "@/components/tiptap-ui-primitive/button"
|
||||||
import { Badge } from "@/components/tiptap-ui-primitive/badge"
|
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
||||||
|
import { parseShortcutKeys } from "@/lib/tiptap-utils"
|
||||||
|
|
||||||
type IconProps = React.SVGProps<SVGSVGElement>
|
type IconProps = React.SVGProps<SVGSVGElement>
|
||||||
type IconComponent = ({ className, ...props }: IconProps) => React.ReactElement
|
type IconComponent = ({ className, ...props }: IconProps) => React.ReactElement
|
||||||
@ -87,7 +87,11 @@ export const ImageUploadButton = forwardRef<
|
|||||||
const handleClick = useCallback(
|
const handleClick = useCallback(
|
||||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
onClick?.(event)
|
onClick?.(event)
|
||||||
if (event.defaultPrevented) return
|
|
||||||
|
if (event.defaultPrevented) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
handleImage()
|
handleImage()
|
||||||
},
|
},
|
||||||
[handleImage, onClick]
|
[handleImage, onClick]
|
||||||
|
|||||||
@ -1,18 +1,18 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import type {Editor} from "@tiptap/react";
|
||||||
import { useCallback, useEffect, useState } from "react"
|
import { useCallback, useEffect, useState } from "react"
|
||||||
import { useHotkeys } from "react-hotkeys-hook"
|
import { useHotkeys } from "react-hotkeys-hook"
|
||||||
import { type Editor } from "@tiptap/react"
|
|
||||||
|
|
||||||
// --- Hooks ---
|
// --- Hooks ---
|
||||||
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
import { ImagePlusIcon } from "@/components/tiptap-icons/image-plus-icon"
|
||||||
import { useIsBreakpoint } from "@/hooks/use-is-breakpoint"
|
import { useIsBreakpoint } from "@/hooks/use-is-breakpoint"
|
||||||
|
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
||||||
|
|
||||||
// --- Lib ---
|
// --- Lib ---
|
||||||
import { isExtensionAvailable } from "@/lib/tiptap-utils"
|
import { isExtensionAvailable } from "@/lib/tiptap-utils"
|
||||||
|
|
||||||
// --- Icons ---
|
// --- Icons ---
|
||||||
import { ImagePlusIcon } from "@/components/tiptap-icons/image-plus-icon"
|
|
||||||
|
|
||||||
export const IMAGE_UPLOAD_SHORTCUT_KEY = "mod+shift+i"
|
export const IMAGE_UPLOAD_SHORTCUT_KEY = "mod+shift+i"
|
||||||
|
|
||||||
@ -39,8 +39,13 @@ export interface UseImageUploadConfig {
|
|||||||
* Checks if image can be inserted in the current editor state
|
* Checks if image can be inserted in the current editor state
|
||||||
*/
|
*/
|
||||||
export function canInsertImage(editor: Editor | null): boolean {
|
export function canInsertImage(editor: Editor | null): boolean {
|
||||||
if (!editor || !editor.isEditable) return false
|
if (!editor || !editor.isEditable) {
|
||||||
if (!isExtensionAvailable(editor, "imageUpload")) return false
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isExtensionAvailable(editor, "imageUpload")) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
return editor.can().insertContent({ type: "imageUpload" })
|
return editor.can().insertContent({ type: "imageUpload" })
|
||||||
}
|
}
|
||||||
@ -49,7 +54,10 @@ export function canInsertImage(editor: Editor | null): boolean {
|
|||||||
* Checks if image is currently active
|
* Checks if image is currently active
|
||||||
*/
|
*/
|
||||||
export function isImageActive(editor: Editor | null): boolean {
|
export function isImageActive(editor: Editor | null): boolean {
|
||||||
if (!editor || !editor.isEditable) return false
|
if (!editor || !editor.isEditable) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
return editor.isActive("imageUpload")
|
return editor.isActive("imageUpload")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -57,8 +65,13 @@ export function isImageActive(editor: Editor | null): boolean {
|
|||||||
* Inserts an image in the editor
|
* Inserts an image in the editor
|
||||||
*/
|
*/
|
||||||
export function insertImage(editor: Editor | null): boolean {
|
export function insertImage(editor: Editor | null): boolean {
|
||||||
if (!editor || !editor.isEditable) return false
|
if (!editor || !editor.isEditable) {
|
||||||
if (!canInsertImage(editor)) return false
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canInsertImage(editor)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return editor
|
return editor
|
||||||
@ -82,13 +95,17 @@ export function shouldShowButton(props: {
|
|||||||
}): boolean {
|
}): boolean {
|
||||||
const { editor, hideWhenUnavailable } = props
|
const { editor, hideWhenUnavailable } = props
|
||||||
|
|
||||||
if (!editor || !editor.isEditable) return false
|
if (!editor || !editor.isEditable) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
if (!hideWhenUnavailable) {
|
if (!hideWhenUnavailable) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isExtensionAvailable(editor, "imageUpload")) return false
|
if (!isExtensionAvailable(editor, "imageUpload")) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
if (!editor.isActive("code")) {
|
if (!editor.isActive("code")) {
|
||||||
return canInsertImage(editor)
|
return canInsertImage(editor)
|
||||||
@ -147,7 +164,9 @@ export function useImageUpload(config?: UseImageUploadConfig) {
|
|||||||
const isActive = isImageActive(editor)
|
const isActive = isImageActive(editor)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editor) return
|
if (!editor) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const handleSelectionUpdate = () => {
|
const handleSelectionUpdate = () => {
|
||||||
setIsVisible(shouldShowButton({ editor, hideWhenUnavailable }))
|
setIsVisible(shouldShowButton({ editor, hideWhenUnavailable }))
|
||||||
@ -163,12 +182,16 @@ export function useImageUpload(config?: UseImageUploadConfig) {
|
|||||||
}, [editor, hideWhenUnavailable])
|
}, [editor, hideWhenUnavailable])
|
||||||
|
|
||||||
const handleImage = useCallback(() => {
|
const handleImage = useCallback(() => {
|
||||||
if (!editor) return false
|
if (!editor) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
const success = insertImage(editor)
|
const success = insertImage(editor)
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
onInserted?.()
|
onInserted?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
return success
|
return success
|
||||||
}, [editor, onInserted])
|
}, [editor, onInserted])
|
||||||
|
|
||||||
|
|||||||
@ -1,11 +1,9 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { forwardRef, useCallback, useEffect, useState } from "react"
|
|
||||||
import type { Editor } from "@tiptap/react"
|
import type { Editor } from "@tiptap/react"
|
||||||
|
import { forwardRef, useCallback, useEffect, useState } from "react"
|
||||||
|
|
||||||
// --- Hooks ---
|
// --- Hooks ---
|
||||||
import { useIsBreakpoint } from "@/hooks/use-is-breakpoint"
|
|
||||||
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
|
||||||
|
|
||||||
// --- Icons ---
|
// --- Icons ---
|
||||||
import { CornerDownLeftIcon } from "@/components/tiptap-icons/corner-down-left-icon"
|
import { CornerDownLeftIcon } from "@/components/tiptap-icons/corner-down-left-icon"
|
||||||
@ -20,19 +18,21 @@ import { useLinkPopover } from "@/components/tiptap-ui/link-popover"
|
|||||||
// --- UI Primitives ---
|
// --- UI Primitives ---
|
||||||
import type { ButtonProps } from "@/components/tiptap-ui-primitive/button"
|
import type { ButtonProps } from "@/components/tiptap-ui-primitive/button"
|
||||||
import { Button } from "@/components/tiptap-ui-primitive/button"
|
import { Button } from "@/components/tiptap-ui-primitive/button"
|
||||||
import {
|
import { ButtonGroup } from "@/components/tiptap-ui-primitive/button-group"
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from "@/components/tiptap-ui-primitive/popover"
|
|
||||||
import { Separator } from "@/components/tiptap-ui-primitive/separator"
|
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardBody,
|
CardBody,
|
||||||
CardItemGroup,
|
CardItemGroup,
|
||||||
} from "@/components/tiptap-ui-primitive/card"
|
} from "@/components/tiptap-ui-primitive/card"
|
||||||
import { Input } from "@/components/tiptap-ui-primitive/input"
|
import { Input } from "@/components/tiptap-ui-primitive/input"
|
||||||
import { ButtonGroup } from "@/components/tiptap-ui-primitive/button-group"
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/tiptap-ui-primitive/popover"
|
||||||
|
import { Separator } from "@/components/tiptap-ui-primitive/separator"
|
||||||
|
import { useIsBreakpoint } from "@/hooks/use-is-breakpoint"
|
||||||
|
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
||||||
|
|
||||||
import "./link-popover.scss"
|
import "./link-popover.scss"
|
||||||
|
|
||||||
@ -259,7 +259,11 @@ export const LinkPopover = forwardRef<HTMLButtonElement, LinkPopoverProps>(
|
|||||||
const handleClick = useCallback(
|
const handleClick = useCallback(
|
||||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
onClick?.(event)
|
onClick?.(event)
|
||||||
if (event.defaultPrevented) return
|
|
||||||
|
if (event.defaultPrevented) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setIsOpen(!isOpen)
|
setIsOpen(!isOpen)
|
||||||
},
|
},
|
||||||
[onClick, isOpen]
|
[onClick, isOpen]
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
import { useCallback, useEffect, useState } from "react"
|
|
||||||
import type { Editor } from "@tiptap/react"
|
import type { Editor } from "@tiptap/react"
|
||||||
|
import { useCallback, useEffect, useState } from "react"
|
||||||
|
|
||||||
// --- Hooks ---
|
// --- Hooks ---
|
||||||
|
import { LinkIcon } from "@/components/tiptap-icons/link-icon"
|
||||||
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
||||||
|
|
||||||
// --- Icons ---
|
// --- Icons ---
|
||||||
import { LinkIcon } from "@/components/tiptap-icons/link-icon"
|
|
||||||
|
|
||||||
// --- Lib ---
|
// --- Lib ---
|
||||||
import {
|
import {
|
||||||
@ -51,11 +51,16 @@ export interface LinkHandlerProps {
|
|||||||
* Checks if a link can be set in the current editor state
|
* Checks if a link can be set in the current editor state
|
||||||
*/
|
*/
|
||||||
export function canSetLink(editor: Editor | null): boolean {
|
export function canSetLink(editor: Editor | null): boolean {
|
||||||
if (!editor || !editor.isEditable) return false
|
if (!editor || !editor.isEditable) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// The third argument 'true' checks whether the current selection is inside an image caption, and prevents setting a link there
|
// The third argument 'true' checks whether the current selection is inside an image caption, and prevents setting a link there
|
||||||
// If the selection is inside an image caption, we can't set a link
|
// If the selection is inside an image caption, we can't set a link
|
||||||
if (isNodeTypeSelected(editor, ["image"], true)) return false
|
if (isNodeTypeSelected(editor, ["image"], true)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return editor.can().setMark("link")
|
return editor.can().setMark("link")
|
||||||
} catch {
|
} catch {
|
||||||
@ -67,7 +72,10 @@ export function canSetLink(editor: Editor | null): boolean {
|
|||||||
* Checks if a link is currently active in the editor
|
* Checks if a link is currently active in the editor
|
||||||
*/
|
*/
|
||||||
export function isLinkActive(editor: Editor | null): boolean {
|
export function isLinkActive(editor: Editor | null): boolean {
|
||||||
if (!editor || !editor.isEditable) return false
|
if (!editor || !editor.isEditable) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
return editor.isActive("link")
|
return editor.isActive("link")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -80,7 +88,9 @@ export function shouldShowLinkButton(props: {
|
|||||||
}): boolean {
|
}): boolean {
|
||||||
const { editor, hideWhenUnavailable } = props
|
const { editor, hideWhenUnavailable } = props
|
||||||
|
|
||||||
if (!editor || !editor.isEditable) return false
|
if (!editor || !editor.isEditable) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
const linkInSchema = isMarkInSchema("link", editor)
|
const linkInSchema = isMarkInSchema("link", editor)
|
||||||
|
|
||||||
@ -110,7 +120,9 @@ export function useLinkHandler(props: LinkHandlerProps) {
|
|||||||
const [url, setUrl] = useState<string | null>(null)
|
const [url, setUrl] = useState<string | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editor) return
|
if (!editor) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Get URL immediately on mount
|
// Get URL immediately on mount
|
||||||
const { href } = editor.getAttributes("link")
|
const { href } = editor.getAttributes("link")
|
||||||
@ -121,7 +133,9 @@ export function useLinkHandler(props: LinkHandlerProps) {
|
|||||||
}, [editor, url])
|
}, [editor, url])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editor) return
|
if (!editor) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const updateLinkState = () => {
|
const updateLinkState = () => {
|
||||||
const { href } = editor.getAttributes("link")
|
const { href } = editor.getAttributes("link")
|
||||||
@ -129,13 +143,16 @@ export function useLinkHandler(props: LinkHandlerProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
editor.on("selectionUpdate", updateLinkState)
|
editor.on("selectionUpdate", updateLinkState)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
editor.off("selectionUpdate", updateLinkState)
|
editor.off("selectionUpdate", updateLinkState)
|
||||||
}
|
}
|
||||||
}, [editor])
|
}, [editor])
|
||||||
|
|
||||||
const setLink = useCallback(() => {
|
const setLink = useCallback(() => {
|
||||||
if (!url || !editor) return
|
if (!url || !editor) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const { selection } = editor.state
|
const { selection } = editor.state
|
||||||
const isEmpty = selection.empty
|
const isEmpty = selection.empty
|
||||||
@ -156,7 +173,10 @@ export function useLinkHandler(props: LinkHandlerProps) {
|
|||||||
}, [editor, onSetLink, url])
|
}, [editor, onSetLink, url])
|
||||||
|
|
||||||
const removeLink = useCallback(() => {
|
const removeLink = useCallback(() => {
|
||||||
if (!editor) return
|
if (!editor) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
editor
|
editor
|
||||||
.chain()
|
.chain()
|
||||||
.focus()
|
.focus()
|
||||||
@ -169,9 +189,12 @@ export function useLinkHandler(props: LinkHandlerProps) {
|
|||||||
|
|
||||||
const openLink = useCallback(
|
const openLink = useCallback(
|
||||||
(target: string = "_blank", features: string = "noopener,noreferrer") => {
|
(target: string = "_blank", features: string = "noopener,noreferrer") => {
|
||||||
if (!url) return
|
if (!url) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const safeUrl = sanitizeUrl(url, window.location.href)
|
const safeUrl = sanitizeUrl(url, window.location.href)
|
||||||
|
|
||||||
if (safeUrl !== "#") {
|
if (safeUrl !== "#") {
|
||||||
window.open(safeUrl, target, features)
|
window.open(safeUrl, target, features)
|
||||||
}
|
}
|
||||||
@ -203,7 +226,9 @@ export function useLinkState(props: {
|
|||||||
const [isVisible, setIsVisible] = useState(true)
|
const [isVisible, setIsVisible] = useState(true)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editor) return
|
if (!editor) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const handleSelectionUpdate = () => {
|
const handleSelectionUpdate = () => {
|
||||||
setIsVisible(
|
setIsVisible(
|
||||||
|
|||||||
@ -1,19 +1,19 @@
|
|||||||
import { forwardRef, useCallback } from "react"
|
import { forwardRef, useCallback } from "react"
|
||||||
|
|
||||||
// --- Lib ---
|
// --- Lib ---
|
||||||
import { parseShortcutKeys } from "@/lib/tiptap-utils"
|
|
||||||
|
|
||||||
// --- Hooks ---
|
// --- Hooks ---
|
||||||
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
|
||||||
|
|
||||||
// --- UI Primitives ---
|
// --- UI Primitives ---
|
||||||
import type { ButtonProps } from "@/components/tiptap-ui-primitive/button"
|
|
||||||
import { Button } from "@/components/tiptap-ui-primitive/button"
|
|
||||||
import { Badge } from "@/components/tiptap-ui-primitive/badge"
|
|
||||||
|
|
||||||
// --- Tiptap UI ---
|
|
||||||
import type { ListType, UseListConfig } from "@/components/tiptap-ui/list-button"
|
import type { ListType, UseListConfig } from "@/components/tiptap-ui/list-button"
|
||||||
import { LIST_SHORTCUT_KEYS, useList } from "@/components/tiptap-ui/list-button"
|
import { LIST_SHORTCUT_KEYS, useList } from "@/components/tiptap-ui/list-button"
|
||||||
|
import { Badge } from "@/components/tiptap-ui-primitive/badge"
|
||||||
|
import type { ButtonProps } from "@/components/tiptap-ui-primitive/button"
|
||||||
|
import { Button } from "@/components/tiptap-ui-primitive/button"
|
||||||
|
|
||||||
|
// --- Tiptap UI ---
|
||||||
|
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
||||||
|
import { parseShortcutKeys } from "@/lib/tiptap-utils"
|
||||||
|
|
||||||
export interface ListButtonProps
|
export interface ListButtonProps
|
||||||
extends Omit<ButtonProps, "type">, UseListConfig {
|
extends Omit<ButtonProps, "type">, UseListConfig {
|
||||||
@ -77,7 +77,11 @@ export const ListButton = forwardRef<HTMLButtonElement, ListButtonProps>(
|
|||||||
const handleClick = useCallback(
|
const handleClick = useCallback(
|
||||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
onClick?.(event)
|
onClick?.(event)
|
||||||
if (event.defaultPrevented) return
|
|
||||||
|
if (event.defaultPrevented) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
handleToggle()
|
handleToggle()
|
||||||
},
|
},
|
||||||
[handleToggle, onClick]
|
[handleToggle, onClick]
|
||||||
|
|||||||
@ -1,16 +1,16 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react"
|
|
||||||
import { type Editor } from "@tiptap/react"
|
|
||||||
import { NodeSelection, TextSelection } from "@tiptap/pm/state"
|
import { NodeSelection, TextSelection } from "@tiptap/pm/state"
|
||||||
|
import type {Editor} from "@tiptap/react";
|
||||||
|
import { useCallback, useEffect, useState } from "react"
|
||||||
|
|
||||||
// --- Hooks ---
|
// --- Hooks ---
|
||||||
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
|
||||||
|
|
||||||
// --- Icons ---
|
// --- Icons ---
|
||||||
import { ListIcon } from "@/components/tiptap-icons/list-icon"
|
import { ListIcon } from "@/components/tiptap-icons/list-icon"
|
||||||
import { ListOrderedIcon } from "@/components/tiptap-icons/list-ordered-icon"
|
import { ListOrderedIcon } from "@/components/tiptap-icons/list-ordered-icon"
|
||||||
import { ListTodoIcon } from "@/components/tiptap-icons/list-todo-icon"
|
import { ListTodoIcon } from "@/components/tiptap-icons/list-todo-icon"
|
||||||
|
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
||||||
|
|
||||||
// --- Lib ---
|
// --- Lib ---
|
||||||
import {
|
import {
|
||||||
@ -73,9 +73,13 @@ export function canToggleList(
|
|||||||
type: ListType,
|
type: ListType,
|
||||||
turnInto: boolean = true
|
turnInto: boolean = true
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!editor || !editor.isEditable) return false
|
if (!editor || !editor.isEditable) {
|
||||||
if (!isNodeInSchema(type, editor) || isNodeTypeSelected(editor, ["image"]))
|
return false
|
||||||
return false
|
}
|
||||||
|
|
||||||
|
if (!isNodeInSchema(type, editor) || isNodeTypeSelected(editor, ["image"])) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
if (!turnInto) {
|
if (!turnInto) {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
@ -101,8 +105,9 @@ export function canToggleList(
|
|||||||
"blockquote",
|
"blockquote",
|
||||||
"codeBlock",
|
"codeBlock",
|
||||||
])
|
])
|
||||||
)
|
) {
|
||||||
return false
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// Either we can set list directly on the selection,
|
// Either we can set list directly on the selection,
|
||||||
// or we can clear formatting/nodes to arrive at a list.
|
// or we can clear formatting/nodes to arrive at a list.
|
||||||
@ -125,7 +130,9 @@ export function canToggleList(
|
|||||||
* Checks if list is currently active
|
* Checks if list is currently active
|
||||||
*/
|
*/
|
||||||
export function isListActive(editor: Editor | null, type: ListType): boolean {
|
export function isListActive(editor: Editor | null, type: ListType): boolean {
|
||||||
if (!editor || !editor.isEditable) return false
|
if (!editor || !editor.isEditable) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "bulletList":
|
case "bulletList":
|
||||||
@ -143,8 +150,13 @@ export function isListActive(editor: Editor | null, type: ListType): boolean {
|
|||||||
* Toggles list in the editor
|
* Toggles list in the editor
|
||||||
*/
|
*/
|
||||||
export function toggleList(editor: Editor | null, type: ListType): boolean {
|
export function toggleList(editor: Editor | null, type: ListType): boolean {
|
||||||
if (!editor || !editor.isEditable) return false
|
if (!editor || !editor.isEditable) {
|
||||||
if (!canToggleList(editor, type)) return false
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canToggleList(editor, type)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const view = editor.view
|
const view = editor.view
|
||||||
@ -176,7 +188,10 @@ export function toggleList(editor: Editor | null, type: ListType): boolean {
|
|||||||
editor,
|
editor,
|
||||||
node: state.selection.$anchor.node(1),
|
node: state.selection.$anchor.node(1),
|
||||||
})?.pos
|
})?.pos
|
||||||
if (!isValidPosition(pos)) return false
|
|
||||||
|
if (!isValidPosition(pos)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
tr = tr.setSelection(NodeSelection.create(state.doc, pos))
|
tr = tr.setSelection(NodeSelection.create(state.doc, pos))
|
||||||
view.dispatch(tr)
|
view.dispatch(tr)
|
||||||
@ -225,7 +240,10 @@ export function toggleList(editor: Editor | null, type: ListType): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const toggle = toggleMap[type]
|
const toggle = toggleMap[type]
|
||||||
if (!toggle) return false
|
|
||||||
|
if (!toggle) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
toggle().run()
|
toggle().run()
|
||||||
}
|
}
|
||||||
@ -248,15 +266,21 @@ export function shouldShowButton(props: {
|
|||||||
}): boolean {
|
}): boolean {
|
||||||
const { editor, type, hideWhenUnavailable } = props
|
const { editor, type, hideWhenUnavailable } = props
|
||||||
|
|
||||||
if (!editor) return false
|
if (!editor) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
if (!hideWhenUnavailable) {
|
if (!hideWhenUnavailable) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!editor.isEditable) return false
|
if (!editor.isEditable) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
if (!isNodeInSchema(type, editor)) return false
|
if (!isNodeInSchema(type, editor)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
if (!editor.isActive("code")) {
|
if (!editor.isActive("code")) {
|
||||||
return canToggleList(editor, type)
|
return canToggleList(editor, type)
|
||||||
@ -316,7 +340,9 @@ export function useList(config: UseListConfig) {
|
|||||||
const isActive = isListActive(editor, type)
|
const isActive = isListActive(editor, type)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editor) return
|
if (!editor) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const handleSelectionUpdate = () => {
|
const handleSelectionUpdate = () => {
|
||||||
setIsVisible(shouldShowButton({ editor, type, hideWhenUnavailable }))
|
setIsVisible(shouldShowButton({ editor, type, hideWhenUnavailable }))
|
||||||
@ -332,12 +358,16 @@ export function useList(config: UseListConfig) {
|
|||||||
}, [editor, type, hideWhenUnavailable])
|
}, [editor, type, hideWhenUnavailable])
|
||||||
|
|
||||||
const handleToggle = useCallback(() => {
|
const handleToggle = useCallback(() => {
|
||||||
if (!editor) return false
|
if (!editor) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
const success = toggleList(editor, type)
|
const success = toggleList(editor, type)
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
onToggled?.()
|
onToggled?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
return success
|
return success
|
||||||
}, [editor, type, onToggled])
|
}, [editor, type, onToggled])
|
||||||
|
|
||||||
|
|||||||
@ -1,14 +1,14 @@
|
|||||||
|
import type {Editor} from "@tiptap/react";
|
||||||
import { useCallback, useState } from "react"
|
import { useCallback, useState } from "react"
|
||||||
import { type Editor } from "@tiptap/react"
|
|
||||||
|
|
||||||
// --- Hooks ---
|
// --- Hooks ---
|
||||||
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
|
||||||
|
|
||||||
// --- Icons ---
|
// --- Icons ---
|
||||||
import { ChevronDownIcon } from "@/components/tiptap-icons/chevron-down-icon"
|
import { ChevronDownIcon } from "@/components/tiptap-icons/chevron-down-icon"
|
||||||
|
|
||||||
// --- Tiptap UI ---
|
// --- Tiptap UI ---
|
||||||
import { ListButton, type ListType } from "@/components/tiptap-ui/list-button"
|
import { ListButton } from "@/components/tiptap-ui/list-button"
|
||||||
|
import type {ListType} from "@/components/tiptap-ui/list-button";
|
||||||
|
|
||||||
import { useListDropdownMenu } from "@/components/tiptap-ui/list-dropdown-menu/use-list-dropdown-menu"
|
import { useListDropdownMenu } from "@/components/tiptap-ui/list-dropdown-menu/use-list-dropdown-menu"
|
||||||
|
|
||||||
@ -22,6 +22,7 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuGroup,
|
DropdownMenuGroup,
|
||||||
} from "@/components/tiptap-ui-primitive/dropdown-menu"
|
} from "@/components/tiptap-ui-primitive/dropdown-menu"
|
||||||
|
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
||||||
|
|
||||||
export interface ListDropdownMenuProps extends Omit<ButtonProps, "type"> {
|
export interface ListDropdownMenuProps extends Omit<ButtonProps, "type"> {
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -1,10 +1,9 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react"
|
|
||||||
import type { Editor } from "@tiptap/react"
|
import type { Editor } from "@tiptap/react"
|
||||||
|
import { useEffect, useMemo, useState } from "react"
|
||||||
|
|
||||||
// --- Hooks ---
|
// --- Hooks ---
|
||||||
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
|
||||||
|
|
||||||
// --- Icons ---
|
// --- Icons ---
|
||||||
import { ListIcon } from "@/components/tiptap-icons/list-icon"
|
import { ListIcon } from "@/components/tiptap-icons/list-icon"
|
||||||
@ -12,15 +11,17 @@ import { ListOrderedIcon } from "@/components/tiptap-icons/list-ordered-icon"
|
|||||||
import { ListTodoIcon } from "@/components/tiptap-icons/list-todo-icon"
|
import { ListTodoIcon } from "@/components/tiptap-icons/list-todo-icon"
|
||||||
|
|
||||||
// --- Lib ---
|
// --- Lib ---
|
||||||
import { isNodeInSchema } from "@/lib/tiptap-utils"
|
|
||||||
|
|
||||||
// --- Tiptap UI ---
|
// --- Tiptap UI ---
|
||||||
import {
|
import {
|
||||||
canToggleList,
|
canToggleList,
|
||||||
isListActive,
|
isListActive,
|
||||||
listIcons,
|
listIcons
|
||||||
type ListType,
|
|
||||||
} from "@/components/tiptap-ui/list-button"
|
} from "@/components/tiptap-ui/list-button"
|
||||||
|
import type {ListType} from "@/components/tiptap-ui/list-button";
|
||||||
|
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
||||||
|
import { isNodeInSchema } from "@/lib/tiptap-utils"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configuration for the list dropdown menu functionality
|
* Configuration for the list dropdown menu functionality
|
||||||
@ -70,7 +71,10 @@ export function canToggleAnyList(
|
|||||||
editor: Editor | null,
|
editor: Editor | null,
|
||||||
listTypes: ListType[]
|
listTypes: ListType[]
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!editor || !editor.isEditable) return false
|
if (!editor || !editor.isEditable) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
return listTypes.some((type) => canToggleList(editor, type))
|
return listTypes.some((type) => canToggleList(editor, type))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -78,7 +82,10 @@ export function isAnyListActive(
|
|||||||
editor: Editor | null,
|
editor: Editor | null,
|
||||||
listTypes: ListType[]
|
listTypes: ListType[]
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!editor || !editor.isEditable) return false
|
if (!editor || !editor.isEditable) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
return listTypes.some((type) => isListActive(editor, type))
|
return listTypes.some((type) => isListActive(editor, type))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -99,13 +106,17 @@ export function shouldShowListDropdown(params: {
|
|||||||
}): boolean {
|
}): boolean {
|
||||||
const { editor, hideWhenUnavailable, listInSchema, canToggleAny } = params
|
const { editor, hideWhenUnavailable, listInSchema, canToggleAny } = params
|
||||||
|
|
||||||
if (!editor) return false
|
if (!editor) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
if (!hideWhenUnavailable) {
|
if (!hideWhenUnavailable) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!listInSchema) return false
|
if (!listInSchema) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
if (!editor.isActive("code")) {
|
if (!editor.isActive("code")) {
|
||||||
return canToggleAny
|
return canToggleAny
|
||||||
@ -121,7 +132,10 @@ export function getActiveListType(
|
|||||||
editor: Editor | null,
|
editor: Editor | null,
|
||||||
availableTypes: ListType[]
|
availableTypes: ListType[]
|
||||||
): ListType | undefined {
|
): ListType | undefined {
|
||||||
if (!editor || !editor.isEditable) return undefined
|
if (!editor || !editor.isEditable) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
return availableTypes.find((type) => isListActive(editor, type))
|
return availableTypes.find((type) => isListActive(editor, type))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -184,7 +198,9 @@ export function useListDropdownMenu(config?: UseListDropdownMenuConfig) {
|
|||||||
const activeList = filteredLists.find((option) => option.type === activeType)
|
const activeList = filteredLists.find((option) => option.type === activeType)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editor) return
|
if (!editor) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const handleSelectionUpdate = () => {
|
const handleSelectionUpdate = () => {
|
||||||
setIsVisible(
|
setIsVisible(
|
||||||
|
|||||||
@ -3,19 +3,19 @@
|
|||||||
import { forwardRef, useCallback } from "react"
|
import { forwardRef, useCallback } from "react"
|
||||||
|
|
||||||
// --- Lib ---
|
// --- Lib ---
|
||||||
import { parseShortcutKeys } from "@/lib/tiptap-utils"
|
|
||||||
|
|
||||||
// --- Hooks ---
|
// --- Hooks ---
|
||||||
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
|
||||||
|
|
||||||
// --- Tiptap UI ---
|
// --- Tiptap UI ---
|
||||||
import type { Mark, UseMarkConfig } from "@/components/tiptap-ui/mark-button"
|
import type { Mark, UseMarkConfig } from "@/components/tiptap-ui/mark-button"
|
||||||
import { MARK_SHORTCUT_KEYS, useMark } from "@/components/tiptap-ui/mark-button"
|
import { MARK_SHORTCUT_KEYS, useMark } from "@/components/tiptap-ui/mark-button"
|
||||||
|
|
||||||
// --- UI Primitives ---
|
// --- UI Primitives ---
|
||||||
|
import { Badge } from "@/components/tiptap-ui-primitive/badge"
|
||||||
import type { ButtonProps } from "@/components/tiptap-ui-primitive/button"
|
import type { ButtonProps } from "@/components/tiptap-ui-primitive/button"
|
||||||
import { Button } from "@/components/tiptap-ui-primitive/button"
|
import { Button } from "@/components/tiptap-ui-primitive/button"
|
||||||
import { Badge } from "@/components/tiptap-ui-primitive/badge"
|
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
||||||
|
import { parseShortcutKeys } from "@/lib/tiptap-utils"
|
||||||
|
|
||||||
export interface MarkButtonProps
|
export interface MarkButtonProps
|
||||||
extends Omit<ButtonProps, "type">, UseMarkConfig {
|
extends Omit<ButtonProps, "type">, UseMarkConfig {
|
||||||
@ -79,7 +79,11 @@ export const MarkButton = forwardRef<HTMLButtonElement, MarkButtonProps>(
|
|||||||
const handleClick = useCallback(
|
const handleClick = useCallback(
|
||||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
onClick?.(event)
|
onClick?.(event)
|
||||||
if (event.defaultPrevented) return
|
|
||||||
|
if (event.defaultPrevented) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
handleMark()
|
handleMark()
|
||||||
},
|
},
|
||||||
[handleMark, onClick]
|
[handleMark, onClick]
|
||||||
|
|||||||
@ -1,11 +1,9 @@
|
|||||||
import { useCallback, useEffect, useState } from "react"
|
|
||||||
import type { Editor } from "@tiptap/react"
|
import type { Editor } from "@tiptap/react"
|
||||||
|
import { useCallback, useEffect, useState } from "react"
|
||||||
|
|
||||||
// --- Hooks ---
|
// --- Hooks ---
|
||||||
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
|
||||||
|
|
||||||
// --- Lib ---
|
// --- Lib ---
|
||||||
import { isMarkInSchema, isNodeTypeSelected } from "@/lib/tiptap-utils"
|
|
||||||
|
|
||||||
// --- Icons ---
|
// --- Icons ---
|
||||||
import { BoldIcon } from "@/components/tiptap-icons/bold-icon"
|
import { BoldIcon } from "@/components/tiptap-icons/bold-icon"
|
||||||
@ -15,6 +13,8 @@ import { StrikeIcon } from "@/components/tiptap-icons/strike-icon"
|
|||||||
import { SubscriptIcon } from "@/components/tiptap-icons/subscript-icon"
|
import { SubscriptIcon } from "@/components/tiptap-icons/subscript-icon"
|
||||||
import { SuperscriptIcon } from "@/components/tiptap-icons/superscript-icon"
|
import { SuperscriptIcon } from "@/components/tiptap-icons/superscript-icon"
|
||||||
import { UnderlineIcon } from "@/components/tiptap-icons/underline-icon"
|
import { UnderlineIcon } from "@/components/tiptap-icons/underline-icon"
|
||||||
|
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
||||||
|
import { isMarkInSchema, isNodeTypeSelected } from "@/lib/tiptap-utils"
|
||||||
|
|
||||||
export type Mark =
|
export type Mark =
|
||||||
| "bold"
|
| "bold"
|
||||||
@ -72,9 +72,13 @@ export const MARK_SHORTCUT_KEYS: Record<Mark, string> = {
|
|||||||
* Checks if a mark can be toggled in the current editor state
|
* Checks if a mark can be toggled in the current editor state
|
||||||
*/
|
*/
|
||||||
export function canToggleMark(editor: Editor | null, type: Mark): boolean {
|
export function canToggleMark(editor: Editor | null, type: Mark): boolean {
|
||||||
if (!editor || !editor.isEditable) return false
|
if (!editor || !editor.isEditable) {
|
||||||
if (!isMarkInSchema(type, editor) || isNodeTypeSelected(editor, ["image"]))
|
return false
|
||||||
return false
|
}
|
||||||
|
|
||||||
|
if (!isMarkInSchema(type, editor) || isNodeTypeSelected(editor, ["image"])) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
return editor.can().toggleMark(type)
|
return editor.can().toggleMark(type)
|
||||||
}
|
}
|
||||||
@ -83,7 +87,10 @@ export function canToggleMark(editor: Editor | null, type: Mark): boolean {
|
|||||||
* Checks if a mark is currently active
|
* Checks if a mark is currently active
|
||||||
*/
|
*/
|
||||||
export function isMarkActive(editor: Editor | null, type: Mark): boolean {
|
export function isMarkActive(editor: Editor | null, type: Mark): boolean {
|
||||||
if (!editor || !editor.isEditable) return false
|
if (!editor || !editor.isEditable) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
return editor.isActive(type)
|
return editor.isActive(type)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -91,8 +98,13 @@ export function isMarkActive(editor: Editor | null, type: Mark): boolean {
|
|||||||
* Toggles a mark in the editor
|
* Toggles a mark in the editor
|
||||||
*/
|
*/
|
||||||
export function toggleMark(editor: Editor | null, type: Mark): boolean {
|
export function toggleMark(editor: Editor | null, type: Mark): boolean {
|
||||||
if (!editor || !editor.isEditable) return false
|
if (!editor || !editor.isEditable) {
|
||||||
if (!canToggleMark(editor, type)) return false
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canToggleMark(editor, type)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
return editor.chain().focus().toggleMark(type).run()
|
return editor.chain().focus().toggleMark(type).run()
|
||||||
}
|
}
|
||||||
@ -107,15 +119,21 @@ export function shouldShowButton(props: {
|
|||||||
}): boolean {
|
}): boolean {
|
||||||
const { editor, type, hideWhenUnavailable } = props
|
const { editor, type, hideWhenUnavailable } = props
|
||||||
|
|
||||||
if (!editor) return false
|
if (!editor) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
if (!hideWhenUnavailable) {
|
if (!hideWhenUnavailable) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!editor.isEditable) return false
|
if (!editor.isEditable) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
if (!isMarkInSchema(type, editor)) return false
|
if (!isMarkInSchema(type, editor)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
if (!editor.isActive("code")) {
|
if (!editor.isActive("code")) {
|
||||||
return canToggleMark(editor, type)
|
return canToggleMark(editor, type)
|
||||||
@ -182,7 +200,9 @@ export function useMark(config: UseMarkConfig) {
|
|||||||
const isActive = isMarkActive(editor, type)
|
const isActive = isMarkActive(editor, type)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editor) return
|
if (!editor) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const handleSelectionUpdate = () => {
|
const handleSelectionUpdate = () => {
|
||||||
setIsVisible(shouldShowButton({ editor, type, hideWhenUnavailable }))
|
setIsVisible(shouldShowButton({ editor, type, hideWhenUnavailable }))
|
||||||
@ -198,12 +218,16 @@ export function useMark(config: UseMarkConfig) {
|
|||||||
}, [editor, type, hideWhenUnavailable])
|
}, [editor, type, hideWhenUnavailable])
|
||||||
|
|
||||||
const handleMark = useCallback(() => {
|
const handleMark = useCallback(() => {
|
||||||
if (!editor) return false
|
if (!editor) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
const success = toggleMark(editor, type)
|
const success = toggleMark(editor, type)
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
onToggled?.()
|
onToggled?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
return success
|
return success
|
||||||
}, [editor, type, onToggled])
|
}, [editor, type, onToggled])
|
||||||
|
|
||||||
|
|||||||
@ -3,10 +3,8 @@
|
|||||||
import { forwardRef, useCallback } from "react"
|
import { forwardRef, useCallback } from "react"
|
||||||
|
|
||||||
// --- Lib ---
|
// --- Lib ---
|
||||||
import { parseShortcutKeys } from "@/lib/tiptap-utils"
|
|
||||||
|
|
||||||
// --- Hooks ---
|
// --- Hooks ---
|
||||||
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
|
||||||
|
|
||||||
// --- Tiptap UI ---
|
// --- Tiptap UI ---
|
||||||
import type {
|
import type {
|
||||||
@ -19,9 +17,11 @@ import {
|
|||||||
} from "@/components/tiptap-ui/text-align-button"
|
} from "@/components/tiptap-ui/text-align-button"
|
||||||
|
|
||||||
// --- UI Primitives ---
|
// --- UI Primitives ---
|
||||||
|
import { Badge } from "@/components/tiptap-ui-primitive/badge"
|
||||||
import type { ButtonProps } from "@/components/tiptap-ui-primitive/button"
|
import type { ButtonProps } from "@/components/tiptap-ui-primitive/button"
|
||||||
import { Button } from "@/components/tiptap-ui-primitive/button"
|
import { Button } from "@/components/tiptap-ui-primitive/button"
|
||||||
import { Badge } from "@/components/tiptap-ui-primitive/badge"
|
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
||||||
|
import { parseShortcutKeys } from "@/lib/tiptap-utils"
|
||||||
|
|
||||||
type IconProps = React.SVGProps<SVGSVGElement>
|
type IconProps = React.SVGProps<SVGSVGElement>
|
||||||
type IconComponent = ({ className, ...props }: IconProps) => React.ReactElement
|
type IconComponent = ({ className, ...props }: IconProps) => React.ReactElement
|
||||||
@ -96,7 +96,11 @@ export const TextAlignButton = forwardRef<
|
|||||||
const handleClick = useCallback(
|
const handleClick = useCallback(
|
||||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
onClick?.(event)
|
onClick?.(event)
|
||||||
if (event.defaultPrevented) return
|
|
||||||
|
if (event.defaultPrevented) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
handleTextAlign()
|
handleTextAlign()
|
||||||
},
|
},
|
||||||
[handleTextAlign, onClick]
|
[handleTextAlign, onClick]
|
||||||
|
|||||||
@ -1,21 +1,21 @@
|
|||||||
import { useCallback, useEffect, useState } from "react"
|
|
||||||
import type { ChainedCommands } from "@tiptap/react"
|
import type { ChainedCommands } from "@tiptap/react"
|
||||||
import { type Editor } from "@tiptap/react"
|
import type {Editor} from "@tiptap/react";
|
||||||
|
import { useCallback, useEffect, useState } from "react"
|
||||||
|
|
||||||
// --- Hooks ---
|
// --- Hooks ---
|
||||||
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
|
||||||
|
|
||||||
// --- Lib ---
|
// --- Lib ---
|
||||||
import {
|
|
||||||
isExtensionAvailable,
|
|
||||||
isNodeTypeSelected,
|
|
||||||
} from "@/lib/tiptap-utils"
|
|
||||||
|
|
||||||
// --- Icons ---
|
// --- Icons ---
|
||||||
import { AlignCenterIcon } from "@/components/tiptap-icons/align-center-icon"
|
import { AlignCenterIcon } from "@/components/tiptap-icons/align-center-icon"
|
||||||
import { AlignJustifyIcon } from "@/components/tiptap-icons/align-justify-icon"
|
import { AlignJustifyIcon } from "@/components/tiptap-icons/align-justify-icon"
|
||||||
import { AlignLeftIcon } from "@/components/tiptap-icons/align-left-icon"
|
import { AlignLeftIcon } from "@/components/tiptap-icons/align-left-icon"
|
||||||
import { AlignRightIcon } from "@/components/tiptap-icons/align-right-icon"
|
import { AlignRightIcon } from "@/components/tiptap-icons/align-right-icon"
|
||||||
|
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
||||||
|
import {
|
||||||
|
isExtensionAvailable,
|
||||||
|
isNodeTypeSelected,
|
||||||
|
} from "@/lib/tiptap-utils"
|
||||||
|
|
||||||
export type TextAlign = "left" | "center" | "right" | "justify"
|
export type TextAlign = "left" | "center" | "right" | "justify"
|
||||||
|
|
||||||
@ -70,12 +70,16 @@ export function canSetTextAlign(
|
|||||||
editor: Editor | null,
|
editor: Editor | null,
|
||||||
align: TextAlign
|
align: TextAlign
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!editor || !editor.isEditable) return false
|
if (!editor || !editor.isEditable) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!isExtensionAvailable(editor, "textAlign") ||
|
!isExtensionAvailable(editor, "textAlign") ||
|
||||||
isNodeTypeSelected(editor, ["image", "horizontalRule"])
|
isNodeTypeSelected(editor, ["image", "horizontalRule"])
|
||||||
)
|
) {
|
||||||
return false
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
return editor.can().setTextAlign(align)
|
return editor.can().setTextAlign(align)
|
||||||
}
|
}
|
||||||
@ -95,7 +99,10 @@ export function isTextAlignActive(
|
|||||||
editor: Editor | null,
|
editor: Editor | null,
|
||||||
align: TextAlign
|
align: TextAlign
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!editor || !editor.isEditable) return false
|
if (!editor || !editor.isEditable) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
return editor.isActive({ textAlign: align })
|
return editor.isActive({ textAlign: align })
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -103,10 +110,16 @@ export function isTextAlignActive(
|
|||||||
* Sets text alignment in the editor
|
* Sets text alignment in the editor
|
||||||
*/
|
*/
|
||||||
export function setTextAlign(editor: Editor | null, align: TextAlign): boolean {
|
export function setTextAlign(editor: Editor | null, align: TextAlign): boolean {
|
||||||
if (!editor || !editor.isEditable) return false
|
if (!editor || !editor.isEditable) {
|
||||||
if (!canSetTextAlign(editor, align)) return false
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canSetTextAlign(editor, align)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
const chain = editor.chain().focus()
|
const chain = editor.chain().focus()
|
||||||
|
|
||||||
if (hasSetTextAlign(chain)) {
|
if (hasSetTextAlign(chain)) {
|
||||||
return chain.setTextAlign(align).run()
|
return chain.setTextAlign(align).run()
|
||||||
}
|
}
|
||||||
@ -124,15 +137,21 @@ export function shouldShowButton(props: {
|
|||||||
}): boolean {
|
}): boolean {
|
||||||
const { editor, hideWhenUnavailable, align } = props
|
const { editor, hideWhenUnavailable, align } = props
|
||||||
|
|
||||||
if (!editor) return false
|
if (!editor) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
if (!hideWhenUnavailable) {
|
if (!hideWhenUnavailable) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!editor.isEditable) return false
|
if (!editor.isEditable) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
if (!isExtensionAvailable(editor, "textAlign")) return false
|
if (!isExtensionAvailable(editor, "textAlign")) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
if (!editor.isActive("code")) {
|
if (!editor.isActive("code")) {
|
||||||
return canSetTextAlign(editor, align)
|
return canSetTextAlign(editor, align)
|
||||||
@ -192,7 +211,9 @@ export function useTextAlign(config: UseTextAlignConfig) {
|
|||||||
const isActive = isTextAlignActive(editor, align)
|
const isActive = isTextAlignActive(editor, align)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editor) return
|
if (!editor) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const handleSelectionUpdate = () => {
|
const handleSelectionUpdate = () => {
|
||||||
setIsVisible(shouldShowButton({ editor, align, hideWhenUnavailable }))
|
setIsVisible(shouldShowButton({ editor, align, hideWhenUnavailable }))
|
||||||
@ -208,12 +229,16 @@ export function useTextAlign(config: UseTextAlignConfig) {
|
|||||||
}, [editor, hideWhenUnavailable, align])
|
}, [editor, hideWhenUnavailable, align])
|
||||||
|
|
||||||
const handleTextAlign = useCallback(() => {
|
const handleTextAlign = useCallback(() => {
|
||||||
if (!editor) return false
|
if (!editor) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
const success = setTextAlign(editor, align)
|
const success = setTextAlign(editor, align)
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
onAligned?.()
|
onAligned?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
return success
|
return success
|
||||||
}, [editor, align, onAligned])
|
}, [editor, align, onAligned])
|
||||||
|
|
||||||
|
|||||||
@ -3,10 +3,8 @@
|
|||||||
import { forwardRef, useCallback } from "react"
|
import { forwardRef, useCallback } from "react"
|
||||||
|
|
||||||
// --- Lib ---
|
// --- Lib ---
|
||||||
import { parseShortcutKeys } from "@/lib/tiptap-utils"
|
|
||||||
|
|
||||||
// --- Hooks ---
|
// --- Hooks ---
|
||||||
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
|
||||||
|
|
||||||
// --- Tiptap UI ---
|
// --- Tiptap UI ---
|
||||||
import type {
|
import type {
|
||||||
@ -19,9 +17,11 @@ import {
|
|||||||
} from "@/components/tiptap-ui/undo-redo-button"
|
} from "@/components/tiptap-ui/undo-redo-button"
|
||||||
|
|
||||||
// --- UI Primitives ---
|
// --- UI Primitives ---
|
||||||
|
import { Badge } from "@/components/tiptap-ui-primitive/badge"
|
||||||
import type { ButtonProps } from "@/components/tiptap-ui-primitive/button"
|
import type { ButtonProps } from "@/components/tiptap-ui-primitive/button"
|
||||||
import { Button } from "@/components/tiptap-ui-primitive/button"
|
import { Button } from "@/components/tiptap-ui-primitive/button"
|
||||||
import { Badge } from "@/components/tiptap-ui-primitive/badge"
|
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
||||||
|
import { parseShortcutKeys } from "@/lib/tiptap-utils"
|
||||||
|
|
||||||
export interface UndoRedoButtonProps
|
export interface UndoRedoButtonProps
|
||||||
extends Omit<ButtonProps, "type">, UseUndoRedoConfig {
|
extends Omit<ButtonProps, "type">, UseUndoRedoConfig {
|
||||||
@ -81,7 +81,11 @@ export const UndoRedoButton = forwardRef<
|
|||||||
const handleClick = useCallback(
|
const handleClick = useCallback(
|
||||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
onClick?.(event)
|
onClick?.(event)
|
||||||
if (event.defaultPrevented) return
|
|
||||||
|
if (event.defaultPrevented) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
handleAction()
|
handleAction()
|
||||||
},
|
},
|
||||||
[handleAction, onClick]
|
[handleAction, onClick]
|
||||||
|
|||||||
@ -1,15 +1,15 @@
|
|||||||
|
import type {Editor} from "@tiptap/react";
|
||||||
import { useCallback, useEffect, useState } from "react"
|
import { useCallback, useEffect, useState } from "react"
|
||||||
import { type Editor } from "@tiptap/react"
|
|
||||||
|
|
||||||
// --- Hooks ---
|
// --- Hooks ---
|
||||||
|
import { Redo2Icon } from "@/components/tiptap-icons/redo2-icon"
|
||||||
|
import { Undo2Icon } from "@/components/tiptap-icons/undo2-icon"
|
||||||
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
|
||||||
|
|
||||||
// --- Lib ---
|
// --- Lib ---
|
||||||
import { isNodeTypeSelected } from "@/lib/tiptap-utils"
|
import { isNodeTypeSelected } from "@/lib/tiptap-utils"
|
||||||
|
|
||||||
// --- Icons ---
|
// --- Icons ---
|
||||||
import { Redo2Icon } from "@/components/tiptap-icons/redo2-icon"
|
|
||||||
import { Undo2Icon } from "@/components/tiptap-icons/undo2-icon"
|
|
||||||
|
|
||||||
export type UndoRedoAction = "undo" | "redo"
|
export type UndoRedoAction = "undo" | "redo"
|
||||||
|
|
||||||
@ -58,8 +58,13 @@ export function canExecuteUndoRedoAction(
|
|||||||
editor: Editor | null,
|
editor: Editor | null,
|
||||||
action: UndoRedoAction
|
action: UndoRedoAction
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!editor || !editor.isEditable) return false
|
if (!editor || !editor.isEditable) {
|
||||||
if (isNodeTypeSelected(editor, ["image"])) return false
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNodeTypeSelected(editor, ["image"])) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
return action === "undo" ? editor.can().undo() : editor.can().redo()
|
return action === "undo" ? editor.can().undo() : editor.can().redo()
|
||||||
}
|
}
|
||||||
@ -71,10 +76,16 @@ export function executeUndoRedoAction(
|
|||||||
editor: Editor | null,
|
editor: Editor | null,
|
||||||
action: UndoRedoAction
|
action: UndoRedoAction
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!editor || !editor.isEditable) return false
|
if (!editor || !editor.isEditable) {
|
||||||
if (!canExecuteUndoRedoAction(editor, action)) return false
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canExecuteUndoRedoAction(editor, action)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
const chain = editor.chain().focus()
|
const chain = editor.chain().focus()
|
||||||
|
|
||||||
return action === "undo" ? chain.undo().run() : chain.redo().run()
|
return action === "undo" ? chain.undo().run() : chain.redo().run()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -88,13 +99,17 @@ export function shouldShowButton(props: {
|
|||||||
}): boolean {
|
}): boolean {
|
||||||
const { editor, hideWhenUnavailable, action } = props
|
const { editor, hideWhenUnavailable, action } = props
|
||||||
|
|
||||||
if (!editor) return false
|
if (!editor) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
if (!hideWhenUnavailable) {
|
if (!hideWhenUnavailable) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!editor.isEditable) return false
|
if (!editor.isEditable) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
if (!editor.isActive("code")) {
|
if (!editor.isActive("code")) {
|
||||||
return canExecuteUndoRedoAction(editor, action)
|
return canExecuteUndoRedoAction(editor, action)
|
||||||
@ -152,7 +167,9 @@ export function useUndoRedo(config: UseUndoRedoConfig) {
|
|||||||
const canExecute = canExecuteUndoRedoAction(editor, action)
|
const canExecute = canExecuteUndoRedoAction(editor, action)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editor) return
|
if (!editor) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const handleUpdate = () => {
|
const handleUpdate = () => {
|
||||||
setIsVisible(shouldShowButton({ editor, hideWhenUnavailable, action }))
|
setIsVisible(shouldShowButton({ editor, hideWhenUnavailable, action }))
|
||||||
@ -168,12 +185,16 @@ export function useUndoRedo(config: UseUndoRedoConfig) {
|
|||||||
}, [editor, hideWhenUnavailable, action])
|
}, [editor, hideWhenUnavailable, action])
|
||||||
|
|
||||||
const handleAction = useCallback(() => {
|
const handleAction = useCallback(() => {
|
||||||
if (!editor) return false
|
if (!editor) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
const success = executeUndoRedoAction(editor, action)
|
const success = executeUndoRedoAction(editor, action)
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
onExecuted?.()
|
onExecuted?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
return success
|
return success
|
||||||
}, [editor, action, onExecuted])
|
}, [editor, action, onExecuted])
|
||||||
|
|
||||||
|
|||||||
@ -44,9 +44,13 @@ export function useCurrentUrl(): UseCurrentUrlReturn {
|
|||||||
const currentPath = currentUrl ?? currentUrlPath;
|
const currentPath = currentUrl ?? currentUrlPath;
|
||||||
|
|
||||||
const clean = (p: string) => {
|
const clean = (p: string) => {
|
||||||
if (!p) return '/';
|
if (!p) {
|
||||||
|
return '/';
|
||||||
|
}
|
||||||
|
|
||||||
// Remove query and hash, then trailing slash
|
// Remove query and hash, then trailing slash
|
||||||
let path = p.split('?')[0].split('#')[0];
|
let path = p.split('?')[0].split('#')[0];
|
||||||
|
|
||||||
if (path.startsWith('http')) {
|
if (path.startsWith('http')) {
|
||||||
try {
|
try {
|
||||||
path = new URL(path).pathname;
|
path = new URL(path).pathname;
|
||||||
@ -54,6 +58,7 @@ export function useCurrentUrl(): UseCurrentUrlReturn {
|
|||||||
// Ignore
|
// Ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return path.replace(/\/+$/, '') || '/';
|
return path.replace(/\/+$/, '') || '/';
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -64,6 +69,7 @@ export function useCurrentUrl(): UseCurrentUrlReturn {
|
|||||||
if (normCheck === '/') {
|
if (normCheck === '/') {
|
||||||
return normCurrent === '/';
|
return normCurrent === '/';
|
||||||
}
|
}
|
||||||
|
|
||||||
return normCurrent.startsWith(normCheck);
|
return normCurrent.startsWith(normCheck);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import type { Editor } from "@tiptap/react"
|
import type { Editor } from "@tiptap/react"
|
||||||
import { useWindowSize } from "@/hooks/use-window-size"
|
|
||||||
import { useBodyRect } from "@/hooks/use-element-rect"
|
|
||||||
import { useEffect } from "react"
|
import { useEffect } from "react"
|
||||||
|
import { useBodyRect } from "@/hooks/use-element-rect"
|
||||||
|
import { useWindowSize } from "@/hooks/use-window-size"
|
||||||
|
|
||||||
export interface CursorVisibilityOptions {
|
export interface CursorVisibilityOptions {
|
||||||
/**
|
/**
|
||||||
@ -35,10 +35,15 @@ export function useCursorVisibility({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const ensureCursorVisibility = () => {
|
const ensureCursorVisibility = () => {
|
||||||
if (!editor) return
|
if (!editor) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const { state, view } = editor
|
const { state, view } = editor
|
||||||
if (!view.hasFocus()) return
|
|
||||||
|
if (!view.hasFocus()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Get current cursor position coordinates
|
// Get current cursor position coordinates
|
||||||
const { from } = state.selection
|
const { from } = state.selection
|
||||||
|
|||||||
@ -59,7 +59,9 @@ export function useElementRect({
|
|||||||
const [rect, setRect] = useState<RectState>(initialRect)
|
const [rect, setRect] = useState<RectState>(initialRect)
|
||||||
|
|
||||||
const getTargetElement = useCallback((): Element | null => {
|
const getTargetElement = useCallback((): Element | null => {
|
||||||
if (!enabled || !isClientSide()) return null
|
if (!enabled || !isClientSide()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
if (!element) {
|
if (!element) {
|
||||||
return document.body
|
return document.body
|
||||||
@ -78,11 +80,15 @@ export function useElementRect({
|
|||||||
|
|
||||||
const updateRect = useThrottledCallback(
|
const updateRect = useThrottledCallback(
|
||||||
() => {
|
() => {
|
||||||
if (!enabled || !isClientSide()) return
|
if (!enabled || !isClientSide()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const targetElement = getTargetElement()
|
const targetElement = getTargetElement()
|
||||||
|
|
||||||
if (!targetElement) {
|
if (!targetElement) {
|
||||||
setRect(initialRect)
|
setRect(initialRect)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -106,11 +112,15 @@ export function useElementRect({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled || !isClientSide()) {
|
if (!enabled || !isClientSide()) {
|
||||||
setRect(initialRect)
|
setRect(initialRect)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetElement = getTargetElement()
|
const targetElement = getTargetElement()
|
||||||
if (!targetElement) return
|
|
||||||
|
if (!targetElement) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
updateRect()
|
updateRect()
|
||||||
|
|
||||||
|
|||||||
@ -28,6 +28,7 @@ export function useIsBreakpoint(
|
|||||||
|
|
||||||
// Add listener
|
// Add listener
|
||||||
mql.addEventListener("change", onChange)
|
mql.addEventListener("change", onChange)
|
||||||
|
|
||||||
return () => mql.removeEventListener("change", onChange)
|
return () => mql.removeEventListener("change", onChange)
|
||||||
}, [mode, breakpoint])
|
}, [mode, breakpoint])
|
||||||
|
|
||||||
|
|||||||
@ -65,83 +65,117 @@ export function useMenuNavigation<T>({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyboardNavigation = (event: KeyboardEvent) => {
|
const handleKeyboardNavigation = (event: KeyboardEvent) => {
|
||||||
if (!items.length) return false
|
if (!items.length) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
const moveNext = () =>
|
const moveNext = () =>
|
||||||
setSelectedIndex((currentIndex) => {
|
setSelectedIndex((currentIndex) => {
|
||||||
if (currentIndex === -1) return 0
|
if (currentIndex === -1) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
return (currentIndex + 1) % items.length
|
return (currentIndex + 1) % items.length
|
||||||
})
|
})
|
||||||
|
|
||||||
const movePrev = () =>
|
const movePrev = () =>
|
||||||
setSelectedIndex((currentIndex) => {
|
setSelectedIndex((currentIndex) => {
|
||||||
if (currentIndex === -1) return items.length - 1
|
if (currentIndex === -1) {
|
||||||
|
return items.length - 1
|
||||||
|
}
|
||||||
|
|
||||||
return (currentIndex - 1 + items.length) % items.length
|
return (currentIndex - 1 + items.length) % items.length
|
||||||
})
|
})
|
||||||
|
|
||||||
switch (event.key) {
|
switch (event.key) {
|
||||||
case "ArrowUp": {
|
case "ArrowUp": {
|
||||||
if (orientation === "horizontal") return false
|
if (orientation === "horizontal") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
movePrev()
|
movePrev()
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
case "ArrowDown": {
|
case "ArrowDown": {
|
||||||
if (orientation === "horizontal") return false
|
if (orientation === "horizontal") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
moveNext()
|
moveNext()
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
case "ArrowLeft": {
|
case "ArrowLeft": {
|
||||||
if (orientation === "vertical") return false
|
if (orientation === "vertical") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
movePrev()
|
movePrev()
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
case "ArrowRight": {
|
case "ArrowRight": {
|
||||||
if (orientation === "vertical") return false
|
if (orientation === "vertical") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
moveNext()
|
moveNext()
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
case "Tab": {
|
case "Tab": {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|
||||||
if (event.shiftKey) {
|
if (event.shiftKey) {
|
||||||
movePrev()
|
movePrev()
|
||||||
} else {
|
} else {
|
||||||
moveNext()
|
moveNext()
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
case "Home": {
|
case "Home": {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
setSelectedIndex(0)
|
setSelectedIndex(0)
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
case "End": {
|
case "End": {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
setSelectedIndex(items.length - 1)
|
setSelectedIndex(items.length - 1)
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
case "Enter": {
|
case "Enter": {
|
||||||
if (event.isComposing) return false
|
if (event.isComposing) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|
||||||
if (selectedIndex !== -1 && items[selectedIndex]) {
|
if (selectedIndex !== -1 && items[selectedIndex]) {
|
||||||
onSelect?.(items[selectedIndex])
|
onSelect?.(items[selectedIndex])
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
case "Escape": {
|
case "Escape": {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
onClose?.()
|
onClose?.()
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -12,6 +12,7 @@ export function useIsMobile() {
|
|||||||
}
|
}
|
||||||
mql.addEventListener("change", onChange)
|
mql.addEventListener("change", onChange)
|
||||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||||
|
|
||||||
return () => mql.removeEventListener("change", onChange)
|
return () => mql.removeEventListener("change", onChange)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { toast } from 'sonner';
|
|||||||
|
|
||||||
// Type definitions for Web Bluetooth and Web USB
|
// Type definitions for Web Bluetooth and Web USB
|
||||||
// This is to satisfy TypeScript if the global types are missing
|
// This is to satisfy TypeScript if the global types are missing
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
||||||
|
|
||||||
export type PrinterType = 'bluetooth' | 'usb' | null;
|
export type PrinterType = 'bluetooth' | 'usb' | null;
|
||||||
export type PaperSize = '58' | '80';
|
export type PaperSize = '58' | '80';
|
||||||
@ -32,8 +32,10 @@ export function usePrinter() {
|
|||||||
const connectBluetooth = useCallback(async () => {
|
const connectBluetooth = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const nav = navigator as any;
|
const nav = navigator as any;
|
||||||
|
|
||||||
if (!nav.bluetooth) {
|
if (!nav.bluetooth) {
|
||||||
toast.error('Bluetooth tidak didukung di browser ini');
|
toast.error('Bluetooth tidak didukung di browser ini');
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -70,6 +72,7 @@ export function usePrinter() {
|
|||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Bluetooth connection error:', error);
|
console.error('Bluetooth connection error:', error);
|
||||||
|
|
||||||
if (error.name !== 'NotFoundError') {
|
if (error.name !== 'NotFoundError') {
|
||||||
toast.error(error.message || 'Gagal menghubungkan printer bluetooth');
|
toast.error(error.message || 'Gagal menghubungkan printer bluetooth');
|
||||||
}
|
}
|
||||||
@ -79,8 +82,10 @@ export function usePrinter() {
|
|||||||
const connectUsb = useCallback(async () => {
|
const connectUsb = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const nav = navigator as any;
|
const nav = navigator as any;
|
||||||
|
|
||||||
if (!nav.usb) {
|
if (!nav.usb) {
|
||||||
toast.error('USB tidak didukung di browser ini');
|
toast.error('USB tidak didukung di browser ini');
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -89,6 +94,7 @@ export function usePrinter() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await device.open();
|
await device.open();
|
||||||
|
|
||||||
if (device.configuration === null) {
|
if (device.configuration === null) {
|
||||||
await device.selectConfiguration(1);
|
await device.selectConfiguration(1);
|
||||||
}
|
}
|
||||||
@ -108,6 +114,7 @@ export function usePrinter() {
|
|||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('USB connection error:', error);
|
console.error('USB connection error:', error);
|
||||||
|
|
||||||
if (error.name !== 'NotFoundError') {
|
if (error.name !== 'NotFoundError') {
|
||||||
toast.error(error.message || 'Gagal menghubungkan printer USB');
|
toast.error(error.message || 'Gagal menghubungkan printer USB');
|
||||||
}
|
}
|
||||||
@ -121,6 +128,7 @@ export function usePrinter() {
|
|||||||
const sendData = useCallback(async (data: Uint8Array) => {
|
const sendData = useCallback(async (data: Uint8Array) => {
|
||||||
if (!state.isConnected || !state.device) {
|
if (!state.isConnected || !state.device) {
|
||||||
toast.error('Printer belum terhubung');
|
toast.error('Printer belum terhubung');
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -134,20 +142,26 @@ export function usePrinter() {
|
|||||||
const server = await device.gatt?.connect();
|
const server = await device.gatt?.connect();
|
||||||
const services = await server.getPrimaryServices();
|
const services = await server.getPrimaryServices();
|
||||||
const writeChar = await findWriteCharacteristic(services);
|
const writeChar = await findWriteCharacteristic(services);
|
||||||
|
|
||||||
if (writeChar) {
|
if (writeChar) {
|
||||||
setCharacteristic(writeChar);
|
setCharacteristic(writeChar);
|
||||||
toast.success('Printer terhubung kembali', { id: 'printer-reconnect' });
|
toast.success('Printer terhubung kembali', { id: 'printer-reconnect' });
|
||||||
} else {
|
} else {
|
||||||
toast.error('Gagal menghubungkan kembali printer', { id: 'printer-reconnect' });
|
toast.error('Gagal menghubungkan kembali printer', { id: 'printer-reconnect' });
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!characteristic) throw new Error('Characteristic tidak ditemukan');
|
if (!characteristic) {
|
||||||
|
throw new Error('Characteristic tidak ditemukan');
|
||||||
|
}
|
||||||
|
|
||||||
const chunkSize = 20;
|
const chunkSize = 20;
|
||||||
|
|
||||||
for (let i = 0; i < data.length; i += chunkSize) {
|
for (let i = 0; i < data.length; i += chunkSize) {
|
||||||
const chunk = data.slice(i, i + chunkSize);
|
const chunk = data.slice(i, i + chunkSize);
|
||||||
|
|
||||||
// Use writeValueWithoutResponse for better stability if supported
|
// Use writeValueWithoutResponse for better stability if supported
|
||||||
if (characteristic.writeValueWithoutResponse) {
|
if (characteristic.writeValueWithoutResponse) {
|
||||||
await characteristic.writeValueWithoutResponse(chunk);
|
await characteristic.writeValueWithoutResponse(chunk);
|
||||||
@ -158,7 +172,10 @@ export function usePrinter() {
|
|||||||
} else if (state.type === 'usb') {
|
} else if (state.type === 'usb') {
|
||||||
const device = state.device as any;
|
const device = state.device as any;
|
||||||
const endpoint = device.configuration.interfaces[0].alternates[0].endpoints.find((e: any) => e.direction === 'out' && e.type === 'bulk');
|
const endpoint = device.configuration.interfaces[0].alternates[0].endpoints.find((e: any) => e.direction === 'out' && e.type === 'bulk');
|
||||||
if (!endpoint) throw new Error('Tidak dapat menemukan USB endpoint');
|
|
||||||
|
if (!endpoint) {
|
||||||
|
throw new Error('Tidak dapat menemukan USB endpoint');
|
||||||
|
}
|
||||||
|
|
||||||
await device.transferOut(endpoint.endpointNumber, data);
|
await device.transferOut(endpoint.endpointNumber, data);
|
||||||
}
|
}
|
||||||
@ -195,11 +212,13 @@ export function usePrinter() {
|
|||||||
async function findWriteCharacteristic(services: any[]) {
|
async function findWriteCharacteristic(services: any[]) {
|
||||||
for (const service of services) {
|
for (const service of services) {
|
||||||
const characteristics = await service.getCharacteristics();
|
const characteristics = await service.getCharacteristics();
|
||||||
|
|
||||||
for (const char of characteristics) {
|
for (const char of characteristics) {
|
||||||
if (char.properties.write || char.properties.writeWithoutResponse) {
|
if (char.properties.write || char.properties.writeWithoutResponse) {
|
||||||
return char;
|
return char;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -47,7 +47,9 @@ export function useScrolling(
|
|||||||
const supportsScrollEnd = element === window && "onscrollend" in window
|
const supportsScrollEnd = element === window && "onscrollend" in window
|
||||||
|
|
||||||
const handleScroll: EventListener = () => {
|
const handleScroll: EventListener = () => {
|
||||||
if (!isScrolling) setIsScrolling(true)
|
if (!isScrolling) {
|
||||||
|
setIsScrolling(true)
|
||||||
|
}
|
||||||
|
|
||||||
if (!supportsScrollEnd) {
|
if (!supportsScrollEnd) {
|
||||||
clearTimeout(timeout)
|
clearTimeout(timeout)
|
||||||
@ -58,15 +60,18 @@ export function useScrolling(
|
|||||||
const handleScrollEnd: EventListener = () => setIsScrolling(false)
|
const handleScrollEnd: EventListener = () => setIsScrolling(false)
|
||||||
|
|
||||||
on(eventTarget, "scroll", handleScroll)
|
on(eventTarget, "scroll", handleScroll)
|
||||||
|
|
||||||
if (supportsScrollEnd) {
|
if (supportsScrollEnd) {
|
||||||
on(eventTarget, "scrollend", handleScrollEnd)
|
on(eventTarget, "scrollend", handleScrollEnd)
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
off(eventTarget, "scroll", handleScroll)
|
off(eventTarget, "scroll", handleScroll)
|
||||||
|
|
||||||
if (supportsScrollEnd) {
|
if (supportsScrollEnd) {
|
||||||
off(eventTarget, "scrollend", handleScrollEnd)
|
off(eventTarget, "scrollend", handleScrollEnd)
|
||||||
}
|
}
|
||||||
|
|
||||||
clearTimeout(timeout)
|
clearTimeout(timeout)
|
||||||
}
|
}
|
||||||
}, [target, debounce, fallbackToDocument, isScrolling])
|
}, [target, debounce, fallbackToDocument, isScrolling])
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import throttle from "lodash.throttle"
|
import throttle from "lodash.throttle"
|
||||||
|
|
||||||
import { useUnmount } from "@/hooks/use-unmount"
|
|
||||||
import { useMemo } from "react"
|
import { useMemo } from "react"
|
||||||
|
import { useUnmount } from "@/hooks/use-unmount"
|
||||||
|
|
||||||
interface ThrottleSettings {
|
interface ThrottleSettings {
|
||||||
leading?: boolean | undefined
|
leading?: boolean | undefined
|
||||||
@ -21,7 +21,7 @@ const defaultOptions: ThrottleSettings = {
|
|||||||
* @param dependencies The dependencies to watch for changes
|
* @param dependencies The dependencies to watch for changes
|
||||||
* @param options The throttle options
|
* @param options The throttle options
|
||||||
*/
|
*/
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
export function useThrottledCallback<T extends (...args: any[]) => any>(
|
export function useThrottledCallback<T extends (...args: any[]) => any>(
|
||||||
fn: T,
|
fn: T,
|
||||||
wait = 250,
|
wait = 250,
|
||||||
|
|||||||
@ -5,7 +5,11 @@ import { useEffect, useState } from "react"
|
|||||||
function getActivePageEditor(editor: Editor): Editor | null {
|
function getActivePageEditor(editor: Editor): Editor | null {
|
||||||
const storage = editor.storage as unknown as Record<string, unknown>
|
const storage = editor.storage as unknown as Record<string, unknown>
|
||||||
const pages = storage.pages as { activeEditor?: Editor | null } | undefined
|
const pages = storage.pages as { activeEditor?: Editor | null } | undefined
|
||||||
if (!pages || !("activeEditor" in pages)) return null
|
|
||||||
|
if (!pages || !("activeEditor" in pages)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
return pages.activeEditor ?? null
|
return pages.activeEditor ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -22,6 +26,7 @@ export function useTiptapEditor(providedEditor?: Editor | null): {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mainEditor) {
|
if (!mainEditor) {
|
||||||
setStorageEditor(null)
|
setStorageEditor(null)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -40,11 +45,14 @@ export function useTiptapEditor(providedEditor?: Editor | null): {
|
|||||||
}, [mainEditor])
|
}, [mainEditor])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!storageEditor) return
|
if (!storageEditor) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const handleDestroy = () => setStorageEditor(null)
|
const handleDestroy = () => setStorageEditor(null)
|
||||||
|
|
||||||
storageEditor.on("destroy", handleDestroy)
|
storageEditor.on("destroy", handleDestroy)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
storageEditor.off("destroy", handleDestroy)
|
storageEditor.off("destroy", handleDestroy)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import { useRef, useEffect } from "react"
|
|||||||
*
|
*
|
||||||
* @param callback Function to be called on component unmount
|
* @param callback Function to be called on component unmount
|
||||||
*/
|
*/
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
export const useUnmount = (callback: (...args: Array<any>) => any) => {
|
export const useUnmount = (callback: (...args: Array<any>) => any) => {
|
||||||
const ref = useRef(callback)
|
const ref = useRef(callback)
|
||||||
ref.current = callback
|
ref.current = callback
|
||||||
|
|||||||
@ -48,10 +48,15 @@ export function useWindowSize(): WindowSizeState {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const handleViewportChange = useThrottledCallback(() => {
|
const handleViewportChange = useThrottledCallback(() => {
|
||||||
if (typeof window === "undefined") return
|
if (typeof window === "undefined") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const vp = window.visualViewport
|
const vp = window.visualViewport
|
||||||
if (!vp) return
|
|
||||||
|
if (!vp) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
width = 0,
|
width = 0,
|
||||||
@ -78,7 +83,10 @@ export function useWindowSize(): WindowSizeState {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const visualViewport = window.visualViewport
|
const visualViewport = window.visualViewport
|
||||||
if (!visualViewport) return
|
|
||||||
|
if (!visualViewport) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
visualViewport.addEventListener("resize", handleViewportChange)
|
visualViewport.addEventListener("resize", handleViewportChange)
|
||||||
|
|
||||||
|
|||||||
@ -12,6 +12,7 @@ export class EscPosEncoder {
|
|||||||
*/
|
*/
|
||||||
initialize(): this {
|
initialize(): this {
|
||||||
this.buffer.push(0x1b, 0x40);
|
this.buffer.push(0x1b, 0x40);
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -21,6 +22,7 @@ export class EscPosEncoder {
|
|||||||
text(value: string): this {
|
text(value: string): this {
|
||||||
const bytes = this.encoder.encode(value);
|
const bytes = this.encoder.encode(value);
|
||||||
this.buffer.push(...Array.from(bytes));
|
this.buffer.push(...Array.from(bytes));
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -29,6 +31,7 @@ export class EscPosEncoder {
|
|||||||
*/
|
*/
|
||||||
line(value: string = ''): this {
|
line(value: string = ''): this {
|
||||||
this.text(value + '\n');
|
this.text(value + '\n');
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -38,6 +41,7 @@ export class EscPosEncoder {
|
|||||||
*/
|
*/
|
||||||
align(value: 0 | 1 | 2): this {
|
align(value: 0 | 1 | 2): this {
|
||||||
this.buffer.push(0x1b, 0x61, value);
|
this.buffer.push(0x1b, 0x61, value);
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -46,6 +50,7 @@ export class EscPosEncoder {
|
|||||||
*/
|
*/
|
||||||
bold(value: boolean): this {
|
bold(value: boolean): this {
|
||||||
this.buffer.push(0x1b, 0x45, value ? 1 : 0);
|
this.buffer.push(0x1b, 0x45, value ? 1 : 0);
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -55,10 +60,21 @@ export class EscPosEncoder {
|
|||||||
*/
|
*/
|
||||||
size(value: 0 | 1 | 2 | 3): this {
|
size(value: 0 | 1 | 2 | 3): this {
|
||||||
let size = 0;
|
let size = 0;
|
||||||
if (value === 1) size = 0x01;
|
|
||||||
if (value === 2) size = 0x10;
|
if (value === 1) {
|
||||||
if (value === 3) size = 0x11;
|
size = 0x01;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === 2) {
|
||||||
|
size = 0x10;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === 3) {
|
||||||
|
size = 0x11;
|
||||||
|
}
|
||||||
|
|
||||||
this.buffer.push(0x1d, 0x21, size);
|
this.buffer.push(0x1d, 0x21, size);
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -67,6 +83,7 @@ export class EscPosEncoder {
|
|||||||
*/
|
*/
|
||||||
cut(): this {
|
cut(): this {
|
||||||
this.buffer.push(0x1d, 0x56, 0x00);
|
this.buffer.push(0x1d, 0x56, 0x00);
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -75,6 +92,7 @@ export class EscPosEncoder {
|
|||||||
*/
|
*/
|
||||||
feed(lines: number = 1): this {
|
feed(lines: number = 1): this {
|
||||||
this.buffer.push(0x1b, 0x64, lines);
|
this.buffer.push(0x1b, 0x64, lines);
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -7,11 +7,15 @@ export const formatTime = (date: Date) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const formatDate = (date: Date | string | undefined) => {
|
export const formatDate = (date: Date | string | undefined) => {
|
||||||
if (!date) return '-';
|
if (!date) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
|
||||||
const parsedDate = date instanceof Date ? date : new Date(date);
|
const parsedDate = date instanceof Date ? date : new Date(date);
|
||||||
|
|
||||||
if (isNaN(parsedDate.getTime())) return '-';
|
if (isNaN(parsedDate.getTime())) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
|
||||||
return parsedDate.toLocaleDateString('id-ID', {
|
return parsedDate.toLocaleDateString('id-ID', {
|
||||||
weekday: 'long',
|
weekday: 'long',
|
||||||
|
|||||||
@ -8,10 +8,11 @@ import {
|
|||||||
} from "@tiptap/pm/state"
|
} from "@tiptap/pm/state"
|
||||||
import { cellAround, CellSelection } from "@tiptap/pm/tables"
|
import { cellAround, CellSelection } from "@tiptap/pm/tables"
|
||||||
import {
|
import {
|
||||||
findParentNodeClosestToPos,
|
findParentNodeClosestToPos
|
||||||
type Editor,
|
|
||||||
type NodeWithPos,
|
|
||||||
} from "@tiptap/react"
|
} from "@tiptap/react"
|
||||||
|
import type {Editor, NodeWithPos} from "@tiptap/react";
|
||||||
|
|
||||||
export const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB
|
export const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB
|
||||||
|
|
||||||
@ -74,6 +75,7 @@ export const formatShortcutKey = (
|
|||||||
) => {
|
) => {
|
||||||
if (isMac) {
|
if (isMac) {
|
||||||
const lowerKey = key.toLowerCase()
|
const lowerKey = key.toLowerCase()
|
||||||
|
|
||||||
return MAC_SYMBOLS[lowerKey] || (capitalize ? key.toUpperCase() : key)
|
return MAC_SYMBOLS[lowerKey] || (capitalize ? key.toUpperCase() : key)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -94,7 +96,9 @@ export const parseShortcutKeys = (props: {
|
|||||||
}) => {
|
}) => {
|
||||||
const { shortcutKeys, delimiter = "+", capitalize = true } = props
|
const { shortcutKeys, delimiter = "+", capitalize = true } = props
|
||||||
|
|
||||||
if (!shortcutKeys) return []
|
if (!shortcutKeys) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
return shortcutKeys
|
return shortcutKeys
|
||||||
.split(delimiter)
|
.split(delimiter)
|
||||||
@ -112,7 +116,10 @@ export const isMarkInSchema = (
|
|||||||
markName: string,
|
markName: string,
|
||||||
editor: Editor | null
|
editor: Editor | null
|
||||||
): boolean => {
|
): boolean => {
|
||||||
if (!editor?.schema) return false
|
if (!editor?.schema) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
return editor.schema.spec.marks.get(markName) !== undefined
|
return editor.schema.spec.marks.get(markName) !== undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -126,7 +133,10 @@ export const isNodeInSchema = (
|
|||||||
nodeName: string,
|
nodeName: string,
|
||||||
editor: Editor | null
|
editor: Editor | null
|
||||||
): boolean => {
|
): boolean => {
|
||||||
if (!editor?.schema) return false
|
if (!editor?.schema) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
return editor.schema.spec.nodes.get(nodeName) !== undefined
|
return editor.schema.spec.nodes.get(nodeName) !== undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -140,14 +150,18 @@ export function focusNextNode(editor: Editor) {
|
|||||||
const { doc, selection } = state
|
const { doc, selection } = state
|
||||||
|
|
||||||
const nextSel = Selection.findFrom(selection.$to, 1, true)
|
const nextSel = Selection.findFrom(selection.$to, 1, true)
|
||||||
|
|
||||||
if (nextSel) {
|
if (nextSel) {
|
||||||
view.dispatch(state.tr.setSelection(nextSel).scrollIntoView())
|
view.dispatch(state.tr.setSelection(nextSel).scrollIntoView())
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
const paragraphType = state.schema.nodes.paragraph
|
const paragraphType = state.schema.nodes.paragraph
|
||||||
|
|
||||||
if (!paragraphType) {
|
if (!paragraphType) {
|
||||||
console.warn("No paragraph node type found in schema.")
|
console.warn("No paragraph node type found in schema.")
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -159,6 +173,7 @@ export function focusNextNode(editor: Editor) {
|
|||||||
const $inside = tr.doc.resolve(end + 1)
|
const $inside = tr.doc.resolve(end + 1)
|
||||||
tr = tr.setSelection(TextSelection.near($inside)).scrollIntoView()
|
tr = tr.setSelection(TextSelection.near($inside)).scrollIntoView()
|
||||||
view.dispatch(tr)
|
view.dispatch(tr)
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -181,7 +196,9 @@ export function isExtensionAvailable(
|
|||||||
editor: Editor | null,
|
editor: Editor | null,
|
||||||
extensionNames: string | string[]
|
extensionNames: string | string[]
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!editor) return false
|
if (!editor) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
const names = Array.isArray(extensionNames)
|
const names = Array.isArray(extensionNames)
|
||||||
? extensionNames
|
? extensionNames
|
||||||
@ -209,13 +226,17 @@ export function isExtensionAvailable(
|
|||||||
export function findNodeAtPosition(editor: Editor, position: number) {
|
export function findNodeAtPosition(editor: Editor, position: number) {
|
||||||
try {
|
try {
|
||||||
const node = editor.state.doc.nodeAt(position)
|
const node = editor.state.doc.nodeAt(position)
|
||||||
|
|
||||||
if (!node) {
|
if (!node) {
|
||||||
console.warn(`No node found at position ${position}`)
|
console.warn(`No node found at position ${position}`)
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return node
|
return node
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error getting node at position ${position}:`, error)
|
console.error(`Error getting node at position ${position}:`, error)
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -235,7 +256,9 @@ export function findNodePosition(props: {
|
|||||||
}): { pos: number; node: PMNode } | null {
|
}): { pos: number; node: PMNode } | null {
|
||||||
const { editor, node, nodePos } = props
|
const { editor, node, nodePos } = props
|
||||||
|
|
||||||
if (!editor || !editor.state?.doc) return null
|
if (!editor || !editor.state?.doc) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
// Zero is valid position
|
// Zero is valid position
|
||||||
const hasValidNode = node !== undefined && node !== null
|
const hasValidNode = node !== undefined && node !== null
|
||||||
@ -256,8 +279,10 @@ export function findNodePosition(props: {
|
|||||||
if (currentNode === node) {
|
if (currentNode === node) {
|
||||||
foundPos = pos
|
foundPos = pos
|
||||||
foundNode = currentNode
|
foundNode = currentNode
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -269,6 +294,7 @@ export function findNodePosition(props: {
|
|||||||
// If we have a valid position, use findNodeAtPosition
|
// If we have a valid position, use findNodeAtPosition
|
||||||
if (hasValidPos) {
|
if (hasValidPos) {
|
||||||
const nodeAtPos = findNodeAtPosition(editor, nodePos!)
|
const nodeAtPos = findNodeAtPosition(editor, nodePos!)
|
||||||
|
|
||||||
if (nodeAtPos) {
|
if (nodeAtPos) {
|
||||||
return { pos: nodePos!, node: nodeAtPos }
|
return { pos: nodePos!, node: nodeAtPos }
|
||||||
}
|
}
|
||||||
@ -289,22 +315,30 @@ export function isNodeTypeSelected(
|
|||||||
nodeTypeNames: string[] = [],
|
nodeTypeNames: string[] = [],
|
||||||
checkAncestorNodes: boolean = false
|
checkAncestorNodes: boolean = false
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!editor || !editor.state.selection) return false
|
if (!editor || !editor.state.selection) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
const { selection } = editor.state
|
const { selection } = editor.state
|
||||||
if (selection.empty) return false
|
|
||||||
|
if (selection.empty) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// Direct node selection check
|
// Direct node selection check
|
||||||
if (selection instanceof NodeSelection) {
|
if (selection instanceof NodeSelection) {
|
||||||
const selectedNode = selection.node
|
const selectedNode = selection.node
|
||||||
|
|
||||||
return selectedNode ? nodeTypeNames.includes(selectedNode.type.name) : false
|
return selectedNode ? nodeTypeNames.includes(selectedNode.type.name) : false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Depth-based ancestor node check
|
// Depth-based ancestor node check
|
||||||
if (checkAncestorNodes) {
|
if (checkAncestorNodes) {
|
||||||
const { $from } = selection
|
const { $from } = selection
|
||||||
|
|
||||||
for (let depth = $from.depth; depth > 0; depth--) {
|
for (let depth = $from.depth; depth > 0; depth--) {
|
||||||
const ancestorNode = $from.node(depth)
|
const ancestorNode = $from.node(depth)
|
||||||
|
|
||||||
if (nodeTypeNames.includes(ancestorNode.type.name)) {
|
if (nodeTypeNames.includes(ancestorNode.type.name)) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@ -325,7 +359,9 @@ export function selectionWithinConvertibleTypes(
|
|||||||
editor: Editor,
|
editor: Editor,
|
||||||
types: string[] = []
|
types: string[] = []
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!editor || types.length === 0) return false
|
if (!editor || types.length === 0) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
const { state } = editor
|
const { state } = editor
|
||||||
const { selection } = state
|
const { selection } = state
|
||||||
@ -333,6 +369,7 @@ export function selectionWithinConvertibleTypes(
|
|||||||
|
|
||||||
if (selection instanceof NodeSelection) {
|
if (selection instanceof NodeSelection) {
|
||||||
const nodeType = selection.node?.type?.name
|
const nodeType = selection.node?.type?.name
|
||||||
|
|
||||||
return !!nodeType && allowed.has(nodeType)
|
return !!nodeType && allowed.has(nodeType)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -341,10 +378,13 @@ export function selectionWithinConvertibleTypes(
|
|||||||
state.doc.nodesBetween(selection.from, selection.to, (node) => {
|
state.doc.nodesBetween(selection.from, selection.to, (node) => {
|
||||||
if (node.isTextblock && !allowed.has(node.type.name)) {
|
if (node.isTextblock && !allowed.has(node.type.name)) {
|
||||||
valid = false
|
valid = false
|
||||||
|
|
||||||
return false // stop early
|
return false // stop early
|
||||||
}
|
}
|
||||||
|
|
||||||
return valid
|
return valid
|
||||||
})
|
})
|
||||||
|
|
||||||
return valid
|
return valid
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -380,6 +420,7 @@ export const handleImageUpload = async (
|
|||||||
if (abortSignal?.aborted) {
|
if (abortSignal?.aborted) {
|
||||||
throw new Error("Upload cancelled")
|
throw new Error("Upload cancelled")
|
||||||
}
|
}
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||||
onProgress?.({ progress })
|
onProgress?.({ progress })
|
||||||
}
|
}
|
||||||
@ -464,6 +505,7 @@ export function sanitizeUrl(
|
|||||||
} catch {
|
} catch {
|
||||||
// If URL creation fails, it's considered invalid
|
// If URL creation fails, it's considered invalid
|
||||||
}
|
}
|
||||||
|
|
||||||
return "#"
|
return "#"
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -483,14 +525,19 @@ export function updateNodesAttr<A extends string = string, V = unknown>(
|
|||||||
attrName: A,
|
attrName: A,
|
||||||
next: V | ((prev: V | undefined) => V | undefined)
|
next: V | ((prev: V | undefined) => V | undefined)
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!targets.length) return false
|
if (!targets.length) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
let changed = false
|
let changed = false
|
||||||
|
|
||||||
for (const { pos } of targets) {
|
for (const { pos } of targets) {
|
||||||
// Always re-read from the transaction's current doc
|
// Always re-read from the transaction's current doc
|
||||||
const currentNode = tr.doc.nodeAt(pos)
|
const currentNode = tr.doc.nodeAt(pos)
|
||||||
if (!currentNode) continue
|
|
||||||
|
if (!currentNode) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
const prevValue = (currentNode.attrs as Record<string, unknown>)[
|
const prevValue = (currentNode.attrs as Record<string, unknown>)[
|
||||||
attrName
|
attrName
|
||||||
@ -500,9 +547,12 @@ export function updateNodesAttr<A extends string = string, V = unknown>(
|
|||||||
? (next as (p: V | undefined) => V | undefined)(prevValue)
|
? (next as (p: V | undefined) => V | undefined)(prevValue)
|
||||||
: next
|
: next
|
||||||
|
|
||||||
if (prevValue === resolvedNext) continue
|
if (prevValue === resolvedNext) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
const nextAttrs: Record<string, unknown> = { ...currentNode.attrs }
|
const nextAttrs: Record<string, unknown> = { ...currentNode.attrs }
|
||||||
|
|
||||||
if (resolvedNext === undefined) {
|
if (resolvedNext === undefined) {
|
||||||
// Remove the key entirely instead of setting null
|
// Remove the key entirely instead of setting null
|
||||||
delete nextAttrs[attrName]
|
delete nextAttrs[attrName]
|
||||||
@ -525,7 +575,9 @@ export function updateNodesAttr<A extends string = string, V = unknown>(
|
|||||||
export function selectCurrentBlockContent(editor: Editor) {
|
export function selectCurrentBlockContent(editor: Editor) {
|
||||||
const { selection, doc } = editor.state
|
const { selection, doc } = editor.state
|
||||||
|
|
||||||
if (!selection.empty) return
|
if (!selection.empty) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const $pos = selection.$from
|
const $pos = selection.$from
|
||||||
let blockNode = null
|
let blockNode = null
|
||||||
@ -577,14 +629,17 @@ export function getSelectedNodesOfType(
|
|||||||
results.push({ node, pos })
|
results.push({ node, pos })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selection instanceof NodeSelection) {
|
if (selection instanceof NodeSelection) {
|
||||||
const { node, from: pos } = selection
|
const { node, from: pos } = selection
|
||||||
|
|
||||||
if (node && allowed.has(node.type.name)) {
|
if (node && allowed.has(node.type.name)) {
|
||||||
results.push({ node, pos })
|
results.push({ node, pos })
|
||||||
}
|
}
|
||||||
|
|
||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -593,8 +648,10 @@ export function getSelectedNodesOfType(
|
|||||||
|
|
||||||
if (cell) {
|
if (cell) {
|
||||||
const cellNode = selection.$anchor.doc.nodeAt(cell.pos)
|
const cellNode = selection.$anchor.doc.nodeAt(cell.pos)
|
||||||
|
|
||||||
if (cellNode && allowed.has(cellNode.type.name)) {
|
if (cellNode && allowed.has(cellNode.type.name)) {
|
||||||
results.push({ node: cellNode, pos: cell.pos })
|
results.push({ node: cellNode, pos: cell.pos })
|
||||||
|
|
||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -626,7 +683,9 @@ export function getSelectedBlockNodes(editor: Editor): PMNode[] {
|
|||||||
const seen = new Set<number>()
|
const seen = new Set<number>()
|
||||||
|
|
||||||
doc.nodesBetween(from, to, (node, pos) => {
|
doc.nodesBetween(from, to, (node, pos) => {
|
||||||
if (!node.isBlock) return
|
if (!node.isBlock) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (!seen.has(pos)) {
|
if (!seen.has(pos)) {
|
||||||
seen.add(pos)
|
seen.add(pos)
|
||||||
|
|||||||
@ -8,8 +8,17 @@ export function cn(...inputs: ClassValue[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function toUrl(url: InertiaLinkProps['href']): string {
|
export function toUrl(url: InertiaLinkProps['href']): string {
|
||||||
if (!url) return '';
|
if (!url) {
|
||||||
if (typeof url === 'string') return url;
|
return '';
|
||||||
if (typeof url === 'object' && url !== null && 'url' in url) return url.url as string;
|
}
|
||||||
|
|
||||||
|
if (typeof url === 'string') {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof url === 'object' && url !== null && 'url' in url) {
|
||||||
|
return url.url as string;
|
||||||
|
}
|
||||||
|
|
||||||
return String(url);
|
return String(url);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { Expense } from '@/types';
|
|
||||||
import { router } from '@inertiajs/react';
|
import { router } from '@inertiajs/react';
|
||||||
import expenseRoutes from '@/routes/expense';
|
import { useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import expenseRoutes from '@/routes/expense';
|
||||||
|
import type { Expense } from '@/types';
|
||||||
|
|
||||||
export function useExpenseIndex() {
|
export function useExpenseIndex() {
|
||||||
const [isFormOpen, setIsFormOpen] = useState(false);
|
const [isFormOpen, setIsFormOpen] = useState(false);
|
||||||
|
|||||||
@ -1,15 +1,15 @@
|
|||||||
import { Head } from '@inertiajs/react';
|
import { Head } from '@inertiajs/react';
|
||||||
import type { Expense } from '@/types';
|
import { Trash2, Plus } from 'lucide-react';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Trash2, X, Plus } from 'lucide-react';
|
|
||||||
import { DataTable } from '@/components/data-table';
|
import { DataTable } from '@/components/data-table';
|
||||||
|
import { DeleteConfirmation } from '@/components/modal/delete-confirmation';
|
||||||
|
import { ImagePreviewDialog } from '@/components/modal/image-preview';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import type { Expense } from '@/types';
|
||||||
|
|
||||||
import { useExpenseIndex } from './hooks/use-expense-index';
|
import { useExpenseIndex } from './hooks/use-expense-index';
|
||||||
import { getColumns } from './partials/columns';
|
import { getColumns } from './partials/columns';
|
||||||
import { ExpenseFormModal } from './partials/expense-form-modal';
|
import { ExpenseFormModal } from './partials/expense-form-modal';
|
||||||
import { DeleteConfirmation } from '@/components/modal/delete-confirmation';
|
|
||||||
import { ImagePreviewDialog } from '@/components/modal/image-preview';
|
|
||||||
|
|
||||||
export default function ExpenseIndex({ expenses }: { expenses: Expense[] }) {
|
export default function ExpenseIndex({ expenses }: { expenses: Expense[] }) {
|
||||||
const {
|
const {
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
import { ColumnDef } from '@tanstack/react-table';
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
import { Expense } from '@/types';
|
|
||||||
import { DataTableColumnHeader } from '@/components/data-table-column-header';
|
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Pencil, Trash2, ImagePlus } from 'lucide-react';
|
import { Pencil, Trash2, ImagePlus } from 'lucide-react';
|
||||||
|
import { DataTableColumnHeader } from '@/components/data-table-column-header';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
import type { Expense } from '@/types';
|
||||||
|
|
||||||
interface ColumnProps {
|
interface ColumnProps {
|
||||||
onEdit: (expense: Expense) => void;
|
onEdit: (expense: Expense) => void;
|
||||||
@ -25,6 +25,7 @@ export const getColumns = ({ onEdit, onDelete, onPreviewImage }: ColumnProps): C
|
|||||||
header: "Bukti",
|
header: "Bukti",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const url = row.original.proof_url;
|
const url = row.original.proof_url;
|
||||||
|
|
||||||
return url ? (
|
return url ? (
|
||||||
<button onClick={() => onPreviewImage(url)} className="block w-fit">
|
<button onClick={() => onPreviewImage(url)} className="block w-fit">
|
||||||
<img src={url} alt="Proof" className="h-10 w-10 object-cover rounded-md border hover:opacity-80 transition-opacity" />
|
<img src={url} alt="Proof" className="h-10 w-10 object-cover rounded-md border hover:opacity-80 transition-opacity" />
|
||||||
@ -77,6 +78,7 @@ export const getColumns = ({ onEdit, onDelete, onPreviewImage }: ColumnProps): C
|
|||||||
header: "Aksi",
|
header: "Aksi",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const expense = row.original;
|
const expense = row.original;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
|
|||||||
@ -1,3 +1,8 @@
|
|||||||
|
import { useForm, router } from '@inertiajs/react';
|
||||||
|
import { ImagePlus, X, Save, Loader } from 'lucide-react';
|
||||||
|
import { useEffect, useState, useRef } from 'react';
|
||||||
|
import { NumericFormat } from 'react-number-format';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@ -9,13 +14,8 @@ import {
|
|||||||
import { Field, FieldError, FieldGroup } from "@/components/ui/field"
|
import { Field, FieldError, FieldGroup } from "@/components/ui/field"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { Expense } from '@/types';
|
|
||||||
import { useForm, router } from '@inertiajs/react';
|
|
||||||
import { useEffect, useState, useRef } from 'react';
|
|
||||||
import expenseRoutes from '@/routes/expense';
|
import expenseRoutes from '@/routes/expense';
|
||||||
import { toast } from 'sonner';
|
import type { Expense } from '@/types';
|
||||||
import { NumericFormat } from 'react-number-format';
|
|
||||||
import { ImagePlus, X, Save, Loader } from 'lucide-react';
|
|
||||||
|
|
||||||
interface ExpenseFormModalProps {
|
interface ExpenseFormModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@ -63,6 +63,7 @@ export function ExpenseFormModal({ isOpen, onClose, expense }: ExpenseFormModalP
|
|||||||
|
|
||||||
const onImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const onImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
|
|
||||||
if (file) {
|
if (file) {
|
||||||
setData('image', file);
|
setData('image', file);
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
@ -76,6 +77,7 @@ export function ExpenseFormModal({ isOpen, onClose, expense }: ExpenseFormModalP
|
|||||||
const removeImage = () => {
|
const removeImage = () => {
|
||||||
setData('image', null);
|
setData('image', null);
|
||||||
setImagePreview(null);
|
setImagePreview(null);
|
||||||
|
|
||||||
if (fileInputRef.current) {
|
if (fileInputRef.current) {
|
||||||
fileInputRef.current.value = '';
|
fileInputRef.current.value = '';
|
||||||
}
|
}
|
||||||
@ -83,6 +85,7 @@ export function ExpenseFormModal({ isOpen, onClose, expense }: ExpenseFormModalP
|
|||||||
|
|
||||||
const onSubmit = (e: React.FormEvent) => {
|
const onSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (isEditing && expense) {
|
if (isEditing && expense) {
|
||||||
router.post(expenseRoutes.update(expense.id).url, {
|
router.post(expenseRoutes.update(expense.id).url, {
|
||||||
...data,
|
...data,
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { Payroll } from '@/types';
|
|
||||||
import { router } from '@inertiajs/react';
|
import { router } from '@inertiajs/react';
|
||||||
import payrollRoutes from '@/routes/payroll';
|
import { useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import payrollRoutes from '@/routes/payroll';
|
||||||
|
import type { Payroll } from '@/types';
|
||||||
|
|
||||||
export function usePayrollIndex() {
|
export function usePayrollIndex() {
|
||||||
const [isFormOpen, setIsFormOpen] = useState(false);
|
const [isFormOpen, setIsFormOpen] = useState(false);
|
||||||
|
|||||||
@ -1,26 +1,17 @@
|
|||||||
import { Head } from '@inertiajs/react';
|
import { Head } from '@inertiajs/react';
|
||||||
import type { Payroll } from '@/types';
|
import { Trash2, Sparkles } from 'lucide-react';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Trash2, Sparkles, Plus, X } from 'lucide-react';
|
|
||||||
import { DataTable } from '@/components/data-table';
|
import { DataTable } from '@/components/data-table';
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
import { DeleteConfirmation } from '@/components/modal/delete-confirmation';
|
||||||
AlertDialogCancel,
|
import { Badge } from '@/components/ui/badge';
|
||||||
AlertDialogContent,
|
import { Button } from '@/components/ui/button';
|
||||||
AlertDialogDescription,
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
AlertDialogFooter,
|
import type { Payroll } from '@/types';
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogMedia,
|
|
||||||
AlertDialogTitle,
|
|
||||||
} from "@/components/ui/alert-dialog"
|
|
||||||
|
|
||||||
import { usePayrollIndex } from './hooks/use-payroll-index';
|
import { usePayrollIndex } from './hooks/use-payroll-index';
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { getColumns } from './partials/columns';
|
import { getColumns } from './partials/columns';
|
||||||
import { PayrollFormModal } from './partials/payroll-form-modal';
|
import { PayrollFormModal } from './partials/payroll-form-modal';
|
||||||
import { DeleteConfirmation } from '@/components/modal/delete-confirmation';
|
|
||||||
|
|
||||||
export default function PayrollIndex({
|
export default function PayrollIndex({
|
||||||
payrolls,
|
payrolls,
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
import { ColumnDef } from '@tanstack/react-table';
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
import { Payroll } from '@/types';
|
import { PencilRuler, Trash2 } from 'lucide-react';
|
||||||
import { DataTableColumnHeader } from '@/components/data-table-column-header';
|
import { DataTableColumnHeader } from '@/components/data-table-column-header';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { PencilRuler, Trash2 } from 'lucide-react';
|
import type { Payroll } from '@/types';
|
||||||
|
|
||||||
interface ColumnProps {
|
interface ColumnProps {
|
||||||
onEdit: (payroll: Payroll) => void;
|
onEdit: (payroll: Payroll) => void;
|
||||||
@ -37,6 +37,7 @@ export const getColumns = ({ onEdit, onDelete, onTogglePaid }: ColumnProps): Col
|
|||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const payroll = row.original;
|
const payroll = row.original;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1 py-1 min-w-[200px]">
|
<div className="flex flex-col gap-1 py-1 min-w-[200px]">
|
||||||
<div className="flex justify-between text-[10px] text-muted-foreground uppercase tracking-tighter">
|
<div className="flex justify-between text-[10px] text-muted-foreground uppercase tracking-tighter">
|
||||||
@ -71,6 +72,7 @@ export const getColumns = ({ onEdit, onDelete, onTogglePaid }: ColumnProps): Col
|
|||||||
header: "Status",
|
header: "Status",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const payroll = row.original;
|
const payroll = row.original;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Switch
|
<Switch
|
||||||
@ -93,6 +95,7 @@ export const getColumns = ({ onEdit, onDelete, onTogglePaid }: ColumnProps): Col
|
|||||||
header: "Aksi",
|
header: "Aksi",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const payroll = row.original;
|
const payroll = row.original;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
|
|||||||
@ -1,3 +1,9 @@
|
|||||||
|
import { useForm } from '@inertiajs/react';
|
||||||
|
import { Loader, Plus, Trash2 } from 'lucide-react';
|
||||||
|
import { X, Save } from 'lucide-react';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { NumericFormat } from 'react-number-format';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@ -6,21 +12,14 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog"
|
} from "@/components/ui/dialog"
|
||||||
import { Field, FieldGroup } from "@/components/ui/field"
|
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from '@/components/ui/empty';
|
||||||
|
import { FieldGroup } from "@/components/ui/field"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { Switch } from '@/components/ui/switch';
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
||||||
import { Payroll } from '@/types';
|
|
||||||
import { useForm } from '@inertiajs/react';
|
|
||||||
import { useEffect } from 'react';
|
|
||||||
import payrollRoutes from '@/routes/payroll';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { Loader, Plus, Trash2 } from 'lucide-react';
|
|
||||||
import { NumericFormat } from 'react-number-format';
|
|
||||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||||
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from '@/components/ui/empty';
|
import { SelectTrigger } from '@/components/ui/select';
|
||||||
import { X, Save } from 'lucide-react';
|
import payrollRoutes from '@/routes/payroll';
|
||||||
|
import type { Payroll } from '@/types';
|
||||||
|
|
||||||
interface PayrollFormModalProps {
|
interface PayrollFormModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@ -76,6 +75,7 @@ export function PayrollFormModal({ isOpen, onClose, payroll }: PayrollFormModalP
|
|||||||
|
|
||||||
const onSubmit = (e: React.FormEvent) => {
|
const onSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (payroll) {
|
if (payroll) {
|
||||||
patch(payrollRoutes.update(payroll.id).url, {
|
patch(payrollRoutes.update(payroll.id).url, {
|
||||||
onSuccess: (response: any) => {
|
onSuccess: (response: any) => {
|
||||||
|
|||||||
@ -1,19 +1,22 @@
|
|||||||
import { Head, Link, useForm, router } from '@inertiajs/react';
|
import { Head, Link, router, useForm } from '@inertiajs/react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { ArrowLeft, Check, ImagePlus, Loader, Minus, Plus, Save, Search, ShoppingCart, Tag, Trash2, X } from 'lucide-react';
|
||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
|
import { NumericFormat } from 'react-number-format';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from '@/components/ui/empty';
|
||||||
import { Field, FieldError } from "@/components/ui/field";
|
import { Field, FieldError } from "@/components/ui/field";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import * as orderRoutes from '@/routes/order';
|
|
||||||
import React, { useMemo, useState } from 'react';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { Product, ProductPrice } from '@/types';
|
|
||||||
import { OrderItem } from '@/types/order';
|
|
||||||
import { Plus, Trash2, CalendarIcon, Minus, ShoppingCart, Search, ImagePlus, X, Tag, ArrowLeft, Check, Loader, Save } from 'lucide-react';
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
|
||||||
import { format } from 'date-fns';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@ -22,21 +25,15 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from '@/components/ui/empty';
|
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
SheetContent,
|
SheetContent,
|
||||||
SheetTrigger,
|
SheetTrigger
|
||||||
SheetClose,
|
|
||||||
} from "@/components/ui/sheet";
|
} from "@/components/ui/sheet";
|
||||||
import { NumericFormat } from 'react-number-format';
|
import { cn } from '@/lib/utils';
|
||||||
|
import * as orderRoutes from '@/routes/order';
|
||||||
|
import type { Product } from '@/types';
|
||||||
|
import type { OrderItem } from '@/types/order';
|
||||||
|
|
||||||
type CartItem = OrderItem & { id: number };
|
type CartItem = OrderItem & { id: number };
|
||||||
type EnumOption = { value: string, label: string };
|
type EnumOption = { value: string, label: string };
|
||||||
@ -60,6 +57,7 @@ export default function OrderCreate({ products, cartItems, orderStatus, orderCha
|
|||||||
const categories = useMemo(() => {
|
const categories = useMemo(() => {
|
||||||
const map = new Map<number, string>();
|
const map = new Map<number, string>();
|
||||||
products.forEach(p => p.categories?.forEach(c => map.set(c.id, c.name)));
|
products.forEach(p => p.categories?.forEach(c => map.set(c.id, c.name)));
|
||||||
|
|
||||||
return Array.from(map.entries()).map(([id, name]) => ({ id, name }));
|
return Array.from(map.entries()).map(([id, name]) => ({ id, name }));
|
||||||
}, [products]);
|
}, [products]);
|
||||||
|
|
||||||
@ -77,6 +75,7 @@ export default function OrderCreate({ products, cartItems, orderStatus, orderCha
|
|||||||
const filteredProducts = products.filter(p => {
|
const filteredProducts = products.filter(p => {
|
||||||
const matchesSearch = p.name.toLowerCase().includes(search.toLowerCase());
|
const matchesSearch = p.name.toLowerCase().includes(search.toLowerCase());
|
||||||
const matchesCategory = selectedCategory === 'all' || p.categories?.some(c => String(c.id) === selectedCategory);
|
const matchesCategory = selectedCategory === 'all' || p.categories?.some(c => String(c.id) === selectedCategory);
|
||||||
|
|
||||||
return matchesSearch && matchesCategory;
|
return matchesSearch && matchesCategory;
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -130,7 +129,10 @@ export default function OrderCreate({ products, cartItems, orderStatus, orderCha
|
|||||||
};
|
};
|
||||||
|
|
||||||
const updateCartQuantity = (itemId: number, qty: number) => {
|
const updateCartQuantity = (itemId: number, qty: number) => {
|
||||||
if (qty < 1) return;
|
if (qty < 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
router.patch(orderRoutes.updateCartItem(itemId).url, {
|
router.patch(orderRoutes.updateCartItem(itemId).url, {
|
||||||
qty
|
qty
|
||||||
}, {
|
}, {
|
||||||
@ -146,16 +148,20 @@ export default function OrderCreate({ products, cartItems, orderStatus, orderCha
|
|||||||
|
|
||||||
const confirmQty = () => {
|
const confirmQty = () => {
|
||||||
const val = parseInt(qtyInputValue);
|
const val = parseInt(qtyInputValue);
|
||||||
|
|
||||||
if (!isNaN(val) && val >= 1 && qtyDialogIndex !== null) {
|
if (!isNaN(val) && val >= 1 && qtyDialogIndex !== null) {
|
||||||
updateCartQuantity(qtyDialogIndex, val);
|
updateCartQuantity(qtyDialogIndex, val);
|
||||||
}
|
}
|
||||||
|
|
||||||
setQtyDialogIndex(null);
|
setQtyDialogIndex(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSubmit = (e: React.FormEvent) => {
|
const onSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (cartItems.length === 0) {
|
if (cartItems.length === 0) {
|
||||||
toast.error('Pilih minimal satu produk');
|
toast.error('Pilih minimal satu produk');
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -187,6 +193,7 @@ export default function OrderCreate({ products, cartItems, orderStatus, orderCha
|
|||||||
|
|
||||||
const PriceTypeLabel = ({ type }: { type: string }) => {
|
const PriceTypeLabel = ({ type }: { type: string }) => {
|
||||||
const option = priceTypes.find(opt => opt.value === type);
|
const option = priceTypes.find(opt => opt.value === type);
|
||||||
|
|
||||||
return option ? option.label : type;
|
return option ? option.label : type;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -526,7 +533,10 @@ export default function OrderCreate({ products, cartItems, orderStatus, orderCha
|
|||||||
cartItem ? "text-foreground hover:text-primary cursor-pointer" : "text-muted-foreground/40 cursor-default"
|
cartItem ? "text-foreground hover:text-primary cursor-pointer" : "text-muted-foreground/40 cursor-default"
|
||||||
)}
|
)}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (!cartItem) return;
|
if (!cartItem) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
openQtyDialog(cartItem, e);
|
openQtyDialog(cartItem, e);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -600,7 +610,11 @@ export default function OrderCreate({ products, cartItems, orderStatus, orderCha
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quantity Input Dialog */}
|
{/* Quantity Input Dialog */}
|
||||||
<Dialog open={qtyDialogIndex !== null} onOpenChange={(open) => { if (!open) setQtyDialogIndex(null); }}>
|
<Dialog open={qtyDialogIndex !== null} onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setQtyDialogIndex(null);
|
||||||
|
}
|
||||||
|
}}>
|
||||||
<DialogContent className="max-w-xs">
|
<DialogContent className="max-w-xs">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Ubah Jumlah</DialogTitle>
|
<DialogTitle>Ubah Jumlah</DialogTitle>
|
||||||
@ -614,7 +628,11 @@ export default function OrderCreate({ products, cartItems, orderStatus, orderCha
|
|||||||
min="1"
|
min="1"
|
||||||
value={qtyInputValue}
|
value={qtyInputValue}
|
||||||
onChange={e => setQtyInputValue(e.target.value)}
|
onChange={e => setQtyInputValue(e.target.value)}
|
||||||
onKeyDown={e => { if (e.key === 'Enter') confirmQty(); }}
|
onKeyDown={e => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
confirmQty();
|
||||||
|
}
|
||||||
|
}}
|
||||||
className="text-lg font-bold"
|
className="text-lg font-bold"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,19 +1,21 @@
|
|||||||
import { Head, Link, useForm, router } from '@inertiajs/react';
|
import { Head, Link, useForm } from '@inertiajs/react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { ArrowLeft, Check, ImagePlus, Loader, Minus, Plus, Save, Search, ShoppingCart, Tag, Trash2, X } from 'lucide-react';
|
||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
|
import { NumericFormat } from 'react-number-format';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
import { Field, FieldError } from "@/components/ui/field";
|
import { Field, FieldError } from "@/components/ui/field";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import * as orderRoutes from '@/routes/order';
|
|
||||||
import React, { useMemo, useState } from 'react';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { Product, ProductPrice } from '@/types';
|
|
||||||
import { Order, OrderItem } from '@/types/order';
|
|
||||||
import { Plus, Trash2, CalendarIcon, Minus, ShoppingCart, Search, ImagePlus, X, Tag, ArrowLeft, Check, Save, Loader } from 'lucide-react';
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
|
||||||
import { format } from 'date-fns';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@ -22,21 +24,15 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from '@/components/ui/empty';
|
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
SheetContent,
|
SheetContent,
|
||||||
SheetTrigger,
|
SheetTrigger
|
||||||
SheetClose,
|
|
||||||
} from "@/components/ui/sheet";
|
} from "@/components/ui/sheet";
|
||||||
import { NumericFormat } from 'react-number-format';
|
import { cn } from '@/lib/utils';
|
||||||
|
import * as orderRoutes from '@/routes/order';
|
||||||
|
import type { Product } from '@/types';
|
||||||
|
import type { Order } from '@/types/order';
|
||||||
|
|
||||||
type EnumOption = { value: string, label: string };
|
type EnumOption = { value: string, label: string };
|
||||||
|
|
||||||
@ -57,6 +53,7 @@ export default function OrderEdit({ order, products, orderStatus, orderChannels,
|
|||||||
const categories = useMemo(() => {
|
const categories = useMemo(() => {
|
||||||
const map = new Map<number, string>();
|
const map = new Map<number, string>();
|
||||||
products.forEach(p => p.categories?.forEach(c => map.set(c.id, c.name)));
|
products.forEach(p => p.categories?.forEach(c => map.set(c.id, c.name)));
|
||||||
|
|
||||||
return Array.from(map.entries()).map(([id, name]) => ({ id, name }));
|
return Array.from(map.entries()).map(([id, name]) => ({ id, name }));
|
||||||
}, [products]);
|
}, [products]);
|
||||||
|
|
||||||
@ -81,6 +78,7 @@ export default function OrderEdit({ order, products, orderStatus, orderChannels,
|
|||||||
const filteredProducts = products.filter(p => {
|
const filteredProducts = products.filter(p => {
|
||||||
const matchesSearch = p.name.toLowerCase().includes(search.toLowerCase());
|
const matchesSearch = p.name.toLowerCase().includes(search.toLowerCase());
|
||||||
const matchesCategory = selectedCategory === 'all' || p.categories?.some(c => String(c.id) === selectedCategory);
|
const matchesCategory = selectedCategory === 'all' || p.categories?.some(c => String(c.id) === selectedCategory);
|
||||||
|
|
||||||
return matchesSearch && matchesCategory;
|
return matchesSearch && matchesCategory;
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -118,22 +116,30 @@ export default function OrderEdit({ order, products, orderStatus, orderChannels,
|
|||||||
const decreaseQuantity = (productId: number, priceType: string, e: React.MouseEvent) => {
|
const decreaseQuantity = (productId: number, priceType: string, e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const index = findItemIndex(productId, priceType);
|
const index = findItemIndex(productId, priceType);
|
||||||
if (index === -1) return;
|
|
||||||
|
if (index === -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const newItems = [...data.items];
|
const newItems = [...data.items];
|
||||||
|
|
||||||
if (newItems[index].qty <= 1) {
|
if (newItems[index].qty <= 1) {
|
||||||
newItems.splice(index, 1);
|
newItems.splice(index, 1);
|
||||||
} else {
|
} else {
|
||||||
newItems[index].qty -= 1;
|
newItems[index].qty -= 1;
|
||||||
newItems[index].total = newItems[index].qty * newItems[index].price;
|
newItems[index].total = newItems[index].qty * newItems[index].price;
|
||||||
}
|
}
|
||||||
|
|
||||||
setData('items', newItems);
|
setData('items', newItems);
|
||||||
};
|
};
|
||||||
|
|
||||||
const increaseQuantity = (productId: number, priceType: string, e: React.MouseEvent) => {
|
const increaseQuantity = (productId: number, priceType: string, e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const product = products.find(p => p.id === productId);
|
const product = products.find(p => p.id === productId);
|
||||||
if (product) addToCart(product, priceType);
|
|
||||||
|
if (product) {
|
||||||
|
addToCart(product, priceType);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeFromCart = (productId: number, priceType: string) => {
|
const removeFromCart = (productId: number, priceType: string) => {
|
||||||
@ -142,8 +148,12 @@ export default function OrderEdit({ order, products, orderStatus, orderChannels,
|
|||||||
};
|
};
|
||||||
|
|
||||||
const updateCartQuantity = (productId: number, priceType: string, qty: number) => {
|
const updateCartQuantity = (productId: number, priceType: string, qty: number) => {
|
||||||
if (qty < 1) return;
|
if (qty < 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const index = findItemIndex(productId, priceType);
|
const index = findItemIndex(productId, priceType);
|
||||||
|
|
||||||
if (index > -1) {
|
if (index > -1) {
|
||||||
const newItems = [...data.items];
|
const newItems = [...data.items];
|
||||||
newItems[index].qty = qty;
|
newItems[index].qty = qty;
|
||||||
@ -160,16 +170,20 @@ export default function OrderEdit({ order, products, orderStatus, orderChannels,
|
|||||||
|
|
||||||
const confirmQty = () => {
|
const confirmQty = () => {
|
||||||
const val = parseInt(qtyInputValue);
|
const val = parseInt(qtyInputValue);
|
||||||
|
|
||||||
if (!isNaN(val) && val >= 1 && qtyDialogIndex) {
|
if (!isNaN(val) && val >= 1 && qtyDialogIndex) {
|
||||||
updateCartQuantity(qtyDialogIndex.product_id, qtyDialogIndex.price_type, val);
|
updateCartQuantity(qtyDialogIndex.product_id, qtyDialogIndex.price_type, val);
|
||||||
}
|
}
|
||||||
|
|
||||||
setQtyDialogIndex(null);
|
setQtyDialogIndex(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSubmit = (e: React.FormEvent) => {
|
const onSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (data.items.length === 0) {
|
if (data.items.length === 0) {
|
||||||
toast.error('Pilih minimal satu produk');
|
toast.error('Pilih minimal satu produk');
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -190,6 +204,7 @@ export default function OrderEdit({ order, products, orderStatus, orderChannels,
|
|||||||
|
|
||||||
const PriceTypeLabel = ({ type }: { type: string }) => {
|
const PriceTypeLabel = ({ type }: { type: string }) => {
|
||||||
const option = priceTypes.find(opt => opt.value === type);
|
const option = priceTypes.find(opt => opt.value === type);
|
||||||
|
|
||||||
return option ? option.label : type;
|
return option ? option.label : type;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -522,7 +537,10 @@ export default function OrderEdit({ order, products, orderStatus, orderChannels,
|
|||||||
cartItem ? "text-foreground hover:text-primary cursor-pointer" : "text-muted-foreground/40 cursor-default"
|
cartItem ? "text-foreground hover:text-primary cursor-pointer" : "text-muted-foreground/40 cursor-default"
|
||||||
)}
|
)}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (!cartItem) return;
|
if (!cartItem) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
openQtyDialog(cartItem, e);
|
openQtyDialog(cartItem, e);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -586,7 +604,11 @@ export default function OrderEdit({ order, products, orderStatus, orderChannels,
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quantity Input Dialog */}
|
{/* Quantity Input Dialog */}
|
||||||
<Dialog open={qtyDialogIndex !== null} onOpenChange={(open) => { if (!open) setQtyDialogIndex(null); }}>
|
<Dialog open={qtyDialogIndex !== null} onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setQtyDialogIndex(null);
|
||||||
|
}
|
||||||
|
}}>
|
||||||
<DialogContent className="max-w-xs">
|
<DialogContent className="max-w-xs">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Ubah Jumlah</DialogTitle>
|
<DialogTitle>Ubah Jumlah</DialogTitle>
|
||||||
@ -600,7 +622,11 @@ export default function OrderEdit({ order, products, orderStatus, orderChannels,
|
|||||||
min="1"
|
min="1"
|
||||||
value={qtyInputValue}
|
value={qtyInputValue}
|
||||||
onChange={e => setQtyInputValue(e.target.value)}
|
onChange={e => setQtyInputValue(e.target.value)}
|
||||||
onKeyDown={e => { if (e.key === 'Enter') confirmQty(); }}
|
onKeyDown={e => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
confirmQty();
|
||||||
|
}
|
||||||
|
}}
|
||||||
className="text-lg font-bold"
|
className="text-lg font-bold"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { Order } from '@/types/order';
|
|
||||||
import { router } from '@inertiajs/react';
|
import { router } from '@inertiajs/react';
|
||||||
import * as orderRoutes from '@/routes/order';
|
import { useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import * as orderRoutes from '@/routes/order';
|
||||||
|
import type { Order } from '@/types/order';
|
||||||
|
|
||||||
export function useOrderIndex() {
|
export function useOrderIndex() {
|
||||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
|
|||||||
@ -1,44 +1,33 @@
|
|||||||
import { Head, Link } from '@inertiajs/react';
|
import { Head, Link } from '@inertiajs/react';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { id } from 'date-fns/locale';
|
||||||
|
import { Bluetooth, Plus, Printer, Trash2, Unplug, Usb } from 'lucide-react';
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import type { Order } from '@/types/order';
|
import { toast } from 'sonner';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Trash2, ShoppingBag, Printer, Bluetooth, Usb, Unplug, Plus, X, ArrowLeft } from 'lucide-react';
|
|
||||||
import { DataTable } from '@/components/data-table';
|
import { DataTable } from '@/components/data-table';
|
||||||
|
import { DeleteConfirmation } from '@/components/modal/delete-confirmation';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuLabel,
|
DropdownMenuLabel,
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
DropdownMenuRadioGroup,
|
DropdownMenuRadioGroup,
|
||||||
DropdownMenuRadioItem,
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
DropdownMenuSub,
|
DropdownMenuSub,
|
||||||
DropdownMenuSubTrigger,
|
|
||||||
DropdownMenuSubContent,
|
DropdownMenuSubContent,
|
||||||
} from "@/components/ui/dropdown-menu"
|
DropdownMenuSubTrigger,
|
||||||
import {
|
DropdownMenuTrigger,
|
||||||
AlertDialog,
|
} from "@/components/ui/dropdown-menu";
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogMedia,
|
|
||||||
AlertDialogTitle,
|
|
||||||
} from "@/components/ui/alert-dialog"
|
|
||||||
|
|
||||||
import * as orderRoutes from '@/routes/order';
|
|
||||||
import { useOrderIndex } from './hooks/use-order-index';
|
|
||||||
import { getColumns } from './partials/columns';
|
|
||||||
import { usePrinter } from '@/hooks/use-printer';
|
import { usePrinter } from '@/hooks/use-printer';
|
||||||
import { EscPosEncoder } from '@/lib/esc-pos-encoder';
|
import { EscPosEncoder } from '@/lib/esc-pos-encoder';
|
||||||
import { format } from 'date-fns';
|
import * as orderRoutes from '@/routes/order';
|
||||||
import { id } from 'date-fns/locale';
|
import type { Order } from '@/types/order';
|
||||||
import { toast } from 'sonner';
|
import { useOrderIndex } from './hooks/use-order-index';
|
||||||
import { DeleteConfirmation } from '@/components/modal/delete-confirmation';
|
import { getColumns } from './partials/columns';
|
||||||
|
|
||||||
export default function OrderIndex({ orders }: { orders: Order[] }) {
|
export default function OrderIndex({ orders }: { orders: Order[] }) {
|
||||||
const {
|
const {
|
||||||
@ -70,6 +59,7 @@ export default function OrderIndex({ orders }: { orders: Order[] }) {
|
|||||||
const handlePrint = useCallback(async (order: Order) => {
|
const handlePrint = useCallback(async (order: Order) => {
|
||||||
if (!isConnected) {
|
if (!isConnected) {
|
||||||
toast.error('Hubungkan printer terlebih dahulu');
|
toast.error('Hubungkan printer terlebih dahulu');
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -93,7 +83,10 @@ export default function OrderIndex({ orders }: { orders: Order[] }) {
|
|||||||
.line(line);
|
.line(line);
|
||||||
|
|
||||||
order.items?.forEach((item, index) => {
|
order.items?.forEach((item, index) => {
|
||||||
if (index > 0) result.line();
|
if (index > 0) {
|
||||||
|
result.line();
|
||||||
|
}
|
||||||
|
|
||||||
result.line(item.product?.name || 'Produk');
|
result.line(item.product?.name || 'Produk');
|
||||||
const qtyPrice = `${item.qty} x ${item.price_formatted}`;
|
const qtyPrice = `${item.qty} x ${item.price_formatted}`;
|
||||||
const subtotal = item.total_formatted;
|
const subtotal = item.total_formatted;
|
||||||
@ -104,6 +97,7 @@ export default function OrderIndex({ orders }: { orders: Order[] }) {
|
|||||||
result.line(line);
|
result.line(line);
|
||||||
|
|
||||||
const discountAmount = Number(order.discount || 0);
|
const discountAmount = Number(order.discount || 0);
|
||||||
|
|
||||||
if (discountAmount > 0) {
|
if (discountAmount > 0) {
|
||||||
const discountLabel = 'Diskon:';
|
const discountLabel = 'Diskon:';
|
||||||
const discountVal = `- Rp ${discountAmount.toLocaleString('id-ID')}`;
|
const discountVal = `- Rp ${discountAmount.toLocaleString('id-ID')}`;
|
||||||
|
|||||||
@ -1,14 +1,14 @@
|
|||||||
import { ColumnDef } from '@tanstack/react-table';
|
|
||||||
import { Order } from '@/types/order';
|
|
||||||
import { DataTableColumnHeader } from '@/components/data-table-column-header';
|
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Pencil, Trash2, ShoppingBag, Printer } from 'lucide-react';
|
|
||||||
import { Link } from '@inertiajs/react';
|
import { Link } from '@inertiajs/react';
|
||||||
import * as orderRoutes from '@/routes/order';
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { id } from 'date-fns/locale';
|
import { id } from 'date-fns/locale';
|
||||||
|
import { Pencil, Trash2, Printer } from 'lucide-react';
|
||||||
|
import { DataTableColumnHeader } from '@/components/data-table-column-header';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
import * as orderRoutes from '@/routes/order';
|
||||||
|
import type { Order } from '@/types/order';
|
||||||
|
|
||||||
interface ColumnProps {
|
interface ColumnProps {
|
||||||
onDelete: (order: Order) => void;
|
onDelete: (order: Order) => void;
|
||||||
@ -100,6 +100,7 @@ export const getColumns = ({ onDelete, onPrint }: ColumnProps): ColumnDef<Order>
|
|||||||
header: "Aksi",
|
header: "Aksi",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const order = row.original;
|
const order = row.original;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
|
|||||||
@ -1,19 +1,26 @@
|
|||||||
import { Head, Link, useForm, router } from '@inertiajs/react';
|
import { Head, Link, useForm, router } from '@inertiajs/react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { format } from 'date-fns';
|
||||||
|
import { Plus, Trash2, CalendarIcon, Minus, ShoppingCart, Search, ImagePlus, X, Loader, Save, ArrowLeft } from 'lucide-react';
|
||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Calendar } from '@/components/ui/calendar';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from '@/components/ui/empty';
|
||||||
import { Field, FieldError } from "@/components/ui/field";
|
import { Field, FieldError } from "@/components/ui/field";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import purchaseRoutes from '@/routes/purchase';
|
|
||||||
import React, { useMemo, useState } from 'react';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { Product, ProductPrice } from '@/types';
|
|
||||||
import { Plus, Trash2, CalendarIcon, Minus, ShoppingCart, Search, ImagePlus, X, Loader, Save, ArrowLeft } from 'lucide-react';
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
import { Calendar } from '@/components/ui/calendar';
|
import type { Product, ProductPrice } from '@/types';
|
||||||
import { format } from 'date-fns';
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@ -22,20 +29,13 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogFooter,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from '@/components/ui/empty';
|
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
SheetContent,
|
SheetContent,
|
||||||
SheetTrigger,
|
SheetTrigger,
|
||||||
SheetClose,
|
SheetClose,
|
||||||
} from "@/components/ui/sheet";
|
} from "@/components/ui/sheet";
|
||||||
|
import purchaseRoutes from '@/routes/purchase';
|
||||||
|
|
||||||
type CartItem = { product_id: number; quantity: number; unit_price: number; product: Product };
|
type CartItem = { product_id: number; quantity: number; unit_price: number; product: Product };
|
||||||
|
|
||||||
@ -57,6 +57,7 @@ export default function PurchaseCreate({ products, cartItems }: { products: Prod
|
|||||||
const categories = useMemo(() => {
|
const categories = useMemo(() => {
|
||||||
const map = new Map<number, string>();
|
const map = new Map<number, string>();
|
||||||
products.forEach(p => p.categories?.forEach(c => map.set(c.id, c.name)));
|
products.forEach(p => p.categories?.forEach(c => map.set(c.id, c.name)));
|
||||||
|
|
||||||
return Array.from(map.entries()).map(([id, name]) => ({ id, name }));
|
return Array.from(map.entries()).map(([id, name]) => ({ id, name }));
|
||||||
}, [products]);
|
}, [products]);
|
||||||
|
|
||||||
@ -69,6 +70,7 @@ export default function PurchaseCreate({ products, cartItems }: { products: Prod
|
|||||||
const filteredProducts = products.filter(p => {
|
const filteredProducts = products.filter(p => {
|
||||||
const matchesSearch = p.name.toLowerCase().includes(search.toLowerCase());
|
const matchesSearch = p.name.toLowerCase().includes(search.toLowerCase());
|
||||||
const matchesCategory = selectedCategory === 'all' || p.categories?.some(c => String(c.id) === selectedCategory);
|
const matchesCategory = selectedCategory === 'all' || p.categories?.some(c => String(c.id) === selectedCategory);
|
||||||
|
|
||||||
return matchesSearch && matchesCategory;
|
return matchesSearch && matchesCategory;
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -89,7 +91,10 @@ export default function PurchaseCreate({ products, cartItems }: { products: Prod
|
|||||||
const decreaseQuantity = (product: Product, e: React.MouseEvent) => {
|
const decreaseQuantity = (product: Product, e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const item = getCartItem(product.id);
|
const item = getCartItem(product.id);
|
||||||
if (!item) return;
|
|
||||||
|
if (!item) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
router.post(purchaseRoutes.addToCart().url, {
|
router.post(purchaseRoutes.addToCart().url, {
|
||||||
product_id: product.id,
|
product_id: product.id,
|
||||||
@ -112,7 +117,10 @@ export default function PurchaseCreate({ products, cartItems }: { products: Prod
|
|||||||
};
|
};
|
||||||
|
|
||||||
const updateCartQuantity = (itemId: number, quantity: number) => {
|
const updateCartQuantity = (itemId: number, quantity: number) => {
|
||||||
if (quantity < 1) return;
|
if (quantity < 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
router.patch(purchaseRoutes.updateCartItem(itemId).url, {
|
router.patch(purchaseRoutes.updateCartItem(itemId).url, {
|
||||||
quantity
|
quantity
|
||||||
}, {
|
}, {
|
||||||
@ -128,16 +136,20 @@ export default function PurchaseCreate({ products, cartItems }: { products: Prod
|
|||||||
|
|
||||||
const confirmQty = () => {
|
const confirmQty = () => {
|
||||||
const val = parseInt(qtyInputValue);
|
const val = parseInt(qtyInputValue);
|
||||||
|
|
||||||
if (!isNaN(val) && val >= 1 && qtyDialogIndex !== null) {
|
if (!isNaN(val) && val >= 1 && qtyDialogIndex !== null) {
|
||||||
updateCartQuantity(qtyDialogIndex, val);
|
updateCartQuantity(qtyDialogIndex, val);
|
||||||
}
|
}
|
||||||
|
|
||||||
setQtyDialogIndex(null);
|
setQtyDialogIndex(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSubmit = (e: React.FormEvent) => {
|
const onSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (cartItems.length === 0) {
|
if (cartItems.length === 0) {
|
||||||
toast.error('Pilih minimal satu produk');
|
toast.error('Pilih minimal satu produk');
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -267,6 +279,7 @@ export default function PurchaseCreate({ products, cartItems }: { products: Prod
|
|||||||
} else {
|
} else {
|
||||||
setData('purchase_date', '');
|
setData('purchase_date', '');
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsCalendarOpen(false);
|
setIsCalendarOpen(false);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -429,7 +442,10 @@ export default function PurchaseCreate({ products, cartItems }: { products: Prod
|
|||||||
cartItem ? "text-foreground hover:text-primary cursor-pointer" : "text-muted-foreground/40 cursor-default"
|
cartItem ? "text-foreground hover:text-primary cursor-pointer" : "text-muted-foreground/40 cursor-default"
|
||||||
)}
|
)}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (!cartItem) return;
|
if (!cartItem) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
openQtyDialog(cartItem, e);
|
openQtyDialog(cartItem, e);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -569,6 +585,7 @@ export default function PurchaseCreate({ products, cartItems }: { products: Prod
|
|||||||
} else {
|
} else {
|
||||||
setData('purchase_date', '');
|
setData('purchase_date', '');
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsCalendarOpen(false);
|
setIsCalendarOpen(false);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -786,7 +803,11 @@ export default function PurchaseCreate({ products, cartItems }: { products: Prod
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quantity Input Dialog */}
|
{/* Quantity Input Dialog */}
|
||||||
<Dialog open={qtyDialogIndex !== null} onOpenChange={(open) => { if (!open) setQtyDialogIndex(null); }}>
|
<Dialog open={qtyDialogIndex !== null} onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setQtyDialogIndex(null);
|
||||||
|
}
|
||||||
|
}}>
|
||||||
<DialogContent className="max-w-xs">
|
<DialogContent className="max-w-xs">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Ubah Jumlah</DialogTitle>
|
<DialogTitle>Ubah Jumlah</DialogTitle>
|
||||||
@ -800,7 +821,11 @@ export default function PurchaseCreate({ products, cartItems }: { products: Prod
|
|||||||
min="1"
|
min="1"
|
||||||
value={qtyInputValue}
|
value={qtyInputValue}
|
||||||
onChange={e => setQtyInputValue(e.target.value)}
|
onChange={e => setQtyInputValue(e.target.value)}
|
||||||
onKeyDown={e => { if (e.key === 'Enter') confirmQty(); }}
|
onKeyDown={e => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
confirmQty();
|
||||||
|
}
|
||||||
|
}}
|
||||||
className="text-lg font-bold"
|
className="text-lg font-bold"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,19 +1,26 @@
|
|||||||
import { Head, Link, useForm, router } from '@inertiajs/react';
|
import { Head, Link, useForm } from '@inertiajs/react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { format } from 'date-fns';
|
||||||
|
import { Plus, Trash2, CalendarIcon, Minus, ShoppingCart, Search, ImagePlus, X, Loader, Save, ArrowLeft } from 'lucide-react';
|
||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Calendar } from '@/components/ui/calendar';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from '@/components/ui/empty';
|
||||||
import { Field, FieldError } from "@/components/ui/field";
|
import { Field, FieldError } from "@/components/ui/field";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import purchaseRoutes from '@/routes/purchase';
|
|
||||||
import React, { useMemo, useState } from 'react';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { Product, ProductPrice, Purchase } from '@/types';
|
|
||||||
import { Plus, Trash2, CalendarIcon, Minus, ShoppingCart, Search, ImagePlus, X, Loader, Save, ArrowLeft } from 'lucide-react';
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
import { Calendar } from '@/components/ui/calendar';
|
import type { Product, ProductPrice, Purchase } from '@/types';
|
||||||
import { format } from 'date-fns';
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@ -22,22 +29,13 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogFooter,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from '@/components/ui/empty';
|
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
SheetContent,
|
SheetContent,
|
||||||
SheetHeader,
|
|
||||||
SheetTitle,
|
|
||||||
SheetTrigger,
|
SheetTrigger,
|
||||||
SheetClose,
|
SheetClose,
|
||||||
} from "@/components/ui/sheet";
|
} from "@/components/ui/sheet";
|
||||||
|
import purchaseRoutes from '@/routes/purchase';
|
||||||
|
|
||||||
type CartItem = { product_id: number; quantity: number; unit_price: number; product: Product };
|
type CartItem = { product_id: number; quantity: number; unit_price: number; product: Product };
|
||||||
|
|
||||||
@ -59,6 +57,7 @@ export default function PurchaseEdit({ purchase, products }: { purchase: Purchas
|
|||||||
const categories = useMemo(() => {
|
const categories = useMemo(() => {
|
||||||
const map = new Map<number, string>();
|
const map = new Map<number, string>();
|
||||||
products.forEach(p => p.categories?.forEach(c => map.set(c.id, c.name)));
|
products.forEach(p => p.categories?.forEach(c => map.set(c.id, c.name)));
|
||||||
|
|
||||||
return Array.from(map.entries()).map(([id, name]) => ({ id, name }));
|
return Array.from(map.entries()).map(([id, name]) => ({ id, name }));
|
||||||
}, [products]);
|
}, [products]);
|
||||||
|
|
||||||
@ -76,6 +75,7 @@ export default function PurchaseEdit({ purchase, products }: { purchase: Purchas
|
|||||||
const filteredProducts = products.filter(p => {
|
const filteredProducts = products.filter(p => {
|
||||||
const matchesSearch = p.name.toLowerCase().includes(search.toLowerCase());
|
const matchesSearch = p.name.toLowerCase().includes(search.toLowerCase());
|
||||||
const matchesCategory = selectedCategory === 'all' || p.categories?.some(c => String(c.id) === selectedCategory);
|
const matchesCategory = selectedCategory === 'all' || p.categories?.some(c => String(c.id) === selectedCategory);
|
||||||
|
|
||||||
return matchesSearch && matchesCategory;
|
return matchesSearch && matchesCategory;
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -101,14 +101,19 @@ export default function PurchaseEdit({ purchase, products }: { purchase: Purchas
|
|||||||
const decreaseQuantity = (product: Product, e: React.MouseEvent) => {
|
const decreaseQuantity = (product: Product, e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const existingIndex = data.items.findIndex(i => i.product_id === product.id);
|
const existingIndex = data.items.findIndex(i => i.product_id === product.id);
|
||||||
if (existingIndex === -1) return;
|
|
||||||
|
if (existingIndex === -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const newItems = [...data.items];
|
const newItems = [...data.items];
|
||||||
|
|
||||||
if (newItems[existingIndex].quantity <= 1) {
|
if (newItems[existingIndex].quantity <= 1) {
|
||||||
newItems.splice(existingIndex, 1);
|
newItems.splice(existingIndex, 1);
|
||||||
} else {
|
} else {
|
||||||
newItems[existingIndex].quantity -= 1;
|
newItems[existingIndex].quantity -= 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
setData('items', newItems);
|
setData('items', newItems);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -123,9 +128,13 @@ export default function PurchaseEdit({ purchase, products }: { purchase: Purchas
|
|||||||
};
|
};
|
||||||
|
|
||||||
const updateCartQuantity = (productId: number, quantity: number) => {
|
const updateCartQuantity = (productId: number, quantity: number) => {
|
||||||
if (quantity < 1) return;
|
if (quantity < 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const newItems = [...data.items];
|
const newItems = [...data.items];
|
||||||
const index = newItems.findIndex(i => i.product_id === productId);
|
const index = newItems.findIndex(i => i.product_id === productId);
|
||||||
|
|
||||||
if (index > -1) {
|
if (index > -1) {
|
||||||
newItems[index].quantity = quantity;
|
newItems[index].quantity = quantity;
|
||||||
setData('items', newItems);
|
setData('items', newItems);
|
||||||
@ -141,16 +150,20 @@ export default function PurchaseEdit({ purchase, products }: { purchase: Purchas
|
|||||||
|
|
||||||
const confirmQty = () => {
|
const confirmQty = () => {
|
||||||
const val = parseInt(qtyInputValue);
|
const val = parseInt(qtyInputValue);
|
||||||
|
|
||||||
if (!isNaN(val) && val >= 1 && qtyDialogIndex !== null) {
|
if (!isNaN(val) && val >= 1 && qtyDialogIndex !== null) {
|
||||||
updateCartQuantity(qtyDialogIndex, val);
|
updateCartQuantity(qtyDialogIndex, val);
|
||||||
}
|
}
|
||||||
|
|
||||||
setQtyDialogIndex(null);
|
setQtyDialogIndex(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSubmit = (e: React.FormEvent) => {
|
const onSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (data.items.length === 0) {
|
if (data.items.length === 0) {
|
||||||
toast.error('Pilih minimal satu produk');
|
toast.error('Pilih minimal satu produk');
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -271,6 +284,7 @@ export default function PurchaseEdit({ purchase, products }: { purchase: Purchas
|
|||||||
} else {
|
} else {
|
||||||
setData('purchase_date', '');
|
setData('purchase_date', '');
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsCalendarOpen(false);
|
setIsCalendarOpen(false);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -433,7 +447,10 @@ export default function PurchaseEdit({ purchase, products }: { purchase: Purchas
|
|||||||
cartItem ? "text-foreground hover:text-primary cursor-pointer" : "text-muted-foreground/40 cursor-default"
|
cartItem ? "text-foreground hover:text-primary cursor-pointer" : "text-muted-foreground/40 cursor-default"
|
||||||
)}
|
)}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (!cartItem) return;
|
if (!cartItem) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
openQtyDialog(cartItem, e);
|
openQtyDialog(cartItem, e);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -573,6 +590,7 @@ export default function PurchaseEdit({ purchase, products }: { purchase: Purchas
|
|||||||
} else {
|
} else {
|
||||||
setData('purchase_date', '');
|
setData('purchase_date', '');
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsCalendarOpen(false);
|
setIsCalendarOpen(false);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -790,7 +808,11 @@ export default function PurchaseEdit({ purchase, products }: { purchase: Purchas
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Quantity Input Dialog */}
|
{/* Quantity Input Dialog */}
|
||||||
<Dialog open={qtyDialogIndex !== null} onOpenChange={(open) => { if (!open) setQtyDialogIndex(null); }}>
|
<Dialog open={qtyDialogIndex !== null} onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setQtyDialogIndex(null);
|
||||||
|
}
|
||||||
|
}}>
|
||||||
<DialogContent className="max-w-xs">
|
<DialogContent className="max-w-xs">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Ubah Jumlah</DialogTitle>
|
<DialogTitle>Ubah Jumlah</DialogTitle>
|
||||||
@ -804,7 +826,11 @@ export default function PurchaseEdit({ purchase, products }: { purchase: Purchas
|
|||||||
min="1"
|
min="1"
|
||||||
value={qtyInputValue}
|
value={qtyInputValue}
|
||||||
onChange={e => setQtyInputValue(e.target.value)}
|
onChange={e => setQtyInputValue(e.target.value)}
|
||||||
onKeyDown={e => { if (e.key === 'Enter') confirmQty(); }}
|
onKeyDown={e => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
confirmQty();
|
||||||
|
}
|
||||||
|
}}
|
||||||
className="text-lg font-bold"
|
className="text-lg font-bold"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { Purchase } from '@/types';
|
|
||||||
import { router } from '@inertiajs/react';
|
import { router } from '@inertiajs/react';
|
||||||
import purchaseRoutes from '@/routes/purchase';
|
import { useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import purchaseRoutes from '@/routes/purchase';
|
||||||
|
import type { Purchase } from '@/types';
|
||||||
|
|
||||||
export function usePurchaseIndex() {
|
export function usePurchaseIndex() {
|
||||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
|
|||||||
@ -1,28 +1,18 @@
|
|||||||
import { Head, Link } from '@inertiajs/react';
|
import { Head, Link } from '@inertiajs/react';
|
||||||
import type { Purchase } from '@/types';
|
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Plus, Trash2 } from 'lucide-react';
|
import { Plus, Trash2 } from 'lucide-react';
|
||||||
import { format } from 'date-fns';
|
|
||||||
import { id } from 'date-fns/locale';
|
|
||||||
import { DataTable } from '@/components/data-table';
|
import { DataTable } from '@/components/data-table';
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogMedia,
|
|
||||||
AlertDialogTitle,
|
|
||||||
} from "@/components/ui/alert-dialog"
|
|
||||||
|
|
||||||
|
import { DeleteConfirmation } from '@/components/modal/delete-confirmation';
|
||||||
|
import {
|
||||||
|
AlertDialogMedia,
|
||||||
|
} from "@/components/ui/alert-dialog"
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { formatDate } from '@/lib/formatters';
|
||||||
import purchaseRoutes from '@/routes/purchase';
|
import purchaseRoutes from '@/routes/purchase';
|
||||||
|
import type { Purchase } from '@/types';
|
||||||
import { usePurchaseIndex } from './hooks/use-purchase-index';
|
import { usePurchaseIndex } from './hooks/use-purchase-index';
|
||||||
import { getColumns } from './partials/columns';
|
import { getColumns } from './partials/columns';
|
||||||
import { DeleteConfirmation } from '@/components/modal/delete-confirmation';
|
|
||||||
import { formatDate } from '@/lib/formatters';
|
|
||||||
|
|
||||||
export default function PurchaseIndex({ purchases }: { purchases: Purchase[] }) {
|
export default function PurchaseIndex({ purchases }: { purchases: Purchase[] }) {
|
||||||
const {
|
const {
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
import { ColumnDef } from '@tanstack/react-table';
|
|
||||||
import { Purchase } from '@/types';
|
|
||||||
import { DataTableColumnHeader } from '@/components/data-table-column-header';
|
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Pencil, Trash2 } from 'lucide-react';
|
|
||||||
import { Link } from '@inertiajs/react';
|
import { Link } from '@inertiajs/react';
|
||||||
import purchaseRoutes from '@/routes/purchase';
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { id } from 'date-fns/locale';
|
import { id } from 'date-fns/locale';
|
||||||
|
import { Pencil, Trash2 } from 'lucide-react';
|
||||||
|
import { DataTableColumnHeader } from '@/components/data-table-column-header';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
import purchaseRoutes from '@/routes/purchase';
|
||||||
|
import type { Purchase } from '@/types';
|
||||||
|
|
||||||
interface ColumnProps {
|
interface ColumnProps {
|
||||||
onDelete: (purchase: Purchase) => void;
|
onDelete: (purchase: Purchase) => void;
|
||||||
@ -21,6 +21,7 @@ export const getColumns = ({ onDelete }: ColumnProps): ColumnDef<Purchase>[] =>
|
|||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const date = row.original.purchase_date;
|
const date = row.original.purchase_date;
|
||||||
|
|
||||||
return format(new Date(date), 'dd MMMM yyyy', { locale: id });
|
return format(new Date(date), 'dd MMMM yyyy', { locale: id });
|
||||||
},
|
},
|
||||||
meta: { title: "Tanggal" },
|
meta: { title: "Tanggal" },
|
||||||
@ -80,6 +81,7 @@ export const getColumns = ({ onDelete }: ColumnProps): ColumnDef<Purchase>[] =>
|
|||||||
header: "Aksi",
|
header: "Aksi",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const purchase = row.original;
|
const purchase = row.original;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { Category } from '@/types';
|
|
||||||
import { router } from '@inertiajs/react';
|
import { router } from '@inertiajs/react';
|
||||||
import categoryRoutes from '@/routes/category';
|
import { useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import categoryRoutes from '@/routes/category';
|
||||||
|
import type { Category } from '@/types';
|
||||||
|
|
||||||
export function useCategoryIndex() {
|
export function useCategoryIndex() {
|
||||||
const [isFormOpen, setIsFormOpen] = useState(false);
|
const [isFormOpen, setIsFormOpen] = useState(false);
|
||||||
|
|||||||
@ -1,14 +1,14 @@
|
|||||||
import { Head } from '@inertiajs/react';
|
import { Head } from '@inertiajs/react';
|
||||||
import type { Category } from '@/types';
|
import { Trash2, Plus } from 'lucide-react';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Trash2, Plus, X } from 'lucide-react';
|
|
||||||
import { DataTable } from '@/components/data-table';
|
import { DataTable } from '@/components/data-table';
|
||||||
|
import { DeleteConfirmation } from '@/components/modal/delete-confirmation';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import type { Category } from '@/types';
|
||||||
|
|
||||||
import { useCategoryIndex } from './hooks/use-category-index';
|
import { useCategoryIndex } from './hooks/use-category-index';
|
||||||
import { getColumns } from './partials/columns';
|
|
||||||
import { CategoryFormModal } from './partials/category-form-modal';
|
import { CategoryFormModal } from './partials/category-form-modal';
|
||||||
import { DeleteConfirmation } from '@/components/modal/delete-confirmation';
|
import { getColumns } from './partials/columns';
|
||||||
|
|
||||||
export default function CategoryIndex({ categories }: { categories: Category[] }) {
|
export default function CategoryIndex({ categories }: { categories: Category[] }) {
|
||||||
const {
|
const {
|
||||||
|
|||||||
@ -1,3 +1,7 @@
|
|||||||
|
import { useForm } from '@inertiajs/react';
|
||||||
|
import { X, Save, Loader } from 'lucide-react';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@ -9,12 +13,8 @@ import {
|
|||||||
import { Field, FieldError, FieldGroup } from "@/components/ui/field"
|
import { Field, FieldError, FieldGroup } from "@/components/ui/field"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { Category } from '@/types';
|
|
||||||
import { useForm } from '@inertiajs/react';
|
|
||||||
import { useEffect } from 'react';
|
|
||||||
import categoryRoutes from '@/routes/category';
|
import categoryRoutes from '@/routes/category';
|
||||||
import { toast } from 'sonner';
|
import type { Category } from '@/types';
|
||||||
import { X, Save, Loader } from 'lucide-react';
|
|
||||||
|
|
||||||
interface CategoryFormModalProps {
|
interface CategoryFormModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@ -49,6 +49,7 @@ export function CategoryFormModal({ isOpen, onClose, category }: CategoryFormMod
|
|||||||
|
|
||||||
const onSubmit = (e: React.FormEvent) => {
|
const onSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (isEditing && category) {
|
if (isEditing && category) {
|
||||||
patch(categoryRoutes.update(category.id).url, {
|
patch(categoryRoutes.update(category.id).url, {
|
||||||
onSuccess: (response: any) => {
|
onSuccess: (response: any) => {
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
import { ColumnDef } from '@tanstack/react-table';
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
import { Category } from '@/types';
|
import { Pencil, Trash2 } from 'lucide-react';
|
||||||
import { DataTableColumnHeader } from '@/components/data-table-column-header';
|
import { DataTableColumnHeader } from '@/components/data-table-column-header';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { Button } from '@/components/ui/button';
|
import type { Category } from '@/types';
|
||||||
import { Pencil, Trash2 } from 'lucide-react';
|
|
||||||
|
|
||||||
interface ColumnProps {
|
interface ColumnProps {
|
||||||
onEdit: (category: Category) => void;
|
onEdit: (category: Category) => void;
|
||||||
@ -26,6 +26,7 @@ export const getColumns = ({ onEdit, onDelete, onToggleStatus }: ColumnProps): C
|
|||||||
meta: { title: "Status" },
|
meta: { title: "Status" },
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const category = row.original;
|
const category = row.original;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Switch
|
<Switch
|
||||||
checked={category.is_active}
|
checked={category.is_active}
|
||||||
@ -39,6 +40,7 @@ export const getColumns = ({ onEdit, onDelete, onToggleStatus }: ColumnProps): C
|
|||||||
header: "Aksi",
|
header: "Aksi",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const category = row.original;
|
const category = row.original;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
|
|||||||
@ -1,15 +1,12 @@
|
|||||||
import { Head, Link } from '@inertiajs/react';
|
import { Head, Link } from '@inertiajs/react';
|
||||||
import { useForm } from '@inertiajs/react';
|
import { useForm } from '@inertiajs/react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { ImagePlus, X, Upload, ArrowLeft, Save, Loader } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Field, FieldError } from "@/components/ui/field"
|
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { Label } from "@/components/ui/label"
|
|
||||||
import productRoutes from '@/routes/product';
|
|
||||||
import React, { useRef, useState } from 'react';
|
import React, { useRef, useState } from 'react';
|
||||||
|
import { NumericFormat } from 'react-number-format';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { SimpleEditor } from '@/components/tiptap-templates/simple/simple-editor'
|
import { SimpleEditor } from '@/components/tiptap-templates/simple/simple-editor'
|
||||||
import { Category } from '@/types/category';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import {
|
import {
|
||||||
Combobox,
|
Combobox,
|
||||||
ComboboxContent,
|
ComboboxContent,
|
||||||
@ -22,8 +19,11 @@ import {
|
|||||||
ComboboxValue,
|
ComboboxValue,
|
||||||
useComboboxAnchor,
|
useComboboxAnchor,
|
||||||
} from "@/components/ui/combobox"
|
} from "@/components/ui/combobox"
|
||||||
import { NumericFormat } from 'react-number-format';
|
import { Field, FieldError } from "@/components/ui/field"
|
||||||
import { ImagePlus, X, Upload, ArrowLeft, Save, Loader } from 'lucide-react';
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import productRoutes from '@/routes/product';
|
||||||
|
import type { Category } from '@/types/category';
|
||||||
|
|
||||||
export default function ProductCreate({ categories }: { categories: Category[] }) {
|
export default function ProductCreate({ categories }: { categories: Category[] }) {
|
||||||
const { data, setData, post, processing, errors } = useForm<{
|
const { data, setData, post, processing, errors } = useForm<{
|
||||||
@ -63,7 +63,11 @@ export default function ProductCreate({ categories }: { categories: Category[] }
|
|||||||
|
|
||||||
const handleThumbnailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleThumbnailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (!file) return;
|
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setData('thumbnail', file);
|
setData('thumbnail', file);
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onloadend = () => setThumbnailPreview(reader.result as string);
|
reader.onloadend = () => setThumbnailPreview(reader.result as string);
|
||||||
@ -73,12 +77,18 @@ export default function ProductCreate({ categories }: { categories: Category[] }
|
|||||||
const removeThumbnail = () => {
|
const removeThumbnail = () => {
|
||||||
setData('thumbnail', null);
|
setData('thumbnail', null);
|
||||||
setThumbnailPreview(null);
|
setThumbnailPreview(null);
|
||||||
if (thumbnailInputRef.current) thumbnailInputRef.current.value = '';
|
|
||||||
|
if (thumbnailInputRef.current) {
|
||||||
|
thumbnailInputRef.current.value = '';
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleImagesChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleImagesChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const files = Array.from(e.target.files ?? []);
|
const files = Array.from(e.target.files ?? []);
|
||||||
if (!files.length) return;
|
|
||||||
|
if (!files.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const newPreviews = files.map(file => ({
|
const newPreviews = files.map(file => ({
|
||||||
file,
|
file,
|
||||||
@ -87,7 +97,10 @@ export default function ProductCreate({ categories }: { categories: Category[] }
|
|||||||
|
|
||||||
setImagePreviews(prev => [...prev, ...newPreviews]);
|
setImagePreviews(prev => [...prev, ...newPreviews]);
|
||||||
setData('images', [...data.images, ...files]);
|
setData('images', [...data.images, ...files]);
|
||||||
if (imagesInputRef.current) imagesInputRef.current.value = '';
|
|
||||||
|
if (imagesInputRef.current) {
|
||||||
|
imagesInputRef.current.value = '';
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeImage = (index: number) => {
|
const removeImage = (index: number) => {
|
||||||
|
|||||||
@ -1,15 +1,11 @@
|
|||||||
import { Head, useForm, Link } from '@inertiajs/react';
|
import { Head, useForm, Link } from '@inertiajs/react';
|
||||||
import type { Product, ProductPrice } from '@/types';
|
import { ImagePlus, X, Upload, ArrowLeft, Save, Loader } from 'lucide-react';
|
||||||
import { Category } from '@/types/category';
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Field, FieldError } from "@/components/ui/field"
|
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { Label } from "@/components/ui/label"
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import productRoutes from '@/routes/product';
|
|
||||||
import React, { useRef, useState } from 'react';
|
import React, { useRef, useState } from 'react';
|
||||||
|
import { NumericFormat } from 'react-number-format';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { SimpleEditor } from '@/components/tiptap-templates/simple/simple-editor';
|
import { SimpleEditor } from '@/components/tiptap-templates/simple/simple-editor';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import {
|
import {
|
||||||
Combobox,
|
Combobox,
|
||||||
ComboboxContent,
|
ComboboxContent,
|
||||||
@ -22,8 +18,12 @@ import {
|
|||||||
ComboboxValue,
|
ComboboxValue,
|
||||||
useComboboxAnchor,
|
useComboboxAnchor,
|
||||||
} from "@/components/ui/combobox"
|
} from "@/components/ui/combobox"
|
||||||
import { NumericFormat } from 'react-number-format';
|
import { Field, FieldError } from "@/components/ui/field"
|
||||||
import { ImagePlus, X, Upload, ArrowLeft, Save, Loader } from 'lucide-react';
|
import { Category } from '@/types/category';
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import productRoutes from '@/routes/product';
|
||||||
|
import type { Product, ProductPrice } from '@/types';
|
||||||
|
|
||||||
interface ExistingImage {
|
interface ExistingImage {
|
||||||
kind: 'existing';
|
kind: 'existing';
|
||||||
@ -42,6 +42,7 @@ type GalleryItem = ExistingImage | NewImage;
|
|||||||
export default function ProductEdit({ product, categories }: { product: Product, categories: Category[] }) {
|
export default function ProductEdit({ product, categories }: { product: Product, categories: Category[] }) {
|
||||||
const getPrice = (type: string) => {
|
const getPrice = (type: string) => {
|
||||||
const priceObj = product.prices?.find((p: ProductPrice) => p.price_type === type);
|
const priceObj = product.prices?.find((p: ProductPrice) => p.price_type === type);
|
||||||
|
|
||||||
return priceObj ? priceObj.price.toString() : '';
|
return priceObj ? priceObj.price.toString() : '';
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -98,7 +99,11 @@ export default function ProductEdit({ product, categories }: { product: Product,
|
|||||||
|
|
||||||
const handleThumbnailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleThumbnailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (!file) return;
|
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setData('thumbnail', file);
|
setData('thumbnail', file);
|
||||||
setThumbnailCleared(false);
|
setThumbnailCleared(false);
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
@ -110,12 +115,18 @@ export default function ProductEdit({ product, categories }: { product: Product,
|
|||||||
setData('thumbnail', null);
|
setData('thumbnail', null);
|
||||||
setThumbnailPreview(null);
|
setThumbnailPreview(null);
|
||||||
setThumbnailCleared(true);
|
setThumbnailCleared(true);
|
||||||
if (thumbnailInputRef.current) thumbnailInputRef.current.value = '';
|
|
||||||
|
if (thumbnailInputRef.current) {
|
||||||
|
thumbnailInputRef.current.value = '';
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleImagesChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleImagesChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const files = Array.from(e.target.files ?? []);
|
const files = Array.from(e.target.files ?? []);
|
||||||
if (!files.length) return;
|
|
||||||
|
if (!files.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const newItems: NewImage[] = files.map(file => ({
|
const newItems: NewImage[] = files.map(file => ({
|
||||||
kind: 'new',
|
kind: 'new',
|
||||||
@ -125,7 +136,10 @@ export default function ProductEdit({ product, categories }: { product: Product,
|
|||||||
|
|
||||||
setGallery(prev => [...prev, ...newItems]);
|
setGallery(prev => [...prev, ...newItems]);
|
||||||
setData('images', [...data.images, ...files]);
|
setData('images', [...data.images, ...files]);
|
||||||
if (imagesInputRef.current) imagesInputRef.current.value = '';
|
|
||||||
|
if (imagesInputRef.current) {
|
||||||
|
imagesInputRef.current.value = '';
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeGalleryItem = (index: number) => {
|
const removeGalleryItem = (index: number) => {
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { Product } from '@/types';
|
|
||||||
import { router } from '@inertiajs/react';
|
import { router } from '@inertiajs/react';
|
||||||
import productRoutes from '@/routes/product';
|
import { useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import productRoutes from '@/routes/product';
|
||||||
|
import type { Product } from '@/types';
|
||||||
|
|
||||||
export function useProductIndex() {
|
export function useProductIndex() {
|
||||||
const [selectedImage, setSelectedImage] = useState<string | null>(null);
|
const [selectedImage, setSelectedImage] = useState<string | null>(null);
|
||||||
|
|||||||
@ -1,15 +1,15 @@
|
|||||||
import { Head, Link } from '@inertiajs/react';
|
import { Head, Link } from '@inertiajs/react';
|
||||||
import type { Product, Category } from '@/types';
|
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Trash2, Plus } from 'lucide-react';
|
import { Trash2, Plus } from 'lucide-react';
|
||||||
import { DataTable } from '@/components/data-table';
|
import { DataTable } from '@/components/data-table';
|
||||||
|
import { DeleteConfirmation } from '@/components/modal/delete-confirmation';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import productRoutes from '@/routes/product';
|
import productRoutes from '@/routes/product';
|
||||||
|
import type { Product, Category } from '@/types';
|
||||||
|
|
||||||
|
import { ImagePreviewDialog } from '../../../../components/modal/image-preview';
|
||||||
import { useProductIndex } from './hooks/use-product-index';
|
import { useProductIndex } from './hooks/use-product-index';
|
||||||
import { getColumns } from './partials/columns';
|
import { getColumns } from './partials/columns';
|
||||||
import { ImagePreviewDialog } from '../../../../components/modal/image-preview';
|
|
||||||
import { DeleteConfirmation } from '@/components/modal/delete-confirmation';
|
|
||||||
|
|
||||||
export default function ProductIndex({ products, categories }: { products: Product[], categories: Category[] }) {
|
export default function ProductIndex({ products, categories }: { products: Product[], categories: Category[] }) {
|
||||||
const {
|
const {
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
import { ColumnDef } from '@tanstack/react-table';
|
import { Link } from '@inertiajs/react';
|
||||||
import { Product, Category } from '@/types';
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
|
import { Pencil, Trash2, ImagePlus } from 'lucide-react';
|
||||||
import { DataTableColumnHeader } from '@/components/data-table-column-header';
|
import { DataTableColumnHeader } from '@/components/data-table-column-header';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Pencil, Trash2, ImagePlus } from 'lucide-react';
|
|
||||||
import { Link } from '@inertiajs/react';
|
|
||||||
import productRoutes from '@/routes/product';
|
import productRoutes from '@/routes/product';
|
||||||
|
import type { Product, Category } from '@/types';
|
||||||
|
|
||||||
interface ColumnProps {
|
interface ColumnProps {
|
||||||
onEdit?: (product: Product) => void;
|
onEdit?: (product: Product) => void;
|
||||||
@ -23,6 +23,7 @@ export const getColumns = ({ onDelete, onToggleStatus, onPreviewImage }: ColumnP
|
|||||||
meta: { title: "Thumbnail" },
|
meta: { title: "Thumbnail" },
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const url = row.original.thumbnail_url;
|
const url = row.original.thumbnail_url;
|
||||||
|
|
||||||
return url ? (
|
return url ? (
|
||||||
<button onClick={() => onPreviewImage(url)} className="block w-fit">
|
<button onClick={() => onPreviewImage(url)} className="block w-fit">
|
||||||
<img src={url} alt="Thumbnail" className="h-10 w-10 object-cover rounded-md border hover:opacity-80 transition-opacity" />
|
<img src={url} alt="Thumbnail" className="h-10 w-10 object-cover rounded-md border hover:opacity-80 transition-opacity" />
|
||||||
@ -49,11 +50,16 @@ export const getColumns = ({ onDelete, onToggleStatus, onPreviewImage }: ColumnP
|
|||||||
meta: { title: "Kategori" },
|
meta: { title: "Kategori" },
|
||||||
filterFn: (row, id, value) => {
|
filterFn: (row, id, value) => {
|
||||||
const categories = row.getValue(id) as Category[];
|
const categories = row.getValue(id) as Category[];
|
||||||
if (!categories) return false;
|
|
||||||
|
if (!categories) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
return categories.some(cat => String(cat.id) === String(value));
|
return categories.some(cat => String(cat.id) === String(value));
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const product = row.original;
|
const product = row.original;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{product.categories?.map((category) => (
|
{product.categories?.map((category) => (
|
||||||
@ -118,6 +124,7 @@ export const getColumns = ({ onDelete, onToggleStatus, onPreviewImage }: ColumnP
|
|||||||
meta: { title: "Status" },
|
meta: { title: "Status" },
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const product = row.original;
|
const product = row.original;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Switch
|
<Switch
|
||||||
checked={product.is_active}
|
checked={product.is_active}
|
||||||
@ -131,6 +138,7 @@ export const getColumns = ({ onDelete, onToggleStatus, onPreviewImage }: ColumnP
|
|||||||
header: "Aksi",
|
header: "Aksi",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const product = row.original;
|
const product = row.original;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
|
|||||||
@ -1,22 +1,22 @@
|
|||||||
import { Head, Link } from '@inertiajs/react';
|
import { Head, Link } from '@inertiajs/react';
|
||||||
import { useForm } from '@inertiajs/react';
|
import { useForm } from '@inertiajs/react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { ArrowLeft, Loader, Save } from 'lucide-react';
|
||||||
|
import React from 'react';
|
||||||
|
import { NumericFormat } from 'react-number-format';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Calendar } from "@/components/ui/calendar"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Field, FieldError } from "@/components/ui/field"
|
import { Field, FieldError } from "@/components/ui/field"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import userRoutes from '@/routes/user';
|
|
||||||
import React from 'react';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
|
||||||
import { Calendar } from "@/components/ui/calendar"
|
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from "@/components/ui/popover"
|
} from "@/components/ui/popover"
|
||||||
import { NumericFormat } from 'react-number-format';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { ArrowLeft, Loader, Save } from 'lucide-react';
|
import userRoutes from '@/routes/user';
|
||||||
|
|
||||||
export default function UserCreate() {
|
export default function UserCreate() {
|
||||||
const [isCalendarOpen, setIsCalendarOpen] = React.useState(false);
|
const [isCalendarOpen, setIsCalendarOpen] = React.useState(false);
|
||||||
@ -170,6 +170,7 @@ export default function UserCreate() {
|
|||||||
} else {
|
} else {
|
||||||
setData('birth_date', '');
|
setData('birth_date', '');
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsCalendarOpen(false);
|
setIsCalendarOpen(false);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,22 +1,22 @@
|
|||||||
import { Head, Link } from '@inertiajs/react';
|
import { Head, Link } from '@inertiajs/react';
|
||||||
import { useForm } from '@inertiajs/react';
|
import { useForm } from '@inertiajs/react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { ArrowLeft, Loader, Save } from 'lucide-react';
|
||||||
|
import React from 'react';
|
||||||
|
import { NumericFormat } from 'react-number-format';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Calendar } from "@/components/ui/calendar"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Field, FieldError } from "@/components/ui/field"
|
import { Field, FieldError } from "@/components/ui/field"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import userRoutes from '@/routes/user';
|
|
||||||
import React from 'react';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
|
||||||
import { Calendar } from "@/components/ui/calendar"
|
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from "@/components/ui/popover"
|
} from "@/components/ui/popover"
|
||||||
import { NumericFormat } from 'react-number-format';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { ArrowLeft, Loader, Save } from 'lucide-react';
|
import userRoutes from '@/routes/user';
|
||||||
|
|
||||||
export default function UserEdit({ user }: { user: any }) {
|
export default function UserEdit({ user }: { user: any }) {
|
||||||
const [isCalendarOpen, setIsCalendarOpen] = React.useState(false);
|
const [isCalendarOpen, setIsCalendarOpen] = React.useState(false);
|
||||||
@ -171,6 +171,7 @@ export default function UserEdit({ user }: { user: any }) {
|
|||||||
} else {
|
} else {
|
||||||
setData('birth_date', '');
|
setData('birth_date', '');
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsCalendarOpen(false);
|
setIsCalendarOpen(false);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { User } from '@/types';
|
|
||||||
import { router } from '@inertiajs/react';
|
import { router } from '@inertiajs/react';
|
||||||
import userRoutes from '@/routes/user';
|
import { useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import userRoutes from '@/routes/user';
|
||||||
|
import type { User } from '@/types';
|
||||||
|
|
||||||
export function useUserIndex() {
|
export function useUserIndex() {
|
||||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
|
|||||||
@ -1,9 +1,7 @@
|
|||||||
import { Head, Link } from '@inertiajs/react';
|
import { Head, Link } from '@inertiajs/react';
|
||||||
import type { User } from '@/types';
|
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Trash2, KeyRound, Plus, X } from 'lucide-react';
|
import { Trash2, KeyRound, Plus, X } from 'lucide-react';
|
||||||
import { DataTable } from '@/components/data-table';
|
import { DataTable } from '@/components/data-table';
|
||||||
|
import { DeleteConfirmation } from '@/components/modal/delete-confirmation';
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@ -15,11 +13,13 @@ import {
|
|||||||
AlertDialogMedia,
|
AlertDialogMedia,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog"
|
} from "@/components/ui/alert-dialog"
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import userRoutes from '@/routes/user';
|
import userRoutes from '@/routes/user';
|
||||||
|
import type { User } from '@/types';
|
||||||
|
|
||||||
import { useUserIndex } from './hooks/use-user-index';
|
import { useUserIndex } from './hooks/use-user-index';
|
||||||
import { getColumns } from './partials/columns';
|
import { getColumns } from './partials/columns';
|
||||||
import { DeleteConfirmation } from '@/components/modal/delete-confirmation';
|
|
||||||
|
|
||||||
export default function UserIndex({ users, defaultPassword }: { users: User[], defaultPassword: string }) {
|
export default function UserIndex({ users, defaultPassword }: { users: User[], defaultPassword: string }) {
|
||||||
const {
|
const {
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
import { ColumnDef } from '@tanstack/react-table';
|
|
||||||
import { User } from '@/types';
|
|
||||||
import { DataTableColumnHeader } from '@/components/data-table-column-header';
|
|
||||||
import { UserInfo } from '@/components/user-info';
|
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { KeyRound, Pencil, Trash2 } from 'lucide-react';
|
|
||||||
import { Link } from '@inertiajs/react';
|
import { Link } from '@inertiajs/react';
|
||||||
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
|
import { KeyRound, Pencil, Trash2 } from 'lucide-react';
|
||||||
|
import { DataTableColumnHeader } from '@/components/data-table-column-header';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
import { UserInfo } from '@/components/user-info';
|
||||||
import userRoutes from '@/routes/user';
|
import userRoutes from '@/routes/user';
|
||||||
|
import type { User } from '@/types';
|
||||||
|
|
||||||
interface ColumnProps {
|
interface ColumnProps {
|
||||||
onResetPassword: (user: User) => void;
|
onResetPassword: (user: User) => void;
|
||||||
@ -22,6 +22,7 @@ export const getColumns = ({ onResetPassword, onDelete }: ColumnProps): ColumnDe
|
|||||||
meta: { title: "Nama dan Alamat Surel" },
|
meta: { title: "Nama dan Alamat Surel" },
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const user = row.original;
|
const user = row.original;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<UserInfo user={user} showEmail={true} />
|
<UserInfo user={user} showEmail={true} />
|
||||||
@ -49,6 +50,7 @@ export const getColumns = ({ onResetPassword, onDelete }: ColumnProps): ColumnDe
|
|||||||
header: "Aksi",
|
header: "Aksi",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const user = row.original;
|
const user = row.original;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import { Head } from '@inertiajs/react';
|
import { Head } from '@inertiajs/react';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
|
||||||
import { DataTable } from '@/components/data-table';
|
import { DataTable } from '@/components/data-table';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import type { Activity } from '@/types';
|
||||||
import { getColumns } from './partials/columns';
|
import { getColumns } from './partials/columns';
|
||||||
import { Activity } from '@/types';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
activities: Activity[];
|
activities: Activity[];
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { ColumnDef } from '@tanstack/react-table';
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Activity } from '@/types';
|
import type { Activity } from '@/types';
|
||||||
|
|
||||||
export function getColumns(): ColumnDef<Activity>[] {
|
export function getColumns(): ColumnDef<Activity>[] {
|
||||||
return [
|
return [
|
||||||
@ -66,7 +66,9 @@ export function getColumns(): ColumnDef<Activity>[] {
|
|||||||
const attributes = row.original.properties?.attributes;
|
const attributes = row.original.properties?.attributes;
|
||||||
const old = row.original.properties?.old;
|
const old = row.original.properties?.old;
|
||||||
|
|
||||||
if (!attributes) return <span className="text-muted-foreground text-xs italic">-</span>;
|
if (!attributes) {
|
||||||
|
return <span className="text-muted-foreground text-xs italic">-</span>;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1 py-1 max-w-[400px]">
|
<div className="flex flex-col gap-1 py-1 max-w-[400px]">
|
||||||
|
|||||||
@ -1,10 +1,7 @@
|
|||||||
import { Head, router } from '@inertiajs/react';
|
import { Head, router } from '@inertiajs/react';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
|
||||||
import { DataTable } from '@/components/data-table';
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { getColumns } from './partials/columns';
|
import { DataTable } from '@/components/data-table';
|
||||||
import { SystemLog } from '@/types';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import system from '@/routes/system';
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@ -12,6 +9,9 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue
|
SelectValue
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import system from '@/routes/system';
|
||||||
|
import type { SystemLog } from '@/types';
|
||||||
|
import { getColumns } from './partials/columns';
|
||||||
import { LogDetailModal } from './partials/log-detail-modal';
|
import { LogDetailModal } from './partials/log-detail-modal';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
import { ColumnDef } from '@tanstack/react-table';
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
|
import { Eye } from 'lucide-react';
|
||||||
import { DataTableColumnHeader } from '@/components/data-table-column-header';
|
import { DataTableColumnHeader } from '@/components/data-table-column-header';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Eye } from 'lucide-react';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { SystemLog } from '@/types';
|
|
||||||
import { getLevelColor } from '@/lib/log-helpers';
|
import { getLevelColor } from '@/lib/log-helpers';
|
||||||
|
import type { SystemLog } from '@/types';
|
||||||
|
|
||||||
interface ColumnProps {
|
interface ColumnProps {
|
||||||
onView: (log: SystemLog) => void;
|
onView: (log: SystemLog) => void;
|
||||||
@ -27,6 +27,7 @@ export const getColumns = ({ onView }: ColumnProps): ColumnDef<SystemLog>[] => [
|
|||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const level = row.original.level;
|
const level = row.original.level;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Badge variant={getLevelColor(level) as any}>
|
<Badge variant={getLevelColor(level) as any}>
|
||||||
{level}
|
{level}
|
||||||
@ -42,6 +43,7 @@ export const getColumns = ({ onView }: ColumnProps): ColumnDef<SystemLog>[] => [
|
|||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const message = row.original.message;
|
const message = row.original.message;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-[400px] lg:max-w-[600px] truncate font-sans text-xs" title={message}>
|
<div className="max-w-[400px] lg:max-w-[600px] truncate font-sans text-xs" title={message}>
|
||||||
{message}
|
{message}
|
||||||
@ -55,6 +57,7 @@ export const getColumns = ({ onView }: ColumnProps): ColumnDef<SystemLog>[] => [
|
|||||||
header: "Aksi",
|
header: "Aksi",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const log = row.original;
|
const log = row.original;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user