added image support and horizontal line support
This commit is contained in:
@@ -8,6 +8,7 @@
|
|||||||
"@kobalte/core": "^0.13.11",
|
"@kobalte/core": "^0.13.11",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@tiptap/core": "^3.18.0",
|
"@tiptap/core": "^3.18.0",
|
||||||
|
"@tiptap/extension-image": "^3.20.0",
|
||||||
"@tiptap/extension-placeholder": "^3.18.0",
|
"@tiptap/extension-placeholder": "^3.18.0",
|
||||||
"@tiptap/extension-task-item": "^3.18.0",
|
"@tiptap/extension-task-item": "^3.18.0",
|
||||||
"@tiptap/extension-task-list": "^3.18.0",
|
"@tiptap/extension-task-list": "^3.18.0",
|
||||||
@@ -467,6 +468,8 @@
|
|||||||
|
|
||||||
"@tiptap/extension-horizontal-rule": ["@tiptap/extension-horizontal-rule@3.18.0", "", { "peerDependencies": { "@tiptap/core": "^3.18.0", "@tiptap/pm": "^3.18.0" } }, "sha512-fEq7DwwQZ496RHNbMQypBVNqoWnhDEERbzWMBqlmfCfc/0FvJrHtsQkk3k4lgqMYqmBwym3Wp0SrRYiyKCPGTw=="],
|
"@tiptap/extension-horizontal-rule": ["@tiptap/extension-horizontal-rule@3.18.0", "", { "peerDependencies": { "@tiptap/core": "^3.18.0", "@tiptap/pm": "^3.18.0" } }, "sha512-fEq7DwwQZ496RHNbMQypBVNqoWnhDEERbzWMBqlmfCfc/0FvJrHtsQkk3k4lgqMYqmBwym3Wp0SrRYiyKCPGTw=="],
|
||||||
|
|
||||||
|
"@tiptap/extension-image": ["@tiptap/extension-image@3.20.0", "", { "peerDependencies": { "@tiptap/core": "^3.20.0" } }, "sha512-0t7HYncV0kYEQS79NFczxdlZoZ8zu8X4VavDqt+mbSAUKRq3gCvgtZ5Zyd778sNmtmbz3arxkEYMIVou2swD0g=="],
|
||||||
|
|
||||||
"@tiptap/extension-italic": ["@tiptap/extension-italic@3.18.0", "", { "peerDependencies": { "@tiptap/core": "^3.18.0" } }, "sha512-1C4nB08psiRo0BPxAbpYq8peUOKnjQWtBCLPbE6B9ToTK3vmUk0AZTqLO11FvokuM1GF5l2Lg3sKrKFuC2hcjQ=="],
|
"@tiptap/extension-italic": ["@tiptap/extension-italic@3.18.0", "", { "peerDependencies": { "@tiptap/core": "^3.18.0" } }, "sha512-1C4nB08psiRo0BPxAbpYq8peUOKnjQWtBCLPbE6B9ToTK3vmUk0AZTqLO11FvokuM1GF5l2Lg3sKrKFuC2hcjQ=="],
|
||||||
|
|
||||||
"@tiptap/extension-link": ["@tiptap/extension-link@3.18.0", "", { "dependencies": { "linkifyjs": "^4.3.2" }, "peerDependencies": { "@tiptap/core": "^3.18.0", "@tiptap/pm": "^3.18.0" } }, "sha512-1J28C4+fKAMQi7q/UsTjAmgmKTnzjExXY98hEBneiVzFDxqF69n7+Vb7nVTNAIhmmJkZMA0DEcMhSiQC/1/u4A=="],
|
"@tiptap/extension-link": ["@tiptap/extension-link@3.18.0", "", { "dependencies": { "linkifyjs": "^4.3.2" }, "peerDependencies": { "@tiptap/core": "^3.18.0", "@tiptap/pm": "^3.18.0" } }, "sha512-1J28C4+fKAMQi7q/UsTjAmgmKTnzjExXY98hEBneiVzFDxqF69n7+Vb7nVTNAIhmmJkZMA0DEcMhSiQC/1/u4A=="],
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"@kobalte/core": "^0.13.11",
|
"@kobalte/core": "^0.13.11",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@tiptap/core": "^3.18.0",
|
"@tiptap/core": "^3.18.0",
|
||||||
|
"@tiptap/extension-image": "^3.20.0",
|
||||||
"@tiptap/extension-placeholder": "^3.18.0",
|
"@tiptap/extension-placeholder": "^3.18.0",
|
||||||
"@tiptap/extension-task-item": "^3.18.0",
|
"@tiptap/extension-task-item": "^3.18.0",
|
||||||
"@tiptap/extension-task-list": "^3.18.0",
|
"@tiptap/extension-task-list": "^3.18.0",
|
||||||
|
|||||||
@@ -2,9 +2,12 @@ import { type Component, createSignal, For, createEffect } from "solid-js";
|
|||||||
import {
|
import {
|
||||||
Heading1, Heading2, Heading3,
|
Heading1, Heading2, Heading3,
|
||||||
List, ListTodo, Type,
|
List, ListTodo, Type,
|
||||||
Code, Quote
|
Code, Quote,
|
||||||
|
Image as ImageIcon, Minus
|
||||||
} from "lucide-solid";
|
} from "lucide-solid";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn, convertImageToWebpFile } from "@/lib/utils";
|
||||||
|
import { activeTaskId, uploadTaskAttachment } from "@/store";
|
||||||
|
import { toast } from "solid-sonner";
|
||||||
|
|
||||||
export interface CommandItem {
|
export interface CommandItem {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -79,6 +82,62 @@ export const getSuggestionItems = ({ query }: { query: string }): CommandItem[]
|
|||||||
editor.chain().focus().deleteRange(range).toggleBlockquote().run();
|
editor.chain().focus().deleteRange(range).toggleBlockquote().run();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Image",
|
||||||
|
description: "Upload an image.",
|
||||||
|
icon: ImageIcon,
|
||||||
|
command: ({ editor, range }: { editor: any, range: any }) => {
|
||||||
|
editor.chain().focus().deleteRange(range).run();
|
||||||
|
const taskId = activeTaskId();
|
||||||
|
if (!taskId) {
|
||||||
|
toast.error("Cannot upload image outside of a task.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "file";
|
||||||
|
input.accept = "image/*";
|
||||||
|
input.onchange = async () => {
|
||||||
|
if (input.files?.length) {
|
||||||
|
const originalFile = input.files[0];
|
||||||
|
toast.promise(
|
||||||
|
(async () => {
|
||||||
|
const webpFile = await convertImageToWebpFile(originalFile);
|
||||||
|
return uploadTaskAttachment(taskId, webpFile);
|
||||||
|
})(),
|
||||||
|
{
|
||||||
|
loading: "Processing & uploading image...",
|
||||||
|
success: (url) => {
|
||||||
|
// Insert image, then run enter to add a newline (paragraph) below it.
|
||||||
|
// Without this, the image stays selected and the next keypress deletes it.
|
||||||
|
editor.chain()
|
||||||
|
.focus()
|
||||||
|
.setImage({ src: url })
|
||||||
|
.run();
|
||||||
|
|
||||||
|
// Manually force a new paragraph at the end so the user can keep typing
|
||||||
|
editor.chain()
|
||||||
|
.focus('end')
|
||||||
|
.insertContent('<p></p>')
|
||||||
|
.run();
|
||||||
|
return "Image uploaded.";
|
||||||
|
},
|
||||||
|
error: "Failed to upload image."
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
input.click();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Divider",
|
||||||
|
description: "Insert a horizontal rule.",
|
||||||
|
icon: Minus,
|
||||||
|
command: ({ editor, range }: { editor: any, range: any }) => {
|
||||||
|
editor.chain().focus().deleteRange(range).setHorizontalRule().run();
|
||||||
|
},
|
||||||
|
},
|
||||||
].filter(item => item.title.toLowerCase().startsWith(query.toLowerCase()));
|
].filter(item => item.title.toLowerCase().startsWith(query.toLowerCase()));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { type Component, createEffect, untrack } from "solid-js";
|
import { type Component, createEffect, untrack, createSignal, Show } from "solid-js";
|
||||||
import { createTiptapEditor } from "solid-tiptap";
|
import { createTiptapEditor } from "solid-tiptap";
|
||||||
import StarterKit from "@tiptap/starter-kit";
|
import StarterKit from "@tiptap/starter-kit";
|
||||||
import Placeholder from "@tiptap/extension-placeholder";
|
import Placeholder from "@tiptap/extension-placeholder";
|
||||||
import TaskList from "@tiptap/extension-task-list";
|
import TaskList from "@tiptap/extension-task-list";
|
||||||
import TaskItem from "@tiptap/extension-task-item";
|
import TaskItem from "@tiptap/extension-task-item";
|
||||||
|
import Image from "@tiptap/extension-image";
|
||||||
|
import { X } from "lucide-solid";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
import { SlashCommands } from "@/lib/slash-command";
|
import { SlashCommands } from "@/lib/slash-command";
|
||||||
@@ -20,6 +22,16 @@ interface TaskEditorProps {
|
|||||||
|
|
||||||
export const TaskEditor: Component<TaskEditorProps> = (props) => {
|
export const TaskEditor: Component<TaskEditorProps> = (props) => {
|
||||||
let editorRef: HTMLDivElement | undefined;
|
let editorRef: HTMLDivElement | undefined;
|
||||||
|
const [fullscreenImage, setFullscreenImage] = createSignal<string | null>(null);
|
||||||
|
const [zoomScale, setZoomScale] = createSignal(1);
|
||||||
|
const [zoomOffset, setZoomOffset] = createSignal({ x: 0, y: 0 });
|
||||||
|
const [isDragging, setIsDragging] = createSignal(false);
|
||||||
|
const [dragStart, setDragStart] = createSignal({ x: 0, y: 0 });
|
||||||
|
|
||||||
|
const resetZoom = () => {
|
||||||
|
setZoomScale(1);
|
||||||
|
setZoomOffset({ x: 0, y: 0 });
|
||||||
|
};
|
||||||
|
|
||||||
const editor = createTiptapEditor(() => ({
|
const editor = createTiptapEditor(() => ({
|
||||||
element: editorRef!,
|
element: editorRef!,
|
||||||
@@ -53,6 +65,11 @@ export const TaskEditor: Component<TaskEditorProps> = (props) => {
|
|||||||
SlashCommands.configure({
|
SlashCommands.configure({
|
||||||
suggestion,
|
suggestion,
|
||||||
}),
|
}),
|
||||||
|
Image.configure({
|
||||||
|
HTMLAttributes: {
|
||||||
|
class: "rounded-lg border border-border max-w-full h-auto",
|
||||||
|
},
|
||||||
|
}),
|
||||||
MobileIndent,
|
MobileIndent,
|
||||||
],
|
],
|
||||||
// Use untrack so the editor doesn't re-initialize when props.content changes
|
// Use untrack so the editor doesn't re-initialize when props.content changes
|
||||||
@@ -68,7 +85,7 @@ export const TaskEditor: Component<TaskEditorProps> = (props) => {
|
|||||||
"prose-headings:font-semibold prose-h1:text-2xl prose-h2:text-xl prose-h3:text-lg",
|
"prose-headings:font-semibold prose-h1:text-2xl prose-h2:text-xl prose-h3:text-lg",
|
||||||
"prose-p:my-1 prose-headings:my-2 prose-ul:my-1 prose-ol:my-1",
|
"prose-p:my-1 prose-headings:my-2 prose-ul:my-1 prose-ol:my-1",
|
||||||
"prose-pre:bg-muted prose-pre:rounded-lg prose-pre:p-4",
|
"prose-pre:bg-muted prose-pre:rounded-lg prose-pre:p-4",
|
||||||
"prose-img:rounded-lg prose-img:border prose-img:border-border",
|
"prose-img:rounded-lg prose-img:border prose-img:border-border prose-img:cursor-zoom-in",
|
||||||
// Custom Task List Styling
|
// Custom Task List Styling
|
||||||
"[&_ul[data-type='taskList']]:list-none [&_ul[data-type='taskList']]:p-0",
|
"[&_ul[data-type='taskList']]:list-none [&_ul[data-type='taskList']]:p-0",
|
||||||
"[&_li[data-type='taskItem']]:flex [&_li[data-type='taskItem']]:items-start",
|
"[&_li[data-type='taskItem']]:flex [&_li[data-type='taskItem']]:items-start",
|
||||||
@@ -98,10 +115,93 @@ export const TaskEditor: Component<TaskEditorProps> = (props) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<>
|
||||||
ref={editorRef}
|
<div
|
||||||
class="w-full cursor-text"
|
ref={editorRef}
|
||||||
onClick={() => editor()?.chain().focus().run()}
|
class="w-full cursor-text"
|
||||||
/>
|
onClick={(e) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (target.tagName.toLowerCase() === 'img') {
|
||||||
|
setFullscreenImage((target as HTMLImageElement).src);
|
||||||
|
} else {
|
||||||
|
editor()?.chain().focus().run();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Show when={fullscreenImage()}>
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-[9999] p-4 bg-background/90 backdrop-blur-sm flex items-center justify-center animate-in fade-in duration-200 overflow-hidden"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
setFullscreenImage(null);
|
||||||
|
resetZoom();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onWheel={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const scaleSpeed = 0.05;
|
||||||
|
const delta = e.deltaY < 0 ? 1 : -1;
|
||||||
|
setZoomScale(s => Math.min(Math.max(1, s + delta * scaleSpeed), 5));
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
if (zoomScale() > 1 && e.target.tagName.toLowerCase() === 'img') {
|
||||||
|
setIsDragging(true);
|
||||||
|
setDragStart({ x: e.clientX - zoomOffset().x, y: e.clientY - zoomOffset().y });
|
||||||
|
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onPointerMove={(e) => {
|
||||||
|
if (isDragging()) {
|
||||||
|
setZoomOffset({
|
||||||
|
x: e.clientX - dragStart().x,
|
||||||
|
y: e.clientY - dragStart().y
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onPointerUp={(e) => {
|
||||||
|
setIsDragging(false);
|
||||||
|
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
|
||||||
|
}}
|
||||||
|
onPointerCancel={(e) => {
|
||||||
|
setIsDragging(false);
|
||||||
|
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="absolute top-4 right-4 p-2 bg-background/50 hover:bg-background/80 rounded-full transition-colors z-[10000]"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setFullscreenImage(null);
|
||||||
|
resetZoom();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size={24} />
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
class="relative flex items-center justify-center w-full h-full pointer-events-none"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={fullscreenImage()!}
|
||||||
|
class={cn(
|
||||||
|
"max-w-full max-h-[90vh] object-contain rounded-md shadow-2xl pointer-events-auto transition-transform duration-75",
|
||||||
|
isDragging() ? "cursor-grabbing" : (zoomScale() > 1 ? "cursor-grab" : "cursor-zoom-in")
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
transform: `translate(${zoomOffset().x}px, ${zoomOffset().y}px) scale(${zoomScale()})`,
|
||||||
|
}}
|
||||||
|
draggable={false}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (zoomScale() === 1) {
|
||||||
|
setZoomScale(2);
|
||||||
|
} else if (!isDragging()) {
|
||||||
|
// Optional: Click to single-zoom-out or do nothing during pan
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,3 +4,94 @@ import { twMerge } from "tailwind-merge"
|
|||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const compressImageBase64 = (base64Str: string, maxWidth = 800, quality = 0.7): Promise<string> => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.src = base64Str;
|
||||||
|
img.onload = () => {
|
||||||
|
let { width, height } = img;
|
||||||
|
if (width > maxWidth) {
|
||||||
|
height = Math.round((height * maxWidth) / width);
|
||||||
|
width = maxWidth;
|
||||||
|
}
|
||||||
|
if (height > maxWidth) {
|
||||||
|
width = Math.round((width * maxWidth) / height);
|
||||||
|
height = maxWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) {
|
||||||
|
resolve(base64Str);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.drawImage(img, 0, 0, width, height);
|
||||||
|
resolve(canvas.toDataURL("image/jpeg", quality));
|
||||||
|
};
|
||||||
|
img.onerror = () => resolve(base64Str);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const convertImageToWebpFile = async (file: File): Promise<File> => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
let { width, height } = img;
|
||||||
|
const maxWidth = 2560; // 2K resolution max for task images
|
||||||
|
|
||||||
|
if (width > maxWidth) {
|
||||||
|
height = Math.round((height * maxWidth) / width);
|
||||||
|
width = maxWidth;
|
||||||
|
}
|
||||||
|
if (height > maxWidth) {
|
||||||
|
width = Math.round((width * maxWidth) / height);
|
||||||
|
height = maxWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) {
|
||||||
|
resolve(file); // Fallback to original
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.drawImage(img, 0, 0, width, height);
|
||||||
|
|
||||||
|
canvas.toBlob(
|
||||||
|
(blob) => {
|
||||||
|
if (!blob) {
|
||||||
|
resolve(file); // Fallback to original
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace original extension with .webp
|
||||||
|
const originalName = file.name;
|
||||||
|
const finalName = originalName.replace(/\.[^/.]+$/, "") + ".webp";
|
||||||
|
|
||||||
|
const newFile = new File([blob], finalName, {
|
||||||
|
type: "image/webp",
|
||||||
|
lastModified: Date.now(),
|
||||||
|
});
|
||||||
|
resolve(newFile);
|
||||||
|
},
|
||||||
|
"image/webp",
|
||||||
|
0.7 // 70% Quality
|
||||||
|
);
|
||||||
|
};
|
||||||
|
img.onerror = () => resolve(file); // Fallback
|
||||||
|
img.src = e.target?.result as string;
|
||||||
|
};
|
||||||
|
reader.onerror = () => resolve(file); // Fallback
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
+26
-1
@@ -41,6 +41,7 @@ export interface Task {
|
|||||||
};
|
};
|
||||||
size?: number; // 0-10, task complexity/size
|
size?: number; // 0-10, task complexity/size
|
||||||
sharedWith?: Array<{ userId: string; access: 'view' | 'edit' }>; // Users this task is shared with
|
sharedWith?: Array<{ userId: string; access: 'view' | 'edit' }>; // Users this task is shared with
|
||||||
|
attachments?: string[]; // Array of filenames from PocketBase
|
||||||
}
|
}
|
||||||
|
|
||||||
export const checkRecurringTasks = () => {
|
export const checkRecurringTasks = () => {
|
||||||
@@ -423,10 +424,34 @@ const mapRecordToTask = (r: any): Task => {
|
|||||||
updated: r.updated,
|
updated: r.updated,
|
||||||
recurrence: r.recurrence,
|
recurrence: r.recurrence,
|
||||||
size: (r.size === null || r.size === undefined) ? undefined : r.size,
|
size: (r.size === null || r.size === undefined) ? undefined : r.size,
|
||||||
sharedWith: r.sharedWith
|
sharedWith: r.sharedWith,
|
||||||
|
attachments: r.attachments || []
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const uploadTaskAttachment = async (taskId: string, file: File): Promise<string> => {
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('attachments', file);
|
||||||
|
|
||||||
|
const record = await pb.collection(TASGRID_COLLECTION).update(taskId, formData);
|
||||||
|
|
||||||
|
// Return the URL for the newly uploaded file. PocketBase appends new files to the array.
|
||||||
|
// We need to find the specific filename that was just uploaded to get its URL.
|
||||||
|
// Since we don't know the exact generated filename, we can return the URL of the last file in the array.
|
||||||
|
if (record.attachments && record.attachments.length > 0) {
|
||||||
|
const latestFilename = record.attachments[record.attachments.length - 1];
|
||||||
|
return pb.files.getURL(record, latestFilename);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("File uploaded but no attachment returned");
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("Error uploading attachment:", err);
|
||||||
|
toast.error("Failed to upload image.");
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const mapRecordToShareRule = (r: any): ShareRule => {
|
const mapRecordToShareRule = (r: any): ShareRule => {
|
||||||
return {
|
return {
|
||||||
id: r.id,
|
id: r.id,
|
||||||
|
|||||||
Reference in New Issue
Block a user