add formatting
This commit is contained in:
@@ -1,12 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useRef } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||||
import { Send } from "lucide-react";
|
import { Send, Bold, Italic, Code, Link as LinkIcon, Image as ImageIcon, X, Eye, EyeOff } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { apiRequest } from "@/lib/storeHelper";
|
import { apiRequest } from "@/lib/storeHelper";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
|
||||||
interface BroadcastDialogProps {
|
interface BroadcastDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -15,25 +17,130 @@ interface BroadcastDialogProps {
|
|||||||
|
|
||||||
export default function BroadcastDialog({ open, setOpen }: BroadcastDialogProps) {
|
export default function BroadcastDialog({ open, setOpen }: BroadcastDialogProps) {
|
||||||
const [broadcastMessage, setBroadcastMessage] = useState("");
|
const [broadcastMessage, setBroadcastMessage] = useState("");
|
||||||
|
const [showPreview, setShowPreview] = useState(true);
|
||||||
const [isSending, setIsSending] = useState(false);
|
const [isSending, setIsSending] = useState(false);
|
||||||
|
const [selectedImage, setSelectedImage] = useState<File | null>(null);
|
||||||
|
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleImageSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
if (file.size > 10 * 1024 * 1024) { // 10MB limit
|
||||||
|
toast.error("Image size must be less than 10MB");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedImage(file);
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onloadend = () => {
|
||||||
|
setImagePreview(reader.result as string);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeImage = () => {
|
||||||
|
setSelectedImage(null);
|
||||||
|
setImagePreview(null);
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const insertMarkdown = (type: 'bold' | 'italic' | 'code' | 'link') => {
|
||||||
|
const textarea = textareaRef.current;
|
||||||
|
if (!textarea) return;
|
||||||
|
|
||||||
|
const start = textarea.selectionStart;
|
||||||
|
const end = textarea.selectionEnd;
|
||||||
|
const selectedText = textarea.value.substring(start, end);
|
||||||
|
let insertText = '';
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'bold':
|
||||||
|
insertText = `**${selectedText || 'bold text'}**`;
|
||||||
|
break;
|
||||||
|
case 'italic':
|
||||||
|
insertText = `__${selectedText || 'italic text'}__`;
|
||||||
|
break;
|
||||||
|
case 'code':
|
||||||
|
insertText = `\`${selectedText || 'code'}\``;
|
||||||
|
break;
|
||||||
|
case 'link':
|
||||||
|
insertText = `[${selectedText || 'link text'}](URL)`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newText = textarea.value.substring(0, start) + insertText + textarea.value.substring(end);
|
||||||
|
setBroadcastMessage(newText);
|
||||||
|
|
||||||
|
// Set cursor position after the inserted text
|
||||||
|
const newCursorPos = start + insertText.length;
|
||||||
|
textarea.focus();
|
||||||
|
textarea.setSelectionRange(newCursorPos, newCursorPos);
|
||||||
|
};
|
||||||
|
|
||||||
const sendBroadcast = async () => {
|
const sendBroadcast = async () => {
|
||||||
if (!broadcastMessage.trim()) {
|
if (!broadcastMessage.trim() && !selectedImage) {
|
||||||
toast.warning("Broadcast message cannot be empty.");
|
toast.warning("Please provide a message or image to broadcast.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsSending(true);
|
setIsSending(true);
|
||||||
const response = await apiRequest("/storefront/broadcast", "POST", { message: broadcastMessage });
|
|
||||||
|
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||||
|
if (!API_URL) throw new Error("API URL not configured");
|
||||||
|
|
||||||
|
// Get auth token from cookie
|
||||||
|
const authToken = document.cookie
|
||||||
|
.split("; ")
|
||||||
|
.find((row) => row.startsWith("Authorization="))
|
||||||
|
?.split("=")[1];
|
||||||
|
|
||||||
|
if (!authToken) {
|
||||||
|
document.location.href = "/login";
|
||||||
|
throw new Error("No authentication token found");
|
||||||
|
}
|
||||||
|
|
||||||
|
let response;
|
||||||
|
|
||||||
|
if (selectedImage) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', selectedImage);
|
||||||
|
if (broadcastMessage.trim()) {
|
||||||
|
formData.append('message', broadcastMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`${API_URL}/storefront/broadcast`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const errorData = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.error || 'Failed to send broadcast');
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await res.json();
|
||||||
|
} else {
|
||||||
|
response = await apiRequest("/storefront/broadcast", "POST", { message: broadcastMessage });
|
||||||
|
}
|
||||||
|
|
||||||
if (response.error) throw new Error(response.error);
|
if (response.error) throw new Error(response.error);
|
||||||
|
|
||||||
toast.success(`Broadcast sent to ${response.totalUsers} users!`);
|
toast.success(`Broadcast sent to ${response.totalUsers} users!`);
|
||||||
setBroadcastMessage("");
|
setBroadcastMessage("");
|
||||||
|
setSelectedImage(null);
|
||||||
|
setImagePreview(null);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error("Failed to send broadcast message.");
|
console.error("Broadcast error:", error);
|
||||||
|
toast.error(error instanceof Error ? error.message : "Failed to send broadcast message.");
|
||||||
} finally {
|
} finally {
|
||||||
setIsSending(false);
|
setIsSending(false);
|
||||||
}
|
}
|
||||||
@@ -41,17 +148,128 @@ export default function BroadcastDialog({ open, setOpen }: BroadcastDialogProps)
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogContent>
|
<DialogContent className="max-w-2xl">
|
||||||
<DialogHeader>
|
<DialogHeader className="flex flex-row items-center gap-2 pb-4">
|
||||||
<Send className="h-6 w-6 text-emerald-600" />
|
<Send className="h-6 w-6 text-emerald-600 flex-shrink-0" />
|
||||||
<DialogTitle>Global Broadcast Message</DialogTitle>
|
<DialogTitle>Global Broadcast Message</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<Textarea
|
<div className="space-y-4">
|
||||||
placeholder="Type your message here..."
|
<div className="flex gap-2 items-center">
|
||||||
value={broadcastMessage}
|
<Button
|
||||||
onChange={(e) => setBroadcastMessage(e.target.value)}
|
type="button"
|
||||||
/>
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => insertMarkdown('bold')}
|
||||||
|
title="Bold (*text*)"
|
||||||
|
>
|
||||||
|
<Bold className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => insertMarkdown('italic')}
|
||||||
|
title="Italic (_text_)"
|
||||||
|
>
|
||||||
|
<Italic className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => insertMarkdown('code')}
|
||||||
|
title="Code (`text`)"
|
||||||
|
>
|
||||||
|
<Code className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => insertMarkdown('link')}
|
||||||
|
title="Link [text](URL)"
|
||||||
|
>
|
||||||
|
<LinkIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleImageSelect}
|
||||||
|
className="hidden"
|
||||||
|
ref={fileInputRef}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
title="Add Image"
|
||||||
|
>
|
||||||
|
<ImageIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<div className="ml-auto">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setShowPreview(!showPreview)}
|
||||||
|
title={showPreview ? "Hide Preview" : "Show Preview"}
|
||||||
|
>
|
||||||
|
{showPreview ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
placeholder="Type your message here... Use Telegram markdown formatting:
|
||||||
|
**bold text**
|
||||||
|
__italic text__
|
||||||
|
`code`
|
||||||
|
[text](URL)"
|
||||||
|
value={broadcastMessage}
|
||||||
|
onChange={(e) => setBroadcastMessage(e.target.value)}
|
||||||
|
className="min-h-[150px] font-mono"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{showPreview && broadcastMessage.trim() && (
|
||||||
|
<div className="border rounded-lg p-4 bg-background/50">
|
||||||
|
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||||
|
<ReactMarkdown>{broadcastMessage}</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{imagePreview && (
|
||||||
|
<div className="relative">
|
||||||
|
<img
|
||||||
|
src={imagePreview}
|
||||||
|
alt="Preview"
|
||||||
|
className="max-h-[200px] rounded-md object-contain"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="icon"
|
||||||
|
className="absolute top-2 right-2"
|
||||||
|
onClick={removeImage}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
{broadcastMessage.length} characters
|
||||||
|
{broadcastMessage.length > 4096 && (
|
||||||
|
<span className="text-red-500 ml-2">
|
||||||
|
Message exceeds Telegram's 4096 character limit
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="secondary" onClick={() => setOpen(false)}>
|
<Button variant="secondary" onClick={() => setOpen(false)}>
|
||||||
@@ -59,7 +277,7 @@ export default function BroadcastDialog({ open, setOpen }: BroadcastDialogProps)
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={sendBroadcast}
|
onClick={sendBroadcast}
|
||||||
disabled={isSending}
|
disabled={isSending || (broadcastMessage.length > 4096) || (!broadcastMessage.trim() && !selectedImage)}
|
||||||
className="bg-emerald-600 hover:bg-emerald-700"
|
className="bg-emerald-600 hover:bg-emerald-700"
|
||||||
>
|
>
|
||||||
{isSending ? "Sending..." : "Send Broadcast"}
|
{isSending ? "Sending..." : "Send Broadcast"}
|
||||||
|
|||||||
1547
package-lock.json
generated
1547
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -43,6 +43,7 @@
|
|||||||
"cmdk": "1.0.4",
|
"cmdk": "1.0.4",
|
||||||
"date-fns": "4.1.0",
|
"date-fns": "4.1.0",
|
||||||
"embla-carousel-react": "8.5.1",
|
"embla-carousel-react": "8.5.1",
|
||||||
|
"form-data": "^4.0.2",
|
||||||
"input-otp": "1.4.1",
|
"input-otp": "1.4.1",
|
||||||
"lucide-react": "^0.454.0",
|
"lucide-react": "^0.454.0",
|
||||||
"next": "14.2.16",
|
"next": "14.2.16",
|
||||||
@@ -51,6 +52,7 @@
|
|||||||
"react-day-picker": "8.10.1",
|
"react-day-picker": "8.10.1",
|
||||||
"react-dom": "^18",
|
"react-dom": "^18",
|
||||||
"react-hook-form": "^7.54.1",
|
"react-hook-form": "^7.54.1",
|
||||||
|
"react-markdown": "^10.0.0",
|
||||||
"react-resizable-panels": "^2.1.7",
|
"react-resizable-panels": "^2.1.7",
|
||||||
"recharts": "2.15.0",
|
"recharts": "2.15.0",
|
||||||
"sonner": "^1.7.4",
|
"sonner": "^1.7.4",
|
||||||
@@ -60,6 +62,7 @@
|
|||||||
"zod": "^3.24.1"
|
"zod": "^3.24.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
"@types/node": "^22",
|
"@types/node": "^22",
|
||||||
"@types/react": "^18",
|
"@types/react": "^18",
|
||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^18",
|
||||||
|
|||||||
@@ -58,6 +58,6 @@ const config: Config = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
plugins: [require("tailwindcss-animate")],
|
plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")],
|
||||||
};
|
};
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
Reference in New Issue
Block a user