v3: added attachments to resources, allow users to paste images into TextEditor, strengthened WS reliability
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { type ReactNode, useState } from "react";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { useWS } from "../hooks/useWS.ts";
|
||||
import { DumpCreateModal } from "./DumpCreateModal.tsx";
|
||||
import { NotificationBell } from "./NotificationBell.tsx";
|
||||
|
||||
@@ -8,6 +9,7 @@ export function AppHeader(
|
||||
{ centerSlot, disableNew }: { centerSlot?: ReactNode; disableNew?: boolean },
|
||||
) {
|
||||
const { user } = useAuth();
|
||||
const { wsStatus, wsErrorMessage } = useWS();
|
||||
const navigate = useNavigate();
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
|
||||
@@ -60,6 +62,12 @@ export function AppHeader(
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{wsStatus === "disconnected" && wsErrorMessage && (
|
||||
<div className="app-header-status" role="alert">
|
||||
<strong>Live updates unavailable.</strong> {wsErrorMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{createModalOpen && (
|
||||
<DumpCreateModal onClose={() => setCreateModalOpen(false)} />
|
||||
)}
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import { forwardRef, useEffect, useImperativeHandle, useRef } from "react";
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { EmojiPicker } from "frimousse";
|
||||
import { MentionDropdown } from "./MentionDropdown.tsx";
|
||||
import { useMentionAutocomplete } from "../hooks/useMentionAutocomplete.ts";
|
||||
import { useEmojiTrigger } from "../hooks/useEmojiTrigger.ts";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { API_URL } from "../config/api.ts";
|
||||
|
||||
export interface TextEditorHandle {
|
||||
focus(): void;
|
||||
@@ -40,6 +49,14 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const emojiViewportRef = useRef<HTMLDivElement>(null);
|
||||
const emojiSearchRef = useRef<HTMLInputElement>(null);
|
||||
const valueRef = useRef(value);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const { authFetch } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
valueRef.current = value;
|
||||
}, [value]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
focus: () => textareaRef.current?.focus(),
|
||||
@@ -76,8 +93,89 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
|
||||
el.style.height = `${el.scrollHeight}px`;
|
||||
}, [value, autoResize]);
|
||||
|
||||
const insertAtCursor = useCallback((text: string) => {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
const start = el.selectionStart ?? valueRef.current.length;
|
||||
const end = el.selectionEnd ?? valueRef.current.length;
|
||||
const newValue = valueRef.current.slice(0, start) +
|
||||
text +
|
||||
valueRef.current.slice(end);
|
||||
onChange(newValue);
|
||||
requestAnimationFrame(() => {
|
||||
if (!textareaRef.current) return;
|
||||
const pos = start + text.length;
|
||||
textareaRef.current.selectionStart = pos;
|
||||
textareaRef.current.selectionEnd = pos;
|
||||
});
|
||||
}, [onChange]);
|
||||
|
||||
const uploadImage = useCallback(async (file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
setUploading(true);
|
||||
try {
|
||||
const res = await authFetch(`${API_URL}/api/attachments`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json.success) {
|
||||
insertAtCursor(``);
|
||||
}
|
||||
} catch {
|
||||
// silently ignore — user can retry
|
||||
} finally {
|
||||
setUploading(false);
|
||||
textareaRef.current?.focus();
|
||||
}
|
||||
}, [authFetch, insertAtCursor]);
|
||||
|
||||
const handlePaste = useCallback(
|
||||
async (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
const imageItem = Array.from(e.clipboardData.items).find(
|
||||
(item) => item.kind === "file" && item.type.startsWith("image/"),
|
||||
);
|
||||
if (!imageItem) return;
|
||||
e.preventDefault();
|
||||
const file = imageItem.getAsFile();
|
||||
if (file) await uploadImage(file);
|
||||
},
|
||||
[uploadImage],
|
||||
);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
async (e: React.DragEvent<HTMLTextAreaElement>) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
const files = Array.from(e.dataTransfer.files).filter((f) =>
|
||||
f.type.startsWith("image/")
|
||||
);
|
||||
for (const file of files) {
|
||||
await uploadImage(file);
|
||||
}
|
||||
},
|
||||
[uploadImage],
|
||||
);
|
||||
|
||||
const handleDragOver = useCallback(
|
||||
(e: React.DragEvent<HTMLTextAreaElement>) => {
|
||||
const hasImage = Array.from(e.dataTransfer.items).some(
|
||||
(item) => item.kind === "file" && item.type.startsWith("image/"),
|
||||
);
|
||||
if (!hasImage) return;
|
||||
e.preventDefault();
|
||||
setDragOver(true);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleDragLeave = useCallback(() => setDragOver(false), []);
|
||||
|
||||
return (
|
||||
<div className="mention-textarea-wrap">
|
||||
<div
|
||||
className={`mention-textarea-wrap${dragOver ? " mention-textarea-wrap--dragover" : ""}`}
|
||||
>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
@@ -94,8 +192,12 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
|
||||
handleMentionKeyDown(e);
|
||||
if (!e.defaultPrevented) onKeyDown?.(e);
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
onPaste={handlePaste}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
placeholder={uploading ? "Uploading image…" : placeholder}
|
||||
disabled={disabled || uploading}
|
||||
rows={rows}
|
||||
id={id}
|
||||
className={className}
|
||||
|
||||
Reference in New Issue
Block a user