diff --git a/src/app/(student)/courses/[slug]/lessons/[lessonId]/page.tsx b/src/app/(student)/courses/[slug]/lessons/[lessonId]/page.tsx index ec0ccd8..9119948 100644 --- a/src/app/(student)/courses/[slug]/lessons/[lessonId]/page.tsx +++ b/src/app/(student)/courses/[slug]/lessons/[lessonId]/page.tsx @@ -289,6 +289,7 @@ export default async function LessonPage({ params }: Props) { comments={comments} currentUserId={session.user.id} currentUserRole={session.user.role ?? "student"} + imageHost={process.env.S3_CDN_URL ?? ""} /> )} diff --git a/src/app/api/student/comment-upload/route.ts b/src/app/api/student/comment-upload/route.ts new file mode 100644 index 0000000..037d668 --- /dev/null +++ b/src/app/api/student/comment-upload/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from "next/server"; +import { headers } from "next/headers"; +import { auth } from "@/lib/auth"; +import { uploadFile } from "@/lib/s3"; +import { randomUUID } from "crypto"; + +// Images only: comments render them inline, and anything else would just be an +// unlabelled blob in the middle of a discussion. +const ALLOWED_TYPES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]); +const ALLOWED_EXTS = new Set(["jpg", "jpeg", "png", "gif", "webp"]); +const MAX_BYTES = 5 * 1024 * 1024; // 5 MB — enough for a screenshot + +export async function POST(req: NextRequest) { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const form = await req.formData(); + const file = form.get("file") as File | null; + if (!file) return NextResponse.json({ error: "Missing file" }, { status: 400 }); + + if (file.size > MAX_BYTES) { + return NextResponse.json({ error: "Изображение слишком большое (макс. 5 МБ)" }, { status: 413 }); + } + + if (!ALLOWED_TYPES.has(file.type)) { + return NextResponse.json({ error: "Разрешены только изображения: jpg, png, gif, webp" }, { status: 415 }); + } + + const ext = file.name.split(".").pop()?.toLowerCase() ?? "bin"; + if (!ALLOWED_EXTS.has(ext)) { + return NextResponse.json({ error: "Недопустимое расширение файла" }, { status: 415 }); + } + + const key = `comments/${session.user.id}/${randomUUID()}.${ext}`; + const buffer = Buffer.from(await file.arrayBuffer()); + const url = await uploadFile(key, buffer, file.type); + + return NextResponse.json({ name: file.name, url, size: file.size }); +} diff --git a/src/app/globals.css b/src/app/globals.css index 8ab27ff..d7796a9 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -243,6 +243,13 @@ } /* Markdown-разметка в комментариях под уроками (react-markdown) */ +.comment-md img { + display: block; + max-width: 100%; + height: auto; + margin: 0.5rem 0; + border: 2px solid var(--border); +} .comment-md > :first-child { margin-top: 0; } .comment-md > :last-child { margin-bottom: 0; } .comment-md p { margin: 0 0 0.5rem; } diff --git a/src/components/student/lesson-comments.tsx b/src/components/student/lesson-comments.tsx index 0554282..69f279e 100644 --- a/src/components/student/lesson-comments.tsx +++ b/src/components/student/lesson-comments.tsx @@ -2,7 +2,7 @@ import { useRef, useState, useTransition, type ReactNode } from "react"; import ReactMarkdown from "react-markdown"; -import rehypeSanitize from "rehype-sanitize"; +import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; import { addComment, deleteComment, editComment } from "@/lib/actions/student-actions"; const MAX_LEN = 10000; @@ -32,6 +32,8 @@ interface Props { comments: Comment[]; currentUserId: string; currentUserRole: string; + /** Public host of our own file storage (S3_CDN_URL) — see CommentBody. */ + imageHost: string; } function formatDate(date: Date) { @@ -44,15 +46,42 @@ function formatDate(date: Date) { }); } +// The default schema drops `alt`, so uploaded images would lose their caption. +const sanitizeSchema = { + ...defaultSchema, + attributes: { + ...defaultSchema.attributes, + img: [...(defaultSchema.attributes?.img ?? []), "alt"], + }, +}; + // Renders comment text as sanitized Markdown (no raw HTML, safe links). -function CommentBody({ text }: { text: string }) { +// `imageHost` is our own file storage: images from anywhere else are shown as a +// plain link instead of being loaded, since a third-party image would fetch — +// and thus expose the address — for every reader of the lesson. +function CommentBody({ text, imageHost }: { text: string; imageHost: string }) { return (
, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + img: ({ node, src, alt, ...props }) => { + const url = typeof src === "string" ? src : ""; + if (!imageHost || !url.startsWith(`${imageHost}/`)) { + return ( + + {alt || url} + + ); + } + return ( + // eslint-disable-next-line @next/next/no-img-element -- пользовательская картинка произвольного размера, next/image здесь не подходит + {alt + ); + }, }} > {text} @@ -107,6 +136,44 @@ interface EditorProps { // new comment, replies, and inline editing. function CommentEditor({ value, onChange, placeholder, disabled, rows = 3 }: EditorProps) { const ref = useRef(null); + const fileRef = useRef(null); + const [uploading, setUploading] = useState(false); + const [uploadError, setUploadError] = useState(null); + + // Insert text at the caret (used for uploaded images). + function insertAtCursor(snippet: string) { + const el = ref.current; + const at = el ? el.selectionStart : value.length; + const next = value.slice(0, at) + snippet + value.slice(at); + onChange(next); + requestAnimationFrame(() => { + el?.focus(); + el?.setSelectionRange(at + snippet.length, at + snippet.length); + }); + } + + async function handleFile(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + e.target.value = ""; // allow picking the same file twice in a row + if (!file) return; + + setUploading(true); + setUploadError(null); + try { + const fd = new FormData(); + fd.append("file", file); + const res = await fetch("/api/student/comment-upload", { method: "POST", body: fd }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error ?? "Не удалось загрузить изображение"); + // Square brackets in the filename would break the Markdown link syntax. + const caption = file.name.replace(/[[\]]/g, ""); + insertAtCursor(`\n![${caption}](${data.url})\n`); + } catch (err) { + setUploadError(err instanceof Error ? err.message : "Не удалось загрузить изображение"); + } finally { + setUploading(false); + } + } // Wrap the current selection with before/after markers. function wrap(before: string, after: string, placeholderText: string) { @@ -152,6 +219,20 @@ function CommentEditor({ value, onChange, placeholder, disabled, rows = 3 }: Edi prefixLines("> ")}>» wrap("`", "`", "код")}>{""} wrap("[", "](https://)", "текст")}>🔗 + fileRef.current?.click()} + > + {uploading ? "…" : "🖼"} + +