astro_avtourist/frontend/src/components/blog/comments/CommentForm.tsx

266 lines
No EOL
9.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { createSignal, Show, For } from "solid-js";
interface ValidationErrors {
content?: string;
}
interface CommentFormProps {
onSubmit: (data: { content: string }) => void;
isReply?: boolean;
onCancel?: () => void;
user?: {
name: string;
email: string;
avatar?: string;
};
initialContent?: string;
isEdit?: boolean;
onUpdate?: (data: { content: string }) => void;
onDelete?: () => void;
}
const EMOJIS = [
"👍", "👎", "❤️", "😊", "😂", "🎉", "🔥", "👏",
"😢", "😮", "😡", "🙏", "⭐", "💯", "❤️‍🔥", "🤔",
"👀", "💪", "🚀", "✨"
];
const DANGEROUS_PATTERNS = [
/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi,
/javascript:/gi,
/on\w+\s*=/gi,
/<iframe\b[^<]*(?:(?!<\/iframe>)<[^<]*)*<\/iframe>/gi,
/<object\b[^<]*(?:(?!<\/object>)<[^<]*)*<\/object>/gi,
/<embed\b[^<]*>/gi,
/data:text\/html/gi,
/expression\s*\(/gi,
/url\s*\(\s*['"]*\s*javascript:/gi,
];
const MAX_MESSAGE_LENGTH = 2000;
const MIN_MESSAGE_LENGTH = 10;
export default function CommentForm(props: CommentFormProps) {
const [content, setContent] = createSignal(props.initialContent || "");
const [errors, setErrors] = createSignal<ValidationErrors>({});
const [touched, setTouched] = createSignal<{ [key: string]: boolean }>({});
const [showEmojiPicker, setShowEmojiPicker] = createSignal(false);
const [consent, setConsent] = createSignal(false);
const sanitizeInput = (input: string): string => {
return input
.replace(/[<>]/g, "")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#x27;")
.replace(/&/g, "&amp;");
};
const containsDangerousContent = (input: string): boolean => {
return DANGEROUS_PATTERNS.some((pattern) => pattern.test(input));
};
const validateContent = (value: string): string | undefined => {
const trimmed = value.trim();
if (!trimmed) return "Комментарий обязателен";
if (trimmed.length < MIN_MESSAGE_LENGTH)
return `Минимум ${MIN_MESSAGE_LENGTH} символов`;
if (trimmed.length > MAX_MESSAGE_LENGTH)
return `Максимум ${MAX_MESSAGE_LENGTH} символов`;
if (containsDangerousContent(trimmed)) return "Обнаружен опасный контент";
return undefined;
};
const handleContentChange = (e: Event) => {
const target = e.target as HTMLTextAreaElement;
let value = target.value;
if (containsDangerousContent(value)) {
DANGEROUS_PATTERNS.forEach((pattern) => {
value = value.replace(pattern, "");
});
}
if (value.length > MAX_MESSAGE_LENGTH) {
value = value.slice(0, MAX_MESSAGE_LENGTH);
}
setContent(value);
if (touched().content) {
setErrors((prev) => ({ ...prev, content: validateContent(value) }));
}
};
const addEmoji = (emoji: string) => {
setContent((prev) => prev + emoji);
setShowEmojiPicker(false);
};
const validateForm = (): boolean => {
const contentError = validateContent(content());
setErrors({ content: contentError });
setTouched({ content: true });
return !contentError;
};
const handleSubmit = (e: Event) => {
e.preventDefault();
if (!validateForm()) return;
if (props.isEdit && props.onUpdate) {
props.onUpdate({ content: sanitizeInput(content().trim()) });
} else {
props.onSubmit({ content: sanitizeInput(content().trim()) });
}
if (!props.isEdit) {
setContent("");
setErrors({});
setTouched({});
}
};
const handleBlur = () => {
setTouched((prev) => ({ ...prev, content: true }));
setErrors((prev) => ({ ...prev, content: validateContent(content()) }));
};
const isValid = () => {
return !errors().content && content().trim() && consent();
};
return (
<div
class={`bg-linear-to-br from-gray-50 to-gray-100 rounded-2xl border border-gray-200 ${props.isReply ? "p-4" : "p-6 md:p-8"}`}
>
<h4
class={`font-semibold text-gray-900 mb-4 flex items-center gap-2 ${props.isReply ? "text-base" : "text-lg"}`}
>
<svg
class="w-5 h-5 text-blue-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
/>
</svg>
{props.isEdit ? "Редактировать комментарий" : props.isReply ? "Написать ответ" : "Оставить комментарий"}
</h4>
<form onSubmit={handleSubmit} class="space-y-4">
<div class="space-y-1">
<div class="relative">
<textarea
placeholder={props.user ? `Написать комментарий как ${props.user.name}...` : "Ваш комментарий... *"}
value={content()}
onInput={handleContentChange}
onBlur={handleBlur}
rows={props.isReply ? 3 : props.isEdit ? 3 : 4}
class={`w-full px-4 py-3 rounded-xl border transition-all resize-none bg-white text-gray-900 placeholder-gray-400 outline-none ${
errors().content && touched().content
? "border-red-300 focus:border-red-500 focus:ring-2 focus:ring-red-200"
: "border-gray-200 focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
}`}
/>
<div class="absolute bottom-3 right-3 flex items-center gap-1">
<div class="relative">
<button
type="button"
onClick={() => setShowEmojiPicker(!showEmojiPicker())}
class="p-2 text-gray-400 hover:text-blue-600 transition-colors rounded-lg hover:bg-gray-100"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.828 14.828a4 4 0 01-5.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</button>
<Show when={showEmojiPicker()}>
<div class="absolute bottom-full right-0 mb-2 bg-white rounded-xl shadow-lg border border-gray-200 p-3 z-10 w-64">
<div class="grid grid-cols-5 gap-2">
<For each={EMOJIS}>
{(emoji) => (
<button
type="button"
onClick={() => addEmoji(emoji)}
class="p-2 text-xl hover:bg-gray-100 rounded-lg transition-colors"
>
{emoji}
</button>
)}
</For>
</div>
</div>
</Show>
</div>
</div>
</div>
<div class="flex justify-between items-center">
<Show when={errors().content && touched().content}>
<p class="text-sm text-red-500 flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
{errors().content}
</p>
</Show>
<p
class={`text-xs text-right ml-auto ${content().length > MAX_MESSAGE_LENGTH * 0.9 ? "text-orange-500" : "text-gray-400"}`}
>
{content().length}/{MAX_MESSAGE_LENGTH}
</p>
</div>
</div>
<div class="flex items-center gap-3 pt-2 pb-2">
<input
type="checkbox"
id="comment-consent"
checked={consent()}
onChange={(e) => setConsent(e.currentTarget.checked)}
class="w-4 h-4 accent-blue-600 cursor-pointer"
/>
<label for="comment-consent" class="text-sm text-gray-600 cursor-pointer">
Я согласен на <a href="/privacy" class="text-blue-600 hover:underline">обработку персональных данных</a>
</label>
</div>
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 pt-2">
<p class="text-sm text-gray-500">
{props.user
? `Вы авторизованы как ${props.user.name}`
: "Ваш email не будет опубликован"}
</p>
<div class="flex gap-2">
<Show when={props.isEdit && props.onCancel}>
<button
type="button"
onClick={props.onCancel}
class="px-6 py-3 text-gray-600 hover:text-gray-800 font-medium transition-colors"
>
Отмена
</button>
</Show>
<button
type="submit"
disabled={!isValid()}
class="px-8 py-3 bg-gradient-to-b from-blue-600 to-blue-800 hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed text-white font-medium rounded-xl transition-all flex items-center gap-2 hover:cursor-pointer group"
>
<svg
class="w-5 h-5 transition-transform duration-300 group-hover:rotate-90"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"/>
</svg>
{props.isEdit ? "Сохранить" : props.isReply ? "Отправить" : "Отправить комментарий"}
</button>
</div>
</div>
</form>
</div>
);
}